MessageView.tsx raw
1 import UserAvatar from '@/components/UserAvatar'
2 import { formatTimestamp } from '@/lib/timestamp'
3 import { cn } from '@/lib/utils'
4 import { useDM } from '@/providers/DMProvider'
5 import { useNostr } from '@/providers/NostrProvider'
6 import client from '@/services/client.service'
7 import indexedDb from '@/services/indexed-db.service'
8 import { TDirectMessage, TProfile } from '@/types'
9 import { ChevronDown, ChevronUp, Loader2, MoreVertical, RefreshCw, Settings, Trash2, Undo2, Users, X } from 'lucide-react'
10 import { useEffect, useRef, useState } from 'react'
11 import { useTranslation } from 'react-i18next'
12 import { Button } from '../ui/button'
13 import { ScrollArea } from '../ui/scroll-area'
14 import { Checkbox } from '../ui/checkbox'
15 import {
16 DropdownMenu,
17 DropdownMenuContent,
18 DropdownMenuItem,
19 DropdownMenuTrigger
20 } from '../ui/dropdown-menu'
21 import MessageComposer from './MessageComposer'
22 import MessageContent from './MessageContent'
23 import MessageInfoModal from './MessageInfoModal'
24 import ConversationSettingsModal from './ConversationSettingsModal'
25 import { useFollowList } from '@/providers/FollowListProvider'
26
27 interface MessageViewProps {
28 onBack?: () => void
29 hideHeader?: boolean
30 }
31
32 export default function MessageView({ onBack, hideHeader }: MessageViewProps) {
33 const { t } = useTranslation()
34 const { pubkey } = useNostr()
35 const {
36 currentConversation,
37 messages,
38 isLoadingConversation,
39 isNewConversation,
40 clearNewConversationFlag,
41 reloadConversation,
42 // Selection mode
43 selectedMessages,
44 isSelectionMode,
45 toggleMessageSelection,
46 clearSelection,
47 deleteSelectedMessages,
48 deleteAllInConversation,
49 undeleteAllInConversation
50 } = useDM()
51 const { followingSet } = useFollowList()
52 const [profile, setProfile] = useState<TProfile | null>(null)
53 const scrollRef = useRef<HTMLDivElement>(null)
54 const [selectedMessage, setSelectedMessage] = useState<TDirectMessage | null>(null)
55 const [messageInfoOpen, setMessageInfoOpen] = useState(false)
56 const [settingsOpen, setSettingsOpen] = useState(false)
57 const [selectedRelays, setSelectedRelays] = useState<string[]>([])
58 const [showPulse, setShowPulse] = useState(false)
59 const [showJumpButton, setShowJumpButton] = useState(false)
60 const [newMessageCount, setNewMessageCount] = useState(0)
61 const lastMessageCountRef = useRef(0)
62 const isAtBottomRef = useRef(true)
63 // Progressive loading: start with 20 messages, load more on demand
64 const [visibleLimit, setVisibleLimit] = useState(20)
65 const LOAD_MORE_INCREMENT = 20
66
67 const isFollowing = currentConversation ? followingSet.has(currentConversation) : false
68
69 // Calculate visible messages (show most recent, allow loading older)
70 const hasMoreMessages = messages.length > visibleLimit
71 const visibleMessages = hasMoreMessages
72 ? messages.slice(-visibleLimit) // Show last N messages (most recent)
73 : messages
74
75 // Load more older messages
76 const loadMoreMessages = () => {
77 setVisibleLimit((prev) => prev + LOAD_MORE_INCREMENT)
78 }
79
80 // Reset visible limit when conversation changes
81 useEffect(() => {
82 setVisibleLimit(20)
83 }, [currentConversation])
84
85 // Handle pulsing animation for new conversations
86 useEffect(() => {
87 if (isNewConversation) {
88 setShowPulse(true)
89 const timer = setTimeout(() => {
90 setShowPulse(false)
91 clearNewConversationFlag()
92 }, 10000)
93 return () => clearTimeout(timer)
94 }
95 }, [isNewConversation, clearNewConversationFlag])
96
97 useEffect(() => {
98 if (!currentConversation) return
99
100 const fetchProfileData = async () => {
101 try {
102 const profileData = await client.fetchProfile(currentConversation)
103 if (profileData) {
104 setProfile(profileData)
105 }
106 } catch (error) {
107 console.error('Failed to fetch profile:', error)
108 }
109 }
110 fetchProfileData()
111 }, [currentConversation])
112
113 // Load saved relay settings when conversation changes
114 useEffect(() => {
115 if (!currentConversation || !pubkey) return
116
117 const loadRelaySettings = async () => {
118 const saved = await indexedDb.getConversationRelaySettings(pubkey, currentConversation)
119 setSelectedRelays(saved || [])
120 }
121 loadRelaySettings()
122 }, [currentConversation, pubkey])
123
124 // Save relay settings when they change
125 const handleRelaysChange = async (relays: string[]) => {
126 setSelectedRelays(relays)
127 if (pubkey && currentConversation) {
128 await indexedDb.putConversationRelaySettings(pubkey, currentConversation, relays)
129 }
130 }
131
132 // Handle scroll position tracking
133 const handleScroll = () => {
134 if (!scrollRef.current) return
135 const { scrollTop, scrollHeight, clientHeight } = scrollRef.current
136 const distanceFromBottom = scrollHeight - scrollTop - clientHeight
137 const atBottom = distanceFromBottom < 100 // 100px threshold
138
139 isAtBottomRef.current = atBottom
140 setShowJumpButton(!atBottom)
141
142 // Reset new message count when user scrolls to bottom
143 if (atBottom) {
144 setNewMessageCount(0)
145 lastMessageCountRef.current = messages.length
146 }
147 }
148
149 // Track new messages when scrolled up
150 useEffect(() => {
151 if (!isAtBottomRef.current && messages.length > lastMessageCountRef.current) {
152 setNewMessageCount(messages.length - lastMessageCountRef.current)
153 } else if (isAtBottomRef.current) {
154 lastMessageCountRef.current = messages.length
155 }
156 }, [messages.length])
157
158 // Scroll to bottom when messages change (only if already at bottom)
159 useEffect(() => {
160 if (scrollRef.current && isAtBottomRef.current) {
161 scrollRef.current.scrollTop = scrollRef.current.scrollHeight
162 lastMessageCountRef.current = messages.length
163 }
164 }, [messages])
165
166 // Scroll to bottom function
167 const scrollToBottom = () => {
168 if (scrollRef.current) {
169 scrollRef.current.scrollTo({
170 top: scrollRef.current.scrollHeight,
171 behavior: 'smooth'
172 })
173 setNewMessageCount(0)
174 lastMessageCountRef.current = messages.length
175 isAtBottomRef.current = true
176 setShowJumpButton(false)
177 }
178 }
179
180 // Reset scroll state when conversation changes
181 useEffect(() => {
182 isAtBottomRef.current = true
183 setShowJumpButton(false)
184 setNewMessageCount(0)
185 lastMessageCountRef.current = 0
186 }, [currentConversation])
187
188 // Scroll to bottom when conversation opens and messages are loaded
189 const hasMessages = messages.length > 0
190 useEffect(() => {
191 if (currentConversation && hasMessages && scrollRef.current) {
192 // Use requestAnimationFrame to ensure DOM is ready
193 requestAnimationFrame(() => {
194 if (scrollRef.current) {
195 scrollRef.current.scrollTop = scrollRef.current.scrollHeight
196 lastMessageCountRef.current = messages.length
197 }
198 })
199 }
200 }, [currentConversation, hasMessages])
201
202 if (!currentConversation || !pubkey) {
203 return null
204 }
205
206 const displayName = profile?.username || currentConversation.slice(0, 8) + '...'
207
208 return (
209 <div className="flex flex-col h-full">
210 {/* Header - show when not hidden, or when in selection mode */}
211 {(!hideHeader || isSelectionMode) && (
212 <div className="flex items-center gap-3 p-3 border-b">
213 {isSelectionMode ? (
214 // Selection mode header
215 <>
216 <Button
217 variant="ghost"
218 size="icon"
219 onClick={clearSelection}
220 className="size-8"
221 title={t('Cancel')}
222 >
223 <X className="size-4" />
224 </Button>
225 <div className="flex items-center gap-2">
226 <Trash2 className="size-4 text-destructive" />
227 <span className="font-medium text-sm">{t('Delete')}</span>
228 </div>
229 <div className="flex-1" />
230 <Button
231 variant="outline"
232 size="sm"
233 onClick={deleteSelectedMessages}
234 disabled={selectedMessages.size === 0}
235 className="text-xs"
236 >
237 {t('Selected')} ({selectedMessages.size})
238 </Button>
239 <Button
240 variant="destructive"
241 size="sm"
242 onClick={deleteAllInConversation}
243 className="text-xs"
244 >
245 {t('All')}
246 </Button>
247 </>
248 ) : (
249 // Normal header
250 <>
251 <UserAvatar userId={currentConversation} className="size-8" />
252 <div className="flex-1 min-w-0">
253 <div className="flex items-center gap-1.5">
254 <span className="font-medium text-sm truncate">{displayName}</span>
255 {isFollowing && (
256 <span title="Following">
257 <Users className="size-3 text-primary" />
258 </span>
259 )}
260 </div>
261 {profile?.nip05 && (
262 <span className="text-xs text-muted-foreground truncate">{profile.nip05}</span>
263 )}
264 </div>
265 <Button
266 variant="ghost"
267 size="icon"
268 className="size-8"
269 title={t('Reload messages')}
270 onClick={reloadConversation}
271 disabled={isLoadingConversation}
272 >
273 <RefreshCw className={cn('size-4', isLoadingConversation && 'animate-spin')} />
274 </Button>
275 <Button
276 variant="ghost"
277 size="icon"
278 className={cn('size-8', showPulse && 'animate-pulse ring-2 ring-primary ring-offset-2')}
279 title={t('Conversation settings')}
280 onClick={() => {
281 setShowPulse(false)
282 clearNewConversationFlag()
283 setSettingsOpen(true)
284 }}
285 >
286 <Settings className="size-4" />
287 </Button>
288 <DropdownMenu>
289 <DropdownMenuTrigger asChild>
290 <Button variant="ghost" size="icon" className="size-8">
291 <MoreVertical className="size-4" />
292 </Button>
293 </DropdownMenuTrigger>
294 <DropdownMenuContent align="end">
295 <DropdownMenuItem onClick={deleteAllInConversation} className="text-destructive focus:text-destructive">
296 <Trash2 className="size-4 mr-2" />
297 {t('Delete All')}
298 </DropdownMenuItem>
299 <DropdownMenuItem onClick={undeleteAllInConversation}>
300 <Undo2 className="size-4 mr-2" />
301 {t('Undelete All')}
302 </DropdownMenuItem>
303 </DropdownMenuContent>
304 </DropdownMenu>
305 {onBack && (
306 <Button
307 variant="ghost"
308 size="icon"
309 className="size-8"
310 title={t('Close conversation')}
311 onClick={onBack}
312 >
313 <X className="size-4" />
314 </Button>
315 )}
316 </>
317 )}
318 </div>
319 )}
320
321 {/* Messages */}
322 <div className="flex-1 relative overflow-hidden">
323 <ScrollArea ref={scrollRef} className="h-full p-3" onScrollCapture={handleScroll}>
324 {isLoadingConversation && messages.length === 0 ? (
325 <div className="flex items-center justify-center h-full">
326 <Loader2 className="size-6 animate-spin text-muted-foreground" />
327 </div>
328 ) : messages.length === 0 ? (
329 <div className="flex items-center justify-center h-full text-muted-foreground">
330 <p className="text-sm">{t('No messages yet. Send one to start the conversation!')}</p>
331 </div>
332 ) : (
333 <div className="space-y-3">
334 {/* Load more button at top */}
335 {hasMoreMessages && (
336 <div className="flex justify-center py-2">
337 <Button
338 variant="ghost"
339 size="sm"
340 onClick={loadMoreMessages}
341 className="text-xs text-muted-foreground"
342 >
343 <ChevronUp className="size-4 mr-1" />
344 {t('Load older messages')} ({messages.length - visibleLimit} more)
345 </Button>
346 </div>
347 )}
348 {isLoadingConversation && (
349 <div className="flex justify-center py-2">
350 <Loader2 className="size-4 animate-spin text-muted-foreground" />
351 </div>
352 )}
353 {visibleMessages.map((message) => {
354 const isOwn = message.senderPubkey === pubkey
355 const isSelected = selectedMessages.has(message.id)
356 return (
357 <div
358 key={message.id}
359 className={cn(
360 'flex items-start gap-2 group',
361 isOwn ? 'flex-row-reverse' : 'flex-row'
362 )}
363 >
364 {/* Checkbox - shows on hover or when in selection mode */}
365 <div
366 className={cn(
367 'flex-shrink-0 transition-opacity',
368 isSelectionMode || isSelected
369 ? 'opacity-100'
370 : 'opacity-0 group-hover:opacity-100'
371 )}
372 >
373 <Checkbox
374 checked={isSelected}
375 onCheckedChange={() => toggleMessageSelection(message.id)}
376 className="mt-2"
377 />
378 </div>
379 <div
380 className={cn(
381 'max-w-[80%] rounded-lg px-3 py-2',
382 isOwn
383 ? 'bg-primary text-primary-foreground'
384 : 'bg-muted',
385 isSelected && 'ring-2 ring-primary ring-offset-2'
386 )}
387 >
388 <MessageContent
389 content={message.content}
390 className="text-sm"
391 isOwnMessage={isOwn}
392 />
393 <div
394 className={cn(
395 'flex items-center justify-between gap-2 mt-1 text-xs',
396 isOwn ? 'text-primary-foreground/70' : 'text-muted-foreground'
397 )}
398 >
399 <span>{formatTimestamp(message.createdAt)}</span>
400 <button
401 onClick={() => {
402 setSelectedMessage(message)
403 setMessageInfoOpen(true)
404 }}
405 className={cn(
406 'font-mono opacity-60 hover:opacity-100 transition-opacity',
407 isOwn ? 'hover:text-primary-foreground' : 'hover:text-foreground'
408 )}
409 title={t('Message info')}
410 >
411 {message.encryptionType === 'nip17' ? '44' : '4'}
412 </button>
413 </div>
414 </div>
415 </div>
416 )
417 })}
418 </div>
419 )}
420 </ScrollArea>
421
422 {/* Jump to newest button */}
423 {showJumpButton && (
424 <Button
425 onClick={scrollToBottom}
426 className="absolute bottom-4 right-4 rounded-full shadow-lg size-10 p-0"
427 size="icon"
428 >
429 <ChevronDown className="size-5" />
430 {newMessageCount > 0 && (
431 <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">
432 {newMessageCount > 99 ? '99+' : newMessageCount}
433 </span>
434 )}
435 </Button>
436 )}
437 </div>
438
439 {/* Composer */}
440 <div className="border-t">
441 <MessageComposer />
442 </div>
443
444 {/* Message Info Modal */}
445 <MessageInfoModal
446 message={selectedMessage}
447 open={messageInfoOpen}
448 onOpenChange={setMessageInfoOpen}
449 />
450
451 {/* Conversation Settings Modal */}
452 <ConversationSettingsModal
453 partnerPubkey={currentConversation}
454 open={settingsOpen}
455 onOpenChange={setSettingsOpen}
456 selectedRelays={selectedRelays}
457 onSelectedRelaysChange={handleRelaysChange}
458 />
459 </div>
460 )
461 }
462