'use client';
// app/components/chat/ChatWindow.tsx
import React, { useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { MessageBubble } from './MessageBubble';
import {
BiPaperclip,
BiSend,
BiArrowToLeft,
BiX,
BiFile,
} from 'react-icons/bi';
import {
useGetMessagesInfinite,
useSendMessage,
} from '@/lib/api/student/queryHooks';
import type { RootState } from '../store';
import type { ChatMessage } from '@/lib/api/student/functions';
import Image from 'next/image';
import { toastUtils } from '@/components/lib/toast';
import { useTranslate } from '@/components/hooks/useTranslate';
import { useNotification } from '@/lib/firebase/NotificationContext';
import { useQueryClient } from '@tanstack/react-query';
// Define the Message type locally to match MessageBubble expectations
type Message = {
id: number;
sender: string;
isSender: boolean;
time: string;
status: 'sent' | 'read';
content: string;
attachment?: { name: string; size?: string; url?: string };
images?: string[];
avatar?: string;
};
// Define the contact type
type Contact = {
id: number;
name: string;
lastMessage: string;
time: string;
avatar: string;
unread: number;
active: boolean;
subject?: string;
};
// Props for ChatWindow
type ChatWindowProps = {
selectedContact: Contact | null;
onBackToContacts?: () => void;
isMobile?: boolean;
};
// File type definitions for validation
// Supported file types for attachments
type FilePreview = {
file: File;
preview: string; // URL for preview (image URLs, or icon for documents)
type: 'image' | 'document' | 'video';
};
// Utility function to check if avatar is a valid URL
// Returns true if avatar is a URL, false if it's text/initials
const isValidUrl = (avatar: string): boolean => {
if (!avatar || avatar.trim() === '') return false;
// Check if it starts with http://, https://, or /
if (
avatar.startsWith('http://') ||
avatar.startsWith('https://') ||
avatar.startsWith('/')
) {
return true;
}
return false;
};
// Utility function to extract time from date-time string
// Handles multiple formats: "DD/MM/YYYY HH:MM" and ISO 8601
// Output format: "11:52"
const extractTime = (dateTimeString: string): string => {
try {
// Handle null, undefined, or empty strings
if (!dateTimeString || dateTimeString.trim() === '') {
return 'now';
}
// First, try the original logic for "DD/MM/YYYY HH:MM" format
const parts = dateTimeString.split(' ');
if (parts.length > 1 && parts[1].includes(':')) {
return parts[1];
}
// If that doesn't work, try parsing as ISO date
const date = new Date(dateTimeString);
if (!isNaN(date.getTime())) {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
// Last fallback
return 'now';
} catch (error) {
console.warn('Error extracting time:', dateTimeString, error);
return 'now';
}
};
// Utility function to validate file type
// Returns file category: 'image', 'document', 'video', or null if not supported
const getFileType = (file: File): 'image' | 'document' | 'video' | null => {
const fileType = file.type.toLowerCase();
// Check if image (jpg, jpeg, png, gif, webp)
if (fileType.startsWith('image/')) {
return 'image';
}
// Check if video (only MP4 supported)
if (fileType === 'video/mp4') {
return 'video';
}
// Check if document (pdf, doc, docx, txt, etc.)
if (
fileType === 'application/pdf' ||
fileType === 'application/msword' ||
fileType ===
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
fileType === 'text/plain' ||
fileType === 'application/vnd.ms-excel' ||
fileType ===
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
) {
return 'document';
}
// Unsupported file type
return null;
};
// Utility function to validate file size
// Returns true if file is valid, false otherwise
const validateFileSize = (file: File): boolean => {
const MAX_VIDEO_SIZE = 10 * 1024 * 1024; // 10MB in bytes
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB for images
const MAX_DOCUMENT_SIZE = 10 * 1024 * 1024; // 10MB for documents
const fileType = getFileType(file);
if (fileType === 'video') {
return file.size <= MAX_VIDEO_SIZE;
} else if (fileType === 'image') {
return file.size <= MAX_IMAGE_SIZE;
} else if (fileType === 'document') {
return file.size <= MAX_DOCUMENT_SIZE;
}
return false;
};
// Utility function to format file size for display
// Input: bytes (number)
// Output: "1.5 MB", "500 KB", etc.
// const formatFileSize = (bytes: number): string => {
// if (bytes < 1024) return bytes + ' B';
// if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
// return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
// };
// Utility function to parse date from date-time string
// Handles multiple formats: "DD/MM/YYYY HH:MM", "DD-MM-YYYY HH:MM", and ISO 8601
// Returns Date object
// Utility function to check if a file is an image based on file type/extension
const isImageFile = (fileType: string): boolean => {
const imageTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
return imageTypes.includes(fileType.toLowerCase());
};
// Utility function to get file name from URL
const getFileNameFromUrl = (url: string): string => {
try {
const urlParts = url.split('/');
return urlParts[urlParts.length - 1] || 'file';
} catch {
return 'file';
}
};
// Utility function to transform API messages to MessageBubble format
const transformMessage = (
apiMessage: ChatMessage,
loggedInUserId: number,
contactAvatar?: string,
): Message => {
const isSender = apiMessage.sender_id === loggedInUserId;
// Separate files into images and other attachments
const images: string[] = [];
let attachment: Message['attachment'] = undefined;
// Process file attachments from API
// API uses "attachment" array, not "file"
if (apiMessage.attachment && apiMessage.attachment.length > 0) {
apiMessage.attachment.forEach((file) => {
// Check if file is an image based on file_type
if (isImageFile(file.file_type)) {
// Add to images array - use "file" field for URL
images.push(file.file);
} else {
// Use the first non-image file as the attachment
// If there are multiple non-image files, we'll show the first one
if (!attachment) {
attachment = {
name: getFileNameFromUrl(file.file), // Extract filename from URL
url: file.file,
};
}
}
});
}
return {
id: apiMessage.id,
sender: 'User', // We don't have sender name in message API, so use placeholder
isSender, // RIGHT side if true, LEFT side if false
time: extractTime(apiMessage.created_at),
status: apiMessage.read_at ? 'read' : 'sent', // Check read_at field for status
content: apiMessage.message || '', // Message text content (can be empty if only files)
attachment,
images: images.length > 0 ? images : undefined,
avatar: contactAvatar, // Use avatar from selected contact
};
};
export const ChatWindow = ({
selectedContact,
onBackToContacts,
isMobile = false,
}: ChatWindowProps) => {
const translate = useTranslate();
// Get the logged-in user ID from Redux store
const user = useSelector((state: RootState) => state.studentAuth.user);
const loggedInUserId = user?.id || 0;
const { lastNotification } = useNotification();
const queryClient = useQueryClient();
// State for message input and file attachments
const [messageInput, setMessageInput] = useState('');
const [selectedFiles, setSelectedFiles] = useState<FilePreview[]>([]);
// Fetch messages for the selected contact using React Query with pagination support
const {
data: messagesInfiniteData,
isLoading: isLoadingMessages,
error: messagesError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useGetMessagesInfinite(selectedContact?.id || null);
// Send message mutation hook
const sendMessageMutation = useSendMessage(selectedContact?.id || null);
// Flatten messages from all pages
const apiMessages =
messagesInfiniteData?.pages.flatMap((page) => page.data || []) || [];
// Track if we should scroll to bottom (initial load or new message)
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(true);
// Track previous scroll height for anchoring when loading history
const [prevScrollHeight, setPrevScrollHeight] = useState(0);
// Refs for scroll and file management
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const loaderRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// IMPORTANT: Reverse the message order
// API returns newest first, but we want oldest first (top) and newest last (bottom)
const reversedApiMessages = [...apiMessages].reverse();
const transformedMessages = reversedApiMessages.map((msg) =>
transformMessage(msg, loggedInUserId, selectedContact?.avatar),
);
// Group messages by date for rendering with date separators
// This creates a structure like: [{ date: "Today", messages: [...] }, { date: "Yesterday", messages: [...] }]
const groupedMessages: Array<{
dateKey: string;
dateLabel: string;
messages: Message[];
}> = [];
transformedMessages.forEach((msg, index) => {
const apiMsg = reversedApiMessages[index];
// Extract just the date part (e.g., "2026/11/03" from "2026/11/03 09:34")
const dateKey = apiMsg.created_at ? apiMsg.created_at.split(' ')[0] : 'Unknown Date';
const dateLabel = dateKey;
// Check if we already have a group for this date
const existingGroup = groupedMessages.find(
(group) => group.dateKey === dateKey,
);
if (existingGroup) {
// Add message to existing date group
existingGroup.messages.push(msg);
} else {
// Create new date group
groupedMessages.push({
dateKey,
dateLabel,
messages: [msg],
});
}
});
// Handler for file selection
// Validates file type and size, creates preview URL
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) return;
const newFiles: FilePreview[] = [];
// Process each selected file
Array.from(files).forEach((file) => {
// Validate file type
const fileType = getFileType(file);
if (!fileType) {
toastUtils.error(translate('fileTypeNotSupported'));
return;
}
// Validate file size
if (!validateFileSize(file)) {
toastUtils.error(translate('fileTooLarge'));
return;
}
// Create preview URL for images and videos
let preview = '';
if (fileType === 'image' || fileType === 'video') {
preview = URL.createObjectURL(file);
}
// Add to newFiles array
newFiles.push({
file,
preview,
type: fileType,
});
});
// Add new files to selected files
setSelectedFiles((prev) => [...prev, ...newFiles]);
// Reset file input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// Handler for removing a selected file
const handleRemoveFile = (index: number) => {
setSelectedFiles((prev) => {
const newFiles = [...prev];
// Revoke the object URL to free memory
if (newFiles[index].preview) {
URL.revokeObjectURL(newFiles[index].preview);
}
newFiles.splice(index, 1);
return newFiles;
});
};
// Handler for sending message
const handleSendMessage = async () => {
// Validation: Message or files must be present
if (!messageInput.trim() && selectedFiles.length === 0) {
toastUtils.error(translate('pleaseEnterMessageOrSelectFile'));
return;
}
// Validation: Contact must be selected
if (!selectedContact?.id) {
toastUtils.error(translate('pleaseSelectContactToSendMessage'));
return;
}
// Prepare message data
const messageData = {
to: String(selectedContact.id), // API expects string format
message: messageInput.trim(),
files: selectedFiles.map((f) => f.file),
};
// Send message using mutation
sendMessageMutation.mutate(messageData, {
onSuccess: () => {
// Clear input and files on success
setMessageInput('');
setSelectedFiles([]);
// Enable scroll to bottom for the user's new message
setShouldScrollToBottom(true);
},
onSettled: () => {
// Revoke all preview URLs to free memory
selectedFiles.forEach((file) => {
if (file.preview) {
URL.revokeObjectURL(file.preview);
}
});
},
});
};
// Intersection Observer to trigger loading history when reaching the top
useEffect(() => {
if (!hasNextPage || isFetchingNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
// Record scroll height before loading more data
if (scrollContainerRef.current) {
setPrevScrollHeight(scrollContainerRef.current.scrollHeight);
}
setShouldScrollToBottom(false);
fetchNextPage();
}
},
{ threshold: 1.0, rootMargin: '100px' },
);
const currentLoader = loaderRef.current;
if (currentLoader) {
observer.observe(currentLoader);
}
return () => {
if (currentLoader) {
observer.unobserve(currentLoader);
}
};
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// Handle scroll position when history is loaded to prevent jumping
useEffect(() => {
if (
!isFetchingNextPage &&
prevScrollHeight > 0 &&
scrollContainerRef.current
) {
const currentScrollHeight = scrollContainerRef.current.scrollHeight;
const scrollDifference = currentScrollHeight - prevScrollHeight;
if (scrollDifference > 0) {
scrollContainerRef.current.scrollTop = scrollDifference;
}
setPrevScrollHeight(0);
}
}, [apiMessages.length, isFetchingNextPage, prevScrollHeight]);
// Handle initial load - scroll to bottom
useEffect(() => {
if (selectedContact?.id) {
setShouldScrollToBottom(true);
}
}, [selectedContact?.id]);
// Auto-scroll logic
useEffect(() => {
if (
shouldScrollToBottom &&
messagesEndRef.current &&
transformedMessages.length > 0
) {
messagesEndRef.current.scrollIntoView({ behavior: 'auto' });
}
}, [transformedMessages.length, shouldScrollToBottom]);
// Cleanup preview URLs on component unmount
useEffect(() => {
return () => {
selectedFiles.forEach((file) => {
if (file.preview) {
URL.revokeObjectURL(file.preview);
}
});
};
}, [selectedFiles]);
// Listen for incoming notifications and refetch messages for the current chat
useEffect(() => {
if (lastNotification?.data) {
const data = lastNotification.data;
const rawSenderId = data.sender_id || data.user_id;
const senderId = rawSenderId ? parseInt(rawSenderId) : null;
// Only invalidate if the notification is for the current conversation
if (senderId && selectedContact?.id === senderId) {
queryClient.invalidateQueries({
queryKey: ['chat', 'messages', 'infinite', selectedContact.id],
});
}
}
}, [lastNotification, queryClient, selectedContact?.id]);
// Show empty state if no contact is selected
if (!selectedContact) {
return (
<main className="grow flex flex-col bg-[#F2F5F7]">
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-gray-200 flex items-center justify-center font-bold text-gray-500 text-2xl md:text-3xl mx-auto mb-4">
💬
</div>
<h3 className="text-lg md:text-xl font-semibold text-gray-700 mb-2">
{translate('selectContactToStartChatting')}
</h3>
<p className="text-sm md:text-base text-gray-500">
{translate('chooseContactFromSidebar')}
</p>
</div>
</div>
</main>
);
}
return (
<main className="grow flex flex-col bg-[#F2F5F7]">
{/* Header - Mobile responsive with extra small screen support */}
<header className="flex items-center justify-between p-1.5 sm:p-2 md:p-3 border-b border-gray-200 bg-white">
<div className="flex items-center min-w-0 flex-1">
{/* Mobile back button */}
{isMobile && onBackToContacts && (
<button
onClick={onBackToContacts}
className="me-1.5 sm:me-2 p-0.5 sm:p-1 hover:bg-gray-100 rounded-full transition-colors shrink-0"
>
<BiArrowToLeft className="w-4 h-4 sm:w-5 sm:h-5 text-gray-600 rtl:rotate-180" />
</button>
)}
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 rounded-full bg-gray-200 flex items-center justify-center font-bold text-gray-500 me-1.5 sm:me-2 md:me-3 text-xs sm:text-sm md:text-base shrink-0">
{/* Check if avatar is URL or text/initials */}
{isValidUrl(selectedContact.avatar) ? (
// Show image if avatar is a valid URL
<Image
src={selectedContact.avatar}
alt="avatar"
width={48}
height={48}
className="w-full h-full object-cover rounded-full"
/>
) : (
// Show initials if avatar is text or empty
<span className="text-xs sm:text-sm md:text-base font-bold text-gray-700">
{selectedContact.avatar}
</span>
)}
</div>
<div className="min-w-0 flex-1">
<h2 className="font-bold text-sm sm:text-base md:text-lg truncate">
{selectedContact.name}
</h2>
{selectedContact.subject && (
<p className="text-xs sm:text-xs md:text-sm text-gray-500 truncate">
{selectedContact.subject}
</p>
)}
</div>
</div>
</header>
{/* Message Area - Mobile responsive padding with extra small screen support */}
<div
ref={scrollContainerRef}
className="grow p-1.5 sm:p-3 md:p-6 overflow-y-auto"
>
{/* Loading history indicator at the top */}
{hasNextPage && (
<div ref={loaderRef} className="flex justify-center p-4">
{isFetchingNextPage ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-(--primary-color) border-t-transparent rounded-full animate-spin"></div>
<p className="text-xs text-gray-500">
{translate('loadingHistory')}
</p>
</div>
) : (
<p className="text-xs text-transparent">Scroll to load more</p>
)}
</div>
)}
{/* Show loading state while fetching initial messages */}
{isLoadingMessages && !isFetchingNextPage && (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-gray-500">
{translate('loadingMessages')}
</p>
</div>
)}
{/* Show error state if API call fails */}
{messagesError && (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-red-500">
{translate('failedToLoadMessages')}
</p>
</div>
)}
{/* Show messages when data is available - grouped by date */}
{!isLoadingMessages && !messagesError && groupedMessages.length > 0 && (
<>
{groupedMessages.map((group) => (
<div key={group.dateKey}>
{/* Date separator - WhatsApp style */}
<div className="text-center my-3 md:my-4">
<div className=" text-gray-600 text-sm font-normal flex items-center justify-center">
<span className="w-[15%] h-[2px] bg-gray-200 hidden sm:block"></span>
<span className="text-gray-600 text-sm font-normal mx-4">
{group.dateLabel}
</span>
<span className="w-[15%] h-[2px] bg-gray-200 hidden sm:block"></span>
</div>
</div>
{/* Messages for this date */}
{group.messages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
receiverId={selectedContact?.id || null}
/>
))}
</div>
))}
{/* Invisible div at the end for auto-scroll */}
<div ref={messagesEndRef} />
</>
)}
{/* Show empty state when no messages found */}
{!isLoadingMessages &&
!messagesError &&
groupedMessages.length === 0 && (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-sm text-gray-500 mb-2">
{translate('noMessagesYet')}
</p>
<p className="text-xs text-gray-400">
{translate('startConversationBySendingMessage')}
</p>
</div>
</div>
)}
</div>
{/* File Preview Area - Shows selected files before sending */}
{selectedFiles.length > 0 && (
<div className="mx-1.5 sm:mx-2 mb-1.5 sm:mb-2 p-2 bg-white rounded-[4px] border border-gray-200 max-h-48 overflow-y-auto">
<p className="text-xs text-gray-600 mb-2 font-medium">
{translate('selectedFiles')} ({selectedFiles.length})
</p>
<div className="flex flex-wrap gap-2">
{selectedFiles.map((filePreview, index) => (
<div
key={index}
className="relative group bg-gray-50 rounded-[4px] overflow-hidden border border-gray-200"
>
{/* Image Preview */}
{filePreview.type === 'image' && (
<div className="w-16 h-16 sm:w-20 sm:h-20 relative">
<Image
src={filePreview.preview}
alt={filePreview.file.name}
fill
className="object-cover"
/>
</div>
)}
{/* Video Preview */}
{filePreview.type === 'video' && (
<div className="w-16 h-16 sm:w-20 sm:h-20 relative bg-black">
<video
src={filePreview.preview}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
<span className="text-white text-xs">â–¶</span>
</div>
</div>
)}
{/* Document Preview */}
{filePreview.type === 'document' && (
<div className="w-16 h-16 sm:w-20 sm:h-20 flex flex-col items-center justify-center p-2">
<BiFile className="w-8 h-8 text-(--primary-color)" />
<span className="text-[8px] text-gray-600 mt-1 truncate w-full text-center">
{filePreview.file.name.split('.').pop()?.toUpperCase()}
</span>
</div>
)}
{/* Remove button */}
<button
onClick={() => handleRemoveFile(index)}
className="absolute top-0.5 end-0.5 bg-red-500 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
>
<BiX className="w-3 h-3" />
</button>
{/* File size label */}
{/* <div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white text-[8px] px-1 py-0.5 text-center">
{formatFileSize(filePreview.file.size)}
</div> */}
</div>
))}
</div>
</div>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.txt,.xls,.xlsx,video/mp4"
onChange={handleFileSelect}
className="hidden"
/>
{/* Input Area - Mobile responsive with extra small screen support */}
<div className="relative mb-1.5 sm:mb-2 mx-1.5 sm:mx-2">
<input
type="text"
placeholder={translate('typeAMessage')}
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
disabled={sendMessageMutation.isPending}
className="w-full h-[40px] sm:h-[48px] md:h-[52px] p-1.5 sm:p-2 md:p-3 pe-16 sm:pe-20 md:pe-24 bg-white rounded-[4px] border border-gray-200 text-xs sm:text-sm md:text-base disabled:bg-gray-100 disabled:cursor-not-allowed"
/>
<div className="flex items-center gap-0.5 sm:gap-1 md:gap-2 absolute end-0.5 sm:end-1 md:end-2 top-1/2 -translate-y-1/2">
{/* File attachment button */}
<button
onClick={() => fileInputRef.current?.click()}
disabled={sendMessageMutation.isPending}
className="p-0.5 sm:p-1 md:p-0 hover:bg-gray-100 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<BiPaperclip className="w-3.5 h-3.5 sm:w-4 sm:h-4 md:w-5 md:h-5 text-(--primary-color) rotate-270" />
</button>
{/* Send button */}
<button
onClick={handleSendMessage}
disabled={sendMessageMutation.isPending}
className="bg-(--primary-color) text-white rounded-[4px] px-1 sm:px-1.5 md:px-2 py-0.5 sm:py-1 flex items-center justify-center text-xs sm:text-xs md:text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="hidden sm:inline md:inline">
{sendMessageMutation.isPending
? translate('sending')
: translate('send')}
</span>
<BiSend className="w-3.5 h-3.5 sm:w-4 sm:h-4 md:w-5 md:h-5 sm:ms-1 md:ms-2" />
</button>
</div>
</div>
</main>
);
};