import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { io } from 'socket.io-client' import { ERROR_MESSAGES } from './constants/errorMessages' import { validateJoinForm } from './utils/validators' import './App.css' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000' function App() { const { t } = useTranslation() const [username, setUsername] = useState('') const [room, setRoom] = useState('general') const [session, setSession] = useState(null) const [status, setStatus] = useState('idle') const [messages, setMessages] = useState([]) const [participants, setParticipants] = useState([]) const [messageInput, setMessageInput] = useState('') const [error, setError] = useState('') const socketRef = useRef(null) const messagesEndRef = useRef(null) const connectionLabel = t(`status.${status}`, { defaultValue: status }) const isConnected = status === 'connected' useEffect(() => { if (!session?.id || !session?.room) { setStatus('idle') return } let isCancelled = false let retryCount = 0 let reconnectTimer const connect = () => { if (isCancelled) return setStatus(retryCount === 0 ? 'connecting' : 'reconnecting') const socket = io(API_URL, { auth: { room: session.room, userId: session.id, }, reconnectionAttempts: 5, transports: ['websocket'], }) socketRef.current = socket socket.on('connect', () => { retryCount = 0 setStatus('connected') }) socket.io.on('reconnect_attempt', () => { if (!isCancelled) { setStatus('reconnecting') } }) socket.io.on('reconnect_failed', () => { if (!isCancelled) { setStatus('error') } }) socket.on('connect_error', (socketError) => { if (isCancelled) return setError(socketError.message || ERROR_MESSAGES.connection.failed) setStatus('error') }) socket.on('disconnect', (reason) => { if (isCancelled) return if (reason !== 'io client disconnect' && retryCount < 5) { retryCount += 1 setStatus('reconnecting') } else { setStatus('error') } }) socket.on('history:init', (payload) => { setMessages(Array.isArray(payload) ? payload : []) }) socket.on('message:new', (payload) => { if (payload) { setMessages((prev) => [...prev, payload]) } }) socket.on('participants:update', (payload) => { setParticipants(Array.isArray(payload) ? payload : []) }) socket.on('connection:ready', () => { setError('') }) } reconnectTimer = setTimeout(() => { if (!socketRef.current?.connected && !isCancelled) { setStatus('connecting') } }, 0) connect() return () => { isCancelled = true clearTimeout(reconnectTimer) socketRef.current?.disconnect() socketRef.current = null } }, [session?.id, session?.room]) useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) const participantCount = participants.length const handleJoin = async (event) => { event.preventDefault() const validationError = validateJoinForm(username, room) if (validationError) { setError(validationError) return } setError('') setStatus(session ? 'reconnecting' : 'connecting') try { const response = await fetch(`${API_URL}/rooms/${encodeURIComponent(room.trim())}/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: username.trim(), userId: session?.id, }), }) if (!response.ok) { throw new Error(ERROR_MESSAGES.api.joinFailed) } const data = await response.json() if (!data?.user) { throw new Error(ERROR_MESSAGES.api.noUserReturned) } setSession(data.user) setUsername(data.user.username) setRoom(data.user.room) await fetchRoomSnapshots(data.user.room) } catch (joinError) { console.error(joinError) setError(joinError.message) setStatus('error') } } const fetchRoomSnapshots = async (targetRoom) => { const encodedRoom = encodeURIComponent(targetRoom) try { const [messagesResponse, participantsResponse] = await Promise.all([ fetch(`${API_URL}/rooms/${encodedRoom}/messages`), fetch(`${API_URL}/rooms/${encodedRoom}/participants`), ]) if (messagesResponse.ok) { const { messages: history } = await messagesResponse.json() setMessages(history) } if (participantsResponse.ok) { const { participants: list } = await participantsResponse.json() setParticipants(list) } } catch (snapshotError) { console.warn(t(ERROR_MESSAGES.api.snapshotFailed), snapshotError) } } const handleSendMessage = (event) => { event.preventDefault() if (!session || !messageInput.trim()) { return } const socket = socketRef.current if (!socket || !socket.connected) { setError(ERROR_MESSAGES.connection.noActiveConnection) return } try { socket.emit('message:send', { text: messageInput.trim(), }) setMessageInput('') setError('') } catch (sendError) { console.error(sendError) setError(ERROR_MESSAGES.connection.sendFailed) } } const handleLeave = async () => { if (!session) return try { await fetch(`${API_URL}/rooms/${encodeURIComponent(session.room)}/leave`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: session.id }), }) } catch (leaveError) { console.warn(t(ERROR_MESSAGES.api.leaveFailed), leaveError) } finally { setSession(null) setMessages([]) setParticipants([]) setStatus('idle') } } const connectionHint = useMemo(() => { if (!session) return t('hint.noSession') switch (status) { case 'connecting': return t('hint.connecting') case 'reconnecting': return t('hint.reconnecting') case 'error': return t('hint.error') default: return t('hint.joined', { room: session.room, username: session.username }) } }, [session, status, t]) return (

{t('app.title')}

{t('app.headline')}

{connectionHint}

{connectionLabel}
setUsername(event.target.value)} />
setRoom(event.target.value.toLowerCase())} />
{session ? ( ) : null}
{error ?

{t(error)}

: null}

{t('chat.title')}

{t('chat.liveFeed', { room: session?.room ?? room })}

{t('chat.messageCount', { count: messages.length })}
{messages.length === 0 ? (

{t('chat.empty')}

) : ( messages.map((message) => (
{message.username} {formatTime(message.createdAt)}

{message.text}

)) )}
setMessageInput(event.target.value)} placeholder={session ? t('chat.placeholder') : t('chat.placeholderDisabled')} disabled={!session || !isConnected} />
) } function formatTime(timestamp) { if (!timestamp) return '' return new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', }).format(new Date(timestamp)) } function formatRelativeTime(timestamp) { if (!timestamp) return '' const formatter = new Intl.RelativeTimeFormat('de', { numeric: 'auto' }) const now = Date.now() const then = new Date(timestamp).getTime() const diffInMinutes = Math.round((then - now) / (1000 * 60)) if (Math.abs(diffInMinutes) < 60) { return formatter.format(diffInMinutes, 'minute') } const diffInHours = Math.round(diffInMinutes / 60) if (Math.abs(diffInHours) < 24) { return formatter.format(diffInHours, 'hour') } const diffInDays = Math.round(diffInHours / 24) return formatter.format(diffInDays, 'day') } export default App