feat: Implement rich media messaging for images, files, and GIFs, and add message read/delivery status indicators.
This commit is contained in:
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { Footer } from '@/components/layout/Footer';
|
||||
import { CommonHeader } from '@/components/layout/CommonHeader';
|
||||
import { PresenceProvider } from '@/components/providers/presence-provider';
|
||||
|
||||
export default function AgentLayout({
|
||||
children,
|
||||
@@ -38,22 +39,24 @@ export default function AgentLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col">
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{/* Header Bar */}
|
||||
<CommonHeader />
|
||||
<PresenceProvider>
|
||||
<div className="min-h-screen bg-white flex flex-col">
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{/* Header Bar */}
|
||||
<CommonHeader />
|
||||
|
||||
{/* Page Content */}
|
||||
<div className="mt-6">
|
||||
{children}
|
||||
{/* Page Content */}
|
||||
<div className="mt-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
</div>
|
||||
</PresenceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { Footer } from '@/components/layout/Footer';
|
||||
import { CommonHeader } from '@/components/layout/CommonHeader';
|
||||
import { PresenceProvider } from '@/components/providers/presence-provider';
|
||||
|
||||
// Pages that don't require authentication
|
||||
const publicPaths = [
|
||||
@@ -77,38 +78,40 @@ export default function UserLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{isDashboard ? (
|
||||
<>
|
||||
{/* Main Content with Header Overlay for Dashboard */}
|
||||
<main className="flex-1 relative">
|
||||
{/* Header overlaying the content */}
|
||||
<div className="absolute top-0 left-0 right-0 z-50 px-4 py-3">
|
||||
<PresenceProvider>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{isDashboard ? (
|
||||
<>
|
||||
{/* Main Content with Header Overlay for Dashboard */}
|
||||
<main className="flex-1 relative">
|
||||
{/* Header overlaying the content */}
|
||||
<div className="absolute top-0 left-0 right-0 z-50 px-4 py-3">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<CommonHeader />
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Regular Header for other pages */}
|
||||
<div className="px-4 py-3 bg-white">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<CommonHeader />
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Regular Header for other pages */}
|
||||
<div className="px-4 py-3 bg-white">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<CommonHeader />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
)}
|
||||
{/* Main Content */}
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
</div>
|
||||
</PresenceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,32 +2,55 @@
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import data from '@emoji-mart/data';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import { GiphyFetch } from '@giphy/js-fetch-api';
|
||||
import { Grid } from '@giphy/react-components';
|
||||
|
||||
const gf = new GiphyFetch(process.env.NEXT_PUBLIC_GIPHY_API_KEY || '');
|
||||
|
||||
interface AttachedFile {
|
||||
file: File;
|
||||
preview?: string; // Object URL for image preview
|
||||
messageType: 'IMAGE' | 'FILE';
|
||||
}
|
||||
|
||||
interface MessageInputProps {
|
||||
onSend?: (message: string) => void;
|
||||
onSendGif?: (gifUrl: string) => void;
|
||||
onSendFile?: (file: File, messageType: 'IMAGE' | 'FILE') => void;
|
||||
onTypingStart?: () => void;
|
||||
onTypingStop?: () => void;
|
||||
isUploading?: boolean;
|
||||
}
|
||||
|
||||
export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInputProps) {
|
||||
export function MessageInput({ onSend, onSendGif, onSendFile, onTypingStart, onTypingStop, isUploading }: MessageInputProps) {
|
||||
const [message, setMessage] = useState('');
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [showGifPicker, setShowGifPicker] = useState(false);
|
||||
const [gifSearchQuery, setGifSearchQuery] = useState('');
|
||||
const [attachedFile, setAttachedFile] = useState<AttachedFile | null>(null);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isTypingRef = useRef(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const emojiPickerRef = useRef<HTMLDivElement>(null);
|
||||
const gifPickerRef = useRef<HTMLDivElement>(null);
|
||||
const emojiButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const gifButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Handle typing indicator
|
||||
const handleTyping = useCallback(() => {
|
||||
// Start typing indicator
|
||||
if (!isTypingRef.current && onTypingStart) {
|
||||
isTypingRef.current = true;
|
||||
onTypingStart();
|
||||
}
|
||||
|
||||
// Clear existing timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set timeout to stop typing indicator after 2 seconds of inactivity
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
if (isTypingRef.current && onTypingStop) {
|
||||
isTypingRef.current = false;
|
||||
@@ -48,9 +71,90 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
|
||||
};
|
||||
}, [onTypingStop]);
|
||||
|
||||
// Close pickers on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
showEmojiPicker &&
|
||||
emojiPickerRef.current &&
|
||||
!emojiPickerRef.current.contains(e.target as Node) &&
|
||||
emojiButtonRef.current &&
|
||||
!emojiButtonRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowEmojiPicker(false);
|
||||
}
|
||||
if (
|
||||
showGifPicker &&
|
||||
gifPickerRef.current &&
|
||||
!gifPickerRef.current.contains(e.target as Node) &&
|
||||
gifButtonRef.current &&
|
||||
!gifButtonRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowGifPicker(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showEmojiPicker, showGifPicker]);
|
||||
|
||||
// Clean up object URL on unmount or when file changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (attachedFile?.preview) {
|
||||
URL.revokeObjectURL(attachedFile.preview);
|
||||
}
|
||||
};
|
||||
}, [attachedFile]);
|
||||
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const preview = URL.createObjectURL(file);
|
||||
setAttachedFile({ file, preview, messageType: 'IMAGE' });
|
||||
// Reset input so same file can be selected again
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAttachedFile({ file, messageType: 'FILE' });
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const removeAttachment = () => {
|
||||
if (attachedFile?.preview) {
|
||||
URL.revokeObjectURL(attachedFile.preview);
|
||||
}
|
||||
setAttachedFile(null);
|
||||
};
|
||||
|
||||
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`;
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
// Send attached file
|
||||
if (attachedFile && onSendFile) {
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
if (isTypingRef.current && onTypingStop) {
|
||||
isTypingRef.current = false;
|
||||
onTypingStop();
|
||||
}
|
||||
|
||||
onSendFile(attachedFile.file, attachedFile.messageType);
|
||||
removeAttachment();
|
||||
setMessage('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send text message
|
||||
if (message.trim() && onSend) {
|
||||
// Stop typing indicator before sending
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
@@ -67,7 +171,7 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
if (!isUploading) handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,11 +180,122 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
|
||||
handleTyping();
|
||||
};
|
||||
|
||||
const handleEmojiSelect = (emoji: { native: string }) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const newMessage = message.slice(0, start) + emoji.native + message.slice(end);
|
||||
setMessage(newMessage);
|
||||
// Restore cursor position after emoji
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + emoji.native.length;
|
||||
textarea.focus();
|
||||
}, 0);
|
||||
} else {
|
||||
setMessage((prev) => prev + emoji.native);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGifSelect = (gif: { images: { original: { url: string } } }, e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
const gifUrl = gif.images.original.url;
|
||||
if (onSendGif) {
|
||||
onSendGif(gifUrl);
|
||||
}
|
||||
setShowGifPicker(false);
|
||||
setGifSearchQuery('');
|
||||
};
|
||||
|
||||
const toggleEmojiPicker = () => {
|
||||
setShowEmojiPicker((prev) => !prev);
|
||||
setShowGifPicker(false);
|
||||
};
|
||||
|
||||
const toggleGifPicker = () => {
|
||||
setShowGifPicker((prev) => !prev);
|
||||
setShowEmojiPicker(false);
|
||||
};
|
||||
|
||||
const fetchGifs = useCallback(
|
||||
(offset: number) => {
|
||||
if (gifSearchQuery.trim()) {
|
||||
return gf.search(gifSearchQuery, { offset, limit: 10 });
|
||||
}
|
||||
return gf.trending({ offset, limit: 10 });
|
||||
},
|
||||
[gifSearchQuery]
|
||||
);
|
||||
|
||||
const canSend = message.trim() || attachedFile;
|
||||
|
||||
return (
|
||||
<div className="border border-[#00293d]/10 rounded-[20px] bg-white p-4">
|
||||
{/* Hidden file inputs */}
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Attachment preview area */}
|
||||
{attachedFile && (
|
||||
<div className="border border-[#00293d]/10 rounded-[15px] p-3 mb-3 bg-[#00293d]/[0.02]">
|
||||
<div className="flex items-center gap-3">
|
||||
{attachedFile.messageType === 'IMAGE' && attachedFile.preview ? (
|
||||
<img
|
||||
src={attachedFile.preview}
|
||||
alt="Preview"
|
||||
className="w-[80px] h-[80px] object-cover rounded-[10px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[50px] h-[50px] bg-[#e58625]/10 rounded-[10px] flex items-center justify-center flex-shrink-0">
|
||||
<Image
|
||||
src="/assets/icons/attachment-icon.svg"
|
||||
alt="File"
|
||||
width={22}
|
||||
height={12}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D] truncate">
|
||||
{attachedFile.file.name}
|
||||
</p>
|
||||
<p className="font-serif font-normal text-[12px] leading-[16px] text-[#00293D]/50">
|
||||
{formatFileSize(attachedFile.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={removeAttachment}
|
||||
className="p-1 hover:bg-gray-100 rounded-full transition-colors cursor-pointer flex-shrink-0"
|
||||
title="Remove attachment"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/close-icon.svg"
|
||||
alt="Remove"
|
||||
width={20}
|
||||
height={20}
|
||||
className="opacity-50"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text input area */}
|
||||
<div className="border border-[#00293d]/10 rounded-[20px] p-4 mb-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -93,7 +308,11 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Attachment buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
|
||||
<button
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
|
||||
title="Attach image"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/gallery-icon.svg"
|
||||
alt="Add image"
|
||||
@@ -101,44 +320,107 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
|
||||
height={24}
|
||||
/>
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
|
||||
title="Attach file"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/chain-icon.svg"
|
||||
alt="Add link"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
|
||||
<Image
|
||||
src="/assets/icons/gif-icon.svg"
|
||||
alt="Add GIF"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
|
||||
<Image
|
||||
src="/assets/icons/emoji-icon.svg"
|
||||
alt="Add emoji"
|
||||
alt="Attach file"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* GIF button with picker */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={gifButtonRef}
|
||||
onClick={toggleGifPicker}
|
||||
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${showGifPicker ? 'bg-[#e58625]/20' : 'hover:bg-gray-100'}`}
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/gif-icon.svg"
|
||||
alt="Add GIF"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</button>
|
||||
{showGifPicker && (
|
||||
<div
|
||||
ref={gifPickerRef}
|
||||
className="absolute bottom-full left-0 mb-2 z-50 bg-white border border-[#00293d]/10 rounded-[15px] shadow-lg overflow-hidden"
|
||||
style={{ width: '360px', height: '420px' }}
|
||||
>
|
||||
<div className="p-3 border-b border-[#00293d]/10">
|
||||
<input
|
||||
type="text"
|
||||
value={gifSearchQuery}
|
||||
onChange={(e) => setGifSearchQuery(e.target.value)}
|
||||
placeholder="Search GIFs..."
|
||||
className="w-full px-3 py-2 border border-[#00293d]/10 rounded-[10px] font-serif text-[14px] text-[#00293D] placeholder-[#00293D]/50 focus:outline-none focus:border-[#e58625]"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto" style={{ height: 'calc(100% - 56px)' }}>
|
||||
<Grid
|
||||
key={gifSearchQuery}
|
||||
fetchGifs={fetchGifs}
|
||||
width={356}
|
||||
columns={2}
|
||||
gutter={6}
|
||||
noLink
|
||||
onGifClick={handleGifSelect}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 right-3 bg-white/80 rounded px-1.5 py-0.5">
|
||||
<span className="font-serif text-[10px] text-[#00293D]/40">Powered by GIPHY</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Emoji button with picker */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={emojiButtonRef}
|
||||
onClick={toggleEmojiPicker}
|
||||
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${showEmojiPicker ? 'bg-[#e58625]/20' : 'hover:bg-gray-100'}`}
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/emoji-icon.svg"
|
||||
alt="Add emoji"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</button>
|
||||
{showEmojiPicker && (
|
||||
<div ref={emojiPickerRef} className="absolute bottom-full left-0 mb-2 z-50">
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
theme="light"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send button and more options */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!message.trim()}
|
||||
disabled={!canSend || isUploading}
|
||||
className={`px-4 py-1.5 font-serif font-normal text-[14px] leading-[19px] rounded-[15px] transition-colors cursor-pointer ${
|
||||
message.trim()
|
||||
canSend && !isUploading
|
||||
? 'bg-[#e58625] text-[#00293d] hover:bg-[#d47720]'
|
||||
: 'bg-[#e58625]/50 text-[#00293d]/50 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Send
|
||||
{isUploading ? 'Uploading...' : 'Send'}
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
|
||||
<Image
|
||||
|
||||
@@ -8,7 +8,7 @@ import { MessageInput } from './MessageInput';
|
||||
import { useMessaging } from '@/hooks/useMessaging';
|
||||
import { connectionRequestsService, type ConnectionRequest } from '@/services/connection-requests.service';
|
||||
import { uploadService } from '@/services/upload.service';
|
||||
import type { Conversation, Message } from '@/types/messaging';
|
||||
import type { Conversation, Message, MessageType } from '@/types/messaging';
|
||||
|
||||
// Helper to get a valid avatar URL (must start with http://, https://, or /)
|
||||
function getValidAvatarUrl(avatar: string | null | undefined): string {
|
||||
@@ -110,12 +110,15 @@ export function MessagingPage() {
|
||||
} = useMessaging();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const [connectedAgents, setConnectedAgents] = useState<ConnectedAgent[]>([]);
|
||||
const [isLoadingAgents, setIsLoadingAgents] = useState(false);
|
||||
const [isStartingConversation, setIsStartingConversation] = useState<string | null>(null);
|
||||
// Store converted avatar URLs by conversation ID
|
||||
const [conversationAvatarUrls, setConversationAvatarUrls] = useState<Record<string, string>>({});
|
||||
// Store converted file URLs by message ID (for S3-stored images/files)
|
||||
const [messageFileUrls, setMessageFileUrls] = useState<Record<string, string>>({});
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const justSwitchedConversationRef = useRef(false);
|
||||
@@ -246,6 +249,39 @@ export function MessagingPage() {
|
||||
convertCurrentAvatar();
|
||||
}, [currentConversation]);
|
||||
|
||||
// Convert S3 keys to presigned download URLs for message attachments
|
||||
useEffect(() => {
|
||||
const convertFileUrls = async () => {
|
||||
for (const msg of messages) {
|
||||
if ((msg.messageType === 'IMAGE' || msg.messageType === 'FILE') && msg.fileUrl) {
|
||||
// Skip if already converted
|
||||
if (messageFileUrls[msg.id]) continue;
|
||||
// Skip if it's already an accessible URL (e.g., Giphy GIF URLs)
|
||||
if (msg.fileUrl.startsWith('http://') || msg.fileUrl.startsWith('https://')) {
|
||||
setMessageFileUrls(prev => ({ ...prev, [msg.id]: msg.fileUrl! }));
|
||||
continue;
|
||||
}
|
||||
// Convert S3 key to presigned download URL
|
||||
try {
|
||||
const presignedUrl = await uploadService.getPresignedDownloadUrl(msg.fileUrl);
|
||||
setMessageFileUrls(prev => ({ ...prev, [msg.id]: presignedUrl }));
|
||||
} catch {
|
||||
// Keep original if conversion fails
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (messages.length > 0) {
|
||||
convertFileUrls();
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// Clear cached file URLs when switching conversations
|
||||
useEffect(() => {
|
||||
setMessageFileUrls({});
|
||||
}, [currentConversation?.id]);
|
||||
|
||||
// Handle starting a new conversation with an agent
|
||||
const handleStartConversation = useCallback(async (agentProfileId: string) => {
|
||||
setIsStartingConversation(agentProfileId);
|
||||
@@ -265,6 +301,35 @@ export function MessagingPage() {
|
||||
await sendMessage(content);
|
||||
}, [sendMessage]);
|
||||
|
||||
// Handle sending a GIF
|
||||
const handleSendGif = useCallback(async (gifUrl: string) => {
|
||||
await sendMessage(gifUrl, 'IMAGE' as MessageType, { fileUrl: gifUrl, mimeType: 'image/gif' });
|
||||
}, [sendMessage]);
|
||||
|
||||
// Handle sending a file (image or document)
|
||||
const handleSendFile = useCallback(async (file: File, messageType: 'IMAGE' | 'FILE') => {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const uploaded = await uploadService.uploadMessageFile(file);
|
||||
// uploaded.url is the S3 key (not a full URL)
|
||||
const content = messageType === 'IMAGE' ? 'Sent an image' : 'Sent a file';
|
||||
await sendMessage(
|
||||
content,
|
||||
messageType as MessageType,
|
||||
{
|
||||
fileUrl: uploaded.url,
|
||||
mimeType: file.type,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file:', error);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [sendMessage]);
|
||||
|
||||
// Handle typing events
|
||||
const handleTypingStart = useCallback(() => {
|
||||
startTyping();
|
||||
@@ -599,16 +664,64 @@ export function MessagingPage() {
|
||||
className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[70%] rounded-[15px] px-4 py-3 ${
|
||||
className={`max-w-[70%] rounded-[15px] ${
|
||||
message.messageType === 'IMAGE' ? 'p-1' : 'px-4 py-3'
|
||||
} ${
|
||||
isOwn
|
||||
? 'bg-[#e58625] text-[#00293D]'
|
||||
: 'bg-[#00293d]/10 text-[#00293D]'
|
||||
}`}
|
||||
>
|
||||
<p className="font-serif font-normal text-[14px] leading-[19px]">
|
||||
{message.content}
|
||||
</p>
|
||||
<div className={`flex items-center gap-2 mt-1 ${isOwn ? 'justify-end' : 'justify-start'}`}>
|
||||
{message.messageType === 'IMAGE' ? (
|
||||
messageFileUrls[message.id] ? (
|
||||
<img
|
||||
src={messageFileUrls[message.id]}
|
||||
alt={message.fileName || 'Image'}
|
||||
className="rounded-[12px] max-w-full cursor-pointer"
|
||||
style={{ maxHeight: '250px' }}
|
||||
onClick={() => window.open(messageFileUrls[message.id], '_blank')}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[200px] h-[150px] rounded-[12px] bg-black/5 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#00293d]" />
|
||||
</div>
|
||||
)
|
||||
) : message.messageType === 'FILE' ? (
|
||||
<a
|
||||
href={messageFileUrls[message.id] || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 no-underline"
|
||||
>
|
||||
<div className={`w-[40px] h-[40px] rounded-[8px] flex items-center justify-center flex-shrink-0 ${isOwn ? 'bg-[#00293D]/10' : 'bg-[#e58625]/10'}`}>
|
||||
<Image
|
||||
src="/assets/icons/attachment-icon.svg"
|
||||
alt="File"
|
||||
width={20}
|
||||
height={11}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-serif font-medium text-[14px] leading-[19px] truncate">
|
||||
{message.fileName || message.content}
|
||||
</p>
|
||||
{message.fileSize && (
|
||||
<p className={`font-serif font-normal text-[12px] leading-[16px] ${isOwn ? 'text-[#00293D]/70' : 'text-[#00293D]/50'}`}>
|
||||
{message.fileSize < 1024
|
||||
? `${message.fileSize} B`
|
||||
: message.fileSize < 1024 * 1024
|
||||
? `${(message.fileSize / 1024).toFixed(1)} KB`
|
||||
: `${(message.fileSize / (1024 * 1024)).toFixed(1)} MB`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<p className="font-serif font-normal text-[14px] leading-[19px]">
|
||||
{message.content}
|
||||
</p>
|
||||
)}
|
||||
<div className={`flex items-center gap-2 mt-1 ${message.messageType === 'IMAGE' ? 'px-2 pb-1' : ''} ${isOwn ? 'justify-end' : 'justify-start'}`}>
|
||||
<p
|
||||
className={`font-serif font-normal text-[12px] leading-[16px] ${
|
||||
isOwn ? 'text-[#00293D]/70' : 'text-[#00293D]/50'
|
||||
@@ -617,8 +730,14 @@ export function MessagingPage() {
|
||||
{formatMessageTime(message.createdAt)}
|
||||
</p>
|
||||
{isOwn && (
|
||||
<span className={`text-[10px] ${message.status === 'READ' ? 'text-blue-500' : 'text-[#00293D]/50'}`}>
|
||||
{message.status === 'READ' ? '✓✓' : message.status === 'DELIVERED' ? '✓✓' : '✓'}
|
||||
<span className="inline-flex items-center">
|
||||
{message.status === 'READ' ? (
|
||||
<Image src="/assets/icons/tick-double-blue.svg" alt="Read" width={16} height={11} />
|
||||
) : message.status === 'DELIVERED' ? (
|
||||
<Image src="/assets/icons/tick-double-gray.svg" alt="Delivered" width={16} height={11} />
|
||||
) : (
|
||||
<Image src="/assets/icons/tick-single-gray.svg" alt="Sent" width={11} height={11} />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -669,8 +788,11 @@ export function MessagingPage() {
|
||||
{/* Message input */}
|
||||
<MessageInput
|
||||
onSend={handleSendMessage}
|
||||
onSendGif={handleSendGif}
|
||||
onSendFile={handleSendFile}
|
||||
onTypingStart={handleTypingStart}
|
||||
onTypingStop={handleTypingStop}
|
||||
isUploading={isUploading}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
166
src/components/providers/presence-provider.tsx
Normal file
166
src/components/providers/presence-provider.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { socketService } from '@/services';
|
||||
|
||||
// Disconnect after 3 minutes of tab being hidden
|
||||
const VISIBILITY_TIMEOUT = 3 * 60 * 1000;
|
||||
// Disconnect after 5 minutes of no user interaction
|
||||
const IDLE_TIMEOUT = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* PresenceProvider handles user online/offline presence globally.
|
||||
* - Connects socket on login (marks user online on the backend)
|
||||
* - Disconnects socket when tab is hidden for too long or user is idle
|
||||
* - Handles auth error recovery (token refresh + reconnect)
|
||||
*/
|
||||
export function PresenceProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, status } = useSession();
|
||||
const idleTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const visibilityTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isIdleRef = useRef(false);
|
||||
const isConnectedRef = useRef(false);
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
const getAccessToken = useCallback(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('accessToken');
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const connectSocket = useCallback(async () => {
|
||||
const token = getAccessToken();
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
await socketService.connect(token);
|
||||
} catch (error) {
|
||||
console.error('[Presence] Socket connect failed:', error);
|
||||
}
|
||||
}, [getAccessToken]);
|
||||
|
||||
const disconnectForIdle = useCallback(() => {
|
||||
if (!isConnectedRef.current) return;
|
||||
isIdleRef.current = true;
|
||||
socketService.disconnect();
|
||||
}, []);
|
||||
|
||||
// Track socket connection state
|
||||
useEffect(() => {
|
||||
const unsubscribe = socketService.onConnectionChange((connected) => {
|
||||
isConnectedRef.current = connected;
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Connect on authentication
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated' && session?.user && !hasInitializedRef.current) {
|
||||
hasInitializedRef.current = true;
|
||||
// Small delay to ensure TokenSync has set localStorage tokens
|
||||
const timer = setTimeout(connectSocket, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
hasInitializedRef.current = false;
|
||||
if (isConnectedRef.current) {
|
||||
socketService.disconnect();
|
||||
}
|
||||
}
|
||||
}, [status, session, connectSocket]);
|
||||
|
||||
// Handle auth errors (token expiry) — single global handler
|
||||
useEffect(() => {
|
||||
const unsubscribe = socketService.onAuthError(async () => {
|
||||
console.log('[Presence] Auth error, refreshing token...');
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) return;
|
||||
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}/auth/refresh`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
localStorage.setItem('accessToken', data.data.accessToken);
|
||||
localStorage.setItem('refreshToken', data.data.refreshToken);
|
||||
socketService.reconnectWithToken(data.data.accessToken);
|
||||
} else {
|
||||
console.error('[Presence] Token refresh failed:', data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Presence] Token refresh error:', error);
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Browser visibility tracking — disconnect when tab hidden for too long
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
visibilityTimerRef.current = setTimeout(() => {
|
||||
if (document.hidden) {
|
||||
disconnectForIdle();
|
||||
}
|
||||
}, VISIBILITY_TIMEOUT);
|
||||
} else {
|
||||
// Tab visible again — clear timer and reconnect if was idle
|
||||
if (visibilityTimerRef.current) {
|
||||
clearTimeout(visibilityTimerRef.current);
|
||||
visibilityTimerRef.current = null;
|
||||
}
|
||||
if (isIdleRef.current) {
|
||||
isIdleRef.current = false;
|
||||
connectSocket();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
if (visibilityTimerRef.current) {
|
||||
clearTimeout(visibilityTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [connectSocket, disconnectForIdle]);
|
||||
|
||||
// User activity tracking — disconnect when idle for too long
|
||||
useEffect(() => {
|
||||
const resetIdleTimer = () => {
|
||||
if (idleTimerRef.current) {
|
||||
clearTimeout(idleTimerRef.current);
|
||||
}
|
||||
|
||||
// Was idle and user became active — reconnect
|
||||
if (isIdleRef.current) {
|
||||
isIdleRef.current = false;
|
||||
connectSocket();
|
||||
}
|
||||
|
||||
idleTimerRef.current = setTimeout(disconnectForIdle, IDLE_TIMEOUT);
|
||||
};
|
||||
|
||||
const events = ['mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart'];
|
||||
events.forEach((e) => document.addEventListener(e, resetIdleTimer, { passive: true }));
|
||||
resetIdleTimer();
|
||||
|
||||
return () => {
|
||||
events.forEach((e) => document.removeEventListener(e, resetIdleTimer));
|
||||
if (idleTimerRef.current) {
|
||||
clearTimeout(idleTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [connectSocket, disconnectForIdle]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ interface UseMessagingReturn {
|
||||
loadConversations: () => Promise<void>;
|
||||
selectConversation: (conversationId: string) => Promise<void>;
|
||||
startConversation: (agentProfileId: string) => Promise<Conversation>;
|
||||
sendMessage: (content: string, messageType?: MessageType) => Promise<void>;
|
||||
sendMessage: (content: string, messageType?: MessageType, fileFields?: { fileUrl?: string; mimeType?: string; fileName?: string; fileSize?: number }) => Promise<void>;
|
||||
loadMoreMessages: () => Promise<void>;
|
||||
startTyping: () => void;
|
||||
stopTyping: () => void;
|
||||
@@ -179,12 +179,13 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
|
||||
|
||||
// Send a message
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, messageType: MessageType = 'TEXT' as MessageType) => {
|
||||
async (content: string, messageType: MessageType = 'TEXT' as MessageType, fileFields?: { fileUrl?: string; mimeType?: string; fileName?: string; fileSize?: number }) => {
|
||||
if (!currentConversationIdRef.current) return;
|
||||
|
||||
const dto: CreateMessageDto = {
|
||||
content,
|
||||
messageType,
|
||||
...fileFields,
|
||||
};
|
||||
|
||||
// Stop typing indicator
|
||||
@@ -226,6 +227,11 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
|
||||
|
||||
// Update conversation in list
|
||||
const conversationId = currentConversationIdRef.current;
|
||||
const previewText = messageType === ('IMAGE' as MessageType)
|
||||
? 'Sent an image'
|
||||
: messageType === ('FILE' as MessageType)
|
||||
? 'Sent a file'
|
||||
: content;
|
||||
setConversations((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === conversationId
|
||||
@@ -233,7 +239,7 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
|
||||
...c,
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
lastMessageText:
|
||||
content.length > 255 ? content.substring(0, 252) + '...' : content,
|
||||
previewText.length > 255 ? previewText.substring(0, 252) + '...' : previewText,
|
||||
}
|
||||
: c
|
||||
)
|
||||
@@ -360,10 +366,17 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
|
||||
if (c.id === message.conversationId) {
|
||||
const isCurrentConversation =
|
||||
c.id === currentConversationIdRef.current;
|
||||
// Show friendly preview for non-text messages
|
||||
let previewText = message.content.substring(0, 255);
|
||||
if (message.messageType === 'IMAGE') {
|
||||
previewText = 'Sent an image';
|
||||
} else if (message.messageType === 'FILE') {
|
||||
previewText = 'Sent a file';
|
||||
}
|
||||
return {
|
||||
...c,
|
||||
lastMessageAt: message.createdAt,
|
||||
lastMessageText: message.content.substring(0, 255),
|
||||
lastMessageText: previewText,
|
||||
unreadCount: isCurrentConversation ? 0 : c.unreadCount + 1,
|
||||
};
|
||||
}
|
||||
@@ -413,38 +426,8 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
|
||||
}
|
||||
);
|
||||
|
||||
// Handle auth errors (token expired, server disconnected us)
|
||||
const unsubscribeAuthError = socketService.onAuthError(async () => {
|
||||
console.log('Socket auth error detected, refreshing token via REST...');
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) {
|
||||
console.error('No refresh token available for socket reconnection');
|
||||
return;
|
||||
}
|
||||
// Refresh token using the same endpoint as the REST API interceptor
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}/auth/refresh`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
const newToken = data.data.accessToken;
|
||||
localStorage.setItem('accessToken', newToken);
|
||||
localStorage.setItem('refreshToken', data.data.refreshToken);
|
||||
console.log('Token refreshed, reconnecting socket...');
|
||||
socketService.reconnectWithToken(newToken);
|
||||
} else {
|
||||
console.error('Token refresh failed:', data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh token for socket reconnection:', error);
|
||||
}
|
||||
});
|
||||
// Auth error handling (token refresh + reconnect) is handled globally
|
||||
// by PresenceProvider — no duplicate handler needed here.
|
||||
|
||||
return () => {
|
||||
unsubscribeConnection();
|
||||
@@ -453,7 +436,6 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
|
||||
unsubscribeTypingStop();
|
||||
unsubscribeStatus();
|
||||
unsubscribeRead();
|
||||
unsubscribeAuthError();
|
||||
};
|
||||
}, [updateUserStatus]);
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ interface PresignedUrlResponse {
|
||||
key: string;
|
||||
}
|
||||
|
||||
type UploadFolder = 'avatars' | 'documents' | 'properties' | 'verification';
|
||||
type UploadFolder = 'avatars' | 'documents' | 'properties' | 'verification' | 'messages';
|
||||
|
||||
class UploadService {
|
||||
private basePath = '/upload';
|
||||
@@ -213,6 +213,39 @@ class UploadService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a message attachment (image or document) to S3
|
||||
* Uses the message-presigned-url endpoint which allows USER and AGENT roles
|
||||
* Returns the S3 key (not full URL) — use getPresignedDownloadUrl() to get a viewable URL
|
||||
*/
|
||||
async uploadMessageFile(
|
||||
file: File,
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
): Promise<UploadedFile> {
|
||||
// Step 1: Get presigned URL from backend (messages folder)
|
||||
const response = await api.post<{ data: PresignedUrlResponse }>(
|
||||
`${this.basePath}/message-presigned-url`,
|
||||
{
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
}
|
||||
);
|
||||
const { uploadUrl, key } = response.data.data;
|
||||
|
||||
// Step 2: Upload directly to S3
|
||||
await this.uploadToS3(uploadUrl, file, onProgress);
|
||||
|
||||
// Step 3: Return uploaded file info with KEY (not full URL) for database storage
|
||||
return {
|
||||
id: key,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
url: key, // Store the S3 key, not the full URL
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned download URL for an S3 key
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user