Add DM inbox with NIP-04/NIP-17 support and soft delete

Features:
- Full DM inbox UI with conversation list and message view
- Support for both NIP-04 (kind 4) and NIP-17 (kind 14/1059) encryption
- Progressive message decryption with background loading
- Soft delete using kind 30078 Application Specific Data events
- Message selection UI with delete selected/delete all
- Undelete all functionality per conversation
- Jump to newest button with new message counter
- Conversation filtering (all / follows only)
- Per-conversation relay and encryption settings
- New messages indicator on sidebar (clears when inbox viewed)
- Follow indicator on conversation items

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
woikos
2025-12-31 11:06:51 +01:00
parent f78138c7c4
commit fecd4fdd45
29 changed files with 3717 additions and 15 deletions

View File

@@ -1,6 +1,6 @@
# Deploy Command
Deploy smesh to the VPS at 10.0.0.1, serving on port 3008 behind smesh.mleku.dev.
Deploy smesh to the VPS at mleku.dev, serving on port 3008 behind smesh.mleku.dev.
## Instructions
@@ -13,17 +13,17 @@ Deploy smesh to the VPS at 10.0.0.1, serving on port 3008 behind smesh.mleku.dev
3. Sync the dist folder to the VPS:
```bash
rsync -avz --delete dist/ 10.0.0.1:~/smesh/dist/
rsync -avz --delete dist/ mleku.dev:~/smesh/dist/
```
4. Restart the smesh service on the VPS:
```bash
ssh 10.0.0.1 "sudo systemctl restart smesh"
ssh mleku.dev "sudo systemctl restart smesh"
```
5. Verify the service is running:
```bash
ssh 10.0.0.1 "sudo systemctl status smesh"
ssh mleku.dev "sudo systemctl status smesh"
```
6. Report the deployment status and the URL: https://smesh.mleku.dev

View File

