File size: 4,944 Bytes
00a912e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 | import { useState, useRef, useEffect, useCallback } from 'react'
import { ArrowDown, MessageSquare, PanelRightClose, Send } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ChatMessage } from './ChatMessage'
import { useChatStore } from '@/stores/chatStore'
import { useRoomStore } from '@/stores/roomStore'
import { useChat } from '@/hooks/useChat'
/** Threshold (px) to consider the user "at the bottom" of the scroll container */
const SCROLL_BOTTOM_THRESHOLD = 80
interface ChatPanelProps {
onCollapse?: () => void
}
export function ChatPanel({ onCollapse }: ChatPanelProps) {
const [input, setInput] = useState('')
const [showNewMsgHint, setShowNewMsgHint] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true)
const messages = useChatStore((s) => s.messages)
const currentUser = useRoomStore((s) => s.currentUser)
const { sendMessage } = useChat()
// Track whether the user has scrolled to the bottom
const handleScroll = useCallback(() => {
const el = scrollContainerRef.current
if (!el) return
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_BOTTOM_THRESHOLD
isAtBottomRef.current = atBottom
if (atBottom) setShowNewMsgHint(false)
}, [])
// Smart auto-scroll: only scroll to bottom if user was already at the bottom
useEffect(() => {
const frame = requestAnimationFrame(() => {
if (isAtBottomRef.current) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
} else {
setShowNewMsgHint(true)
}
})
return () => cancelAnimationFrame(frame)
}, [messages])
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
setShowNewMsgHint(false)
}, [])
const handleSend = () => {
if (!input.trim()) return
sendMessage(input)
setInput('')
}
return (
<div className="flex min-h-0 w-full flex-1 flex-col border-l border-border/50 bg-background/60 backdrop-blur-sm">
{/* Header */}
<div className="relative flex shrink-0 items-center gap-2 border-b border-border/50 px-4 py-3">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
{onCollapse && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute right-3"
onClick={onCollapse}
aria-label="收起聊天"
>
<PanelRightClose className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>收起聊天</TooltipContent>
</Tooltip>
)}
<span className="text-sm font-medium">聊天</span>
</div>
{/* Messages */}
<div className="relative min-h-0 flex-1">
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="absolute inset-0 overflow-y-auto px-3"
aria-live="polite"
aria-label="聊天消息"
>
<div className="py-3">
{messages.length === 0 ? (
<p className="text-center text-sm text-muted-foreground/30 py-8">还没有消息,开始聊天吧~</p>
) : (
messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} isOwnMessage={msg.userId === currentUser?.id} />
))
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* New message hint */}
{showNewMsgHint && (
<Button
variant="secondary"
size="sm"
className="absolute bottom-2 left-1/2 z-10 -translate-x-1/2 gap-1 rounded-full shadow-md"
onClick={scrollToBottom}
>
<ArrowDown className="h-3.5 w-3.5" />
新消息
</Button>
)}
</div>
{/* Input */}
<div className="flex shrink-0 gap-2 border-t border-border/50 px-3 py-3">
<Input
placeholder="输入消息..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
className="flex-1"
aria-label="输入聊天消息"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="secondary" onClick={handleSend} disabled={!input.trim()} aria-label="发送消息">
<Send className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>发送</TooltipContent>
</Tooltip>
</div>
</div>
)
}
|