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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
81
src/components/Inbox/ConversationItem.tsx
Normal file
81
src/components/Inbox/ConversationItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
145
src/components/Inbox/ConversationList.tsx
Normal file
145
src/components/Inbox/ConversationList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
313
src/components/Inbox/ConversationSettingsModal.tsx
Normal file
313
src/components/Inbox/ConversationSettingsModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
80
src/components/Inbox/InboxContent.tsx
Normal file
80
src/components/Inbox/InboxContent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
src/components/Inbox/MessageComposer.tsx
Normal file
77
src/components/Inbox/MessageComposer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
src/components/Inbox/MessageInfoModal.tsx
Normal file
133
src/components/Inbox/MessageInfoModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
400
src/components/Inbox/MessageView.tsx
Normal file
400
src/components/Inbox/MessageView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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="·" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
25
src/components/Sidebar/InboxButton.tsx
Normal file
25
src/components/Sidebar/InboxButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
29
src/lib/timestamp.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
64
src/pages/primary/InboxPage/index.tsx
Normal file
64
src/pages/primary/InboxPage/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
779
src/providers/DMProvider.tsx
Normal file
779
src/providers/DMProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
778
src/services/dm.service.ts
Normal 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
|
||||
}
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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
43
src/types/index.d.ts
vendored
@@ -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
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user