/opt/canhelp/apps/web/src/app/chat
NameSizeModeActions
page.tsx242370644editdlrm
Edit: /opt/canhelp/apps/web/src/app/chat/page.tsx (24237B)
'use client' import { useState, useEffect, useRef, useCallback, Suspense } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import Link from 'next/link' import { useSession } from '@/lib/auth' import { useLocale } from '@/context/locale' import { getChatRooms, getMessages, getDailyUsage } from '@/lib/api' import { formatShortName } from '@/lib/formatName' import { io, Socket } from 'socket.io-client' const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000' interface RoomEntry { room: { id: string taskId: string | null customerId: string specialistId: string createdAt: string } task: { id: string; title: string } | null otherUser: { id: string; name: string; firstName?: string; lastName?: string; image?: string } | null lastMessage?: { body: string; createdAt: string; translations?: Record | null; imageUrl?: string | null } | null } interface MessageEntry { message: { id: string roomId: string senderId: string body: string imageUrl?: string | null isRead: boolean createdAt: string translations?: Record | null } sender: { id: string; name: string; firstName?: string | null; lastName?: string | null; image?: string } | null } function ChatPageInner() { const router = useRouter() const searchParams = useSearchParams() const { data: session, isPending } = useSession() const { t, locale } = useLocale() const [rooms, setRooms] = useState([]) const [loadingRooms, setLoadingRooms] = useState(true) const [activeRoomId, setActiveRoomId] = useState(null) const [messages, setMessages] = useState([]) const [loadingMessages, setLoadingMessages] = useState(false) const [input, setInput] = useState('') const [connected, setConnected] = useState(false) const [msgLimit, setMsgLimit] = useState<{ left: number | null; sent: number } | null>(null) const [uploadingImage, setUploadingImage] = useState(false) const [lightboxUrl, setLightboxUrl] = useState(null) const socketRef = useRef(null) const messagesEndRef = useRef(null) const inputRef = useRef(null) const fileInputRef = useRef(null) useEffect(() => { if (!isPending && !session) router.push('/login') }, [session, isPending]) // Load chat rooms useEffect(() => { if (!session) return getChatRooms() .then((data) => { setRooms(data) const roomParam = searchParams.get('room') if (roomParam) setActiveRoomId(roomParam) }) .catch((err) => console.error('[chat] getChatRooms error:', err)) .finally(() => setLoadingRooms(false)) getDailyUsage() .then((u) => setMsgLimit({ left: u.messagesLeft, sent: u.messagesSent })) .catch((err) => console.error('[chat] getDailyUsage error:', err)) }, [session]) // Setup Socket.IO useEffect(() => { if (!session) return const socket = io(API_URL, { withCredentials: true, transports: ['websocket', 'polling'], }) socketRef.current = socket socket.on('connect', () => setConnected(true)) socket.on('disconnect', () => setConnected(false)) socket.on('new_message', (msg: MessageEntry['message']) => { setMessages((prev) => { // avoid duplicate if we get it back from our own send if (prev.some((m) => m.message.id === msg.id)) return prev // sender info: only known for current user; others show placeholder (? avatar) const sender = session.user && session.user.id === msg.senderId ? { id: session.user.id, name: session.user.name ?? '', image: session.user.image ?? undefined } : null return [ ...prev, { message: msg, sender }, ] }) // Update lastMessage in rooms list and bubble room to top setRooms((prev) => { const updated = prev.map((r) => r.room.id === msg.roomId ? { ...r, lastMessage: { body: msg.body, createdAt: msg.createdAt, translations: msg.translations ?? null, imageUrl: msg.imageUrl ?? null } } : r ) return updated.sort((a, b) => { const aTime = a.lastMessage?.createdAt ? new Date(a.lastMessage.createdAt).getTime() : new Date(a.room.createdAt).getTime() const bTime = b.lastMessage?.createdAt ? new Date(b.lastMessage.createdAt).getTime() : new Date(b.room.createdAt).getTime() return bTime - aTime }) }) }) socket.on('message_translated', ({ id, roomId, translations }: { id: string; roomId?: string; translations: Record }) => { setMessages((prev) => prev.map((m) => m.message.id === id ? { ...m, message: { ...m.message, translations } } : m, ), ) // Also update lastMessage in sidebar if this is the last message of that room if (roomId) { setRooms((prev) => prev.map((r) => r.room.id === roomId && r.lastMessage ? { ...r, lastMessage: { ...r.lastMessage, translations } } : r, ), ) } }) return () => { socket.disconnect() socketRef.current = null } }, [session]) // Join room and load messages when activeRoomId changes useEffect(() => { if (!activeRoomId) return setLoadingMessages(true) setMessages([]) getMessages(activeRoomId) .then(setMessages) .catch((err) => console.error('[chat] getMessages error:', err)) .finally(() => setLoadingMessages(false)) socketRef.current?.emit('join_room', activeRoomId) socketRef.current?.emit('mark_read', activeRoomId) }, [activeRoomId]) // Focus input when active room changes useEffect(() => { if (!activeRoomId) return const t = setTimeout(() => inputRef.current?.focus(), 50) return () => clearTimeout(t) }, [activeRoomId]) // Auto-scroll on new messages (within container only) useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) }, [messages]) // Close lightbox on Escape useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setLightboxUrl(null) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, []) const sendMessage = useCallback(() => { const body = input.trim() if (!body || !activeRoomId || !socketRef.current) return if (msgLimit && msgLimit.left !== null && msgLimit.left <= 0) return socketRef.current.emit('send_message', { roomId: activeRoomId, body, locale }) setInput('') // decrement local counter setMsgLimit((prev) => prev && prev.left !== null ? { ...prev, left: prev.left - 1, sent: prev.sent + 1 } : prev) }, [input, activeRoomId, msgLimit]) const uploadAndSendImage = useCallback(async (file: File) => { if (!activeRoomId || !socketRef.current) return setUploadingImage(true) try { const formData = new FormData() formData.append('file', file) const res = await fetch(`${API_URL}/api/uploads`, { method: 'POST', credentials: 'include', body: formData, }) if (!res.ok) throw new Error('Upload failed') const { url } = await res.json() as { url: string } socketRef.current.emit('send_message', { roomId: activeRoomId, body: '', imageUrl: url, locale }) } catch (_) { // silent } finally { setUploadingImage(false) if (fileInputRef.current) fileInputRef.current.value = '' } }, [activeRoomId, locale]) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() sendMessage() } } const LOCALE_TAG: Record = { ru: 'ru-RU', en: 'en-US', el: 'el-GR' } const localeTag = LOCALE_TAG[locale] ?? 'el-GR' function fmtMsgTime(iso: string): string { const d = new Date(iso) const now = new Date() const diffMin = Math.floor((now.getTime() - d.getTime()) / 60000) if (diffMin < 1) return t('chat.just_now') if (diffMin < 60) return `${diffMin}${t('chat.min_ago')}` if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString(localeTag, { hour: '2-digit', minute: '2-digit' }) return d.toLocaleDateString(localeTag, { day: '2-digit', month: '2-digit' }) } if (isPending || loadingRooms) { return
{t('common.loading')}
} const activeRoom = rooms.find((r) => r.room.id === activeRoomId) const currentUserId = session?.user?.id return (
{/* Rooms sidebar */}

{t('nav.chat')}

{connected ? t('chat.connected') : t('chat.disconnected')}
{rooms.length === 0 ? (
{t('chat.no_rooms')}
) : ( rooms.map(({ room, task, otherUser, lastMessage }) => { const roomLabel = task?.title ?? (formatShortName(otherUser?.firstName, otherUser?.lastName, otherUser?.name) || t('chat.direct_chat')) const roomSub = task ? (formatShortName(otherUser?.firstName, otherUser?.lastName, otherUser?.name) || '') : t('chat.direct_chat') return ( ) }) )}
{/* Messages panel */}
{!activeRoomId ? (
{t('chat.select')}
) : ( <> {/* Header */}

{activeRoom?.task?.title ?? (formatShortName(activeRoom?.otherUser?.firstName, activeRoom?.otherUser?.lastName, activeRoom?.otherUser?.name) || t('chat.direct_chat'))}

{/* Task banner */} {activeRoom?.task && (

{t('chat.linked_task')}

{activeRoom.task.title}

)} {/* Messages */}
{loadingMessages ? (
{t('common.loading')}
) : messages.length === 0 ? (
{t('chat.no_messages')}
) : ( messages.map(({ message, sender }) => { const isMe = message.senderId === currentUserId return (
{/* Avatar */}
{sender?.image ? ( {sender.name} ) : ( sender?.name?.[0]?.toUpperCase() ?? '?' )}
{/* Bubble */}
{!isMe && ( {formatShortName(sender?.firstName, sender?.lastName, sender?.name)} )} {message.imageUrl ? ( ) : (
{message.translations?.[locale] ?? message.body}
)} {new Date(message.createdAt).toLocaleTimeString(localeTag, { hour: '2-digit', minute: '2-digit', })}
) }) )}
{/* Lightbox */} {lightboxUrl && (
setLightboxUrl(null)} >
e.stopPropagation()}> {/* eslint-disable-next-line @next/next/no-img-element */} photo e.stopPropagation()} >
)} {/* Input */}
{/* Hidden file input */} { const file = e.target.files?.[0] if (file) uploadAndSendImage(file) }} /> {/* Limit banners */} {msgLimit && msgLimit.left !== null && msgLimit.left <= 0 && (
{t('chat.limit_reached', 'Daily message limit reached')} {t('dashboard.plan.upgrade', 'Upgrade plan')}
)} {msgLimit && msgLimit.left !== null && msgLimit.left > 0 && msgLimit.left <= Math.ceil((msgLimit.left + msgLimit.sent) * 0.2) && (
{t('chat.limit_warning', '{n} messages left today').replace('{n}', String(msgLimit.left))} {t('dashboard.plan.upgrade', 'Upgrade plan')}
)}
{/* Image upload button */}