ConversationItem.tsx raw
1 import UserAvatar from '@/components/UserAvatar'
2 import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
3 import { formatTimestamp } from '@/lib/timestamp'
4 import { cn } from '@/lib/utils'
5 import client from '@/services/client.service'
6 import { TConversation, TProfile } from '@/types'
7 import { Lock, Users, X } from 'lucide-react'
8 import { useCallback, useEffect, useRef, useState } from 'react'
9
10 interface ConversationItemProps {
11 conversation: TConversation
12 isActive: boolean
13 isFollowing: boolean
14 onClick: () => void
15 onClose?: () => void
16 navIndex?: number
17 }
18
19 export default function ConversationItem({
20 conversation,
21 isActive,
22 isFollowing,
23 onClick,
24 onClose,
25 navIndex
26 }: ConversationItemProps) {
27 const [profile, setProfile] = useState<TProfile | null>(null)
28 const buttonRef = useRef<HTMLButtonElement>(null)
29
30 const handleActivate = useCallback(() => {
31 buttonRef.current?.click()
32 }, [])
33
34 const { ref: navRef, isSelected } = useKeyboardNavigable(1, navIndex ?? 0, {
35 meta: { type: 'sidebar', onActivate: handleActivate }
36 })
37
38 useEffect(() => {
39 const fetchProfileData = async () => {
40 try {
41 const profileData = await client.fetchProfile(conversation.partnerPubkey)
42 if (profileData) {
43 setProfile(profileData)
44 }
45 } catch (error) {
46 console.error('Failed to fetch profile:', error)
47 }
48 }
49 fetchProfileData()
50 }, [conversation.partnerPubkey])
51
52 const displayName = profile?.username || conversation.partnerPubkey.slice(0, 8) + '...'
53 const formattedTime = formatTimestamp(conversation.lastMessageAt)
54
55 return (
56 <div ref={navRef} className="scroll-mt-[6.5rem]">
57 <button
58 ref={buttonRef}
59 onClick={onClick}
60 className={cn(
61 'w-full flex items-start gap-3 p-3 hover:bg-accent/50 transition-colors text-left',
62 isActive && 'bg-accent',
63 isSelected && 'ring-2 ring-primary ring-inset'
64 )}
65 >
66 <UserAvatar userId={conversation.partnerPubkey} className="size-10 flex-shrink-0" />
67
68 <div className="flex-1 min-w-0">
69 <div className="flex items-center justify-between gap-2">
70 <div className="flex items-center gap-1.5 min-w-0">
71 <span className="font-medium text-sm truncate">{displayName}</span>
72 {isFollowing && (
73 <span className="text-xs text-primary flex-shrink-0" title="Following">
74 <Users className="size-3" />
75 </span>
76 )}
77 </div>
78 <div className="flex items-center gap-1 flex-shrink-0">
79 <span className="text-xs text-muted-foreground">{formattedTime}</span>
80 {isActive && onClose && (
81 <button
82 onClick={(e) => {
83 e.stopPropagation()
84 onClose()
85 }}
86 className="p-0.5 rounded hover:bg-muted-foreground/20 transition-colors"
87 title="Close conversation"
88 >
89 <X className="size-4 text-muted-foreground" />
90 </button>
91 )}
92 </div>
93 </div>
94
95 <div className="flex items-center gap-1.5 mt-0.5">
96 {conversation.preferredEncryption === 'nip17' && (
97 <span title="NIP-17 encrypted">
98 <Lock className="size-3 text-green-500 flex-shrink-0" />
99 </span>
100 )}
101 <p className="text-sm text-muted-foreground truncate">{conversation.lastMessagePreview}</p>
102 </div>
103
104 {conversation.unreadCount > 0 && (
105 <span className="inline-flex items-center justify-center size-5 text-xs rounded-full bg-primary text-primary-foreground mt-1">
106 {conversation.unreadCount}
107 </span>
108 )}
109 </div>
110 </button>
111 </div>
112 )
113 }
114