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

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