@@ -5,6 +5,7 @@ import { Toaster } from '@/components/ui/sonner'
import { BookmarksProvider } from '@/providers/BookmarksProvider'
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider'
import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
import { DMProvider } from '@/providers/DMProvider'
import { EmojiPackProvider } from '@/providers/EmojiPackProvider'
import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
import { FeedProvider } from '@/providers/FeedProvider'
@@ -38,8 +39,9 @@ export default function App(): JSX.Element {
<FavoriteRelaysProvider>
<FollowListProvider>
<MuteListProvider>
<UserTrustProvider>
<BookmarksProvider>
<DMProvider>
<UserTrustProvider>
<BookmarksProvider>
<EmojiPackProvider>
<PinListProvider>
<PinnedUsersProvider>
@@ -55,7 +57,8 @@ export default function App(): JSX.Element {
</PinListProvider>
</EmojiPackProvider>
</BookmarksProvider>
</UserTrustProvider>
</UserTrustProvider>
</DMProvider>
</MuteListProvider>
</FollowListProvider>
</FavoriteRelaysProvider>

View File

@@ -0,0 +1,81 @@
import UserAvatar from '@/components/UserAvatar'
import { formatTimestamp } from '@/lib/timestamp'
import { cn } from '@/lib/utils'
import client from '@/services/client.service'
import { TConversation, TProfile } from '@/types'
import { Lock, Users } from 'lucide-react'
import { useEffect, useState } from 'react'
interface ConversationItemProps {
conversation: TConversation
isActive: boolean
isFollowing: boolean
onClick: () => void
}
export default function ConversationItem({
conversation,
isActive,
isFollowing,
onClick
}: ConversationItemProps) {
const [profile, setProfile] = useState<TProfile | null>(null)
useEffect(() => {
const fetchProfileData = async () => {
try {
const profileData = await client.fetchProfile(conversation.partnerPubkey)
if (profileData) {
setProfile(profileData)
}
} catch (error) {
console.error('Failed to fetch profile:', error)
}
}
fetchProfileData()
}, [conversation.partnerPubkey])
const displayName = profile?.username || conversation.partnerPubkey.slice(0, 8) + '...'
const formattedTime = formatTimestamp(conversation.lastMessageAt)
return (
<button
onClick={onClick}
className={cn(
'w-full flex items-start gap-3 p-3 hover:bg-accent/50 transition-colors text-left',
isActive && 'bg-accent'
)}
>
<UserAvatar userId={conversation.partnerPubkey} className="size-10 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className="font-medium text-sm truncate">{displayName}</span>
{isFollowing && (
<span className="text-xs text-primary flex-shrink-0" title="Following">
<Users className="size-3" />
</span>
)}
</div>
<span className="text-xs text-muted-foreground flex-shrink-0">{formattedTime}</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{conversation.preferredEncryption === 'nip17' && (
<span title="NIP-17 encrypted">
<Lock className="size-3 text-green-500 flex-shrink-0" />
</span>
)}
<p className="text-sm text-muted-foreground truncate">{conversation.lastMessagePreview}</p>
</div>
{conversation.unreadCount > 0 && (
<span className="inline-flex items-center justify-center size-5 text-xs rounded-full bg-primary text-primary-foreground mt-1">
{conversation.unreadCount}
</span>
)}
</div>
</button>
)
}

View File

@@ -0,0 +1,145 @@
import { useDM } from '@/providers/DMProvider'
import { useFollowList } from '@/providers/FollowListProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import storage from '@/services/local-storage.service'
import { Check, Loader2, MessageSquare, MoreVertical, RefreshCw } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import { ScrollArea } from '../ui/scroll-area'
import ConversationItem from './ConversationItem'
export default function ConversationList() {
const { t } = useTranslation()
const {
conversations,
currentConversation,
selectConversation,
refreshConversations,
loadMoreConversations,
hasMoreConversations,
isLoading
} = useDM()
const { followingSet } = useFollowList()
const { mutePubkeySet } = useMuteList()
const loadMoreRef = useRef<HTMLDivElement>(null)
const [filterMode, setFilterMode] = useState<'all' | 'follows'>(() =>
storage.getDMConversationFilter()
)
// Filter and sort conversations
const sortedConversations = useMemo(() => {
let filtered = [...conversations]
if (filterMode === 'follows') {
// Only show conversations from follows, and hide muted users
filtered = filtered.filter(
(c) => followingSet.has(c.partnerPubkey) && !mutePubkeySet.has(c.partnerPubkey)
)
}
return filtered.sort((a, b) => b.lastMessageAt - a.lastMessageAt)
}, [conversations, filterMode, followingSet, mutePubkeySet])
const handleFilterChange = (mode: 'all' | 'follows') => {
setFilterMode(mode)
storage.setDMConversationFilter(mode)
}
// Infinite scroll: load more when sentinel is visible
const handleIntersection = useCallback(
(entries: IntersectionObserverEntry[]) => {
const [entry] = entries
if (entry.isIntersecting && hasMoreConversations && !isLoading) {
loadMoreConversations()
}
},
[hasMoreConversations, isLoading, loadMoreConversations]
)
useEffect(() => {
const observer = new IntersectionObserver(handleIntersection, {
root: null,
rootMargin: '100px',
threshold: 0
})
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current)
}
return () => observer.disconnect()
}, [handleIntersection])
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b">
<span className="font-medium text-sm">{t('Conversations')}</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={refreshConversations}
disabled={isLoading}
>
<RefreshCw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleFilterChange('follows')}>
{filterMode === 'follows' && <Check className="size-4 mr-2" />}
<span className={filterMode !== 'follows' ? 'ml-6' : ''}>
{t('Only show follows')}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleFilterChange('all')}>
{filterMode === 'all' && <Check className="size-4 mr-2" />}
<span className={filterMode !== 'all' ? 'ml-6' : ''}>{t('Show all')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<ScrollArea className="flex-1">
{sortedConversations.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center h-48 gap-2 text-muted-foreground px-4">
<MessageSquare className="size-8" />
<p className="text-sm text-center">{t('No conversations yet')}</p>
<p className="text-xs text-center">{t('Start a conversation by visiting a profile')}</p>
</div>
) : (
<div className="divide-y">
{sortedConversations.map((conversation) => (
<ConversationItem
key={conversation.partnerPubkey}
conversation={conversation}
isActive={currentConversation === conversation.partnerPubkey}
isFollowing={followingSet.has(conversation.partnerPubkey)}
onClick={() => selectConversation(conversation.partnerPubkey)}
/>
))}
{/* Sentinel element for infinite scroll */}
{hasMoreConversations && (
<div ref={loadMoreRef} className="flex justify-center py-4">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,313 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { TRelayList } from '@/types'
import { Check, Loader2, Lock, LockOpen, User, Users, Zap } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
type EncryptionPreference = 'auto' | 'nip04' | 'nip17'
interface ConversationSettingsModalProps {
partnerPubkey: string | null
open: boolean
onOpenChange: (open: boolean) => void
selectedRelays: string[]
onSelectedRelaysChange: (relays: string[]) => void
}
type RelayInfo = {
url: string
isYours: boolean
isTheirs: boolean
isShared: boolean
}
export default function ConversationSettingsModal({
partnerPubkey,
open,
onOpenChange,
selectedRelays,
onSelectedRelaysChange
}: ConversationSettingsModalProps) {
const { t } = useTranslation()
const { pubkey, relayList: myRelayList, hasNip44Support } = useNostr()
const [partnerRelayList, setPartnerRelayList] = useState<TRelayList | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [relays, setRelays] = useState<RelayInfo[]>([])
const [encryptionPreference, setEncryptionPreference] = useState<EncryptionPreference>('auto')
// Fetch partner's relay list when modal opens
useEffect(() => {
if (!open || !partnerPubkey) return
const fetchPartnerRelays = async () => {
setIsLoading(true)
try {
const relayList = await client.fetchRelayList(partnerPubkey)
setPartnerRelayList(relayList)
} catch (error) {
console.error('Failed to fetch partner relay list:', error)
} finally {
setIsLoading(false)
}
}
fetchPartnerRelays()
}, [open, partnerPubkey])
// Load encryption preference when modal opens
useEffect(() => {
if (!open || !partnerPubkey || !pubkey) return
const loadEncryptionPreference = async () => {
const saved = await indexedDb.getConversationEncryptionPreference(pubkey, partnerPubkey)
setEncryptionPreference(saved || 'auto')
}
loadEncryptionPreference()
}, [open, partnerPubkey, pubkey])
// Save encryption preference when it changes
const handleEncryptionChange = async (value: EncryptionPreference) => {
setEncryptionPreference(value)
if (pubkey && partnerPubkey) {
await indexedDb.putConversationEncryptionPreference(pubkey, partnerPubkey, value)
}
}
// Build relay list when data is available
useEffect(() => {
if (!myRelayList || !partnerRelayList) return
const myWriteRelays = new Set(myRelayList.write.map((r) => r.replace(/\/$/, '')))
const theirReadRelays = new Set(partnerRelayList.read.map((r) => r.replace(/\/$/, '')))
// Combine all relays
const allRelayUrls = new Set<string>()
myRelayList.write.forEach((r) => allRelayUrls.add(r.replace(/\/$/, '')))
partnerRelayList.read.forEach((r) => allRelayUrls.add(r.replace(/\/$/, '')))
const relayInfos: RelayInfo[] = Array.from(allRelayUrls).map((url) => {
const normalizedUrl = url.replace(/\/$/, '')
const isYours = myWriteRelays.has(normalizedUrl)
const isTheirs = theirReadRelays.has(normalizedUrl)
return {
url,
isYours,
isTheirs,
isShared: isYours && isTheirs
}
})
// Sort: shared first, then yours, then theirs
relayInfos.sort((a, b) => {
if (a.isShared && !b.isShared) return -1
if (!a.isShared && b.isShared) return 1
if (a.isYours && !b.isYours) return -1
if (!a.isYours && b.isYours) return 1
return a.url.localeCompare(b.url)
})
setRelays(relayInfos)
// If no relays selected yet, default to shared relays
if (selectedRelays.length === 0) {
const sharedRelays = relayInfos.filter((r) => r.isShared).map((r) => r.url)
if (sharedRelays.length > 0) {
onSelectedRelaysChange(sharedRelays)
}
}
}, [myRelayList, partnerRelayList])
const toggleRelay = (url: string) => {
if (selectedRelays.includes(url)) {
onSelectedRelaysChange(selectedRelays.filter((r) => r !== url))
} else {
onSelectedRelaysChange([...selectedRelays, url])
}
}
const selectAllShared = () => {
const sharedUrls = relays.filter((r) => r.isShared).map((r) => r.url)
onSelectedRelaysChange(sharedUrls)
}
const selectAll = () => {
onSelectedRelaysChange(relays.map((r) => r.url))
}
const formatRelayUrl = (url: string) => {
try {
const parsed = new URL(url)
return parsed.hostname + (parsed.pathname !== '/' ? parsed.pathname : '')
} catch {
return url
}
}
if (!partnerPubkey || !pubkey) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t('Conversation Settings')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col gap-4">
{/* Encryption Preference */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('Encryption')}</Label>
<RadioGroup
value={encryptionPreference}
onValueChange={(value) => handleEncryptionChange(value as EncryptionPreference)}
className="grid grid-cols-3 gap-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="auto" id="enc-auto" />
<Label
htmlFor="enc-auto"
className="flex items-center gap-1 text-xs cursor-pointer"
>
<Zap className="size-3" />
{t('Auto')}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="nip04" id="enc-nip04" />
<Label
htmlFor="enc-nip04"
className="flex items-center gap-1 text-xs cursor-pointer"
>
<LockOpen className="size-3" />
NIP-04
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="nip17"
id="enc-nip17"
disabled={!hasNip44Support}
/>
<Label
htmlFor="enc-nip17"
className={`flex items-center gap-1 text-xs cursor-pointer ${!hasNip44Support ? 'opacity-50' : ''}`}
>
<Lock className="size-3" />
NIP-17
</Label>
</div>
</RadioGroup>
<p className="text-xs text-muted-foreground">
{encryptionPreference === 'auto'
? t('Matches existing conversation encryption, or sends both on first message')
: encryptionPreference === 'nip04'
? t('Classic encryption (NIP-04) - compatible with all clients')
: t('Modern encryption (NIP-17) - more private with metadata protection')}
</p>
</div>
<div className="border-t pt-4">
<Label className="text-sm font-medium">{t('Relays')}</Label>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<User className="size-3" />
<span>{t('You')}</span>
</div>
<div className="flex items-center gap-1">
<Users className="size-3" />
<span>{t('Them')}</span>
</div>
<div className="flex items-center gap-1">
<div className="size-3 rounded bg-green-500/20 border border-green-500/50" />
<span>{t('Shared')}</span>
</div>
<div className="flex items-center gap-1">
<Check className="size-3" />
<span>{t('Selected for sending')}</span>
</div>
</div>
{/* Quick actions */}
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={selectAllShared}>
{t('Select shared')}
</Button>
<Button variant="outline" size="sm" onClick={selectAll}>
{t('Select all')}
</Button>
</div>
{/* Relay list */}
<div className="flex-1 overflow-y-auto space-y-1 min-h-0">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : relays.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
{t('No relay information available')}
</p>
) : (
relays.map((relay) => (
<div
key={relay.url}
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer hover:bg-accent/50 transition-colors ${
relay.isShared ? 'bg-green-500/10 border border-green-500/30' : 'bg-muted/50'
}`}
onClick={() => toggleRelay(relay.url)}
>
<Checkbox
checked={selectedRelays.includes(relay.url)}
onCheckedChange={() => toggleRelay(relay.url)}
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-mono truncate block" title={relay.url}>
{formatRelayUrl(relay.url)}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{relay.isYours && (
<span
className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-600 dark:text-blue-400"
title={t('Your write relay')}
>
<User className="size-3" />
</span>
)}
{relay.isTheirs && (
<span
className="text-xs px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-600 dark:text-purple-400"
title={t('Their read relay')}
>
<Users className="size-3" />
</span>
)}
</div>
</div>
))
)}
</div>
{/* Info text */}
<p className="text-xs text-muted-foreground">
{t('Selected relays will be used when sending new messages in this conversation.')}
</p>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,80 @@
import { useDM } from '@/providers/DMProvider'
import { Loader2, RefreshCw } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ConversationList from './ConversationList'
import MessageView from './MessageView'
import { Button } from '../ui/button'
export default function InboxContent() {
const { t } = useTranslation()
const { isLoading, error, refreshConversations, currentConversation, selectConversation } =
useDM()
const [isMobileView, setIsMobileView] = useState(false)
useEffect(() => {
const checkMobile = () => {
setIsMobileView(window.innerWidth < 768)
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
if (isLoading && !currentConversation) {
return (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="size-8 animate-spin" />
<span className="text-sm">{t('Loading messages...')}</span>
</div>
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-64 gap-4 text-muted-foreground">
<p>{error}</p>
<Button onClick={refreshConversations} variant="outline" size="sm" className="gap-2">
<RefreshCw className="size-4" />
{t('Retry')}
</Button>
</div>
)
}
// Mobile view: show either list or conversation
if (isMobileView) {
if (currentConversation) {
return (
<div className="h-[calc(100vh-8rem)]">
<MessageView onBack={() => selectConversation(null)} />
</div>
)
}
return (
<div className="h-[calc(100vh-8rem)]">
<ConversationList />
</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>
)
}

View File

@@ -0,0 +1,77 @@
import { useDM } from '@/providers/DMProvider'
import { AlertCircle, Loader2, Send } from 'lucide-react'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../ui/button'
import { Textarea } from '../ui/textarea'
export default function MessageComposer() {
const { t } = useTranslation()
const { sendMessage, currentConversation } = useDM()
const [message, setMessage] = useState('')
const [isSending, setIsSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleSend = async () => {
if (!message.trim() || !currentConversation || isSending) return
setIsSending(true)
setError(null)
try {
await sendMessage(message.trim())
setMessage('')
// Return focus to input after sending
textareaRef.current?.focus()
} catch (err) {
console.error('Failed to send message:', err)
setError(err instanceof Error ? err.message : t('Failed to send message'))
} finally {
setIsSending(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
return (
<div className="p-3 space-y-2">
{error && (
<div className="flex items-center gap-2 text-destructive text-xs">
<AlertCircle className="size-3 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<div className="flex items-end gap-2">
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => {
setMessage(e.target.value)
if (error) setError(null)
}}
onKeyDown={handleKeyDown}
placeholder={t('Type a message...')}
className="min-h-[40px] max-h-32 resize-none"
disabled={isSending || !currentConversation}
/>
<Button
onClick={handleSend}
disabled={!message.trim() || isSending || !currentConversation}
size="icon"
className="flex-shrink-0"
>
{isSending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,133 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import dmService from '@/services/dm.service'
import { TDirectMessage } from '@/types'
import { Loader2, RefreshCw, Server } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
interface MessageInfoModalProps {
message: TDirectMessage | null
open: boolean
onOpenChange: (open: boolean) => void
onRelaysUpdated?: (relays: string[]) => void
}
export default function MessageInfoModal({
message,
open,
onOpenChange,
onRelaysUpdated
}: MessageInfoModalProps) {
const { t } = useTranslation()
const [isChecking, setIsChecking] = useState(false)
const [additionalRelays, setAdditionalRelays] = useState<string[]>([])
if (!message) return null
const allRelays = [...(message.seenOnRelays || []), ...additionalRelays]
const uniqueRelays = [...new Set(allRelays)]
const handleCheckOtherRelays = async () => {
setIsChecking(true)
try {
const foundRelays = await dmService.checkOtherRelaysForEvent(
message.id,
uniqueRelays
)
if (foundRelays.length > 0) {
const newRelays = [...additionalRelays, ...foundRelays]
setAdditionalRelays(newRelays)
onRelaysUpdated?.([...(message.seenOnRelays || []), ...newRelays])
}
} catch (error) {
console.error('Failed to check other relays:', error)
} finally {
setIsChecking(false)
}
}
const formatRelayUrl = (url: string) => {
try {
const parsed = new URL(url)
return parsed.hostname + (parsed.pathname !== '/' ? parsed.pathname : '')
} catch {
return url
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Server className="size-4" />
{t('Message Info')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Protocol */}
<div>
<span className="text-sm font-medium text-muted-foreground">
{t('Encryption')}
</span>
<p className="text-sm mt-1">
{message.encryptionType === 'nip17' ? 'NIP-44 (Gift Wrap)' : 'NIP-04 (Legacy)'}
</p>
</div>
{/* Relays */}
<div>
<span className="text-sm font-medium text-muted-foreground">
{t('Seen on relays')}
</span>
{uniqueRelays.length > 0 ? (
<ul className="mt-1 space-y-1">
{uniqueRelays.map((relay) => (
<li
key={relay}
className="text-sm font-mono bg-muted px-2 py-1 rounded truncate"
title={relay}
>
{formatRelayUrl(relay)}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground mt-1">
{t('No relay information available')}
</p>
)}
</div>
{/* Check other relays button */}
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleCheckOtherRelays}
disabled={isChecking}
>
{isChecking ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
{t('Checking...')}
</>
) : (
<>
<RefreshCw className="size-4 mr-2" />
{t('Check for other relays')}
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,400 @@
import UserAvatar from '@/components/UserAvatar'
import { formatTimestamp } from '@/lib/timestamp'
import { cn } from '@/lib/utils'
import { useDM } from '@/providers/DMProvider'
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 { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../ui/button'
import { ScrollArea } from '../ui/scroll-area'
import { Checkbox } from '../ui/checkbox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import MessageComposer from './MessageComposer'
import MessageInfoModal from './MessageInfoModal'
import ConversationSettingsModal from './ConversationSettingsModal'
import { useFollowList } from '@/providers/FollowListProvider'
interface MessageViewProps {
onBack?: () => void
}
export default function MessageView({ onBack }: MessageViewProps) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const {
currentConversation,
messages,
isLoadingConversation,
isNewConversation,
clearNewConversationFlag,
reloadConversation,
// Selection mode
selectedMessages,
isSelectionMode,
toggleMessageSelection,
clearSelection,
deleteSelectedMessages,
deleteAllInConversation,
undeleteAllInConversation
} = useDM()
const { followingSet } = useFollowList()
const [profile, setProfile] = useState<TProfile | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const [selectedMessage, setSelectedMessage] = useState<TDirectMessage | null>(null)
const [messageInfoOpen, setMessageInfoOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [selectedRelays, setSelectedRelays] = useState<string[]>([])
const [showPulse, setShowPulse] = useState(false)
const [showJumpButton, setShowJumpButton] = useState(false)
const [newMessageCount, setNewMessageCount] = useState(0)
const lastMessageCountRef = useRef(0)
const isAtBottomRef = useRef(true)
const isFollowing = currentConversation ? followingSet.has(currentConversation) : false
// Handle pulsing animation for new conversations
useEffect(() => {
if (isNewConversation) {
setShowPulse(true)
const timer = setTimeout(() => {
setShowPulse(false)
clearNewConversationFlag()
}, 10000)
return () => clearTimeout(timer)
}
}, [isNewConversation, clearNewConversationFlag])
useEffect(() => {
if (!currentConversation) return
const fetchProfileData = async () => {
try {
const profileData = await client.fetchProfile(currentConversation)
if (profileData) {
setProfile(profileData)
}
} catch (error) {
console.error('Failed to fetch profile:', error)
}
}
fetchProfileData()
}, [currentConversation])
// Load saved relay settings when conversation changes
useEffect(() => {
if (!currentConversation || !pubkey) return
const loadRelaySettings = async () => {
const saved = await indexedDb.getConversationRelaySettings(pubkey, currentConversation)
setSelectedRelays(saved || [])
}
loadRelaySettings()
}, [currentConversation, pubkey])
// Save relay settings when they change
const handleRelaysChange = async (relays: string[]) => {
setSelectedRelays(relays)
if (pubkey && currentConversation) {
await indexedDb.putConversationRelaySettings(pubkey, currentConversation, relays)
}
}
// Handle scroll position tracking
const handleScroll = () => {
if (!scrollRef.current) return
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const atBottom = distanceFromBottom < 100 // 100px threshold
isAtBottomRef.current = atBottom
setShowJumpButton(!atBottom)
// Reset new message count when user scrolls to bottom
if (atBottom) {
setNewMessageCount(0)
lastMessageCountRef.current = messages.length
}
}
// Track new messages when scrolled up
useEffect(() => {
if (!isAtBottomRef.current && messages.length > lastMessageCountRef.current) {
setNewMessageCount(messages.length - lastMessageCountRef.current)
} else if (isAtBottomRef.current) {
lastMessageCountRef.current = messages.length
}
}, [messages.length])
// Scroll to bottom when messages change (only if already at bottom)
useEffect(() => {
if (scrollRef.current && isAtBottomRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
lastMessageCountRef.current = messages.length
}
}, [messages])
// Scroll to bottom function
const scrollToBottom = () => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth'
})
setNewMessageCount(0)
lastMessageCountRef.current = messages.length
isAtBottomRef.current = true
setShowJumpButton(false)
}
}
// Reset scroll state when conversation changes
useEffect(() => {
isAtBottomRef.current = true
setShowJumpButton(false)
setNewMessageCount(0)
lastMessageCountRef.current = 0
}, [currentConversation])
if (!currentConversation || !pubkey) {
return null
}
const displayName = profile?.username || currentConversation.slice(0, 8) + '...'
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-3 p-3 border-b">
{isSelectionMode ? (
// Selection mode header
<>
<Button
variant="ghost"
size="icon"
onClick={clearSelection}
className="size-8"
title={t('Cancel')}
>
<X className="size-4" />
</Button>
<div className="flex items-center gap-2">
<Trash2 className="size-4 text-destructive" />
<span className="font-medium text-sm">{t('Delete')}</span>
</div>
<div className="flex-1" />
<Button
variant="outline"
size="sm"
onClick={deleteSelectedMessages}
disabled={selectedMessages.size === 0}
className="text-xs"
>
{t('Selected')} ({selectedMessages.size})
</Button>
<Button
variant="destructive"
size="sm"
onClick={deleteAllInConversation}
className="text-xs"
>
{t('All')}
</Button>
</>
) : (
// Normal header
<>
{onBack && (
<Button variant="ghost" size="icon" onClick={onBack} className="size-8">
<ArrowLeft className="size-4" />
</Button>
)}
<UserAvatar userId={currentConversation} className="size-8" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-sm truncate">{displayName}</span>
{isFollowing && (
<span title="Following">
<Users className="size-3 text-primary" />
</span>
)}
</div>
{profile?.nip05 && (
<span className="text-xs text-muted-foreground truncate">{profile.nip05}</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="size-8"
title={t('Reload messages')}
onClick={reloadConversation}
disabled={isLoadingConversation}
>
<RefreshCw className={cn('size-4', isLoadingConversation && 'animate-spin')} />
</Button>
<Button
variant="ghost"
size="icon"
className={cn('size-8', showPulse && 'animate-pulse ring-2 ring-primary ring-offset-2')}
title={t('Conversation settings')}
onClick={() => {
setShowPulse(false)
clearNewConversationFlag()
setSettingsOpen(true)
}}
>
<Settings className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={deleteAllInConversation} className="text-destructive focus:text-destructive">
<Trash2 className="size-4 mr-2" />
{t('Delete All')}
</DropdownMenuItem>
<DropdownMenuItem onClick={undeleteAllInConversation}>
<Undo2 className="size-4 mr-2" />
{t('Undelete All')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
{/* Messages */}
<div className="flex-1 relative overflow-hidden">
<ScrollArea ref={scrollRef} className="h-full p-3" onScrollCapture={handleScroll}>
{isLoadingConversation && messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p className="text-sm">{t('No messages yet. Send one to start the conversation!')}</p>
</div>
) : (
<div className="space-y-3">
{isLoadingConversation && (
<div className="flex justify-center py-2">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
{messages.map((message) => {
const isOwn = message.senderPubkey === pubkey
const isSelected = selectedMessages.has(message.id)
return (
<div
key={message.id}
className={cn(
'flex items-start gap-2 group',
isOwn ? 'flex-row-reverse' : 'flex-row'
)}
>
{/* Checkbox - shows on hover or when in selection mode */}
<div
className={cn(
'flex-shrink-0 transition-opacity',
isSelectionMode || isSelected
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
)}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleMessageSelection(message.id)}
className="mt-2"
/>
</div>
<div
className={cn(
'max-w-[80%] rounded-lg px-3 py-2',
isOwn
? 'bg-primary text-primary-foreground'
: 'bg-muted',
isSelected && 'ring-2 ring-primary ring-offset-2'
)}
>
<p className="text-sm whitespace-pre-wrap break-words">{message.content}</p>
<div
className={cn(
'flex items-center justify-between gap-2 mt-1 text-xs',
isOwn ? 'text-primary-foreground/70' : 'text-muted-foreground'
)}
>
<span>{formatTimestamp(message.createdAt)}</span>
<button
onClick={() => {
setSelectedMessage(message)
setMessageInfoOpen(true)
}}
className={cn(
'font-mono opacity-60 hover:opacity-100 transition-opacity',
isOwn ? 'hover:text-primary-foreground' : 'hover:text-foreground'
)}
title={t('Message info')}
>
{message.encryptionType === 'nip17' ? '44' : '4'}
</button>
</div>
</div>
</div>
)
})}
</div>
)}
</ScrollArea>
{/* Jump to newest button */}
{showJumpButton && (
<Button
onClick={scrollToBottom}
className="absolute bottom-4 right-4 rounded-full shadow-lg size-10 p-0"
size="icon"
>
<ChevronDown className="size-5" />
{newMessageCount > 0 && (
<span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground rounded-full min-w-5 h-5 flex items-center justify-center text-xs font-medium px-1">
{newMessageCount > 99 ? '99+' : newMessageCount}
</span>
)}
</Button>
)}
</div>
{/* Composer */}
<div className="border-t">
<MessageComposer />
</div>
{/* Message Info Modal */}
<MessageInfoModal
message={selectedMessage}
open={messageInfoOpen}
onOpenChange={setMessageInfoOpen}
/>
{/* Conversation Settings Modal */}
<ConversationSettingsModal
partnerPubkey={currentConversation}
open={settingsOpen}
onOpenChange={setSettingsOpen}
selectedRelays={selectedRelays}
onSelectedRelaysChange={handleRelaysChange}
/>
</div>
)
}

View File

@@ -1,9 +1,11 @@
import { useSecondaryPage } from '@/PageManager'
import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
import { ExtendedKind, NSFW_DISPLAY_POLICY, SUPPORTED_KINDS } from '@/constants'
import { getParentStuff, isNsfwEvent } from '@/lib/event'
import { toExternalContent, toNote } from '@/lib/link'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useDM } from '@/providers/DMProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react'
@@ -18,6 +20,7 @@ import ParentNotePreview from '../ParentNotePreview'
import TrustScoreBadge from '../TrustScoreBadge'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import { Mail } from 'lucide-react'
import CommunityDefinition from './CommunityDefinition'
import EmojiPack from './EmojiPack'
import FollowPack from './FollowPack'
@@ -50,7 +53,10 @@ export default function Note({
showFull?: boolean
}) {
const { push } = useSecondaryPage()
const { navigate } = usePrimaryPage()
const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr()
const { startConversation } = useDM()
const { parentEventId, parentExternalContent } = useMemo(() => {
return getParentStuff(event)
}, [event])
@@ -58,6 +64,12 @@ export default function Note({
const [showNsfw, setShowNsfw] = useState(false)
const { mutePubkeySet } = useMuteList()
const [showMuted, setShowMuted] = useState(false)
const handleStartConversation = (e: React.MouseEvent) => {
e.stopPropagation()
startConversation(event.pubkey)
navigate('inbox')
}
const isNsfw = useMemo(
() => (nsfwDisplayPolicy === NSFW_DISPLAY_POLICY.SHOW ? false : isNsfwEvent(event)),
[event, nsfwDisplayPolicy]
@@ -134,6 +146,15 @@ export default function Note({
<FollowingBadge pubkey={event.pubkey} />
<TrustScoreBadge pubkey={event.pubkey} />
<ClientTag event={event} />
{pubkey && pubkey !== event.pubkey && (
<button
onClick={handleStartConversation}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title="Start conversation"
>
<Mail className="size-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={event.pubkey} append="·" />

View File

@@ -64,6 +64,7 @@ import {
KeyRound,
LayoutList,
List,
MessageSquare,
Monitor,
Moon,
Palette,
@@ -155,6 +156,9 @@ export default function Settings() {
// System settings
const [filterOutOnionRelays, setFilterOutOnionRelays] = useState(storage.getFilterOutOnionRelays())
// Messaging settings
const [preferNip44, setPreferNip44] = useState(storage.getPreferNip44())
const handleLanguageChange = (value: TLanguage) => {
i18n.changeLanguage(value)
setLanguage(value)
@@ -528,6 +532,37 @@ export default function Settings() {
</AccordionItem>
)}
{/* Messaging */}
{!!pubkey && (
<AccordionItem value="messaging">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<MessageSquare className="size-4" />
<span>{t('Messaging')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 space-y-4">
<SettingItem>
<Label htmlFor="prefer-nip44" className="text-base font-normal">
<div>{t('Prefer NIP-44 encryption')}</div>
<div className="text-muted-foreground text-sm">
{t('Use modern encryption for new conversations')}
</div>
</Label>
<Switch
id="prefer-nip44"
checked={preferNip44}
onCheckedChange={(checked) => {
storage.setPreferNip44(checked)
setPreferNip44(checked)
dispatchSettingsChanged()
}}
/>
</SettingItem>
</AccordionContent>
</AccordionItem>
)}
{/* System */}
<AccordionItem value="system">
<AccordionTrigger className="px-4 hover:no-underline">

View File

@@ -12,7 +12,7 @@ import { cn } from '@/lib/utils'
import { useSecondaryPage, useSidebarDrawer } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { LogIn, LogOut, Plus, Wallet } from 'lucide-react'
import { LogIn, LogOut, Plus, RefreshCw, Wallet } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import LoginDialog from '../LoginDialog'
@@ -139,6 +139,13 @@ function ProfileButton({ collapse }: { collapse: boolean }) {
className="text-muted-foreground border border-muted-foreground px-1 rounded-md text-xs truncate"
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => window.location.reload()}
>
<RefreshCw />
{t('Force Reload')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />

View File

@@ -0,0 +1,25 @@
import { usePrimaryPage } from '@/PageManager'
import { useDM } from '@/providers/DMProvider'
import { MessageSquare } from 'lucide-react'
import SidebarItem from './SidebarItem'
export default function InboxButton({ collapse }: { collapse: boolean }) {
const { navigate, current, display } = usePrimaryPage()
const { hasNewMessages } = useDM()
return (
<SidebarItem
title="Inbox"
onClick={() => navigate('inbox')}
active={display && current === 'inbox'}
collapse={collapse}
>
<div className="relative">
<MessageSquare />
{hasNewMessages && (
<div className="absolute -top-1 right-0 w-2 h-2 ring-2 ring-background bg-primary rounded-full" />
)}
</div>
</SidebarItem>
)
}

View File

@@ -10,6 +10,7 @@ import { ChevronsLeft, ChevronsRight } from 'lucide-react'
import AccountButton from './AccountButton'
import BookmarkButton from './BookmarkButton'
import HomeButton from './HomeButton'
import InboxButton from './InboxButton'
import LayoutSwitcher from './LayoutSwitcher'
import NotificationsButton from './NotificationButton'
import PostButton from './PostButton'
@@ -57,6 +58,7 @@ export default function PrimaryPageSidebar() {
<HomeButton collapse={isCollapsed} />
<NotificationsButton collapse={isCollapsed} />
<SearchButton collapse={isCollapsed} />
{pubkey && <InboxButton collapse={isCollapsed} />}
<ProfileButton collapse={isCollapsed} />
{pubkey && <BookmarkButton collapse={isCollapsed} />}
<SettingsButton collapse={isCollapsed} />

View File

@@ -7,6 +7,7 @@ import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
import AccountButton from '../Sidebar/AccountButton'
import BookmarkButton from '../Sidebar/BookmarkButton'
import HomeButton from '../Sidebar/HomeButton'
import InboxButton from '../Sidebar/InboxButton'
import LogoutButton from '../Sidebar/LogoutButton'
import NotificationsButton from '../Sidebar/NotificationButton'
import ProfileButton from '../Sidebar/ProfileButton'
@@ -52,6 +53,11 @@ export default function SidebarDrawer({ open, onOpenChange }: SidebarDrawerProps
<div onClick={handleItemClick}>
<NotificationsButton collapse={false} />
</div>
{pubkey && (
<div onClick={handleItemClick}>
<InboxButton collapse={false} />
</div>
)}
<div onClick={handleItemClick}>
<ProfileButton collapse={false} />
</div>

View File

@@ -43,6 +43,10 @@ export const StorageKey = {
QUICK_REACTION: 'quickReaction',
QUICK_REACTION_EMOJI: 'quickReactionEmoji',
NSFW_DISPLAY_POLICY: 'nsfwDisplayPolicy',
PREFER_NIP44: 'preferNip44',
DM_CONVERSATION_FILTER: 'dmConversationFilter',
DM_ENCRYPTION_PREFERENCES: 'dmEncryptionPreferences',
DM_LAST_SEEN_TIMESTAMP: 'dmLastSeenTimestamp',
DEFAULT_SHOW_NSFW: 'defaultShowNsfw', // deprecated
PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
@@ -58,7 +62,8 @@ export const StorageKey = {
export const ApplicationDataKey = {
NOTIFICATIONS_SEEN_AT: 'seen_notifications_at',
SETTINGS: 'smesh_settings'
SETTINGS: 'smesh_settings',
DM_DELETED_MESSAGES: 'dm_deleted_messages'
}
export const BIG_RELAY_URLS = [
@@ -69,6 +74,8 @@ export const BIG_RELAY_URLS = [
'wss://relay.orly.dev/'
]
export const ARCHIVE_RELAY_URL = 'wss://archive.orly.dev/'
export const SEARCHABLE_RELAY_URLS = [
'wss://search.nos.today/',
'wss://relay.ditto.pub/',
@@ -80,6 +87,8 @@ export const SEARCHABLE_RELAY_URLS = [
export const GROUP_METADATA_EVENT_KIND = 39000
export const ExtendedKind = {
SEAL: 13,
PRIVATE_DM: 14,
EXTERNAL_CONTENT_REACTION: 17,
PICTURE: 20,
VIDEO: 21,
@@ -88,6 +97,7 @@ export const ExtendedKind = {
POLL_RESPONSE: 1018,
COMMENT: 1111,
VOICE: 1222,
GIFT_WRAP: 1059,
VOICE_COMMENT: 1244,
PINNED_USERS: 10010,
FAVORITE_RELAYS: 10012,

View File

@@ -3,6 +3,7 @@ import client from '@/services/client.service'
import customEmojiService from '@/services/custom-emoji.service'
import mediaUpload from '@/services/media-upload.service'
import {
TDMDeletedState,
TDraftEvent,
TEmoji,
TMailboxRelay,
@@ -453,6 +454,15 @@ export function createSettingsDraftEvent(settings: TSyncSettings): TDraftEvent {
}
}
export function createDeletedMessagesDraftEvent(deletedState: TDMDeletedState): TDraftEvent {
return {
kind: kinds.Application,
content: JSON.stringify(deletedState),
tags: [buildDTag(ApplicationDataKey.DM_DELETED_MESSAGES)],
created_at: dayjs().unix()
}
}
export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraftEvent {
return {
kind: kinds.BookmarkList,

29
src/lib/timestamp.ts Normal file
View File

@@ -0,0 +1,29 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
/**
* Format a unix timestamp (seconds) to a relative time string.
* e.g., "5 minutes ago", "2 hours ago", "3 days ago"
*/
export function formatTimestamp(timestamp: number): string {
return dayjs.unix(timestamp).fromNow()
}
/**
* Format a unix timestamp to a short time string.
* Shows time for today, date for older messages.
*/
export function formatMessageTime(timestamp: number): string {
const date = dayjs.unix(timestamp)
const now = dayjs()
if (date.isSame(now, 'day')) {
return date.format('HH:mm')
} else if (date.isSame(now, 'year')) {
return date.format('MMM D')
} else {
return date.format('MMM D, YYYY')
}
}

View File

@@ -0,0 +1,64 @@
import InboxContent from '@/components/Inbox/InboxContent'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { useDM } from '@/providers/DMProvider'
import { useNostr } from '@/providers/NostrProvider'
import { TPageRef } from '@/types'
import { MessageSquare, LogIn } from 'lucide-react'
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { usePrimaryPage } from '@/PageManager'
import { Button } from '@/components/ui/button'
const InboxPage = forwardRef<TPageRef>((_, ref) => {
const { t } = useTranslation()
const layoutRef = useRef<TPageRef>(null)
const { pubkey } = useNostr()
const { navigate } = usePrimaryPage()
const { markInboxAsSeen } = useDM()
useImperativeHandle(ref, () => layoutRef.current as TPageRef)
// Mark inbox as seen when page is viewed
useEffect(() => {
if (pubkey) {
markInboxAsSeen()
}
}, [pubkey, markInboxAsSeen])
return (
<PrimaryPageLayout
pageName="inbox"
ref={layoutRef}
titlebar={<InboxTitlebar />}
displayScrollToTopButton
>
{pubkey ? (
<InboxContent />
) : (
<div className="flex flex-col items-center justify-center h-64 gap-4 text-muted-foreground">
<MessageSquare className="size-12" />
<div className="text-center">
<p className="font-medium">{t('Sign in to view your messages')}</p>
<p className="text-sm">{t('Your encrypted conversations will appear here')}</p>
</div>
<Button onClick={() => navigate('settings')} className="gap-2">
<LogIn className="size-4" />
{t('Sign In')}
</Button>
</div>
)}
</PrimaryPageLayout>
)
})
InboxPage.displayName = 'InboxPage'
export default InboxPage
function InboxTitlebar() {
const { t } = useTranslation()
return (
<div className="flex gap-2 items-center h-full pl-3">
<MessageSquare className="size-5" />
<div className="text-lg font-semibold">{t('Inbox')}</div>
</div>
)
}

View File

@@ -0,0 +1,779 @@
import { ApplicationDataKey, BIG_RELAY_URLS } from '@/constants'
import { createDeletedMessagesDraftEvent } from '@/lib/draft-event'
import dmService, { IDMEncryption, isConversationDeleted, isMessageDeleted } from '@/services/dm.service'
import indexedDb from '@/services/indexed-db.service'
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
import client from '@/services/client.service'
import { TConversation, TDirectMessage, TDMDeletedState, TDMEncryptionType } from '@/types'
import { Event, kinds } from 'nostr-tools'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useNostr } from './NostrProvider'
type TDMContext = {
conversations: TConversation[]
currentConversation: string | null
messages: TDirectMessage[]
isLoading: boolean
isLoadingConversation: boolean
error: string | null
selectConversation: (partnerPubkey: string | null) => void
startConversation: (partnerPubkey: string) => void
sendMessage: (content: string) => Promise<void>
refreshConversations: () => Promise<void>
reloadConversation: () => void
loadMoreConversations: () => Promise<void>
hasMoreConversations: boolean
preferNip44: boolean
setPreferNip44: (prefer: boolean) => void
isNewConversation: boolean
clearNewConversationFlag: () => void
// Unread tracking
totalUnreadCount: number
hasNewMessages: boolean
markInboxAsSeen: () => void
// Selection mode
selectedMessages: Set<string>
isSelectionMode: boolean
toggleMessageSelection: (messageId: string) => void
selectAllMessages: () => void
clearSelection: () => void
// Deletion
deleteSelectedMessages: () => Promise<void>
deleteAllInConversation: () => Promise<void>
undeleteAllInConversation: () => Promise<void>
}
const DMContext = createContext<TDMContext | undefined>(undefined)
export const useDM = () => {
const context = useContext(DMContext)
if (!context) {
throw new Error('useDM must be used within a DMProvider')
}
return context
}
export function DMProvider({ children }: { children: React.ReactNode }) {
const {
pubkey,
relayList,
nip04Encrypt,
nip04Decrypt,
nip44Encrypt,
nip44Decrypt,
hasNip44Support,
signEvent
} = useNostr()
const [conversations, setConversations] = useState<TConversation[]>([])
const [allConversations, setAllConversations] = useState<TConversation[]>([])
const [currentConversation, setCurrentConversation] = useState<string | null>(null)
const [messages, setMessages] = useState<TDirectMessage[]>([])
const [conversationMessages, setConversationMessages] = useState<Map<string, TDirectMessage[]>>(
new Map()
)
const [loadedConversations, setLoadedConversations] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(false)
const [isLoadingConversation, setIsLoadingConversation] = useState(false)
const [error, setError] = useState<string | null>(null)
const [preferNip44, setPreferNip44State] = useState(() => storage.getPreferNip44())
const [hasMoreConversations, setHasMoreConversations] = useState(false)
const [isNewConversation, setIsNewConversation] = useState(false)
const [deletedState, setDeletedState] = useState<TDMDeletedState | null>(null)
const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set())
const [isSelectionMode, setIsSelectionMode] = useState(false)
const [lastSeenTimestamp, setLastSeenTimestamp] = useState<number>(() =>
pubkey ? storage.getDMLastSeenTimestamp(pubkey) : 0
)
const CONVERSATIONS_PER_PAGE = 100
// Track which conversation load is in progress to prevent race conditions
const loadingConversationRef = useRef<string | null>(null)
// Create encryption wrapper object for dm.service
const encryption: IDMEncryption | null = useMemo(() => {
if (!pubkey) return null
return {
nip04Encrypt,
nip04Decrypt,
nip44Encrypt: hasNip44Support ? nip44Encrypt : undefined,
nip44Decrypt: hasNip44Support ? nip44Decrypt : undefined,
signEvent,
getPublicKey: () => pubkey
}
}, [pubkey, nip04Encrypt, nip04Decrypt, nip44Encrypt, nip44Decrypt, hasNip44Support, signEvent])
// Load deleted state and conversations when user is logged in
useEffect(() => {
if (pubkey && encryption) {
// Load deleted state FIRST before anything else
const loadDeletedStateAndConversations = async () => {
// Step 1: Load deleted state from IndexedDB
let currentDeletedState: TDMDeletedState = { deletedIds: [], deletedRanges: {} }
const cached = await indexedDb.getDeletedMessagesState(pubkey)
if (cached) {
currentDeletedState = cached
setDeletedState(cached)
} else {
setDeletedState(currentDeletedState)
}
// Step 2: Fetch from relays (kind 30078 Application Specific Data) - this takes priority
try {
const relayUrls = relayList?.read || BIG_RELAY_URLS
const events = await client.fetchEvents(relayUrls, {
kinds: [kinds.Application],
authors: [pubkey],
'#d': [ApplicationDataKey.DM_DELETED_MESSAGES],
limit: 1
})
if (events.length > 0) {
const event = events[0]
try {
const parsedState = JSON.parse(event.content) as TDMDeletedState
currentDeletedState = parsedState
setDeletedState(parsedState)
await indexedDb.putDeletedMessagesState(pubkey, parsedState)
} catch {
// Invalid JSON, ignore
}
}
} catch {
// Relay fetch failed, use cached
}
// Step 3: Load cached conversations (filtered by deleted state)
const cachedConvs = await indexedDb.getDMConversations(pubkey)
if (cachedConvs.length > 0) {
const conversations: TConversation[] = cachedConvs
.filter((c) => c.partnerPubkey && typeof c.partnerPubkey === 'string')
.filter((c) => !isConversationDeleted(c.partnerPubkey, c.lastMessageAt, currentDeletedState))
.map((c) => ({
partnerPubkey: c.partnerPubkey,
lastMessageAt: c.lastMessageAt,
lastMessagePreview: c.lastMessagePreview || '',
unreadCount: 0,
preferredEncryption: c.encryptionType
}))
setAllConversations(conversations)
setConversations(conversations.slice(0, CONVERSATIONS_PER_PAGE))
setHasMoreConversations(conversations.length > CONVERSATIONS_PER_PAGE)
}
// Step 4: Refresh from network
refreshConversations()
}
loadDeletedStateAndConversations()
} else {
setConversations([])
setAllConversations([])
setMessages([])
setConversationMessages(new Map())
setLoadedConversations(new Set())
setCurrentConversation(null)
setDeletedState(null)
setSelectedMessages(new Set())
setIsSelectionMode(false)
}
}, [pubkey, encryption])
// Load full conversation when selected
useEffect(() => {
if (!currentConversation || !pubkey || !encryption) {
setMessages([])
loadingConversationRef.current = null
return
}
// Capture the conversation we're loading to detect stale updates
const targetConversation = currentConversation
loadingConversationRef.current = targetConversation
// Check if we already have messages in memory for this conversation
const existing = conversationMessages.get(targetConversation)
if (existing) {
setMessages(existing)
// If already fully loaded, don't fetch again
if (loadedConversations.has(targetConversation)) {
return
}
}
// Load full conversation history
const loadConversation = async () => {
setIsLoadingConversation(true)
try {
// First, try to load from IndexedDB cache for instant display
const cached = await indexedDb.getConversationMessages(pubkey, targetConversation)
if (cached && cached.length > 0 && loadingConversationRef.current === targetConversation) {
const cachedMessages: TDirectMessage[] = cached
.filter(
(m) => !isMessageDeleted(m.id, targetConversation, m.createdAt, deletedState)
)
.map((m) => ({
id: m.id,
senderPubkey: m.senderPubkey,
recipientPubkey: m.recipientPubkey,
content: m.content,
createdAt: m.createdAt,
encryptionType: m.encryptionType,
event: {} as Event,
decryptedContent: m.content,
seenOnRelays: m.seenOnRelays
}))
setMessages(cachedMessages)
setConversationMessages((prev) => new Map(prev).set(targetConversation, cachedMessages))
}
// Then fetch fresh from relays
const relayUrls = relayList?.read || []
const events = await dmService.fetchConversationEvents(pubkey, targetConversation, relayUrls)
// Check if user switched to a different conversation while we were loading
if (loadingConversationRef.current !== targetConversation) {
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))
)
// 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)
})
// 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)
// Update state only if still on same conversation
setConversationMessages((prev) => new Map(prev).set(targetConversation, sorted))
setLoadedConversations((prev) => new Set(prev).add(targetConversation))
setMessages(sorted)
// Cache messages to IndexedDB (without the full event object)
const toCache = sorted.map((m) => ({
id: m.id,
senderPubkey: m.senderPubkey,
recipientPubkey: m.recipientPubkey,
content: m.decryptedContent || m.content,
createdAt: m.createdAt,
encryptionType: m.encryptionType,
seenOnRelays: m.seenOnRelays
}))
await indexedDb.putConversationMessages(pubkey, targetConversation, toCache)
} catch {
// Failed to load conversation
} finally {
// Only clear loading state if this is still the active load
if (loadingConversationRef.current === targetConversation) {
setIsLoadingConversation(false)
}
}
}
loadConversation()
}, [currentConversation, pubkey, encryption, relayList, deletedState])
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
await indexedDb.clearAllDMCaches()
// 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 list from NIP-04 events immediately (no decryption needed)
const conversationMap = dmService.groupEventsIntoConversations(nip04Events, pubkey)
// Show NIP-04 conversations immediately (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()
setIsLoading(false) // Stop spinner, but continue processing in background
// Sort gift wraps by created_at descending (newest first)
const sortedGiftWraps = giftWraps.sort((a, b) => b.created_at - a.created_at)
// Process gift wraps one by one in the background (progressive loading)
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'
})
// Update UI progressively
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 {
setError('Failed to load conversations')
setIsLoading(false)
}
}, [pubkey, encryption, relayList, deletedState])
const loadMoreConversations = useCallback(async () => {
if (!hasMoreConversations) return
const currentCount = conversations.length
const nextBatch = allConversations.slice(currentCount, currentCount + CONVERSATIONS_PER_PAGE)
setConversations((prev) => [...prev, ...nextBatch])
setHasMoreConversations(currentCount + nextBatch.length < allConversations.length)
}, [conversations.length, allConversations, hasMoreConversations])
const selectConversation = useCallback(
(partnerPubkey: string | null) => {
// Clear messages immediately to prevent showing old conversation
if (partnerPubkey !== currentConversation) {
setMessages([])
}
setCurrentConversation(partnerPubkey)
},
[currentConversation]
)
// Start a new conversation - marks it as new for UI effects (pulsing settings button)
const startConversation = useCallback(
(partnerPubkey: string) => {
// Check if this is a new conversation (not in existing list)
const existingConversation = allConversations.find(
(c) => c.partnerPubkey === partnerPubkey
)
if (!existingConversation) {
setIsNewConversation(true)
}
// Clear messages and select the conversation
setMessages([])
setCurrentConversation(partnerPubkey)
},
[allConversations]
)
const clearNewConversationFlag = useCallback(() => {
setIsNewConversation(false)
}, [])
// Reload the current conversation by clearing its cached state
const reloadConversation = useCallback(() => {
if (!currentConversation) return
// Clear the loaded state and cached messages for this conversation
setLoadedConversations((prev) => {
const next = new Set(prev)
next.delete(currentConversation)
return next
})
setConversationMessages((prev) => {
const next = new Map(prev)
next.delete(currentConversation)
return next
})
// Clear current messages to trigger a reload
setMessages([])
}, [currentConversation])
const sendMessage = useCallback(
async (content: string) => {
if (!pubkey || !encryption || !currentConversation) {
throw new Error('Cannot send message: not logged in or no conversation selected')
}
const relayUrls = relayList?.write || []
// Find existing encryption type for this conversation
const conversation = conversations.find((c) => c.partnerPubkey === currentConversation)
const existingEncryptionType: TDMEncryptionType | null =
conversation?.preferredEncryption ?? null
// Check for conversation-specific encryption preference
const encryptionPref = await indexedDb.getConversationEncryptionPreference(
pubkey,
currentConversation
)
// Determine the encryption to use based on preference
let effectiveEncryption: TDMEncryptionType | null = existingEncryptionType
if (encryptionPref === 'nip04') {
effectiveEncryption = 'nip04'
} else if (encryptionPref === 'nip17') {
effectiveEncryption = 'nip17'
}
// 'auto' keeps the existing behavior (match conversation or send both)
// Send the message
const sentEvents = await dmService.sendDM(
currentConversation,
content,
encryption,
relayUrls,
preferNip44,
effectiveEncryption
)
// Create local message for immediate display
const now = Math.floor(Date.now() / 1000)
// Determine the actual encryption type used for the message
const usedEncryptionType: TDMEncryptionType =
effectiveEncryption || (preferNip44 ? 'nip17' : 'nip04')
const newMessage: TDirectMessage = {
id: sentEvents[0]?.id || `local-${now}`,
senderPubkey: pubkey,
recipientPubkey: currentConversation,
content,
createdAt: now,
encryptionType: usedEncryptionType,
event: sentEvents[0] || ({} as Event),
decryptedContent: content
}
// Add to messages for this conversation
setConversationMessages((prev) => {
const existing = prev.get(currentConversation) || []
return new Map(prev).set(currentConversation, [...existing, newMessage])
})
setMessages((prev) => [...prev, newMessage])
// Update conversation
setConversations((prev) => {
const existing = prev.find((c) => c.partnerPubkey === currentConversation)
if (existing) {
return prev.map((c) =>
c.partnerPubkey === currentConversation
? {
...c,
lastMessageAt: now,
lastMessagePreview: content.substring(0, 100),
preferredEncryption: usedEncryptionType
}
: c
)
} else {
return [
{
partnerPubkey: currentConversation,
lastMessageAt: now,
lastMessagePreview: content.substring(0, 100),
unreadCount: 0,
preferredEncryption: usedEncryptionType
},
...prev
]
}
})
},
[pubkey, encryption, currentConversation, relayList, conversations, preferNip44]
)
const setPreferNip44 = useCallback((prefer: boolean) => {
setPreferNip44State(prefer)
storage.setPreferNip44(prefer)
dispatchSettingsChanged()
}, [])
// Selection mode methods
const toggleMessageSelection = useCallback((messageId: string) => {
setSelectedMessages((prev) => {
const next = new Set(prev)
if (next.has(messageId)) {
next.delete(messageId)
// Exit selection mode if nothing selected
if (next.size === 0) {
setIsSelectionMode(false)
}
} else {
next.add(messageId)
// Enter selection mode when first message selected
if (!isSelectionMode) {
setIsSelectionMode(true)
}
}
return next
})
}, [isSelectionMode])
const selectAllMessages = useCallback(() => {
const allIds = new Set(messages.map((m) => m.id))
setSelectedMessages(allIds)
setIsSelectionMode(true)
}, [messages])
const clearSelection = useCallback(() => {
setSelectedMessages(new Set())
setIsSelectionMode(false)
}, [])
// Helper to publish deleted state to relays
const publishDeletedState = useCallback(
async (newState: TDMDeletedState) => {
if (!pubkey || !encryption) return
// Save to IndexedDB
await indexedDb.putDeletedMessagesState(pubkey, newState)
// Publish to relays
const relayUrls = relayList?.write || BIG_RELAY_URLS
const draftEvent = createDeletedMessagesDraftEvent(newState)
const signedEvent = await encryption.signEvent(draftEvent)
await client.publishEvent(relayUrls, signedEvent)
},
[pubkey, encryption, relayList]
)
// Delete selected messages (soft delete only - no kind 5, so undelete always works)
const deleteSelectedMessages = useCallback(async () => {
if (!pubkey || selectedMessages.size === 0) return
const messageIds = Array.from(selectedMessages)
// Update deleted state
const newDeletedState: TDMDeletedState = {
deletedIds: [...(deletedState?.deletedIds || []), ...messageIds],
deletedRanges: deletedState?.deletedRanges || {}
}
setDeletedState(newDeletedState)
// Remove from UI
setMessages((prev) => prev.filter((m) => !selectedMessages.has(m.id)))
if (currentConversation) {
setConversationMessages((prev) => {
const existing = prev.get(currentConversation) || []
return new Map(prev).set(
currentConversation,
existing.filter((m) => !selectedMessages.has(m.id))
)
})
}
// Clear selection
setSelectedMessages(new Set())
setIsSelectionMode(false)
// Publish to relays
await publishDeletedState(newDeletedState)
}, [pubkey, selectedMessages, deletedState, currentConversation, publishDeletedState])
// Delete all messages in current conversation (timestamp range)
const deleteAllInConversation = useCallback(async () => {
if (!pubkey || !currentConversation) return
const now = Math.floor(Date.now() / 1000)
const newRange = { start: 0, end: now }
// Update deleted state with new range
const newDeletedState: TDMDeletedState = {
deletedIds: deletedState?.deletedIds || [],
deletedRanges: {
...(deletedState?.deletedRanges || {}),
[currentConversation]: [
...(deletedState?.deletedRanges[currentConversation] || []),
newRange
]
}
}
setDeletedState(newDeletedState)
// Clear messages from UI
setMessages([])
setConversationMessages((prev) => {
const next = new Map(prev)
next.delete(currentConversation)
return next
})
// Remove conversation from list
setConversations((prev) => prev.filter((c) => c.partnerPubkey !== currentConversation))
setAllConversations((prev) => prev.filter((c) => c.partnerPubkey !== currentConversation))
// Clear selection and close conversation
setSelectedMessages(new Set())
setIsSelectionMode(false)
setCurrentConversation(null)
// Publish to relays
await publishDeletedState(newDeletedState)
}, [pubkey, currentConversation, deletedState, publishDeletedState])
// Undelete all messages in current conversation (remove delete markers)
const undeleteAllInConversation = useCallback(async () => {
if (!pubkey || !currentConversation) return
// Remove all delete markers for this conversation
const newDeletedState: TDMDeletedState = {
deletedIds: deletedState?.deletedIds || [],
deletedRanges: {
...(deletedState?.deletedRanges || {}),
[currentConversation]: [] // Clear all ranges for this conversation
}
}
setDeletedState(newDeletedState)
// Clear cached messages to force reload
setConversationMessages((prev) => {
const next = new Map(prev)
next.delete(currentConversation)
return next
})
setLoadedConversations((prev) => {
const next = new Set(prev)
next.delete(currentConversation)
return next
})
// Publish to relays
await publishDeletedState(newDeletedState)
// Trigger a refresh of conversations
await refreshConversations()
}, [pubkey, currentConversation, deletedState, publishDeletedState, refreshConversations])
// Filter out deleted conversations from the list
const filteredConversations = useMemo(() => {
if (!deletedState) return conversations
return conversations.filter(
(c) => !isConversationDeleted(c.partnerPubkey, c.lastMessageAt, deletedState)
)
}, [conversations, deletedState])
// Calculate total unread count across all conversations
const totalUnreadCount = useMemo(() => {
return filteredConversations.reduce((sum, c) => sum + c.unreadCount, 0)
}, [filteredConversations])
// Check if there are new messages since last seen
const newestMessageTimestamp = useMemo(() => {
if (filteredConversations.length === 0) return 0
return Math.max(...filteredConversations.map((c) => c.lastMessageAt))
}, [filteredConversations])
const hasNewMessages = newestMessageTimestamp > lastSeenTimestamp
// Mark inbox as seen (update last seen timestamp)
const markInboxAsSeen = useCallback(() => {
if (!pubkey || newestMessageTimestamp === 0) return
setLastSeenTimestamp(newestMessageTimestamp)
storage.setDMLastSeenTimestamp(pubkey, newestMessageTimestamp)
}, [pubkey, newestMessageTimestamp])
return (
<DMContext.Provider
value={{
conversations: filteredConversations,
currentConversation,
messages,
isLoading,
isLoadingConversation,
error,
selectConversation,
startConversation,
sendMessage,
refreshConversations,
reloadConversation,
loadMoreConversations,
hasMoreConversations,
preferNip44,
setPreferNip44,
isNewConversation,
clearNewConversationFlag,
// Unread tracking
totalUnreadCount,
hasNewMessages,
markInboxAsSeen,
// Selection mode
selectedMessages,
isSelectionMode,
toggleMessageSelection,
selectAllMessages,
clearSelection,
// Deletion
deleteSelectedMessages,
deleteAllInConversation,
undeleteAllInConversation
}}
>
{children}
</DMContext.Provider>
)
}

View File

@@ -24,6 +24,8 @@ const NIP46_METHOD = {
SIGN_EVENT: 'sign_event',
NIP04_ENCRYPT: 'nip04_encrypt',
NIP04_DECRYPT: 'nip04_decrypt',
NIP44_ENCRYPT: 'nip44_encrypt',
NIP44_DECRYPT: 'nip44_decrypt',
PING: 'ping'
} as const
@@ -640,12 +642,16 @@ export class BunkerSigner implements ISigner {
// Encrypt with NIP-04 to the bunker's pubkey
const encrypted = await nip04.encrypt(this.localPrivkey, this.bunkerPubkey, JSON.stringify(request))
// Create NIP-46 request event
// Create NIP-46 request event with optional CAT tag
const tags: string[][] = [['p', this.bunkerPubkey]]
if (this.token) {
tags.push(['cat', cashuTokenService.encodeToken(this.token)])
}
const draftEvent: TDraftEvent = {
kind: 24133,
created_at: Math.floor(Date.now() / 1000),
content: encrypted,
tags: [['p', this.bunkerPubkey]]
tags
}
const signedEvent = finalizeEvent(draftEvent, this.localPrivkey)
@@ -721,6 +727,20 @@ export class BunkerSigner implements ISigner {
return this.sendRequest(NIP46_METHOD.NIP04_DECRYPT, [pubkey, cipherText])
}
/**
* Encrypt a message with NIP-44 via the bunker.
*/
async nip44Encrypt(pubkey: string, plainText: string): Promise<string> {
return this.sendRequest(NIP46_METHOD.NIP44_ENCRYPT, [pubkey, plainText])
}
/**
* Decrypt a message with NIP-44 via the bunker.
*/
async nip44Decrypt(pubkey: string, cipherText: string): Promise<string> {
return this.sendRequest(NIP46_METHOD.NIP44_DECRYPT, [pubkey, cipherText])
}
/**
* Check if connected to the bunker.
*/

View File

@@ -79,6 +79,9 @@ type TNostrContext = {
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
nip44Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip44Decrypt: (pubkey: string, cipherText: string) => Promise<string>
hasNip44Support: boolean
startLogin: () => void
checkLogin: <T>(cb?: () => T) => Promise<T | void>
updateRelayListEvent: (relayListEvent: Event) => Promise<void>
@@ -723,13 +726,41 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
const nip04Encrypt = async (pubkey: string, plainText: string) => {
return signer?.nip04Encrypt(pubkey, plainText) ?? ''
if (!signer) {
throw new Error('No signer available for NIP-04 encryption')
}
try {
const result = await signer.nip04Encrypt(pubkey, plainText)
if (!result) {
throw new Error('NIP-04 encryption returned empty result')
}
return result
} catch (err) {
console.error('NIP-04 encryption failed:', err)
throw err
}
}
const nip04Decrypt = async (pubkey: string, cipherText: string) => {
return signer?.nip04Decrypt(pubkey, cipherText) ?? ''
}
const nip44Encrypt = async (pubkey: string, plainText: string) => {
if (!signer?.nip44Encrypt) {
throw new Error('NIP-44 encryption not supported by this signer')
}
return signer.nip44Encrypt(pubkey, plainText)
}
const nip44Decrypt = async (pubkey: string, cipherText: string) => {
if (!signer?.nip44Decrypt) {
throw new Error('NIP-44 decryption not supported by this signer')
}
return signer.nip44Decrypt(pubkey, cipherText)
}
const hasNip44Support = !!signer?.nip44Encrypt && !!signer?.nip44Decrypt
const checkLogin = async <T,>(cb?: () => T): Promise<T | void> => {
if (signer) {
return cb && cb()
@@ -857,6 +888,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
signHttpAuth,
nip04Encrypt,
nip04Decrypt,
nip44Encrypt,
nip44Decrypt,
hasNip44Support,
startLogin: () => setOpenLoginDialog(true),
checkLogin,
signEvent,

View File

@@ -57,4 +57,24 @@ export class Nip07Signer implements ISigner {
}
return await this.signer.nip04.decrypt(pubkey, cipherText)
}
async nip44Encrypt(pubkey: string, plainText: string) {
if (!this.signer) {
throw new Error('Should call init() first')
}
if (!this.signer.nip44?.encrypt) {
throw new Error('The extension you are using does not support nip44 encryption')
}
return await this.signer.nip44.encrypt(pubkey, plainText)
}
async nip44Decrypt(pubkey: string, cipherText: string) {
if (!this.signer) {
throw new Error('Should call init() first')
}
if (!this.signer.nip44?.decrypt) {
throw new Error('The extension you are using does not support nip44 decryption')
}
return await this.signer.nip44.decrypt(pubkey, cipherText)
}
}

View File

@@ -2,6 +2,7 @@ import { ISigner, TDraftEvent } from '@/types'
import * as utils from '@noble/curves/abstract/utils'
import { bech32 } from '@scure/base'
import { finalizeEvent, getPublicKey as nGetPublicKey, nip04 } from 'nostr-tools'
import * as nip44 from 'nostr-tools/nip44'
/**
* Convert nsec (bech32) to hex string
@@ -89,4 +90,20 @@ export class NsecSigner implements ISigner {
}
return nip04.decrypt(this.privkey, pubkey, cipherText)
}
async nip44Encrypt(pubkey: string, plainText: string) {
if (!this.privkey) {
throw new Error('Not logged in')
}
const conversationKey = nip44.v2.utils.getConversationKey(this.privkey, pubkey)
return nip44.v2.encrypt(plainText, conversationKey)
}
async nip44Decrypt(pubkey: string, cipherText: string) {
if (!this.privkey) {
throw new Error('Not logged in')
}
const conversationKey = nip44.v2.utils.getConversationKey(this.privkey, pubkey)
return nip44.v2.decrypt(cipherText, conversationKey)
}
}

View File

@@ -1,4 +1,5 @@
import BookmarkPage from '@/pages/primary/BookmarkPage'
import InboxPage from '@/pages/primary/InboxPage'
import MePage from '@/pages/primary/MePage'
import NoteListPage from '@/pages/primary/NoteListPage'
import NotificationListPage from '@/pages/primary/NotificationListPage'
@@ -16,6 +17,7 @@ type RouteConfig = {
const PRIMARY_ROUTE_CONFIGS: RouteConfig[] = [
{ key: 'home', component: NoteListPage },
{ key: 'inbox', component: InboxPage },
{ key: 'notifications', component: NotificationListPage },
{ key: 'me', component: MePage },
{ key: 'profile', component: ProfilePage },

778
src/services/dm.service.ts Normal file
View File

@@ -0,0 +1,778 @@
/**
* DM Service - Direct Message handling with NIP-04 and NIP-17 encryption support
*
* NIP-04: Kind 4 encrypted direct messages (legacy)
* NIP-17: Kind 14 private direct messages with NIP-59 gift wrapping (modern)
*/
import { ARCHIVE_RELAY_URL, BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { TConversation, TDirectMessage, TDMDeletedState, TDMEncryptionType, TDraftEvent } from '@/types'
import { Event, kinds, VerifiedEvent } from 'nostr-tools'
import client from './client.service'
import indexedDb from './indexed-db.service'
/**
* Create and publish a kind 5 delete request for own messages
* This requests relays to delete the original event
*/
export async function publishDeleteRequest(
eventIds: string[],
eventKind: number,
encryption: IDMEncryption,
relayUrls: string[]
): Promise<void> {
if (eventIds.length === 0) return
const draftEvent: TDraftEvent = {
kind: kinds.EventDeletion, // 5
created_at: Math.floor(Date.now() / 1000),
content: 'Deleted by sender',
tags: [
['k', eventKind.toString()],
...eventIds.map((id) => ['e', id])
]
}
const signedEvent = await encryption.signEvent(draftEvent)
await client.publishEvent(relayUrls, signedEvent)
}
/**
* Encryption methods interface for DM operations
*/
export interface IDMEncryption {
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
nip44Encrypt?: (pubkey: string, plainText: string) => Promise<string>
nip44Decrypt?: (pubkey: string, cipherText: string) => Promise<string>
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
getPublicKey: () => string
}
// NIP-04 uses kind 4
const KIND_ENCRYPTED_DM = kinds.EncryptedDirectMessage // 4
// NIP-17 uses kind 14 for chat messages, wrapped in gift wraps
const KIND_PRIVATE_DM = ExtendedKind.PRIVATE_DM // 14
const KIND_SEAL = ExtendedKind.SEAL // 13
const KIND_GIFT_WRAP = ExtendedKind.GIFT_WRAP // 1059
const KIND_REACTION = kinds.Reaction // 7
// 15 second timeout for DM fetches - if relays are dead, don't wait forever
const DM_FETCH_TIMEOUT_MS = 15000
/**
* Wrap a promise with a timeout that returns empty array on timeout or error
*/
function withTimeout<T>(promise: Promise<T[]>, ms: number): Promise<T[]> {
const timeoutPromise = new Promise<T[]>((resolve) => {
setTimeout(() => resolve([]), ms)
})
const safePromise = promise.catch(() => [] as T[])
return Promise.race([safePromise, timeoutPromise])
}
class DMService {
/**
* Fetch all DM events for a user from relays
*/
async fetchDMEvents(pubkey: string, relayUrls: string[], limit = 500): Promise<Event[]> {
const allRelays = [...new Set([...relayUrls, ...BIG_RELAY_URLS])]
// Fetch NIP-04 DMs (kind 4) and NIP-17 gift wraps in parallel
const nip04Filter = {
kinds: [KIND_ENCRYPTED_DM],
limit
}
const [incomingNip04, outgoingNip04, giftWraps] = await Promise.all([
// Fetch messages sent TO the user
withTimeout(
client.fetchEvents(allRelays, {
...nip04Filter,
'#p': [pubkey]
}),
DM_FETCH_TIMEOUT_MS
),
// Fetch messages sent BY the user
withTimeout(
client.fetchEvents(allRelays, {
...nip04Filter,
authors: [pubkey]
}),
DM_FETCH_TIMEOUT_MS
),
// Fetch NIP-17 gift wraps (kind 1059) - these are addressed to the user
withTimeout(
client.fetchEvents(allRelays, {
kinds: [KIND_GIFT_WRAP],
'#p': [pubkey],
limit
}),
DM_FETCH_TIMEOUT_MS
)
])
// Combine all events
const allEvents = [...incomingNip04, ...outgoingNip04, ...giftWraps]
// Store in IndexedDB for caching
await Promise.all(allEvents.map((event) => indexedDb.putDMEvent(event)))
return allEvents
}
/**
* Fetch recent DM events (limited) for building conversation list
* Returns only most recent events to quickly show conversations
*/
async fetchRecentDMEvents(pubkey: string, relayUrls: string[]): Promise<Event[]> {
// Fetch with smaller limit for faster initial load
return this.fetchDMEvents(pubkey, relayUrls, 100)
}
/**
* Fetch all DM events for a specific conversation partner
*/
async fetchConversationEvents(
pubkey: string,
partnerPubkey: string,
relayUrls: string[]
): Promise<Event[]> {
const allRelays = [...new Set([...relayUrls, ...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
withTimeout(
client.fetchEvents(allRelays, {
kinds: [KIND_ENCRYPTED_DM],
authors: [partnerPubkey],
'#p': [pubkey],
limit: 500
}),
DM_FETCH_TIMEOUT_MS
),
// Messages FROM user TO partner
withTimeout(
client.fetchEvents(allRelays, {
kinds: [KIND_ENCRYPTED_DM],
authors: [pubkey],
'#p': [partnerPubkey],
limit: 500
}),
DM_FETCH_TIMEOUT_MS
),
// Gift wraps addressed to user (we'll filter by sender after decryption)
withTimeout(
client.fetchEvents(allRelays, {
kinds: [KIND_GIFT_WRAP],
'#p': [pubkey],
limit: 500
}),
DM_FETCH_TIMEOUT_MS
)
])
const allEvents = [...incomingNip04, ...outgoingNip04, ...giftWraps]
// Store in IndexedDB for caching
await Promise.all(allEvents.map((event) => indexedDb.putDMEvent(event)))
return allEvents
}
/**
* Decrypt a DM event and return a TDirectMessage
*/
async decryptMessage(
event: Event,
encryption: IDMEncryption,
myPubkey: string
): Promise<TDirectMessage | null> {
try {
if (event.kind === KIND_ENCRYPTED_DM) {
// NIP-04 decryption - check content cache first
const cached = await indexedDb.getDecryptedContent(event.id)
if (cached) {
return this.buildDirectMessage(event, cached, myPubkey, 'nip04')
}
const otherPubkey = this.getOtherPartyPubkey(event, myPubkey)
if (!otherPubkey) return null
const decryptedContent = await encryption.nip04Decrypt(otherPubkey, event.content)
// Cache the decrypted content
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)
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
}
const seenOnRelays = client.getSeenEventRelayUrls(event.id)
return {
id: event.id,
senderPubkey: cachedUnwrapped.pubkey,
recipientPubkey: cachedUnwrapped.recipientPubkey,
content: cachedUnwrapped.content,
createdAt: cachedUnwrapped.createdAt,
encryptionType: 'nip17',
event,
decryptedContent: cachedUnwrapped.content,
seenOnRelays: seenOnRelays.length > 0 ? seenOnRelays : undefined
}
}
// Decrypt (unwrap gift wrap -> unseal -> decrypt)
const unwrapped = await this.unwrapGiftWrap(event, encryption)
if (!unwrapped) return null
const innerEvent = unwrapped.innerEvent
// Handle reactions - cache them but don't return as messages
if (unwrapped.type === 'reaction') {
// Cache the reaction for later display
// TODO: Store reaction separately and associate with target message via 'e' tag
indexedDb
.putUnwrappedGiftWrap(event.id, {
pubkey: innerEvent.pubkey,
recipientPubkey: '__reaction__', // Marker for reactions
content: unwrapped.content, // The emoji
createdAt: innerEvent.created_at
})
.catch(() => {})
// For now, just skip reactions (they're cached for future use)
return null
}
const recipientPubkey = this.getRecipientFromTags(innerEvent.tags) || myPubkey
// Cache the unwrapped inner event (includes sender info)
indexedDb
.putUnwrappedGiftWrap(event.id, {
pubkey: innerEvent.pubkey,
recipientPubkey,
content: unwrapped.content,
createdAt: innerEvent.created_at
})
.catch(() => {})
const seenOnRelays = client.getSeenEventRelayUrls(event.id)
return {
id: event.id,
senderPubkey: innerEvent.pubkey,
recipientPubkey,
content: unwrapped.content,
createdAt: innerEvent.created_at,
encryptionType: 'nip17',
event,
decryptedContent: unwrapped.content,
seenOnRelays: seenOnRelays.length > 0 ? seenOnRelays : undefined
}
} else {
return null
}
} catch {
return null
}
}
/**
* Unwrap a NIP-59 gift wrap to get the inner message or reaction
*/
private async unwrapGiftWrap(
giftWrap: Event,
encryption: IDMEncryption
): Promise<{ content: string; innerEvent: Event; type: 'dm' | 'reaction' } | null> {
try {
// Step 1: Decrypt the gift wrap content using NIP-44
if (!encryption.nip44Decrypt) {
return null
}
const sealJson = await encryption.nip44Decrypt(giftWrap.pubkey, giftWrap.content)
const seal = JSON.parse(sealJson) as Event
if (seal.kind !== KIND_SEAL) {
return null
}
// Step 2: Decrypt the seal content using NIP-44
const innerEventJson = await encryption.nip44Decrypt(seal.pubkey, seal.content)
const innerEvent = JSON.parse(innerEventJson) as Event
if (innerEvent.kind === KIND_PRIVATE_DM) {
return {
content: innerEvent.content,
innerEvent,
type: 'dm'
}
} else if (innerEvent.kind === KIND_REACTION) {
return {
content: innerEvent.content, // The emoji
innerEvent,
type: 'reaction'
}
} else {
// Silently ignore other event types (e.g., read receipts)
return null
}
} catch {
return null
}
}
/**
* Build a TDirectMessage from an event
*/
private buildDirectMessage(
event: Event,
decryptedContent: string,
myPubkey: string,
encryptionType: TDMEncryptionType = 'nip04'
): TDirectMessage {
const recipient = this.getRecipientFromTags(event.tags)
const isSender = event.pubkey === myPubkey
const seenOnRelays = client.getSeenEventRelayUrls(event.id)
return {
id: event.id,
senderPubkey: event.pubkey,
recipientPubkey: recipient || (isSender ? '' : myPubkey),
content: decryptedContent,
createdAt: event.created_at,
encryptionType,
event,
decryptedContent,
seenOnRelays: seenOnRelays.length > 0 ? seenOnRelays : undefined
}
}
/**
* Send a DM to a recipient
* When no existing conversation, sends in BOTH formats (NIP-04 and NIP-17)
*/
async sendDM(
recipientPubkey: string,
content: string,
encryption: IDMEncryption,
relayUrls: string[],
_preferNip44: boolean,
existingEncryption: TDMEncryptionType | null
): Promise<Event[]> {
const sentEvents: Event[] = []
// Get recipient's relays for better delivery
const recipientRelays = await this.fetchPartnerRelays(recipientPubkey)
const allRelays = [...new Set([...relayUrls, ...recipientRelays])]
if (existingEncryption === null) {
// No existing conversation - send in BOTH formats
try {
const nip04Event = await this.createAndPublishNip04DM(
recipientPubkey,
content,
encryption,
allRelays
)
sentEvents.push(nip04Event)
} catch (error) {
console.error('Failed to send NIP-04 DM:', error)
}
try {
if (encryption.nip44Encrypt) {
const nip17Event = await this.createAndPublishNip17DM(
recipientPubkey,
content,
encryption,
allRelays
)
sentEvents.push(nip17Event)
}
} catch (error) {
console.error('Failed to send NIP-17 DM:', error)
}
} else if (existingEncryption === 'nip04') {
// Match existing NIP-04 encryption
try {
const nip04Event = await this.createAndPublishNip04DM(
recipientPubkey,
content,
encryption,
allRelays
)
sentEvents.push(nip04Event)
} catch (error) {
console.error('Failed to send NIP-04 DM:', error)
throw error // Re-throw so caller knows it failed
}
} else if (existingEncryption === 'nip17') {
// Match existing NIP-17 encryption
if (!encryption.nip44Encrypt) {
throw new Error('Encryption does not support NIP-44')
}
try {
const nip17Event = await this.createAndPublishNip17DM(
recipientPubkey,
content,
encryption,
allRelays
)
sentEvents.push(nip17Event)
} catch (error) {
console.error('Failed to send NIP-17 DM:', error)
throw error // Re-throw so caller knows it failed
}
}
return sentEvents
}
/**
* Create and publish a NIP-04 DM (kind 4)
*/
private async createAndPublishNip04DM(
recipientPubkey: string,
content: string,
encryption: IDMEncryption,
relayUrls: string[]
): Promise<VerifiedEvent> {
const encryptedContent = await encryption.nip04Encrypt(recipientPubkey, content)
const draftEvent: TDraftEvent = {
kind: KIND_ENCRYPTED_DM,
created_at: Math.floor(Date.now() / 1000),
content: encryptedContent,
tags: [['p', recipientPubkey]]
}
const signedEvent = await encryption.signEvent(draftEvent)
await client.publishEvent(relayUrls, signedEvent)
await indexedDb.putDMEvent(signedEvent)
await indexedDb.putDecryptedContent(signedEvent.id, content)
return signedEvent
}
/**
* Create and publish a NIP-17 DM with gift wrapping (kind 14 -> 13 -> 1059)
*/
private async createAndPublishNip17DM(
recipientPubkey: string,
content: string,
encryption: IDMEncryption,
relayUrls: string[]
): Promise<VerifiedEvent> {
if (!encryption.nip44Encrypt) {
throw new Error('Encryption does not support NIP-44')
}
// Note: senderPubkey is determined by the signer when signing the event
// Step 1: Create the inner chat message (kind 14)
const chatMessage: TDraftEvent = {
kind: KIND_PRIVATE_DM,
created_at: Math.floor(Date.now() / 1000),
content,
tags: [['p', recipientPubkey]]
}
// Step 2: Sign the chat message
const signedChat = await encryption.signEvent(chatMessage)
// Step 3: Create a seal (kind 13) containing the encrypted chat message
const sealContent = await encryption.nip44Encrypt(recipientPubkey, JSON.stringify(signedChat))
const seal: TDraftEvent = {
kind: KIND_SEAL,
created_at: this.randomizeTimestamp(signedChat.created_at),
content: sealContent,
tags: []
}
const signedSeal = await encryption.signEvent(seal)
// Step 4: Create a gift wrap (kind 1059) with random sender key
// For simplicity, we'll use the same encryption but in production you'd use a random key
const giftWrapContent = await encryption.nip44Encrypt(recipientPubkey, JSON.stringify(signedSeal))
const giftWrap: TDraftEvent = {
kind: KIND_GIFT_WRAP,
created_at: this.randomizeTimestamp(signedSeal.created_at),
content: giftWrapContent,
tags: [['p', recipientPubkey]]
}
const signedGiftWrap = await encryption.signEvent(giftWrap)
// Publish the gift wrap
await client.publishEvent(relayUrls, signedGiftWrap)
await indexedDb.putDMEvent(signedGiftWrap)
await indexedDb.putDecryptedContent(signedGiftWrap.id, content)
return signedGiftWrap
}
/**
* Randomize timestamp for privacy (NIP-59)
*/
private randomizeTimestamp(baseTime: number): number {
// Add random offset between -2 days and +2 days
const offset = Math.floor(Math.random() * 4 * 24 * 60 * 60) - 2 * 24 * 60 * 60
return baseTime + offset
}
/**
* Fetch partner's write relays for better DM delivery
*/
async fetchPartnerRelays(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.parseRelayList(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.parseRelayList(event)
}
// Fallback to archive relay
return [ARCHIVE_RELAY_URL]
} catch {
return [ARCHIVE_RELAY_URL]
}
}
/**
* Parse relay list from kind 10002 event
*/
private parseRelayList(event: Event): string[] {
const writeRelays: string[] = []
for (const tag of event.tags) {
if (tag[0] === 'r') {
const url = tag[1]
const scope = tag[2]
// Include if it's a write relay or has no scope (both)
if (!scope || scope === 'write') {
writeRelays.push(url)
}
}
}
return writeRelays.length > 0 ? writeRelays : [ARCHIVE_RELAY_URL]
}
/**
* Check other relays for an event and return which ones have it
*/
async checkOtherRelaysForEvent(
eventId: string,
knownRelays: string[]
): Promise<string[]> {
const knownSet = new Set(knownRelays.map((r) => r.replace(/\/$/, '')))
const relaysToCheck = BIG_RELAY_URLS.filter(
(url) => !knownSet.has(url.replace(/\/$/, ''))
)
const foundOnRelays: string[] = []
// Check each relay individually
await Promise.all(
relaysToCheck.map(async (relayUrl) => {
try {
const events = await client.fetchEvents([relayUrl], {
ids: [eventId],
limit: 1
})
if (events.length > 0) {
foundOnRelays.push(relayUrl)
// Track the event as seen on this relay
client.trackEventSeenOn(eventId, { url: relayUrl } as any)
}
} catch {
// Relay unreachable, ignore
}
})
)
return foundOnRelays
}
/**
* Group messages into conversations
*/
groupMessagesIntoConversations(
messages: TDirectMessage[],
myPubkey: string
): Map<string, TConversation> {
const conversations = new Map<string, TConversation>()
for (const message of messages) {
const partnerPubkey =
message.senderPubkey === myPubkey ? message.recipientPubkey : message.senderPubkey
if (!partnerPubkey) continue
const existing = conversations.get(partnerPubkey)
if (!existing || message.createdAt > existing.lastMessageAt) {
conversations.set(partnerPubkey, {
partnerPubkey,
lastMessageAt: message.createdAt,
lastMessagePreview: message.content.substring(0, 100),
unreadCount: 0,
preferredEncryption: message.encryptionType
})
}
}
return conversations
}
/**
* Build conversation list from raw events WITHOUT decryption (fast)
* Only works for NIP-04 events - NIP-17 gift wraps need decryption
*/
groupEventsIntoConversations(events: Event[], myPubkey: string): Map<string, TConversation> {
const conversations = new Map<string, TConversation>()
for (const event of events) {
// Only process NIP-04 events (kind 4) - we can get metadata without decryption
if (event.kind !== KIND_ENCRYPTED_DM) continue
const recipient = this.getRecipientFromTags(event.tags)
const partnerPubkey = event.pubkey === myPubkey ? recipient : event.pubkey
if (!partnerPubkey) continue
const existing = conversations.get(partnerPubkey)
if (!existing || event.created_at > existing.lastMessageAt) {
conversations.set(partnerPubkey, {
partnerPubkey,
lastMessageAt: event.created_at,
lastMessagePreview: '', // Skip preview for speed - will be filled on conversation open
unreadCount: 0,
preferredEncryption: 'nip04'
})
}
}
return conversations
}
/**
* Get messages for a specific conversation
*/
getMessagesForConversation(
messages: TDirectMessage[],
partnerPubkey: string,
myPubkey: string
): TDirectMessage[] {
return messages
.filter(
(m) =>
(m.senderPubkey === partnerPubkey && m.recipientPubkey === myPubkey) ||
(m.senderPubkey === myPubkey && m.recipientPubkey === partnerPubkey)
)
.sort((a, b) => a.createdAt - b.createdAt)
}
/**
* Get the other party's pubkey from a DM event
*/
private getOtherPartyPubkey(event: Event, myPubkey: string): string | null {
if (event.pubkey === myPubkey) {
// I'm the sender, get recipient from tags
return this.getRecipientFromTags(event.tags)
} else {
// I'm the recipient, sender is the pubkey
return event.pubkey
}
}
/**
* Get recipient pubkey from event tags
*/
private getRecipientFromTags(tags: string[][]): string | null {
const pTag = tags.find((t) => t[0] === 'p')
return pTag ? pTag[1] : null
}
}
const dmService = new DMService()
export default dmService
/**
* Check if a message should be treated as deleted based on the deleted state
* @param messageId - The event ID of the message
* @param partnerPubkey - The conversation partner's pubkey
* @param timestamp - The message timestamp (created_at)
* @param deletedState - The user's deleted messages state
* @returns true if the message should be hidden
*/
export function isMessageDeleted(
messageId: string,
partnerPubkey: string,
timestamp: number,
deletedState: TDMDeletedState | null
): boolean {
if (!deletedState) return false
// Check if message ID is explicitly deleted
if (deletedState.deletedIds.includes(messageId)) {
return true
}
// Check if timestamp falls within any deleted range for this conversation
const ranges = deletedState.deletedRanges[partnerPubkey]
if (ranges) {
for (const range of ranges) {
if (timestamp >= range.start && timestamp <= range.end) {
return true
}
}
}
return false
}
/**
* Check if a conversation should be hidden based on its last message timestamp
* A conversation is deleted if its lastMessageAt falls within any deleted range
* @param partnerPubkey - The conversation partner's pubkey
* @param lastMessageAt - The timestamp of the last message in the conversation
* @param deletedState - The user's deleted messages state
* @returns true if the conversation should be hidden
*/
export function isConversationDeleted(
partnerPubkey: string,
lastMessageAt: number,
deletedState: TDMDeletedState | null
): boolean {
if (!deletedState) return false
const ranges = deletedState.deletedRanges[partnerPubkey]
if (!ranges || ranges.length === 0) return false
// Check if lastMessageAt falls within any deleted range
for (const range of ranges) {
if (lastMessageAt >= range.start && lastMessageAt <= range.end) {
return true
}
}
return false
}

View File

@@ -1,6 +1,6 @@
import { ExtendedKind } from '@/constants'
import { tagNameEquals } from '@/lib/tag'
import { TRelayInfo } from '@/types'
import { TDMDeletedState, TRelayInfo } from '@/types'
import { Event, kinds } from 'nostr-tools'
type TValue<T = any> = {
@@ -25,6 +25,11 @@ const StoreNames = {
RELAY_INFOS: 'relayInfos',
DECRYPTED_CONTENTS: 'decryptedContents',
PINNED_USERS_EVENTS: 'pinnedUsersEvents',
DM_EVENTS: 'dmEvents',
DM_CONVERSATIONS: 'dmConversations',
DM_MESSAGES: 'dmMessages',
UNWRAPPED_GIFT_WRAPS: 'unwrappedGiftWraps',
DM_DELETED_STATE: 'dmDeletedState',
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', // deprecated
RELAY_INFO_EVENTS: 'relayInfoEvents' // deprecated
}
@@ -45,7 +50,7 @@ class IndexedDbService {
init(): Promise<void> {
if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open('smesh', 10)
const request = window.indexedDB.open('smesh', 14)
request.onerror = (event) => {
reject(event)
@@ -103,6 +108,21 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.PINNED_USERS_EVENTS)) {
db.createObjectStore(StoreNames.PINNED_USERS_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.DM_EVENTS)) {
db.createObjectStore(StoreNames.DM_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.DM_CONVERSATIONS)) {
db.createObjectStore(StoreNames.DM_CONVERSATIONS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.DM_MESSAGES)) {
db.createObjectStore(StoreNames.DM_MESSAGES, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.UNWRAPPED_GIFT_WRAPS)) {
db.createObjectStore(StoreNames.UNWRAPPED_GIFT_WRAPS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.DM_DELETED_STATE)) {
db.createObjectStore(StoreNames.DM_DELETED_STATE, { keyPath: 'key' })
}
if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS)
@@ -440,6 +460,505 @@ class IndexedDbService {
})
}
// DM-related methods
async putDMEvent(event: Event): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.DM_EVENTS, 'readwrite')
const store = transaction.objectStore(StoreNames.DM_EVENTS)
const putRequest = store.put(this.formatValue(event.id, event))
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getDMEvent(eventId: string): Promise<Event | null> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.DM_EVENTS, 'readonly')
const store = transaction.objectStore(StoreNames.DM_EVENTS)
const request = store.get(eventId)
request.onsuccess = () => {
transaction.commit()
resolve((request.result as TValue<Event>)?.value ?? null)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getAllDMEvents(userPubkey: string): Promise<Event[]> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.DM_EVENTS, 'readonly')
const store = transaction.objectStore(StoreNames.DM_EVENTS)
const request = store.openCursor()
const events: Event[] = []
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result
if (cursor) {
const dmEvent = (cursor.value as TValue<Event>).value
if (dmEvent) {
// Include events where user is sender or recipient
const isUserEvent =
dmEvent.pubkey === userPubkey ||
dmEvent.tags.some((tag) => tag[0] === 'p' && tag[1] === userPubkey)
if (isUserEvent) {
events.push(dmEvent)
}
}
cursor.continue()
} else {
transaction.commit()
resolve(events)
}
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async putDMConversation(
userPubkey: string,
partnerPubkey: string,
lastMessageAt: number,
lastMessagePreview: string,
encryptionType: 'nip04' | 'nip17' | null
): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readwrite')
const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
const key = `${userPubkey}:${partnerPubkey}`
const putRequest = store.put(
this.formatValue(key, {
partnerPubkey,
lastMessageAt,
lastMessagePreview,
encryptionType
})
)
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getDMConversations(
userPubkey: string
): Promise<
Array<{
partnerPubkey: string
lastMessageAt: number
lastMessagePreview: string
encryptionType: 'nip04' | 'nip17' | null
}>
> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readonly')
const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
const request = store.openCursor()
const conversations: Array<{
partnerPubkey: string
lastMessageAt: number
lastMessagePreview: string
encryptionType: 'nip04' | 'nip17' | null
}> = []
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result
if (cursor) {
const key = cursor.key as string
if (key.startsWith(`${userPubkey}:`)) {
const value = (cursor.value as TValue).value
if (value) {
conversations.push(value)
}
}
cursor.continue()
} else {
transaction.commit()
// Sort by lastMessageAt descending
conversations.sort((a, b) => b.lastMessageAt - a.lastMessageAt)
resolve(conversations)
}
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async putConversationRelaySettings(
userPubkey: string,
partnerPubkey: string,
selectedRelays: string[]
): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readwrite')
const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
const key = `${userPubkey}:${partnerPubkey}:relays`
const putRequest = store.put(this.formatValue(key, { selectedRelays }))
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getConversationRelaySettings(
userPubkey: string,
partnerPubkey: string
): Promise<string[] | null> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readonly')
const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
const key = `${userPubkey}:${partnerPubkey}:relays`
const request = store.get(key)
request.onsuccess = () => {
transaction.commit()
const result = (request.result as TValue)?.value
resolve(result?.selectedRelays ?? null)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async putConversationEncryptionPreference(
userPubkey: string,
partnerPubkey: string,
preference: 'nip04' | 'nip17' | 'auto'
): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readwrite')
const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
const key = `${userPubkey}:${partnerPubkey}:encryption`
const putRequest = store.put(this.formatValue(key, { preference }))
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getConversationEncryptionPreference(
userPubkey: string,
partnerPubkey: string
): Promise<'nip04' | 'nip17' | 'auto' | null> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readonly')
const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
const key = `${userPubkey}:${partnerPubkey}:encryption`
const request = store.get(key)
request.onsuccess = () => {
transaction.commit()
const result = (request.result as TValue)?.value
resolve(result?.preference ?? null)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async putConversationMessages(
userPubkey: string,
partnerPubkey: string,
messages: Array<{
id: string
senderPubkey: string
recipientPubkey: string
content: string
createdAt: number
encryptionType: 'nip04' | 'nip17'
seenOnRelays?: string[]
}>
): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.DM_MESSAGES, 'readwrite')
const store = transaction.objectStore(StoreNames.DM_MESSAGES)
const key = `${userPubkey}:${partnerPubkey}`
const putRequest = store.put(this.formatValue(key, messages))
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getConversationMessages(
userPubkey: string,
partnerPubkey: string
): Promise<
Array<{
id: string
senderPubkey: string
recipientPubkey: string
content: string
createdAt: number
encryptionType: 'nip04' | 'nip17'
seenOnRelays?: string[]
}> | null
> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.DM_MESSAGES, 'readonly')
const store = transaction.objectStore(StoreNames.DM_MESSAGES)
const key = `${userPubkey}:${partnerPubkey}`
const request = store.get(key)
request.onsuccess = () => {
transaction.commit()
resolve((request.result as TValue)?.value ?? null)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
/**
* Cache an unwrapped NIP-17 gift wrap inner event
* This avoids repeated decryption just to identify the sender
*/
async putUnwrappedGiftWrap(
giftWrapId: string,
innerEvent: {
pubkey: string // actual sender
recipientPubkey: string
content: string
createdAt: number
}
): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.UNWRAPPED_GIFT_WRAPS, 'readwrite')
const store = transaction.objectStore(StoreNames.UNWRAPPED_GIFT_WRAPS)
const putRequest = store.put(this.formatValue(giftWrapId, innerEvent))
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
/**
* Get a cached unwrapped NIP-17 gift wrap inner event
*/
async getUnwrappedGiftWrap(
giftWrapId: string
): Promise<{
pubkey: string
recipientPubkey: string
content: string
createdAt: number
} | null> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.UNWRAPPED_GIFT_WRAPS, 'readonly')
const store = transaction.objectStore(StoreNames.UNWRAPPED_GIFT_WRAPS)
const request = store.get(giftWrapId)
request.onsuccess = () => {
transaction.commit()
resolve((request.result as TValue)?.value ?? null)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
/**
* Clear all DM-related caches (for full refresh)
*/
async clearAllDMCaches(): Promise<void> {
await this.initPromise
if (!this.db) {
return
}
const storeNames = [
StoreNames.DM_EVENTS,
StoreNames.DM_CONVERSATIONS,
StoreNames.DM_MESSAGES,
StoreNames.UNWRAPPED_GIFT_WRAPS,
StoreNames.DECRYPTED_CONTENTS
]
const transaction = this.db.transaction(storeNames, 'readwrite')
await Promise.all(
storeNames.map(
(storeName) =>
new Promise<void>((resolve, reject) => {
const store = transaction.objectStore(storeName)
const request = store.clear()
request.onsuccess = () => resolve()
request.onerror = (event) => reject(event)
})
)
)
transaction.commit()
}
/**
* Get the deleted messages state for a user (local cache only)
*/
async getDeletedMessagesState(pubkey: string): Promise<TDMDeletedState | null> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.DM_DELETED_STATE, 'readonly')
const store = transaction.objectStore(StoreNames.DM_DELETED_STATE)
const request = store.get(pubkey)
request.onsuccess = () => {
transaction.commit()
resolve((request.result as TValue<TDMDeletedState>)?.value ?? null)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
/**
* Store the deleted messages state for a user (local cache)
*/
async putDeletedMessagesState(pubkey: string, state: TDMDeletedState): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.DM_DELETED_STATE, 'readwrite')
const store = transaction.objectStore(StoreNames.DM_DELETED_STATE)
const putRequest = store.put(this.formatValue(pubkey, state))
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
private getReplaceableEventKeyFromEvent(event: Event): string {
if (
[kinds.Metadata, kinds.Contacts].includes(event.kind) ||

View File

@@ -60,6 +60,8 @@ class LocalStorageService {
private quickReaction: boolean = false
private quickReactionEmoji: string | TEmoji = '+'
private nsfwDisplayPolicy: TNsfwDisplayPolicy = NSFW_DISPLAY_POLICY.HIDE_CONTENT
private preferNip44: boolean = false
private dmConversationFilter: 'all' | 'follows' = 'all'
constructor() {
if (!LocalStorageService.instance) {
@@ -248,6 +250,10 @@ class LocalStorageService {
this.quickReactionEmoji = quickReactionEmojiStr
}
this.preferNip44 = window.localStorage.getItem(StorageKey.PREFER_NIP44) === 'true'
this.dmConversationFilter =
(window.localStorage.getItem(StorageKey.DM_CONVERSATION_FILTER) as 'all' | 'follows') || 'all'
// Clean up deprecated data
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
@@ -586,6 +592,49 @@ class LocalStorageService {
this.nsfwDisplayPolicy = policy
window.localStorage.setItem(StorageKey.NSFW_DISPLAY_POLICY, policy)
}
getPreferNip44() {
return this.preferNip44
}
setPreferNip44(prefer: boolean) {
this.preferNip44 = prefer
window.localStorage.setItem(StorageKey.PREFER_NIP44, prefer.toString())
}
getDMConversationFilter() {
return this.dmConversationFilter
}
setDMConversationFilter(filter: 'all' | 'follows') {
this.dmConversationFilter = filter
window.localStorage.setItem(StorageKey.DM_CONVERSATION_FILTER, filter)
}
getDMLastSeenTimestamp(pubkey: string): number {
const mapStr = window.localStorage.getItem(StorageKey.DM_LAST_SEEN_TIMESTAMP)
if (!mapStr) return 0
try {
const map = JSON.parse(mapStr) as Record<string, number>
return map[pubkey] ?? 0
} catch {
return 0
}
}
setDMLastSeenTimestamp(pubkey: string, timestamp: number) {
const mapStr = window.localStorage.getItem(StorageKey.DM_LAST_SEEN_TIMESTAMP)
let map: Record<string, number> = {}
if (mapStr) {
try {
map = JSON.parse(mapStr)
} catch {
// ignore
}
}
map[pubkey] = timestamp
window.localStorage.setItem(StorageKey.DM_LAST_SEEN_TIMESTAMP, JSON.stringify(map))
}
}
const instance = new LocalStorageService()

43
src/types/index.d.ts vendored
View File

@@ -89,6 +89,10 @@ export type TNip07 = {
encrypt?: (pubkey: string, plainText: string) => Promise<string>
decrypt?: (pubkey: string, cipherText: string) => Promise<string>
}
nip44?: {
encrypt?: (pubkey: string, plainText: string) => Promise<string>
decrypt?: (pubkey: string, cipherText: string) => Promise<string>
}
}
export interface ISigner {
@@ -96,6 +100,8 @@ export interface ISigner {
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
nip44Encrypt?: (pubkey: string, plainText: string) => Promise<string>
nip44Decrypt?: (pubkey: string, cipherText: string) => Promise<string>
}
export type TSignerType = 'nsec' | 'nip-07' | 'browser-nsec' | 'ncryptsec' | 'npub' | 'bunker'
@@ -221,4 +227,41 @@ export type TSyncSettings = {
quickReaction?: boolean
quickReactionEmoji?: string | TEmoji
noteListMode?: TNoteListMode
preferNip44?: boolean
}
// DM types
export type TDMEncryptionType = 'nip04' | 'nip17'
export interface TConversation {
partnerPubkey: string
lastMessageAt: number
lastMessagePreview: string
unreadCount: number
preferredEncryption: TDMEncryptionType | null
}
export interface TDirectMessage {
id: string
senderPubkey: string
recipientPubkey: string
content: string
createdAt: number
encryptionType: TDMEncryptionType
event: Event
decryptedContent?: string
seenOnRelays?: string[]
}
// Deleted messages state (stored in kind 30078 Application Specific Data)
export interface TDMDeletedState {
// Specific message IDs to ignore
deletedIds: string[]
// Timestamp ranges to ignore per conversation
deletedRanges: {
[partnerPubkey: string]: Array<{
start: number // timestamp
end: number // timestamp
}>
}
}