- Add keyboard mode toggle button (⇧K) in sidebar - Triple-Escape to quickly exit keyboard mode - Extract QrScannerModal to shared component - Add QR scanner for NWC wallet connection in Settings - Update Help page with keyboard toggle documentation - Fix keyboard navigation getting stuck on inbox - Improve feed loading after login (loads immediately) - DM conversation page layout improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
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 { ChevronDown, ChevronUp, 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 MessageContent from './MessageContent'
|
|
import MessageInfoModal from './MessageInfoModal'
|
|
import ConversationSettingsModal from './ConversationSettingsModal'
|
|
import { useFollowList } from '@/providers/FollowListProvider'
|
|
|
|
interface MessageViewProps {
|
|
onBack?: () => void
|
|
hideHeader?: boolean
|
|
}
|
|
|
|
export default function MessageView({ onBack, hideHeader }: 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)
|
|
// Progressive loading: start with 20 messages, load more on demand
|
|
const [visibleLimit, setVisibleLimit] = useState(20)
|
|
const LOAD_MORE_INCREMENT = 20
|
|
|
|
const isFollowing = currentConversation ? followingSet.has(currentConversation) : false
|
|
|
|
// Calculate visible messages (show most recent, allow loading older)
|
|
const hasMoreMessages = messages.length > visibleLimit
|
|
const visibleMessages = hasMoreMessages
|
|
? messages.slice(-visibleLimit) // Show last N messages (most recent)
|
|
: messages
|
|
|
|
// Load more older messages
|
|
const loadMoreMessages = () => {
|
|
setVisibleLimit((prev) => prev + LOAD_MORE_INCREMENT)
|
|
}
|
|
|
|
// Reset visible limit when conversation changes
|
|
useEffect(() => {
|
|
setVisibleLimit(20)
|
|
}, [currentConversation])
|
|
|
|
// 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])
|
|
|
|
// Scroll to bottom when conversation opens and messages are loaded
|
|
const hasMessages = messages.length > 0
|
|
useEffect(() => {
|
|
if (currentConversation && hasMessages && scrollRef.current) {
|
|
// Use requestAnimationFrame to ensure DOM is ready
|
|
requestAnimationFrame(() => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
|
lastMessageCountRef.current = messages.length
|
|
}
|
|
})
|
|
}
|
|
}, [currentConversation, hasMessages])
|
|
|
|
if (!currentConversation || !pubkey) {
|
|
return null
|
|
}
|
|
|
|
const displayName = profile?.username || currentConversation.slice(0, 8) + '...'
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Header - show when not hidden, or when in selection mode */}
|
|
{(!hideHeader || isSelectionMode) && (
|
|
<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
|
|
<>
|
|
<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>
|
|
{onBack && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8"
|
|
title={t('Close conversation')}
|
|
onClick={onBack}
|
|
>
|
|
<X className="size-4" />
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</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">
|
|
{/* Load more button at top */}
|
|
{hasMoreMessages && (
|
|
<div className="flex justify-center py-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={loadMoreMessages}
|
|
className="text-xs text-muted-foreground"
|
|
>
|
|
<ChevronUp className="size-4 mr-1" />
|
|
{t('Load older messages')} ({messages.length - visibleLimit} more)
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{isLoadingConversation && (
|
|
<div className="flex justify-center py-2">
|
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
{visibleMessages.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'
|
|
)}
|
|
>
|
|
<MessageContent
|
|
content={message.content}
|
|
className="text-sm"
|
|
isOwnMessage={isOwn}
|
|
/>
|
|
<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>
|
|
)
|
|
}
|