| // src/components/threads/ThreadMessageList.tsx | |
| import { useRef, useEffect, useCallback } from "react"; | |
| import type { ThreadMessage, ReplyContext } from "@/types/thread"; | |
| import { MessageBubble } from "@/components/messages/MessageBubble"; | |
| import { Loader2, MessageSquare, ArrowDown } from "lucide-react"; | |
| interface ThreadMessageListProps { | |
| messages: ThreadMessage[]; | |
| loading: boolean; | |
| loadingMore: boolean; // separate state for loading older messages | |
| currentUsername: string; | |
| currentUserId: number; | |
| onReply: (ctx: ReplyContext) => void; | |
| onShowProfile: (username: string) => void; | |
| hasMore: boolean; | |
| onLoadMore: () => void; | |
| } | |
| // Group messages by sender (consecutive same author) | |
| function groupMessages(msgs: ThreadMessage[]) { | |
| const groups: { sender: string; messages: ThreadMessage[] }[] = []; | |
| for (const msg of msgs) { | |
| const last = groups[groups.length - 1]; | |
| if (last && last.sender === msg.from) { | |
| last.messages.push(msg); | |
| } else { | |
| groups.push({ sender: msg.from, messages: [msg] }); | |
| } | |
| } | |
| return groups; | |
| } | |
| export function ThreadMessageList({ | |
| messages, | |
| loading, | |
| loadingMore, | |
| currentUsername, | |
| currentUserId, | |
| onReply, | |
| onShowProfile, | |
| hasMore, | |
| onLoadMore, | |
| }: ThreadMessageListProps) { | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const shouldAutoScrollRef = useRef(true); | |
| // Auto‑scroll to bottom when new messages arrive (user not scrolled up) | |
| const scrollToBottom = useCallback(() => { | |
| if (containerRef.current && shouldAutoScrollRef.current) { | |
| containerRef.current.scrollTop = containerRef.current.scrollHeight; | |
| } | |
| }, []); | |
| // Detect user scroll – if they scroll up, disable auto‑scroll | |
| useEffect(() => { | |
| const el = containerRef.current; | |
| if (!el) return; | |
| const handleScroll = () => { | |
| const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50; | |
| shouldAutoScrollRef.current = isNearBottom; | |
| }; | |
| el.addEventListener("scroll", handleScroll); | |
| return () => el.removeEventListener("scroll", handleScroll); | |
| }, []); | |
| // Scroll to bottom on initial load or when messages change (if auto‑scroll enabled) | |
| useEffect(() => { | |
| scrollToBottom(); | |
| }, [messages, scrollToBottom]); | |
| // Infinite scroll up (load older messages when scrolled to top) | |
| useEffect(() => { | |
| const el = containerRef.current; | |
| if (!el) return; | |
| const handleScroll = () => { | |
| if (el.scrollTop === 0 && hasMore && !loading && !loadingMore) { | |
| onLoadMore(); | |
| } | |
| }; | |
| el.addEventListener("scroll", handleScroll); | |
| return () => el.removeEventListener("scroll", handleScroll); | |
| }, [hasMore, loading, loadingMore, onLoadMore]); | |
| if (loading && messages.length === 0) { | |
| return ( | |
| <div className="flex flex-1 items-center justify-center"> | |
| <Loader2 className="h-6 w-6 animate-spin text-gray-400" /> | |
| </div> | |
| ); | |
| } | |
| if (!loading && messages.length === 0) { | |
| return ( | |
| <div className="flex flex-1 flex-col items-center justify-center text-gray-400"> | |
| <MessageSquare className="mb-3 h-10 w-10 opacity-30" /> | |
| <p className="text-sm">No replies yet. Start the conversation!</p> | |
| </div> | |
| ); | |
| } | |
| const groupedMessages = groupMessages(messages); | |
| return ( | |
| <div ref={containerRef} className="flex-1 overflow-y-auto px-4 py-3"> | |
| {/* Load older indicator */} | |
| {hasMore && ( | |
| <div className="py-2 text-center"> | |
| {loadingMore ? ( | |
| <Loader2 className="mx-auto h-4 w-4 animate-spin text-primary" /> | |
| ) : ( | |
| <button | |
| onClick={onLoadMore} | |
| className="text-xs text-primary hover:underline" | |
| > | |
| Load earlier replies | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| <div className="space-y-1"> | |
| {groupedMessages.map((group, gi) => ( | |
| <div key={gi} className="space-y-0.5"> | |
| {group.messages.map((msg, mi) => { | |
| const isFirst = mi === 0; | |
| const isOwn = msg.from === currentUsername; | |
| const timeStr = new Date(msg.sentAt).toLocaleTimeString([], { | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| }); | |
| return ( | |
| <div key={msg.id} className="group relative"> | |
| {isFirst && ( | |
| <div className="mb-1 mt-3 flex items-center gap-2"> | |
| <div | |
| className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-full bg-gradient-to-br from-primary/30 to-secondary/30 text-xs font-bold text-primary" | |
| onClick={() => onShowProfile(msg.from)} | |
| > | |
| {msg.from[0]?.toUpperCase()} | |
| </div> | |
| <span | |
| className="cursor-pointer text-sm font-semibold hover:underline" | |
| onClick={() => onShowProfile(msg.from)} | |
| > | |
| {msg.from} | |
| </span> | |
| <span className="text-[10px] text-gray-400">{timeStr}</span> | |
| </div> | |
| )} | |
| <div className="ml-9"> | |
| <div | |
| className={`inline-block max-w-[85%] rounded-2xl px-4 py-2 text-sm ${ | |
| isOwn | |
| ? "rounded-tl-sm bg-primary/10 text-gray-800 dark:text-gray-200" | |
| : "rounded-tl-sm bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200" | |
| }`} | |
| > | |
| <p className="whitespace-pre-wrap break-words">{msg.body}</p> | |
| {msg.isAiGenerated && ( | |
| <span className="mt-1 block text-[10px] text-purple-500"> | |
| 🤖 AI generated | |
| </span> | |
| )} | |
| </div> | |
| <button | |
| onClick={() => | |
| onReply({ | |
| messageId: msg.id, | |
| authorUsername: msg.from, | |
| body: msg.body, | |
| timestamp: msg.sentAt, | |
| }) | |
| } | |
| className="ml-1 text-[10px] text-gray-400 opacity-0 transition hover:text-primary group-hover:opacity-100" | |
| > | |
| Reply | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ))} | |
| </div> | |
| {/* Scroll to bottom button (appears when user manually scrolled up) */} | |
| {!shouldAutoScrollRef.current && messages.length > 0 && ( | |
| <button | |
| onClick={scrollToBottom} | |
| className="fixed bottom-20 right-6 z-10 rounded-full bg-primary p-2 text-white shadow-lg hover:bg-primary/90" | |
| aria-label="Scroll to bottom" | |
| > | |
| <ArrowDown className="h-5 w-5" /> | |
| </button> | |
| )} | |
| </div> | |
| ); | |
| } |
Xet Storage Details
- Size:
- 7.11 kB
- Xet hash:
- f6c4a7ff305bd150c54d8dd4003d73a454f8850fd81ad3a640d176b1553d27e9
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.