tahamajs/IE / Projects /front /src /components /threads /ThreadMessageList.tsx
tahamajs's picture
download
raw
7.11 kB
// 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.