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>
82 lines
2.7 KiB
TypeScript
82 lines
2.7 KiB
TypeScript
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>
|
|
)
|
|
}
|