feat: Implement rich media messaging for images, files, and GIFs, and add message read/delivery status indicators.

This commit is contained in:
pradeepkumar
2026-02-21 22:11:42 +05:30
parent 3d2171e0bb
commit c5dc139633
12 changed files with 1237 additions and 118 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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}
/>
</>
) : (

View 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}</>;
}

View File

@@ -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]);

View File

@@ -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
*/