import { Dialog, DialogBackdrop, DialogPanel, DialogTitle, } from '@headlessui/react' import { ArrowLeftIcon, BellIcon, BellSlashIcon, ChatBubbleLeftRightIcon, DocumentIcon, FaceSmileIcon, HashtagIcon, MagnifyingGlassIcon, MapPinIcon, ArrowRightIcon, ArrowUturnLeftIcon, PaperAirplaneIcon, PaperClipIcon, PlusIcon, UserGroupIcon, XMarkIcon, } from '@heroicons/react/24/outline' import { MapPinIcon as MapPinSolidIcon } from '@heroicons/react/24/solid' import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type ReactNode, } from 'react' import { useNavigate, useParams } from 'react-router' import Avatar from '../../components/Avatar' import AddressMapPicker from '../../components/AddressMapPicker' import LoadingSpinner from '../../components/LoadingSpinner' import type { User } from '../../components/types' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' type ChatUser = { id: string username: string displayName: string email: string avatar: string unit: string onlineStatus: boolean presenceStatus: 'online' | 'away' | 'offline' lastSeenAt?: string | null awaySince?: string | null lastOnlineAt?: string | null } type ChatLocation = { lat: number lng: number label: string } type ChatLinkPreview = { url: string title: string description: string imageUrl: string siteName: string } type ChatMessage = { id: string conversationId: string body: string createdAt: string editedAt?: string | null sender: ChatUser attachments: ChatAttachment[] replyTo?: ChatMessageReference | null forwardedFrom?: ChatMessageReference | null location?: ChatLocation | null linkPreview?: ChatLinkPreview | null } type ChatAttachment = { id: string fileName: string contentType: string sizeBytes: number url: string } type ChatMessageReference = { id: string body: string senderDisplayName: string createdAt: string } type ChatConversation = { id: string type: 'team' | 'direct' | 'group' | 'channel' name: string image: string teamId: string participants: ChatUser[] lastMessage?: ChatMessage | null lastMessageAt?: string | null muted: boolean unreadCount: number createdAt: string } type ChatSearchResult = { conversationId: string messageId: string body: string senderDisplayName: string createdAt: string } type ChatPageProps = { currentUser: User } type ChatSocketEvent = { type: | 'connected' | 'message.created' | 'message.updated' | 'typing.updated' | 'error' clientId?: string conversationId?: string userId?: string userDisplayName?: string typing?: boolean message?: ChatMessage error?: string } type UserNotificationEvent = CustomEvent<{ data?: { user?: Partial & { id: string } } }> function classNames(...classes: Array) { return classes.filter(Boolean).join(' ') } function displayName(user: ChatUser) { return user.displayName || user.username || user.email } function conversationTitle( conversation: ChatConversation, currentUserId: string, ) { if (conversation.type !== 'direct') { return conversation.name } const otherUser = conversation.participants.find( (participant) => participant.id !== currentUserId, ) return otherUser ? displayName(otherUser) : conversation.name || 'Chat' } function formatLastSeen(value?: string | null) { if (!value) { return 'Offline' } const date = new Date(value) if (Number.isNaN(date.getTime())) { return 'Offline' } const today = new Date() const yesterday = new Date() yesterday.setDate(today.getDate() - 1) const time = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', }).format(date) if (date.toDateString() === today.toDateString()) { return `Zuletzt online heute um ${time}` } if (date.toDateString() === yesterday.toDateString()) { return `Zuletzt online gestern um ${time}` } return `Zuletzt online am ${new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', }).format(date)} um ${time}` } function formatAwayDuration(value: string, now: number) { const awaySince = new Date(value).getTime() if (Number.isNaN(awaySince)) { return 'Abwesend' } const totalMinutes = Math.max(0, Math.floor((now - awaySince) / 60_000)) if (totalMinutes < 1) { return 'Seit gerade eben abwesend' } if (totalMinutes < 60) { return `Seit ${totalMinutes} ${totalMinutes === 1 ? 'Minute' : 'Minuten'} abwesend` } const totalHours = Math.floor(totalMinutes / 60) const remainingMinutes = totalMinutes % 60 if (totalHours < 24) { return remainingMinutes > 0 ? `Seit ${totalHours} Std. ${remainingMinutes} Min. abwesend` : `Seit ${totalHours} ${totalHours === 1 ? 'Stunde' : 'Stunden'} abwesend` } const days = Math.floor(totalHours / 24) return `Seit ${days} ${days === 1 ? 'Tag' : 'Tagen'} abwesend` } function userStatusText(user: ChatUser | undefined, now: number) { if (!user) { return 'Offline' } if (user.presenceStatus === 'online') { return 'Online' } if (user.presenceStatus === 'away') { return user.awaySince ? formatAwayDuration(user.awaySince, now) : 'Abwesend' } return formatLastSeen(user.lastOnlineAt) } function conversationPartner( conversation: ChatConversation, currentUserId: string, ) { return conversation.participants.find( (participant) => participant.id !== currentUserId, ) } function DirectTypingIndicator({ user }: { user: ChatUser | undefined }) { const name = user ? displayName(user) : 'Die andere Person' return (
{name} schreibt gerade.
) } function formatTime(value: string) { return new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', }).format(new Date(value)) } function isSameMessageDay(first: string, second: string) { const firstDate = new Date(first) const secondDate = new Date(second) if ( Number.isNaN(firstDate.getTime()) || Number.isNaN(secondDate.getTime()) ) { return first === second } return ( firstDate.getFullYear() === secondDate.getFullYear() && firstDate.getMonth() === secondDate.getMonth() && firstDate.getDate() === secondDate.getDate() ) } function formatMessageDate(value: string) { const date = new Date(value) if (Number.isNaN(date.getTime())) { return '' } const today = new Date() const yesterday = new Date(today) yesterday.setDate(today.getDate() - 1) if (isSameMessageDay(value, today.toISOString())) { return 'Heute' } if (isSameMessageDay(value, yesterday.toISOString())) { return 'Gestern' } return new Intl.DateTimeFormat('de-DE', { weekday: 'long', day: '2-digit', month: 'long', year: date.getFullYear() === today.getFullYear() ? undefined : 'numeric', }).format(date) } function formatConversationTime(value?: string | null) { if (!value) { return '' } const date = new Date(value) const today = new Date() const isToday = date.toDateString() === today.toDateString() return new Intl.DateTimeFormat('de-DE', isToday ? { hour: '2-digit', minute: '2-digit' } : { day: '2-digit', month: '2-digit' } ).format(date) } function MessageLinkPreview({ preview, ownMessage, }: { preview: ChatLinkPreview ownMessage: boolean }) { return ( {preview.imageUrl && ( )}
{preview.siteName && (

{preview.siteName}

)}

{preview.title || preview.url}

{preview.description && (

{preview.description}

)}
) } // Textkürzel wie :) :D ;) <3 werden direkt in Emojis umgewandelt. Die Wort- // grenzen (^|\s) … (?=\s|$) verhindern Treffer innerhalb von URLs oder Wörtern. const emoticonReplacements: Array<[RegExp, string]> = [ [/(^|\s)<3(?=\s|$)/g, '$1❤️'], [/(^|\s):'\((?=\s|$)/g, '$1😢'], [/(^|\s):-?\)(?=\s|$)/g, '$1🙂'], [/(^|\s):-?D(?=\s|$)/g, '$1😀'], [/(^|\s):-?\((?=\s|$)/g, '$1🙁'], [/(^|\s);-?\)(?=\s|$)/g, '$1😉'], [/(^|\s):-?[pP](?=\s|$)/g, '$1😛'], [/(^|\s):-?[oO](?=\s|$)/g, '$1😮'], [/(^|\s):-?\|(?=\s|$)/g, '$1😐'], [/(^|\s):-?\/(?=\s|$)/g, '$1😕'], [/(^|\s)8-?\)(?=\s|$)/g, '$1😎'], ] function convertEmoticons(text: string) { let result = text for (const [pattern, replacement] of emoticonReplacements) { result = result.replace(pattern, replacement) } return result } const chatUrlPattern = /https?:\/\/[^\s<>"']+/ function extractFirstUrl(text: string) { const match = text.match(chatUrlPattern) if (!match) { return '' } return match[0].replace(/[.,!?;:)\]}'"]+$/, '') } function lastMessagePreviewText(message?: ChatMessage | null) { if (!message) { return '' } if (message.body) { return message.body } if (message.location) { return '📍 Standort' } if (message.attachments?.length) { return '📎 Datei' } return '' } const chatEmojis = [ '😀', '😂', '😊', '😍', '👍', '👏', '🙏', '🎉', '❤️', '🔥', '✅', '🤔', '😅', '😢', '😮', '🚀', ] function formatFileSize(size: number) { if (size < 1024) { return `${size} B` } if (size < 1024 * 1024) { return `${Math.round(size / 1024)} KB` } return `${(size / (1024 * 1024)).toFixed(1)} MB` } function locationMapsUrl(lat: number, lng: number) { return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}` } // Leichtgewichtige statische Karten-Vorschau: eine einzelne OSM-Kachel, vertikal // auf den Punkt zugeschnitten, mit exakt platziertem Pin. Klick öffnet die Karte. function LocationPreview({ location, ownMessage, }: { location: ChatLocation ownMessage: boolean }) { const zoom = 15 const tileCount = 2 ** zoom const latRad = (location.lat * Math.PI) / 180 const xWorld = ((location.lng + 180) / 360) * tileCount const yWorld = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * tileCount const xTile = Math.floor(xWorld) const yTile = Math.floor(yWorld) const pointX = (xWorld - xTile) * 256 const pointY = (yWorld - yTile) * 256 const viewportHeight = 160 const cropTop = Math.min(Math.max(pointY - viewportHeight / 2, 0), 256 - viewportHeight) const tileUrl = `https://tile.openstreetmap.org/${zoom}/${xTile}/${yTile}.png` return (
Kartenausschnitt
{location.label || 'Geteilter Standort'}
) } function renderMessageText(body: string, ownMessage: boolean): ReactNode { const urlPattern = /(https?:\/\/[^\s]+)/gi const parts = body.split(urlPattern) return parts.map((part, index) => { if (!/^https?:\/\//i.test(part)) { return {part} } return ( {part} ) }) } async function readApiError(response: Response, fallback: string) { try { const data = await response.json() return data.error || fallback } catch { return fallback } } function getChatWebSocketUrl() { const url = new URL(`${API_URL.replace(/\/$/, '')}/chat/ws`) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' return url.toString() } export default function ChatPage({ currentUser }: ChatPageProps) { const navigate = useNavigate() const { channelId, conversationId, teamId } = useParams() const messagesEndRef = useRef(null) const attachmentInputRef = useRef(null) const lastPreviewUrlRef = useRef('') const chatSocketRef = useRef(null) const selectedConversationIdRef = useRef(undefined) const typingConversationIdRef = useRef(undefined) const typingStopTimerRef = useRef(undefined) const pendingMessageRef = useRef<{ clientId: string body: string attachments: ChatAttachment[] replyTo?: ChatMessage | null forwardedFrom?: ChatMessage | null } | null>(null) const [conversations, setConversations] = useState([]) const [users, setUsers] = useState([]) const [messages, setMessages] = useState([]) const [activeTab, setActiveTab] = useState<'chats' | 'channels'>(() => channelId ? 'channels' : 'chats', ) const [search, setSearch] = useState('') const [searchResults, setSearchResults] = useState([]) const [searchingMessages, setSearchingMessages] = useState(false) const [userSearch, setUserSearch] = useState('') const [messageBody, setMessageBody] = useState('') const [pendingAttachments, setPendingAttachments] = useState([]) const [replyTo, setReplyTo] = useState(null) const [forwardMessage, setForwardMessage] = useState(null) const [forwardDraft, setForwardDraft] = useState(null) const [emojiOpen, setEmojiOpen] = useState(false) const [uploadingAttachment, setUploadingAttachment] = useState(false) const [loadingConversations, setLoadingConversations] = useState(true) const [loadingMessages, setLoadingMessages] = useState(true) const [sending, setSending] = useState(false) const [startingDirectChat, setStartingDirectChat] = useState(false) const [newChatOpen, setNewChatOpen] = useState(false) const [membersOpen, setMembersOpen] = useState(false) const [locationPickerOpen, setLocationPickerOpen] = useState(false) const [locationDraft, setLocationDraft] = useState<{ lat: number lon: number } | null>(null) const [locationLabel, setLocationLabel] = useState('') const [attachMenuOpen, setAttachMenuOpen] = useState(false) const [draftPreview, setDraftPreview] = useState(null) const [dismissedPreviewUrl, setDismissedPreviewUrl] = useState('') const [socketConnected, setSocketConnected] = useState(false) const [statusNow, setStatusNow] = useState(() => Date.now()) const [typingUsers, setTypingUsers] = useState< Record> >({}) const [error, setError] = useState('') const selectedConversation = useMemo(() => { if (channelId) { return conversations.find( (conversation) => conversation.id === channelId && conversation.type === 'channel', ) } if (teamId) { return conversations.find( (conversation) => conversation.teamId === teamId, ) } if (conversationId) { return conversations.find( (conversation) => conversation.id === conversationId, ) } return undefined }, [channelId, conversationId, conversations, teamId]) const visibleTab = selectedConversation ? selectedConversation.type === 'channel' ? 'channels' : 'chats' : activeTab const tabConversations = useMemo( () => conversations.filter((conversation) => visibleTab === 'channels' ? conversation.type === 'channel' : conversation.type !== 'channel', ), [conversations, visibleTab], ) const unreadByTab = useMemo( () => conversations.reduce( (counts, conversation) => { const tab = conversation.type === 'channel' ? 'channels' : 'chats' counts[tab] += conversation.unreadCount return counts }, { chats: 0, channels: 0 }, ), [conversations], ) const filteredConversations = useMemo(() => { const normalizedSearch = search.trim().toLowerCase() if (!normalizedSearch) { return tabConversations } const matchingConversationIds = new Set( searchResults.map((result) => result.conversationId), ) return tabConversations.filter( (conversation) => conversationTitle(conversation, currentUser.id) .toLowerCase() .includes(normalizedSearch) || matchingConversationIds.has(conversation.id), ) }, [currentUser.id, search, searchResults, tabConversations]) const searchResultByConversation = useMemo( () => new Map( searchResults.map((result) => [result.conversationId, result] as const), ), [searchResults], ) const filteredUsers = useMemo(() => { const normalizedSearch = userSearch.trim().toLowerCase() if (!normalizedSearch) { return users } return users.filter((user) => [displayName(user), user.email, user.unit] .join(' ') .toLowerCase() .includes(normalizedSearch), ) }, [userSearch, users]) async function loadOverview(showLoading: boolean) { try { const [conversationsResponse, usersResponse] = await Promise.all([ fetch(`${API_URL}/chat/conversations`, { credentials: 'include' }), fetch(`${API_URL}/chat/users`, { credentials: 'include' }), ]) if (!conversationsResponse.ok || !usersResponse.ok) { throw new Error('Chats konnten nicht geladen werden') } const [conversationsData, usersData] = await Promise.all([ conversationsResponse.json(), usersResponse.json(), ]) setConversations(conversationsData.conversations ?? []) setUsers(usersData.users ?? []) setError('') } catch (loadError) { if (showLoading) { setError( loadError instanceof Error ? loadError.message : 'Chats konnten nicht geladen werden', ) } } finally { if (showLoading) { setLoadingConversations(false) } } } async function loadMessages(selectedConversationId: string) { await Promise.resolve() setLoadingMessages(true) setError('') try { const response = await fetch( `${API_URL}/chat/conversations/${selectedConversationId}/messages`, { credentials: 'include' }, ) if (!response.ok) { throw new Error( await readApiError(response, 'Nachrichten konnten nicht geladen werden'), ) } const data = await response.json() setMessages(data.messages ?? []) setConversations((current) => current.map((conversation) => conversation.id === selectedConversationId ? { ...conversation, unreadCount: 0 } : conversation, ), ) window.dispatchEvent(new Event('chat-unread-changed')) } catch (loadError) { setError( loadError instanceof Error ? loadError.message : 'Nachrichten konnten nicht geladen werden', ) } finally { setLoadingMessages(false) } } async function startDirectChat(userId: string) { setStartingDirectChat(true) setError('') try { const response = await fetch(`${API_URL}/chat/direct`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }), }) if (!response.ok) { throw new Error( await readApiError(response, 'Direktchat konnte nicht geöffnet werden'), ) } const data = await response.json() const conversation = data.conversation as ChatConversation setConversations((current) => [ conversation, ...current.filter((item) => item.id !== conversation.id), ]) setNewChatOpen(false) setUserSearch('') navigate(`/chat/${conversation.id}`) } catch (startError) { setError( startError instanceof Error ? startError.message : 'Direktchat konnte nicht geöffnet werden', ) } finally { setStartingDirectChat(false) } } async function toggleConversationMute(conversation: ChatConversation) { const muted = !conversation.muted setError('') try { const response = await fetch( `${API_URL}/chat/conversations/${conversation.id}/mute`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ muted }), }, ) if (!response.ok) { throw new Error( await readApiError( response, 'Stummschaltung konnte nicht gespeichert werden', ), ) } setConversations((current) => current.map((item) => item.id === conversation.id ? { ...item, muted } : item, ), ) } catch (muteError) { setError( muteError instanceof Error ? muteError.message : 'Stummschaltung konnte nicht gespeichert werden', ) } } const sendTypingState = useCallback((conversationId: string, typing: boolean) => { const socket = chatSocketRef.current if (!socket || socket.readyState !== WebSocket.OPEN) { return } socket.send( JSON.stringify({ type: typing ? 'typing.start' : 'typing.stop', conversationId, }), ) }, []) const stopTyping = useCallback(() => { if (typingStopTimerRef.current !== undefined) { window.clearTimeout(typingStopTimerRef.current) typingStopTimerRef.current = undefined } const conversationId = typingConversationIdRef.current if (conversationId) { sendTypingState(conversationId, false) typingConversationIdRef.current = undefined } }, [sendTypingState]) function handleMessageBodyChange(value: string) { const nextValue = convertEmoticons(value) setMessageBody(nextValue) const nextUrl = extractFirstUrl(nextValue) if (!nextUrl || nextUrl === dismissedPreviewUrl) { setDraftPreview(null) lastPreviewUrlRef.current = '' } if (!selectedConversationId || !nextValue.trim()) { stopTyping() return } if (typingConversationIdRef.current !== selectedConversationId) { stopTyping() sendTypingState(selectedConversationId, true) typingConversationIdRef.current = selectedConversationId } if (typingStopTimerRef.current !== undefined) { window.clearTimeout(typingStopTimerRef.current) } typingStopTimerRef.current = window.setTimeout(stopTyping, 2_000) } async function markConversationRead(conversationId: string) { setConversations((current) => current.map((conversation) => conversation.id === conversationId ? { ...conversation, unreadCount: 0 } : conversation, ), ) window.dispatchEvent(new Event('chat-unread-changed')) await fetch(`${API_URL}/chat/conversations/${conversationId}/read`, { method: 'POST', credentials: 'include', }).catch(() => undefined) window.dispatchEvent(new Event('chat-unread-changed')) } function sendSocketMessage( conversation: ChatConversation, body: string, options?: { attachments?: ChatAttachment[] replyToMessageId?: string forwardedMessage?: ChatMessage location?: ChatLocation }, ) { const socket = chatSocketRef.current if (!socket || socket.readyState !== WebSocket.OPEN) { setError('Die Chat-Verbindung wird gerade neu aufgebaut.') return false } const clientId = crypto.randomUUID() pendingMessageRef.current = { clientId, body, attachments: options?.attachments ?? [], replyTo, forwardedFrom: options?.forwardedMessage, } setSending(true) setError('') try { socket.send( JSON.stringify({ type: 'message.send', clientId, conversationId: conversation.id, body, attachmentIds: (options?.attachments ?? []).map( (attachment) => attachment.id, ), replyToMessageId: options?.replyToMessageId ?? '', forwardedFromMessageId: options?.forwardedMessage?.id ?? '', location: options?.location ?? null, }), ) return true } catch { pendingMessageRef.current = null setSending(false) setError('Nachricht konnte nicht gesendet werden') return false } } function sendMessage(event: FormEvent) { event.preventDefault() const body = messageBody.trim() if ( (!body && pendingAttachments.length === 0 && !forwardDraft) || !selectedConversation || sending ) { return } const sent = sendSocketMessage(selectedConversation, body, { attachments: pendingAttachments, replyToMessageId: replyTo?.id, forwardedMessage: forwardDraft ?? undefined, }) if (sent) { stopTyping() setMessageBody('') setPendingAttachments([]) setReplyTo(null) setForwardDraft(null) setEmojiOpen(false) setDraftPreview(null) setDismissedPreviewUrl('') lastPreviewUrlRef.current = '' } } function sendLocation() { if (!selectedConversation || !locationDraft || sending) { return } const sent = sendSocketMessage(selectedConversation, '', { location: { lat: locationDraft.lat, lng: locationDraft.lon, label: locationLabel.trim(), }, }) if (sent) { setLocationPickerOpen(false) setLocationDraft(null) setLocationLabel('') } } async function uploadAttachment(event: ChangeEvent) { const file = event.target.files?.[0] event.target.value = '' if (!file) { return } if (file.size > 10 * 1024 * 1024) { setError('Dateien dürfen höchstens 10 MB groß sein.') return } setUploadingAttachment(true) setError('') try { const formData = new FormData() formData.append('file', file) const response = await fetch(`${API_URL}/chat/attachments`, { method: 'POST', credentials: 'include', body: formData, }) if (!response.ok) { throw new Error( await readApiError(response, 'Datei konnte nicht hochgeladen werden'), ) } const data = await response.json() setPendingAttachments((current) => [ ...current, data.attachment as ChatAttachment, ]) } catch (uploadError) { setError( uploadError instanceof Error ? uploadError.message : 'Datei konnte nicht hochgeladen werden', ) } finally { setUploadingAttachment(false) } } function forwardToConversation(conversation: ChatConversation) { if ( !forwardMessage || sending || conversation.type === 'channel' || conversation.id === forwardMessage.conversationId ) { return } setMessageBody('') setPendingAttachments([]) setReplyTo(null) setDraftPreview(null) setDismissedPreviewUrl('') setForwardDraft(forwardMessage) setForwardMessage(null) openConversation(conversation, true) } function openConversation( conversation: ChatConversation, preserveForwardDraft = false, ) { setLoadingMessages(true) setError('') if (!preserveForwardDraft) { setForwardDraft(null) } if (conversation.type === 'channel') { setActiveTab('channels') navigate(`/chat/channel/${conversation.id}`) return } setActiveTab('chats') if (conversation.type === 'team' && conversation.teamId) { navigate(`/chat/team/${conversation.teamId}`) return } navigate(`/chat/${conversation.id}`) } const selectedConversationId = selectedConversation?.id useEffect(() => { selectedConversationIdRef.current = selectedConversationId }, [selectedConversationId]) useEffect(() => { let socket: WebSocket | null = null let reconnectTimer: number | undefined let reconnectAttempt = 0 let stopped = false function restorePendingMessage(message: string) { const pendingMessage = pendingMessageRef.current pendingMessageRef.current = null if (pendingMessage) { setMessageBody((current) => current || pendingMessage.body) setPendingAttachments((current) => current.length > 0 ? current : pendingMessage.attachments, ) if (pendingMessage.replyTo) { setReplyTo(pendingMessage.replyTo) } if (pendingMessage.forwardedFrom) { setForwardDraft(pendingMessage.forwardedFrom) } } setSending(false) setError(message) } function connect() { socket = new WebSocket(getChatWebSocketUrl()) chatSocketRef.current = socket socket.onopen = () => { reconnectAttempt = 0 setSocketConnected(true) } socket.onmessage = (event) => { let payload: ChatSocketEvent try { payload = JSON.parse(event.data) as ChatSocketEvent } catch { return } if (payload.type === 'connected') { setSocketConnected(true) return } if (payload.type === 'error') { if ( payload.clientId && payload.clientId === pendingMessageRef.current?.clientId ) { restorePendingMessage( payload.error || 'Nachricht konnte nicht gesendet werden', ) } return } if ( payload.type === 'typing.updated' && payload.conversationId && payload.userId ) { setTypingUsers((current) => { const conversationUsers = { ...(current[payload.conversationId as string] ?? {}), } if (payload.typing) { conversationUsers[payload.userId as string] = { name: payload.userDisplayName || 'Jemand', expiresAt: Date.now() + 4_000, } } else { delete conversationUsers[payload.userId as string] } return { ...current, [payload.conversationId as string]: conversationUsers, } }) return } const message = payload.message if (!message) { return } if (payload.type === 'message.updated') { setMessages((current) => current.map((item) => (item.id === message.id ? message : item)), ) setConversations((current) => current.map((conversation) => conversation.lastMessage?.id === message.id ? { ...conversation, lastMessage: message } : conversation, ), ) return } if (payload.type !== 'message.created') { return } setConversations((current) => { const next = current.map((conversation) => conversation.id === message.conversationId ? { ...conversation, lastMessage: message, lastMessageAt: message.createdAt, unreadCount: message.sender.id === currentUser.id || message.conversationId === selectedConversationIdRef.current ? 0 : (conversation.unreadCount ?? 0) + 1, } : conversation, ) return [...next].sort( (a, b) => new Date(b.lastMessageAt || b.createdAt).getTime() - new Date(a.lastMessageAt || a.createdAt).getTime(), ) }) if ( message.conversationId === selectedConversationIdRef.current ) { setMessages((current) => current.some((item) => item.id === message.id) ? current : [...current, message], ) if (message.sender.id !== currentUser.id) { void markConversationRead(message.conversationId) } } else if (message.sender.id !== currentUser.id) { window.dispatchEvent(new Event('chat-unread-changed')) } if ( payload.clientId && payload.clientId === pendingMessageRef.current?.clientId ) { pendingMessageRef.current = null setSending(false) } } socket.onclose = () => { if (chatSocketRef.current === socket) { chatSocketRef.current = null } setSocketConnected(false) typingConversationIdRef.current = undefined if (pendingMessageRef.current) { restorePendingMessage( 'Die Verbindung wurde unterbrochen. Die Nachricht wurde nicht gesendet.', ) } if (stopped) { return } const delay = Math.min(1000 * 2 ** reconnectAttempt, 10_000) reconnectAttempt += 1 reconnectTimer = window.setTimeout(connect, delay) } } connect() return () => { stopped = true setSocketConnected(false) if (reconnectTimer !== undefined) { window.clearTimeout(reconnectTimer) } chatSocketRef.current = null stopTyping() socket?.close(1000, 'Chat geschlossen') } }, [currentUser.id, stopTyping]) useEffect(() => { const initialLoadId = window.setTimeout(() => { void loadOverview(true) }, 0) const intervalId = window.setInterval(() => { void loadOverview(false) }, 30_000) return () => { window.clearTimeout(initialLoadId) window.clearInterval(intervalId) } }, []) useEffect(() => { const query = search.trim() if (query.length < 2) { return } const controller = new AbortController() const timeoutId = window.setTimeout(() => { setSearchingMessages(true) void fetch(`${API_URL}/chat/search?q=${encodeURIComponent(query)}`, { credentials: 'include', signal: controller.signal, }) .then((response) => (response.ok ? response.json() : null)) .then((data) => { if (data) { setSearchResults(data.results ?? []) } }) .catch(() => undefined) .finally(() => { if (!controller.signal.aborted) { setSearchingMessages(false) } }) }, 250) return () => { window.clearTimeout(timeoutId) controller.abort() } }, [search]) useEffect(() => { const intervalId = window.setInterval(() => { setStatusNow(Date.now()) }, 30_000) return () => window.clearInterval(intervalId) }, []) useEffect(() => { const intervalId = window.setInterval(() => { const now = Date.now() setTypingUsers((current) => { let changed = false const next: typeof current = {} for (const [conversationId, usersById] of Object.entries(current)) { const activeUsers = Object.fromEntries( Object.entries(usersById).filter(([, typingUser]) => { const active = typingUser.expiresAt > now changed ||= !active return active }), ) next[conversationId] = activeUsers } return changed ? next : current }) }, 1_000) return () => window.clearInterval(intervalId) }, []) useEffect(() => { function handleUserNotification(event: Event) { const updatedUser = (event as UserNotificationEvent).detail?.data?.user if (!updatedUser?.id) { return } const updateChatUser = (chatUser: ChatUser) => chatUser.id === updatedUser.id ? { ...chatUser, ...updatedUser } : chatUser setUsers((current) => current.map(updateChatUser)) setConversations((current) => current.map((conversation) => ({ ...conversation, participants: conversation.participants.map(updateChatUser), lastMessage: conversation.lastMessage ? { ...conversation.lastMessage, sender: updateChatUser(conversation.lastMessage.sender), } : conversation.lastMessage, })), ) setMessages((current) => current.map((message) => ({ ...message, sender: updateChatUser(message.sender), })), ) void loadOverview(false) } window.addEventListener('user-notification', handleUserNotification) return () => { window.removeEventListener('user-notification', handleUserNotification) } }, []) useEffect(() => { if (!selectedConversationId) { stopTyping() return } if ( typingConversationIdRef.current && typingConversationIdRef.current !== selectedConversationId ) { stopTyping() } const loadId = window.setTimeout(() => { void loadMessages(selectedConversationId) }, 0) return () => window.clearTimeout(loadId) }, [selectedConversationId, stopTyping]) useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) useEffect(() => { const url = extractFirstUrl(messageBody) if (!url || url === dismissedPreviewUrl) { lastPreviewUrlRef.current = '' return } if (url === lastPreviewUrlRef.current) { return } let cancelled = false const timeoutId = window.setTimeout(() => { lastPreviewUrlRef.current = url void fetch(`${API_URL}/chat/link-preview`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), }) .then((response) => (response.ok ? response.json() : null)) .then((data) => { if (!cancelled) { setDraftPreview(data?.preview ?? null) } }) .catch(() => undefined) }, 600) return () => { cancelled = true window.clearTimeout(timeoutId) } }, [messageBody, dismissedPreviewUrl]) const selectedPartner = selectedConversation ? conversationPartner(selectedConversation, currentUser.id) : undefined const selectedTypingUsers = selectedConversationId ? Object.values(typingUsers[selectedConversationId] ?? {}) : [] const selectedTypingText = selectedTypingUsers.length === 1 ? `${selectedTypingUsers[0].name} schreibt...` : selectedTypingUsers.length > 1 ? `${selectedTypingUsers.length} Personen schreiben...` : '' const showDirectTypingIndicator = selectedConversation?.type === 'direct' && selectedTypingUsers.length > 0 useEffect(() => { if (!showDirectTypingIndicator) { return } messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [showDirectTypingIndicator]) function handleSearchChange(value: string) { setSearch(value) if (value.trim().length < 2) { setSearchResults([]) setSearchingMessages(false) } } function selectTab(tab: 'chats' | 'channels') { setActiveTab(tab) setSearch('') setSearchResults([]) if ( selectedConversation && (selectedConversation.type === 'channel') !== (tab === 'channels') ) { navigate('/chat') } } return (
{selectedConversation ? ( <>
{selectedConversation.type === 'channel' ? ( selectedConversation.image ? ( ) : ( ) ) : selectedConversation.type !== 'direct' ? ( ) : ( )} {selectedConversation.type === 'channel' ? (

{conversationTitle(selectedConversation, currentUser.id)}

Schreibgeschützter Channel

) : selectedConversation.type !== 'direct' ? ( ) : (

{conversationTitle(selectedConversation, currentUser.id)}

{selectedTypingText || userStatusText(selectedPartner, statusNow)}

)} {selectedConversation.type !== 'direct' && ( )}
{loadingMessages ? (
) : messages.length === 0 ? (

{selectedConversation.type === 'channel' ? 'Noch keine Meldungen' : 'Hier beginnt der Chat'}

{selectedConversation.type === 'channel' ? 'Neue Meldungen erscheinen hier automatisch.' : 'Schreibe die erste Nachricht.'}

{showDirectTypingIndicator && ( )}
) : (
{messages.map((message, index) => { const ownMessage = message.sender.id === currentUser.id const previousMessage = messages[index - 1] const showDateSeparator = !previousMessage || !isSameMessageDay( previousMessage.createdAt, message.createdAt, ) const dateLabel = showDateSeparator ? formatMessageDate(message.createdAt) : '' return ( {showDateSeparator && (
{dateLabel}
)}
{!ownMessage && ( )}
{!ownMessage && selectedConversation.type !== 'direct' && (

{displayName(message.sender)}

)} {message.forwardedFrom && (

Weitergeleitet von{' '} {message.forwardedFrom.senderDisplayName}

{message.forwardedFrom.body ? renderMessageText( message.forwardedFrom.body, ownMessage, ) : 'Dateianhang'}

)} {message.replyTo && (

{message.replyTo.senderDisplayName}

{message.replyTo.body || 'Dateianhang'}

)} {message.linkPreview && ( )} {message.body && (

{renderMessageText(message.body, ownMessage)}

)} {message.location && ( )} {message.attachments?.length > 0 && (
{message.attachments.map((attachment) => attachment.contentType.startsWith('image/') ? ( {attachment.fileName} ) : ( {attachment.fileName} {formatFileSize(attachment.sizeBytes)} ), )}
)}

{formatTime(message.createdAt)}

) })} {showDirectTypingIndicator && ( )}
)}
{error && (
{error}
)} {selectedConversation.type === 'channel' ? (
Dieser Channel ist schreibgeschützt. Meldungen werden von der angebundenen Integration veröffentlicht.
) : (
{forwardDraft && (

Weiterleiten von {displayName(forwardDraft.sender)}

{forwardDraft.body || 'Dateianhang'}

)} {replyTo && (

Antwort auf {displayName(replyTo.sender)}

{replyTo.body || 'Dateianhang'}

)} {pendingAttachments.length > 0 && (
{pendingAttachments.map((attachment) => ( {attachment.fileName} ))}
)} {draftPreview && (
{draftPreview.imageUrl && ( )}
{draftPreview.siteName && (

{draftPreview.siteName}

)}

{draftPreview.title || draftPreview.url}

{draftPreview.description && (

{draftPreview.description}

)}
)} {emojiOpen && (
{chatEmojis.map((emoji) => ( ))}
)}
void uploadAttachment(event)} />
{attachMenuOpen && ( <>
)}