Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d58162890 | ||
|
|
9820a1c6c0 | ||
|
|
ad5f9cccf9 | ||
|
|
2e3b854037 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "smesh",
|
"name": "smesh",
|
||||||
"version": "0.2.2",
|
"version": "0.2.5",
|
||||||
"description": "A user-friendly Nostr client for exploring relay feeds",
|
"description": "A user-friendly Nostr client for exploring relay feeds",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { formatTimestamp } from '@/lib/timestamp'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { TConversation, TProfile } from '@/types'
|
import { TConversation, TProfile } from '@/types'
|
||||||
import { Lock, Users } from 'lucide-react'
|
import { Lock, Users, X } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
interface ConversationItemProps {
|
interface ConversationItemProps {
|
||||||
@@ -11,13 +11,15 @@ interface ConversationItemProps {
|
|||||||
isActive: boolean
|
isActive: boolean
|
||||||
isFollowing: boolean
|
isFollowing: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ConversationItem({
|
export default function ConversationItem({
|
||||||
conversation,
|
conversation,
|
||||||
isActive,
|
isActive,
|
||||||
isFollowing,
|
isFollowing,
|
||||||
onClick
|
onClick,
|
||||||
|
onClose
|
||||||
}: ConversationItemProps) {
|
}: ConversationItemProps) {
|
||||||
const [profile, setProfile] = useState<TProfile | null>(null)
|
const [profile, setProfile] = useState<TProfile | null>(null)
|
||||||
|
|
||||||
@@ -58,7 +60,21 @@ export default function ConversationItem({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground flex-shrink-0">{formattedTime}</span>
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<span className="text-xs text-muted-foreground">{formattedTime}</span>
|
||||||
|
{isActive && onClose && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded hover:bg-muted-foreground/20 transition-colors"
|
||||||
|
title="Close conversation"
|
||||||
|
>
|
||||||
|
<X className="size-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { toDMConversation } from '@/lib/link'
|
||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { useDM } from '@/providers/DMProvider'
|
import { useDM } from '@/providers/DMProvider'
|
||||||
import { useFollowList } from '@/providers/FollowListProvider'
|
import { useFollowList } from '@/providers/FollowListProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
@@ -17,6 +19,7 @@ import ConversationItem from './ConversationItem'
|
|||||||
|
|
||||||
export default function ConversationList() {
|
export default function ConversationList() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { push, pop } = useSecondaryPage()
|
||||||
const {
|
const {
|
||||||
conversations,
|
conversations,
|
||||||
currentConversation,
|
currentConversation,
|
||||||
@@ -128,7 +131,17 @@ export default function ConversationList() {
|
|||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
isActive={currentConversation === conversation.partnerPubkey}
|
isActive={currentConversation === conversation.partnerPubkey}
|
||||||
isFollowing={followingSet.has(conversation.partnerPubkey)}
|
isFollowing={followingSet.has(conversation.partnerPubkey)}
|
||||||
onClick={() => selectConversation(conversation.partnerPubkey)}
|
onClick={() => {
|
||||||
|
// If already viewing a different conversation, pop first to replace
|
||||||
|
if (currentConversation && currentConversation !== conversation.partnerPubkey) {
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
push(toDMConversation(conversation.partnerPubkey))
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
selectConversation(null)
|
||||||
|
pop()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{/* Sentinel element for infinite scroll */}
|
{/* Sentinel element for infinite scroll */}
|
||||||
|
|||||||
@@ -1,27 +1,14 @@
|
|||||||
import { useDM } from '@/providers/DMProvider'
|
import { useDM } from '@/providers/DMProvider'
|
||||||
import { Loader2, RefreshCw } from 'lucide-react'
|
import { Loader2, RefreshCw } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ConversationList from './ConversationList'
|
import ConversationList from './ConversationList'
|
||||||
import MessageView from './MessageView'
|
|
||||||
import { Button } from '../ui/button'
|
import { Button } from '../ui/button'
|
||||||
|
|
||||||
export default function InboxContent() {
|
export default function InboxContent() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isLoading, error, refreshConversations, currentConversation, selectConversation } =
|
const { isLoading, error, refreshConversations } = useDM()
|
||||||
useDM()
|
|
||||||
const [isMobileView, setIsMobileView] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (isLoading) {
|
||||||
const checkMobile = () => {
|
|
||||||
setIsMobileView(window.innerWidth < 768)
|
|
||||||
}
|
|
||||||
checkMobile()
|
|
||||||
window.addEventListener('resize', checkMobile)
|
|
||||||
return () => window.removeEventListener('resize', checkMobile)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (isLoading && !currentConversation) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
@@ -44,37 +31,10 @@ export default function InboxContent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile view: show either list or conversation
|
// Conversations list - clicking opens in secondary panel (or overlay on mobile)
|
||||||
if (isMobileView) {
|
|
||||||
if (currentConversation) {
|
|
||||||
return (
|
|
||||||
<div className="h-[calc(100vh-8rem)]">
|
|
||||||
<MessageView onBack={() => selectConversation(null)} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-8rem)]">
|
<div className="h-[calc(100vh-8rem)]">
|
||||||
<ConversationList />
|
<ConversationList />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desktop view: split pane
|
|
||||||
return (
|
|
||||||
<div className="flex h-[calc(100vh-8rem)]">
|
|
||||||
<div className="w-80 border-r flex-shrink-0 overflow-hidden">
|
|
||||||
<ConversationList />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
{currentConversation ? (
|
|
||||||
<MessageView />
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
||||||
<p>{t('Select a conversation to view messages')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { useDM } from '@/providers/DMProvider'
|
import { useDM } from '@/providers/DMProvider'
|
||||||
import { AlertCircle, Loader2, Send } from 'lucide-react'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useRef, useState } from 'react'
|
import { AlertCircle, ChevronDown, ChevronUp, Loader2, Send } from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '../ui/button'
|
import { Button } from '../ui/button'
|
||||||
import { Textarea } from '../ui/textarea'
|
import { Textarea } from '../ui/textarea'
|
||||||
@@ -8,18 +10,47 @@ import { Textarea } from '../ui/textarea'
|
|||||||
export default function MessageComposer() {
|
export default function MessageComposer() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { sendMessage, currentConversation } = useDM()
|
const { sendMessage, currentConversation } = useDM()
|
||||||
|
const { relayList } = useNostr()
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [isSending, setIsSending] = useState(false)
|
const [isSending, setIsSending] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showRelays, setShowRelays] = useState(false)
|
||||||
|
const [selectedRelays, setSelectedRelays] = useState<Set<string>>(new Set())
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
// Get user's write relays
|
||||||
|
const writeRelays = useMemo(() => relayList?.write || [], [relayList])
|
||||||
|
|
||||||
|
// Initialize selected relays when write relays change
|
||||||
|
useEffect(() => {
|
||||||
|
if (writeRelays.length > 0 && selectedRelays.size === 0) {
|
||||||
|
setSelectedRelays(new Set(writeRelays))
|
||||||
|
}
|
||||||
|
}, [writeRelays])
|
||||||
|
|
||||||
|
const toggleRelay = (url: string) => {
|
||||||
|
setSelectedRelays((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(url)) {
|
||||||
|
// Don't allow deselecting all relays
|
||||||
|
if (next.size > 1) {
|
||||||
|
next.delete(url)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next.add(url)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!message.trim() || !currentConversation || isSending) return
|
if (!message.trim() || !currentConversation || isSending) return
|
||||||
|
|
||||||
setIsSending(true)
|
setIsSending(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await sendMessage(message.trim())
|
const relaysToUse = Array.from(selectedRelays)
|
||||||
|
await sendMessage(message.trim(), relaysToUse.length > 0 ? relaysToUse : undefined)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
// Return focus to input after sending
|
// Return focus to input after sending
|
||||||
textareaRef.current?.focus()
|
textareaRef.current?.focus()
|
||||||
@@ -38,6 +69,11 @@ export default function MessageComposer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format relay URL for display
|
||||||
|
const formatRelayUrl = (url: string) => {
|
||||||
|
return url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 space-y-2">
|
<div className="p-3 space-y-2">
|
||||||
{error && (
|
{error && (
|
||||||
@@ -46,6 +82,41 @@ export default function MessageComposer() {
|
|||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Relay selector */}
|
||||||
|
{writeRelays.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRelays(!showRelays)}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{showRelays ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
|
||||||
|
<span>
|
||||||
|
{t('Relays')} ({selectedRelays.size}/{writeRelays.length})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{showRelays && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{writeRelays.map((url) => (
|
||||||
|
<button
|
||||||
|
key={url}
|
||||||
|
onClick={() => toggleRelay(url)}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2 py-0.5 rounded-full border transition-colors',
|
||||||
|
selectedRelays.has(url)
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-muted text-muted-foreground border-muted hover:border-primary/50'
|
||||||
|
)}
|
||||||
|
title={url}
|
||||||
|
>
|
||||||
|
{formatRelayUrl(url)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
|
|||||||
120
src/components/Inbox/MessageContent.tsx
Normal file
120
src/components/Inbox/MessageContent.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { useNostr } from '@/providers/NostrProvider'
|
|||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import indexedDb from '@/services/indexed-db.service'
|
import indexedDb from '@/services/indexed-db.service'
|
||||||
import { TDirectMessage, TProfile } from '@/types'
|
import { TDirectMessage, TProfile } from '@/types'
|
||||||
import { ArrowLeft, ChevronDown, Loader2, MoreVertical, RefreshCw, Settings, Trash2, Undo2, Users, X } from 'lucide-react'
|
import { ChevronDown, ChevronUp, Loader2, MoreVertical, RefreshCw, Settings, Trash2, Undo2, Users, X } from 'lucide-react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '../ui/button'
|
import { Button } from '../ui/button'
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '../ui/dropdown-menu'
|
} from '../ui/dropdown-menu'
|
||||||
import MessageComposer from './MessageComposer'
|
import MessageComposer from './MessageComposer'
|
||||||
|
import MessageContent from './MessageContent'
|
||||||
import MessageInfoModal from './MessageInfoModal'
|
import MessageInfoModal from './MessageInfoModal'
|
||||||
import ConversationSettingsModal from './ConversationSettingsModal'
|
import ConversationSettingsModal from './ConversationSettingsModal'
|
||||||
import { useFollowList } from '@/providers/FollowListProvider'
|
import { useFollowList } from '@/providers/FollowListProvider'
|
||||||
@@ -58,9 +59,28 @@ export default function MessageView({ onBack }: MessageViewProps) {
|
|||||||
const [newMessageCount, setNewMessageCount] = useState(0)
|
const [newMessageCount, setNewMessageCount] = useState(0)
|
||||||
const lastMessageCountRef = useRef(0)
|
const lastMessageCountRef = useRef(0)
|
||||||
const isAtBottomRef = useRef(true)
|
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
|
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
|
// Handle pulsing animation for new conversations
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNewConversation) {
|
if (isNewConversation) {
|
||||||
@@ -212,11 +232,6 @@ export default function MessageView({ onBack }: MessageViewProps) {
|
|||||||
) : (
|
) : (
|
||||||
// Normal header
|
// Normal header
|
||||||
<>
|
<>
|
||||||
{onBack && (
|
|
||||||
<Button variant="ghost" size="icon" onClick={onBack} className="size-8">
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<UserAvatar userId={currentConversation} className="size-8" />
|
<UserAvatar userId={currentConversation} className="size-8" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -271,6 +286,17 @@ export default function MessageView({ onBack }: MessageViewProps) {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
{onBack && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
title={t('Close conversation')}
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -288,12 +314,26 @@ export default function MessageView({ onBack }: MessageViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<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 && (
|
{isLoadingConversation && (
|
||||||
<div className="flex justify-center py-2">
|
<div className="flex justify-center py-2">
|
||||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((message) => {
|
{visibleMessages.map((message) => {
|
||||||
const isOwn = message.senderPubkey === pubkey
|
const isOwn = message.senderPubkey === pubkey
|
||||||
const isSelected = selectedMessages.has(message.id)
|
const isSelected = selectedMessages.has(message.id)
|
||||||
return (
|
return (
|
||||||
@@ -328,7 +368,11 @@ export default function MessageView({ onBack }: MessageViewProps) {
|
|||||||
isSelected && 'ring-2 ring-primary ring-offset-2'
|
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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between gap-2 mt-1 text-xs',
|
'flex items-center justify-between gap-2 mt-1 text-xs',
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ export const StorageKey = {
|
|||||||
export const ApplicationDataKey = {
|
export const ApplicationDataKey = {
|
||||||
NOTIFICATIONS_SEEN_AT: 'seen_notifications_at',
|
NOTIFICATIONS_SEEN_AT: 'seen_notifications_at',
|
||||||
SETTINGS: 'smesh_settings',
|
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 = [
|
export const BIG_RELAY_URLS = [
|
||||||
|
|||||||
@@ -92,3 +92,7 @@ export const toUserAggregationDetail = (feedId: string, pubkey: string) => {
|
|||||||
}
|
}
|
||||||
export const toLogin = () => '/login'
|
export const toLogin = () => '/login'
|
||||||
export const toLogout = () => '/logout'
|
export const toLogout = () => '/logout'
|
||||||
|
export const toDMConversation = (pubkey: string) => {
|
||||||
|
const npub = pubkey.startsWith('npub') ? pubkey : nip19.npubEncode(pubkey)
|
||||||
|
return `/dm/${npub}`
|
||||||
|
}
|
||||||
|
|||||||
66
src/pages/secondary/DMConversationPage/index.tsx
Normal file
66
src/pages/secondary/DMConversationPage/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import MessageView from '@/components/Inbox/MessageView'
|
||||||
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { useDM } from '@/providers/DMProvider'
|
||||||
|
import { TPageRef } from '@/types'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface DMConversationPageProps {
|
||||||
|
pubkey?: string
|
||||||
|
index?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DMConversationPage = forwardRef<TPageRef, DMConversationPageProps>(({ pubkey, index }, ref) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const layoutRef = useRef<TPageRef>(null)
|
||||||
|
const { selectConversation, currentConversation } = useDM()
|
||||||
|
const { pop } = useSecondaryPage()
|
||||||
|
|
||||||
|
// Decode npub to hex if needed
|
||||||
|
const hexPubkey = useMemo(() => {
|
||||||
|
if (!pubkey) return null
|
||||||
|
if (pubkey.startsWith('npub')) {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(pubkey)
|
||||||
|
return decoded.type === 'npub' ? decoded.data : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pubkey
|
||||||
|
}, [pubkey])
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => layoutRef.current as TPageRef)
|
||||||
|
|
||||||
|
// Select the conversation when this page mounts
|
||||||
|
useEffect(() => {
|
||||||
|
if (hexPubkey && hexPubkey !== currentConversation) {
|
||||||
|
selectConversation(hexPubkey)
|
||||||
|
}
|
||||||
|
}, [hexPubkey, selectConversation, currentConversation])
|
||||||
|
|
||||||
|
// Clear conversation when page unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
selectConversation(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
selectConversation(null)
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryPageLayout ref={layoutRef} index={index} title={t('Conversation')}>
|
||||||
|
<div className="h-full">
|
||||||
|
<MessageView onBack={handleBack} />
|
||||||
|
</div>
|
||||||
|
</SecondaryPageLayout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
DMConversationPage.displayName = 'DMConversationPage'
|
||||||
|
export default DMConversationPage
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { ApplicationDataKey, BIG_RELAY_URLS } from '@/constants'
|
import { ApplicationDataKey, BIG_RELAY_URLS } from '@/constants'
|
||||||
import { createDeletedMessagesDraftEvent } from '@/lib/draft-event'
|
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 indexedDb from '@/services/indexed-db.service'
|
||||||
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
|
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
@@ -18,7 +24,7 @@ type TDMContext = {
|
|||||||
error: string | null
|
error: string | null
|
||||||
selectConversation: (partnerPubkey: string | null) => void
|
selectConversation: (partnerPubkey: string | null) => void
|
||||||
startConversation: (partnerPubkey: string) => void
|
startConversation: (partnerPubkey: string) => void
|
||||||
sendMessage: (content: string) => Promise<void>
|
sendMessage: (content: string, customRelayUrls?: string[]) => Promise<void>
|
||||||
refreshConversations: () => Promise<void>
|
refreshConversations: () => Promise<void>
|
||||||
reloadConversation: () => void
|
reloadConversation: () => void
|
||||||
loadMoreConversations: () => Promise<void>
|
loadMoreConversations: () => Promise<void>
|
||||||
@@ -89,6 +95,13 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// Track which conversation load is in progress to prevent race conditions
|
// Track which conversation load is in progress to prevent race conditions
|
||||||
const loadingConversationRef = useRef<string | null>(null)
|
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)
|
||||||
|
// Background subscription for real-time DM updates
|
||||||
|
const dmSubscriptionRef = useRef<{ close: () => void } | null>(null)
|
||||||
|
// Track newest message timestamp from subscription (to update hasNewMessages)
|
||||||
|
const [newestIncomingTimestamp, setNewestIncomingTimestamp] = useState(0)
|
||||||
|
|
||||||
// Create encryption wrapper object for dm.service
|
// Create encryption wrapper object for dm.service
|
||||||
const encryption: IDMEncryption | null = useMemo(() => {
|
const encryption: IDMEncryption | null = useMemo(() => {
|
||||||
@@ -106,6 +119,15 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Load deleted state and conversations when user is logged in
|
// Load deleted state and conversations when user is logged in
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pubkey && encryption) {
|
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
|
// Load deleted state FIRST before anything else
|
||||||
const loadDeletedStateAndConversations = async () => {
|
const loadDeletedStateAndConversations = async () => {
|
||||||
// Step 1: Load deleted state from IndexedDB
|
// Step 1: Load deleted state from IndexedDB
|
||||||
@@ -160,12 +182,23 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setHasMoreConversations(conversations.length > CONVERSATIONS_PER_PAGE)
|
setHasMoreConversations(conversations.length > CONVERSATIONS_PER_PAGE)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Refresh from network
|
// Step 4: Background refresh from network (don't clear existing data)
|
||||||
refreshConversations()
|
backgroundRefreshConversations()
|
||||||
|
|
||||||
|
// Step 5: Start real-time subscription for new DMs
|
||||||
|
if (dmSubscriptionRef.current) {
|
||||||
|
dmSubscriptionRef.current.close()
|
||||||
|
}
|
||||||
|
const relayUrls = relayList?.read || []
|
||||||
|
dmSubscriptionRef.current = dmService.subscribeToDMs(pubkey, relayUrls, (event) => {
|
||||||
|
// New DM event received - update timestamp to trigger hasNewMessages
|
||||||
|
setNewestIncomingTimestamp(event.created_at)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDeletedStateAndConversations()
|
loadDeletedStateAndConversations()
|
||||||
} else {
|
} else {
|
||||||
|
// Clear all state on logout
|
||||||
setConversations([])
|
setConversations([])
|
||||||
setAllConversations([])
|
setAllConversations([])
|
||||||
setMessages([])
|
setMessages([])
|
||||||
@@ -175,8 +208,18 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setDeletedState(null)
|
setDeletedState(null)
|
||||||
setSelectedMessages(new Set())
|
setSelectedMessages(new Set())
|
||||||
setIsSelectionMode(false)
|
setIsSelectionMode(false)
|
||||||
|
// Clear in-memory plaintext cache
|
||||||
|
clearPlaintextCache()
|
||||||
|
// Stop DM subscription
|
||||||
|
if (dmSubscriptionRef.current) {
|
||||||
|
dmSubscriptionRef.current.close()
|
||||||
|
dmSubscriptionRef.current = null
|
||||||
}
|
}
|
||||||
}, [pubkey, encryption])
|
// Reset initialization flag so we reload on next login
|
||||||
|
hasInitializedRef.current = false
|
||||||
|
lastPubkeyRef.current = null
|
||||||
|
}
|
||||||
|
}, [pubkey, encryption, relayList])
|
||||||
|
|
||||||
// Load full conversation when selected
|
// Load full conversation when selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -235,28 +278,43 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return // Abort - user switched conversations
|
return // Abort - user switched conversations
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt all messages in parallel for speed
|
// Decrypt messages in batches to avoid blocking UI
|
||||||
const decryptedResults = await Promise.all(
|
// Progressive updates: show messages as they're decrypted
|
||||||
events.map((event) => dmService.decryptMessage(event, encryption, pubkey))
|
const allDecrypted: TDirectMessage[] = []
|
||||||
)
|
|
||||||
|
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)
|
// Filter to only messages in this conversation (excluding deleted)
|
||||||
const decrypted = decryptedResults.filter((message): message is TDirectMessage => {
|
const validMessages = batchMessages.filter((message) => {
|
||||||
if (!message) return false
|
|
||||||
const partner =
|
const partner =
|
||||||
message.senderPubkey === pubkey ? message.recipientPubkey : message.senderPubkey
|
message.senderPubkey === pubkey ? message.recipientPubkey : message.senderPubkey
|
||||||
if (partner !== targetConversation) return false
|
if (partner !== targetConversation) return false
|
||||||
// Filter out deleted messages
|
|
||||||
return !isMessageDeleted(message.id, targetConversation, message.createdAt, deletedState)
|
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)
|
// Check again after decryption (which can take time)
|
||||||
if (loadingConversationRef.current !== targetConversation) {
|
if (loadingConversationRef.current !== targetConversation) {
|
||||||
return // Abort - user switched conversations
|
return // Abort - user switched conversations
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by time
|
// Final sort
|
||||||
const sorted = decrypted.sort((a, b) => a.createdAt - b.createdAt)
|
const sorted = allDecrypted.sort((a, b) => a.createdAt - b.createdAt)
|
||||||
|
|
||||||
// Update state only if still on same conversation
|
// Update state only if still on same conversation
|
||||||
setConversationMessages((prev) => new Map(prev).set(targetConversation, sorted))
|
setConversationMessages((prev) => new Map(prev).set(targetConversation, sorted))
|
||||||
@@ -287,22 +345,11 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
loadConversation()
|
loadConversation()
|
||||||
}, [currentConversation, pubkey, encryption, relayList, deletedState])
|
}, [currentConversation, pubkey, encryption, relayList, deletedState])
|
||||||
|
|
||||||
const refreshConversations = useCallback(async () => {
|
// Background refresh - merges new data without clearing existing cache
|
||||||
|
const backgroundRefreshConversations = useCallback(async () => {
|
||||||
if (!pubkey || !encryption) return
|
if (!pubkey || !encryption) return
|
||||||
|
|
||||||
setIsLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
// Clear all local state
|
|
||||||
setConversations([])
|
|
||||||
setAllConversations([])
|
|
||||||
setConversationMessages(new Map())
|
|
||||||
setLoadedConversations(new Set())
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Clear all DM caches for a fresh start
|
|
||||||
await indexedDb.clearAllDMCaches()
|
|
||||||
|
|
||||||
// Get relay URLs
|
// Get relay URLs
|
||||||
const relayUrls = relayList?.read || []
|
const relayUrls = relayList?.read || []
|
||||||
|
|
||||||
@@ -313,8 +360,123 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const nip04Events = events.filter((e) => e.kind === 4)
|
const nip04Events = events.filter((e) => e.kind === 4)
|
||||||
const giftWraps = events.filter((e) => e.kind === 1059)
|
const giftWraps = events.filter((e) => e.kind === 1059)
|
||||||
|
|
||||||
// Build conversation list from NIP-04 events immediately (no decryption needed)
|
// Build conversation map from existing conversations
|
||||||
const conversationMap = dmService.groupEventsIntoConversations(nip04Events, pubkey)
|
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 - fetches fresh data from network (manual action)
|
||||||
|
const refreshConversations = useCallback(async () => {
|
||||||
|
if (!pubkey || !encryption) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
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 (merge, don't replace)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Show NIP-04 conversations immediately (filtered by deleted state)
|
// Show NIP-04 conversations immediately (filtered by deleted state)
|
||||||
const updateAndShowConversations = () => {
|
const updateAndShowConversations = () => {
|
||||||
@@ -392,7 +554,7 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setError('Failed to load conversations')
|
setError('Failed to load conversations')
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [pubkey, encryption, relayList, deletedState])
|
}, [pubkey, encryption, relayList, deletedState, allConversations])
|
||||||
|
|
||||||
const loadMoreConversations = useCallback(async () => {
|
const loadMoreConversations = useCallback(async () => {
|
||||||
if (!hasMoreConversations) return
|
if (!hasMoreConversations) return
|
||||||
@@ -455,12 +617,15 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, [currentConversation])
|
}, [currentConversation])
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
async (content: string) => {
|
async (content: string, customRelayUrls?: string[]) => {
|
||||||
if (!pubkey || !encryption || !currentConversation) {
|
if (!pubkey || !encryption || !currentConversation) {
|
||||||
throw new Error('Cannot send message: not logged in or no conversation selected')
|
throw new Error('Cannot send message: not logged in or no conversation selected')
|
||||||
}
|
}
|
||||||
|
|
||||||
const relayUrls = relayList?.write || []
|
// Use custom relays if provided, otherwise fall back to user's write relays
|
||||||
|
const relayUrls = customRelayUrls && customRelayUrls.length > 0
|
||||||
|
? customRelayUrls
|
||||||
|
: (relayList?.write || [])
|
||||||
|
|
||||||
// Find existing encryption type for this conversation
|
// Find existing encryption type for this conversation
|
||||||
const conversation = conversations.find((c) => c.partnerPubkey === currentConversation)
|
const conversation = conversations.find((c) => c.partnerPubkey === currentConversation)
|
||||||
@@ -705,9 +870,9 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Publish to relays
|
// Publish to relays
|
||||||
await publishDeletedState(newDeletedState)
|
await publishDeletedState(newDeletedState)
|
||||||
|
|
||||||
// Trigger a refresh of conversations
|
// Trigger a background refresh of conversations
|
||||||
await refreshConversations()
|
await backgroundRefreshConversations()
|
||||||
}, [pubkey, currentConversation, deletedState, publishDeletedState, refreshConversations])
|
}, [pubkey, currentConversation, deletedState, publishDeletedState, backgroundRefreshConversations])
|
||||||
|
|
||||||
// Filter out deleted conversations from the list
|
// Filter out deleted conversations from the list
|
||||||
const filteredConversations = useMemo(() => {
|
const filteredConversations = useMemo(() => {
|
||||||
@@ -724,9 +889,12 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// Check if there are new messages since last seen
|
// Check if there are new messages since last seen
|
||||||
const newestMessageTimestamp = useMemo(() => {
|
const newestMessageTimestamp = useMemo(() => {
|
||||||
if (filteredConversations.length === 0) return 0
|
const fromConversations = filteredConversations.length === 0
|
||||||
return Math.max(...filteredConversations.map((c) => c.lastMessageAt))
|
? 0
|
||||||
}, [filteredConversations])
|
: Math.max(...filteredConversations.map((c) => c.lastMessageAt))
|
||||||
|
// Also consider real-time incoming messages
|
||||||
|
return Math.max(fromConversations, newestIncomingTimestamp)
|
||||||
|
}, [filteredConversations, newestIncomingTimestamp])
|
||||||
|
|
||||||
const hasNewMessages = newestMessageTimestamp > lastSeenTimestamp
|
const hasNewMessages = newestMessageTimestamp > lastSeenTimestamp
|
||||||
|
|
||||||
@@ -735,6 +903,8 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (!pubkey || newestMessageTimestamp === 0) return
|
if (!pubkey || newestMessageTimestamp === 0) return
|
||||||
setLastSeenTimestamp(newestMessageTimestamp)
|
setLastSeenTimestamp(newestMessageTimestamp)
|
||||||
storage.setDMLastSeenTimestamp(pubkey, newestMessageTimestamp)
|
storage.setDMLastSeenTimestamp(pubkey, newestMessageTimestamp)
|
||||||
|
// Reset incoming timestamp so indicator clears
|
||||||
|
setNewestIncomingTimestamp(0)
|
||||||
}, [pubkey, newestMessageTimestamp])
|
}, [pubkey, newestMessageTimestamp])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AppearanceSettingsPage from '@/pages/secondary/AppearanceSettingsPage'
|
import AppearanceSettingsPage from '@/pages/secondary/AppearanceSettingsPage'
|
||||||
import BookmarkPage from '@/pages/secondary/BookmarkPage'
|
import BookmarkPage from '@/pages/secondary/BookmarkPage'
|
||||||
|
import DMConversationPage from '@/pages/secondary/DMConversationPage'
|
||||||
import EmojiPackSettingsPage from '@/pages/secondary/EmojiPackSettingsPage'
|
import EmojiPackSettingsPage from '@/pages/secondary/EmojiPackSettingsPage'
|
||||||
import ExternalContentPage from '@/pages/secondary/ExternalContentPage'
|
import ExternalContentPage from '@/pages/secondary/ExternalContentPage'
|
||||||
import FollowingListPage from '@/pages/secondary/FollowingListPage'
|
import FollowingListPage from '@/pages/secondary/FollowingListPage'
|
||||||
@@ -53,6 +54,7 @@ const SECONDARY_ROUTE_CONFIGS = [
|
|||||||
{ path: '/mutes', element: <MuteListPage /> },
|
{ path: '/mutes', element: <MuteListPage /> },
|
||||||
{ path: '/rizful', element: <RizfulPage /> },
|
{ path: '/rizful', element: <RizfulPage /> },
|
||||||
{ path: '/bookmarks', element: <BookmarkPage /> },
|
{ path: '/bookmarks', element: <BookmarkPage /> },
|
||||||
|
{ path: '/dm/:pubkey', element: <DMConversationPage /> },
|
||||||
{ path: '/follow-packs/:id', element: <FollowPackPage /> },
|
{ path: '/follow-packs/:id', element: <FollowPackPage /> },
|
||||||
{ path: '/user-aggregation/:feedId/:npub', element: <UserAggregationDetailPage /> }
|
{ path: '/user-aggregation/:feedId/:npub', element: <UserAggregationDetailPage /> }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,6 +11,74 @@ import { Event, kinds, VerifiedEvent } from 'nostr-tools'
|
|||||||
import client from './client.service'
|
import client from './client.service'
|
||||||
import indexedDb from './indexed-db.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
|
* Create and publish a kind 5 delete request for own messages
|
||||||
* This requests relays to delete the original event
|
* This requests relays to delete the original event
|
||||||
@@ -141,6 +209,10 @@ class DMService {
|
|||||||
): Promise<Event[]> {
|
): Promise<Event[]> {
|
||||||
const allRelays = [...new Set([...relayUrls, ...BIG_RELAY_URLS])]
|
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)
|
// Fetch NIP-04 messages between user and partner (with timeout)
|
||||||
const [incomingNip04, outgoingNip04, giftWraps] = await Promise.all([
|
const [incomingNip04, outgoingNip04, giftWraps] = await Promise.all([
|
||||||
// Messages FROM partner TO user
|
// Messages FROM partner TO user
|
||||||
@@ -163,9 +235,9 @@ class DMService {
|
|||||||
}),
|
}),
|
||||||
DM_FETCH_TIMEOUT_MS
|
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(
|
withTimeout(
|
||||||
client.fetchEvents(allRelays, {
|
client.fetchEvents(inboxRelays, {
|
||||||
kinds: [KIND_GIFT_WRAP],
|
kinds: [KIND_GIFT_WRAP],
|
||||||
'#p': [pubkey],
|
'#p': [pubkey],
|
||||||
limit: 500
|
limit: 500
|
||||||
@@ -192,10 +264,18 @@ class DMService {
|
|||||||
): Promise<TDirectMessage | null> {
|
): Promise<TDirectMessage | null> {
|
||||||
try {
|
try {
|
||||||
if (event.kind === KIND_ENCRYPTED_DM) {
|
if (event.kind === KIND_ENCRYPTED_DM) {
|
||||||
// NIP-04 decryption - check content cache first
|
// NIP-04 decryption - check in-memory cache first (fastest)
|
||||||
const cached = await indexedDb.getDecryptedContent(event.id)
|
const memCached = getCachedPlaintext(event.id)
|
||||||
if (cached) {
|
if (memCached) {
|
||||||
return this.buildDirectMessage(event, cached, myPubkey, 'nip04')
|
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)
|
const otherPubkey = this.getOtherPartyPubkey(event, myPubkey)
|
||||||
@@ -203,18 +283,44 @@ class DMService {
|
|||||||
|
|
||||||
const decryptedContent = await encryption.nip04Decrypt(otherPubkey, event.content)
|
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(() => {})
|
indexedDb.putDecryptedContent(event.id, decryptedContent).catch(() => {})
|
||||||
|
|
||||||
return this.buildDirectMessage(event, decryptedContent, myPubkey, 'nip04')
|
return this.buildDirectMessage(event, decryptedContent, myPubkey, 'nip04')
|
||||||
} else if (event.kind === KIND_GIFT_WRAP) {
|
} 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)
|
const cachedUnwrapped = await indexedDb.getUnwrappedGiftWrap(event.id)
|
||||||
if (cachedUnwrapped) {
|
if (cachedUnwrapped) {
|
||||||
// Skip reactions in cache for now (they're stored but not returned as messages)
|
// Skip reactions in cache for now (they're stored but not returned as messages)
|
||||||
if (cachedUnwrapped.recipientPubkey === '__reaction__') {
|
if (cachedUnwrapped.recipientPubkey === '__reaction__') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
// Populate in-memory cache
|
||||||
|
setCachedPlaintext(event.id, `${cachedUnwrapped.pubkey}|${cachedUnwrapped.recipientPubkey}|${cachedUnwrapped.content}`)
|
||||||
const seenOnRelays = client.getSeenEventRelayUrls(event.id)
|
const seenOnRelays = client.getSeenEventRelayUrls(event.id)
|
||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
@@ -253,7 +359,8 @@ class DMService {
|
|||||||
|
|
||||||
const recipientPubkey = this.getRecipientFromTags(innerEvent.tags) || myPubkey
|
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
|
indexedDb
|
||||||
.putUnwrappedGiftWrap(event.id, {
|
.putUnwrappedGiftWrap(event.id, {
|
||||||
pubkey: innerEvent.pubkey,
|
pubkey: innerEvent.pubkey,
|
||||||
@@ -369,8 +476,14 @@ class DMService {
|
|||||||
const sentEvents: Event[] = []
|
const sentEvents: Event[] = []
|
||||||
|
|
||||||
// Get recipient's relays for better delivery
|
// Get recipient's relays for better delivery
|
||||||
const recipientRelays = await this.fetchPartnerRelays(recipientPubkey)
|
// Use inbox relays for NIP-17 (where recipient receives messages)
|
||||||
const allRelays = [...new Set([...relayUrls, ...recipientRelays])]
|
// 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) {
|
if (existingEncryption === null) {
|
||||||
// No existing conversation - send in BOTH formats
|
// No existing conversation - send in BOTH formats
|
||||||
@@ -388,11 +501,12 @@ class DMService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (encryption.nip44Encrypt) {
|
if (encryption.nip44Encrypt) {
|
||||||
|
// Use inbox relays for NIP-17 delivery
|
||||||
const nip17Event = await this.createAndPublishNip17DM(
|
const nip17Event = await this.createAndPublishNip17DM(
|
||||||
recipientPubkey,
|
recipientPubkey,
|
||||||
content,
|
content,
|
||||||
encryption,
|
encryption,
|
||||||
allRelays
|
inboxRelays
|
||||||
)
|
)
|
||||||
sentEvents.push(nip17Event)
|
sentEvents.push(nip17Event)
|
||||||
}
|
}
|
||||||
@@ -414,7 +528,7 @@ class DMService {
|
|||||||
throw error // Re-throw so caller knows it failed
|
throw error // Re-throw so caller knows it failed
|
||||||
}
|
}
|
||||||
} else if (existingEncryption === 'nip17') {
|
} else if (existingEncryption === 'nip17') {
|
||||||
// Match existing NIP-17 encryption
|
// Match existing NIP-17 encryption - use inbox relays
|
||||||
if (!encryption.nip44Encrypt) {
|
if (!encryption.nip44Encrypt) {
|
||||||
throw new Error('Encryption does not support NIP-44')
|
throw new Error('Encryption does not support NIP-44')
|
||||||
}
|
}
|
||||||
@@ -423,7 +537,7 @@ class DMService {
|
|||||||
recipientPubkey,
|
recipientPubkey,
|
||||||
content,
|
content,
|
||||||
encryption,
|
encryption,
|
||||||
allRelays
|
inboxRelays
|
||||||
)
|
)
|
||||||
sentEvents.push(nip17Event)
|
sentEvents.push(nip17Event)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -533,7 +647,7 @@ class DMService {
|
|||||||
// Try to get relay list from IndexedDB first
|
// Try to get relay list from IndexedDB first
|
||||||
const cachedEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)
|
const cachedEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)
|
||||||
if (cachedEvent) {
|
if (cachedEvent) {
|
||||||
return this.parseRelayList(cachedEvent)
|
return this.parseWriteRelays(cachedEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from relays
|
// Fetch from relays
|
||||||
@@ -546,7 +660,7 @@ class DMService {
|
|||||||
if (relayListEvents.length > 0) {
|
if (relayListEvents.length > 0) {
|
||||||
const event = relayListEvents[0]
|
const event = relayListEvents[0]
|
||||||
await indexedDb.putReplaceableEvent(event)
|
await indexedDb.putReplaceableEvent(event)
|
||||||
return this.parseRelayList(event)
|
return this.parseWriteRelays(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to archive relay
|
// 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[] = []
|
const writeRelays: string[] = []
|
||||||
|
|
||||||
for (const tag of event.tags) {
|
for (const tag of event.tags) {
|
||||||
@@ -576,6 +722,27 @@ class DMService {
|
|||||||
return writeRelays.length > 0 ? writeRelays : [ARCHIVE_RELAY_URL]
|
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
|
* Check other relays for an event and return which ones have it
|
||||||
*/
|
*/
|
||||||
@@ -710,6 +877,54 @@ class DMService {
|
|||||||
const pTag = tags.find((t) => t[0] === 'p')
|
const pTag = tags.find((t) => t[0] === 'p')
|
||||||
return pTag ? pTag[1] : null
|
return pTag ? pTag[1] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to incoming DMs in real-time
|
||||||
|
* Returns a close function to stop the subscription
|
||||||
|
*/
|
||||||
|
subscribeToDMs(
|
||||||
|
pubkey: string,
|
||||||
|
relayUrls: string[],
|
||||||
|
onEvent: (event: Event) => void
|
||||||
|
): { close: () => void } {
|
||||||
|
const allRelays = [...new Set([...relayUrls, ...BIG_RELAY_URLS])]
|
||||||
|
const since = Math.floor(Date.now() / 1000) - 60 // Start from 1 minute ago to catch recent
|
||||||
|
|
||||||
|
// Subscribe to NIP-04 DMs (kind 4) addressed to user
|
||||||
|
const nip04Sub = client.subscribe(
|
||||||
|
allRelays,
|
||||||
|
[
|
||||||
|
{ kinds: [KIND_ENCRYPTED_DM], '#p': [pubkey], since },
|
||||||
|
{ kinds: [KIND_ENCRYPTED_DM], authors: [pubkey], since }
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent: (event) => {
|
||||||
|
indexedDb.putDMEvent(event).catch(() => {})
|
||||||
|
onEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscribe to NIP-17 gift wraps (kind 1059) addressed to user
|
||||||
|
const giftWrapSub = client.subscribe(
|
||||||
|
allRelays,
|
||||||
|
{ kinds: [KIND_GIFT_WRAP], '#p': [pubkey], since },
|
||||||
|
{
|
||||||
|
onevent: (event) => {
|
||||||
|
indexedDb.putDMEvent(event).catch(() => {})
|
||||||
|
onEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: async () => {
|
||||||
|
const [nip04, giftWrap] = await Promise.all([nip04Sub, giftWrapSub])
|
||||||
|
nip04.close()
|
||||||
|
giftWrap.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dmService = new DMService()
|
const dmService = new DMService()
|
||||||
|
|||||||
Reference in New Issue
Block a user