import { ChevronDownIcon, PaperAirplaneIcon, XMarkIcon, } from '@heroicons/react/20/solid' import { ArrowTopRightOnSquareIcon, ChatBubbleLeftRightIcon, FaceSmileIcon, HashtagIcon, MapPinIcon, PaperClipIcon, PlusIcon, UserGroupIcon, } from '@heroicons/react/24/outline' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, } from 'react' import { useLocation, useNavigate } from 'react-router' import AddressMapPicker from './AddressMapPicker' import Avatar, { type PresenceStatus } from './Avatar' import { useChatSocket } from './ChatSocketProvider' import LoadingSpinner from './LoadingSpinner' import Modal, { ModalTitle } from './Modal' import { scrollbarClassName } from './Scrollbar' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' export type PinnedChat = { id: string type: 'direct' | 'group' | 'channel' title: string image?: string isMember?: boolean partnerId?: string presenceStatus?: PresenceStatus } const pinnedChatsEventName = 'teg:pinned-chats-changed' type DockChatUser = { id: string username: string displayName: string email: string avatar: string } type DockChatAttachment = { id: string fileName: string contentType?: string sizeBytes?: number url?: string } type DockChatLocation = { lat: number lng: number label: string } type DockChatMessage = { id: string conversationId: string type: 'user' | 'system' body: string createdAt: string sender: DockChatUser attachments?: DockChatAttachment[] location?: DockChatLocation | null reactions?: DockChatReaction[] } type DockChatReactionUser = { id: string displayName: string } type DockChatReaction = { emoji: string users: DockChatReactionUser[] count: number } type DockChatSocketEvent = { type: | 'connected' | 'message.created' | 'message.updated' | 'messages.reload' | 'typing.updated' | 'conversation.removed' | 'error' conversationId?: string userId?: string userDisplayName?: string typing?: boolean message?: DockChatMessage error?: string } type UserNotificationEvent = CustomEvent<{ data?: { user?: { id: string presenceStatus?: PresenceStatus } } }> function pinnedChatsStorageKey(userId: string) { return `teg:pinned-chats:${userId}` } function classNames(...classes: Array) { return classes.filter(Boolean).join(' ') } function resizeDockTextarea(textarea: HTMLTextAreaElement | null) { if (!textarea) { return } textarea.style.height = 'auto' textarea.style.height = `${textarea.scrollHeight}px` } function normalizePinnedChats(value: unknown): PinnedChat[] { if (!Array.isArray(value)) { return [] } const chats: PinnedChat[] = [] for (const item of value) { if (!item || typeof item !== 'object') { continue } const chat = item as Partial if ( typeof chat.id !== 'string' || typeof chat.title !== 'string' || !['direct', 'group', 'channel'].includes(String(chat.type)) ) { continue } chats.push({ id: chat.id, type: chat.type as PinnedChat['type'], title: chat.title, image: typeof chat.image === 'string' ? chat.image : undefined, isMember: typeof chat.isMember === 'boolean' ? chat.isMember : undefined, partnerId: typeof chat.partnerId === 'string' ? chat.partnerId : undefined, presenceStatus: chat.presenceStatus === 'online' || chat.presenceStatus === 'away' || chat.presenceStatus === 'offline' ? chat.presenceStatus : undefined, }) } return chats } export function readPinnedChats(userId: string) { if (!userId || typeof window === 'undefined') { return [] } try { return normalizePinnedChats( JSON.parse( window.localStorage.getItem(pinnedChatsStorageKey(userId)) ?? '[]', ), ) } catch { return [] } } export function writePinnedChats(userId: string, chats: PinnedChat[]) { if (!userId || typeof window === 'undefined') { return } window.localStorage.setItem( pinnedChatsStorageKey(userId), JSON.stringify(chats.slice(0, 6)), ) window.dispatchEvent(new CustomEvent(pinnedChatsEventName)) } export function isPinnedChat(userId: string, chatId: string) { return readPinnedChats(userId).some((chat) => chat.id === chatId) } export function togglePinnedChat(userId: string, chat: PinnedChat) { const currentChats = readPinnedChats(userId) const exists = currentChats.some((item) => item.id === chat.id) writePinnedChats( userId, exists ? currentChats.filter((item) => item.id !== chat.id) : [chat, ...currentChats.filter((item) => item.id !== chat.id)], ) return !exists } function chatHref(chat: PinnedChat) { return chat.type === 'channel' ? `/chat/channel/${chat.id}` : `/chat/${chat.id}` } function isCurrentChatRoute(chat: PinnedChat, pathname: string) { return pathname === chatHref(chat) } async function readApiError(response: Response, fallback: string) { const data = await response.json().catch(() => null) return data?.error ?? fallback } function displayName(user: DockChatUser) { return user.displayName || user.username || user.email || 'Unbekannt' } function chatStatusText(chat: PinnedChat) { if (chat.type === 'direct') { if (chat.presenceStatus === 'online') { return 'Online' } if (chat.presenceStatus === 'away') { return 'Abwesend' } return 'Offline' } if (chat.type === 'channel') { return 'Channel' } return chat.isMember === false ? 'Öffentlich' : 'Gruppe' } function formatTime(value: string) { const date = new Date(value) if (Number.isNaN(date.getTime())) { return '' } return new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', }).format(date) } function dockLocationMapsUrl(lat: number, lng: number) { return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}` } function DockLocationPreview({ isOwn, location, }: { isOwn: boolean location: DockChatLocation }) { return ( {location.label || 'Geteilter Standort'} ) } function typingStatusText(names: string[]) { if (names.length === 1) { return `${names[0]} schreibt...` } if (names.length > 1) { return `${names.length} Personen schreiben...` } return '' } function DockStatusLine({ chat, typingNames, }: { chat: PinnedChat typingNames: string[] }) { const typingText = typingStatusText(typingNames) if (typingText) { return ( {typingText} ) } return ( {chatStatusText(chat)} ) } function mergeMessage( messages: DockChatMessage[] | undefined, message: DockChatMessage, ) { const currentMessages = messages ?? [] const nextMessages = currentMessages.some((item) => item.id === message.id) ? currentMessages.map((item) => (item.id === message.id ? message : item)) : [...currentMessages, message] return nextMessages.sort((left, right) => { const leftTime = new Date(left.createdAt).getTime() const rightTime = new Date(right.createdAt).getTime() if (leftTime !== rightTime) { return leftTime - rightTime } return left.id.localeCompare(right.id) }) } const dockChatEmojis = [ '\u{1F600}', '\u{1F602}', '\u{1F60A}', '\u{1F60D}', '\u{1F44D}', '\u{1F44F}', '\u{1F64F}', '\u{1F389}', '\u2764\uFE0F', '\u{1F525}', '\u2705', '\u{1F914}', '\u{1F605}', '\u{1F622}', '\u{1F62E}', '\u{1F680}', ] const dockReactionEmojis = dockChatEmojis.slice(0, 8) function dockEmojiShortcut(emoji: string) { switch (emoji) { case '\u{1F600}': return ':D' case '\u{1F60A}': return ':)' case '\u{1F622}': return ":'(" case '\u{1F62E}': return ':O' case '\u2764\uFE0F': return '<3' case '\u{1F602}': return 'Freudentränen' case '\u{1F60D}': return 'Verliebt' case '\u{1F44D}': return 'Daumen hoch' case '\u{1F44F}': return 'Applaus' case '\u{1F64F}': return 'Danke' case '\u{1F389}': return 'Feiern' case '\u{1F525}': return 'Feuer' case '\u2705': return 'Erledigt' case '\u{1F914}': return 'Nachdenklich' case '\u{1F605}': return 'Schwitzend lachen' case '\u{1F680}': return 'Rakete' default: return emoji } } const dockEmoticonReplacements: Array<[RegExp, string]> = [ [/(^|\s)<3(?=\s|$)/g, '$1\u2764\uFE0F'], [/(^|\s):'\((?=\s|$)/g, '$1\u{1F622}'], [/(^|\s):-?\)(?=\s|$)/g, '$1\u{1F642}'], [/(^|\s):-?D(?=\s|$)/g, '$1\u{1F600}'], [/(^|\s):-?\((?=\s|$)/g, '$1\u{1F641}'], [/(^|\s);-?\)(?=\s|$)/g, '$1\u{1F609}'], [/(^|\s):-?[pP](?=\s|$)/g, '$1\u{1F61B}'], [/(^|\s):-?[oO](?=\s|$)/g, '$1\u{1F62E}'], [/(^|\s):-?\|(?=\s|$)/g, '$1\u{1F610}'], [/(^|\s):-?\/(?=\s|$)/g, '$1\u{1F615}'], [/(^|\s)8-?\)(?=\s|$)/g, '$1\u{1F60E}'], ] function convertDockEmoticons(text: string) { let result = text for (const [pattern, replacement] of dockEmoticonReplacements) { result = result.replace(pattern, replacement) } return result } function DockTypingIndicator({ chat, names, }: { chat: PinnedChat names: string[] }) { const name = names.length === 1 ? names[0] : names.length > 1 ? `${names.length} Personen` : 'Jemand' return (
{name} schreibt gerade.
) } function DockMessageReactions({ userId, isOwn, reactions, disabled, onToggle, }: { userId: string isOwn: boolean reactions?: DockChatReaction[] disabled?: boolean onToggle: (emoji: string) => void }) { const visibleReactions = (reactions ?? []).filter( (reaction) => reaction.count > 0, ) if (visibleReactions.length === 0) { return null } return (
{visibleReactions.map((reaction) => { const reactedByCurrentUser = reaction.users.some( (reactionUser) => reactionUser.id === userId, ) const title = reaction.users .map((reactionUser) => reactionUser.id === userId ? 'Du' : reactionUser.displayName || 'Unbekannt', ) .join(', ') return ( ) })}
) } function ChatIcon({ chat }: { chat: PinnedChat }) { if (chat.image) { return ( ) } if (chat.type === 'direct') { return ( ) } const Icon = chat.type === 'channel' ? HashtagIcon : chat.type === 'group' ? UserGroupIcon : ChatBubbleLeftRightIcon return ( ) } export default function PinnedChatDock({ userId }: { userId: string }) { const navigate = useNavigate() const location = useLocation() const { send, subscribe } = useChatSocket() const [pinnedChats, setPinnedChats] = useState(() => readPinnedChats(userId)) const [openChatId, setOpenChatId] = useState(null) const [messagesByChatId, setMessagesByChatId] = useState< Record >({}) const [draftsByChatId, setDraftsByChatId] = useState>({}) const [pendingAttachmentsByChatId, setPendingAttachmentsByChatId] = useState< Record >({}) const [loadingChatIds, setLoadingChatIds] = useState(() => new Set()) const [sendingChatIds, setSendingChatIds] = useState(() => new Set()) const [uploadingChatIds, setUploadingChatIds] = useState(() => new Set()) const [errorsByChatId, setErrorsByChatId] = useState>({}) const [emojiOpenChatId, setEmojiOpenChatId] = useState(null) const [reactionPickerMessageId, setReactionPickerMessageId] = useState('') const [reactingMessageIds, setReactingMessageIds] = useState( () => new Set(), ) const [attachMenuOpenChatId, setAttachMenuOpenChatId] = useState(null) const [locationPickerChatId, setLocationPickerChatId] = useState(null) const [locationDraft, setLocationDraft] = useState<{ lat: number lon: number } | null>(null) const [locationLabel, setLocationLabel] = useState('') const [typingNow, setTypingNow] = useState(() => Date.now()) const [typingUsers, setTypingUsers] = useState< Record> >({}) const attachmentInputRef = useRef(null) const textareaRefsByChatId = useRef(new Map()) const messagesEndRef = useRef(null) const typingChatIdRef = useRef(undefined) const typingStopTimerRef = useRef(undefined) const isChatRoute = location.pathname === '/chat' || location.pathname.startsWith('/chat/') const reloadPinnedChats = useCallback(() => { const chats = readPinnedChats(userId) setPinnedChats(chats) setOpenChatId((current) => current && chats.some((chat) => chat.id === current) ? current : null, ) }, [userId]) const openChatMessages = openChatId ? messagesByChatId[openChatId] ?? [] : [] const openChatDraft = openChatId ? draftsByChatId[openChatId] ?? '' : '' const openTypingCount = openChatId ? Object.values(typingUsers[openChatId] ?? {}).filter( (typingUser) => typingUser.expiresAt > typingNow, ).length : 0 const visiblePinnedChats = useMemo( () => pinnedChats.filter( (chat) => !isCurrentChatRoute(chat, location.pathname), ), [location.pathname, pinnedChats], ) const dockChats = useMemo( () => isChatRoute ? visiblePinnedChats.filter((chat) => chat.id === openChatId) : visiblePinnedChats, [isChatRoute, openChatId, visiblePinnedChats], ) const locationChat = locationPickerChatId ? pinnedChats.find((chat) => chat.id === locationPickerChatId) : undefined const setChatLoading = useCallback((chatId: string, loading: boolean) => { setLoadingChatIds((current) => { const next = new Set(current) if (loading) { next.add(chatId) } else { next.delete(chatId) } return next }) }, []) const setChatSending = useCallback((chatId: string, sending: boolean) => { setSendingChatIds((current) => { const next = new Set(current) if (sending) { next.add(chatId) } else { next.delete(chatId) } return next }) }, []) const setChatUploading = useCallback((chatId: string, uploading: boolean) => { setUploadingChatIds((current) => { const next = new Set(current) if (uploading) { next.add(chatId) } else { next.delete(chatId) } return next }) }, []) const sendTypingState = useCallback( (chatId: string, typing: boolean) => { send({ type: typing ? 'typing.start' : 'typing.stop', conversationId: chatId, }) }, [send], ) const stopTyping = useCallback(() => { if (typingStopTimerRef.current !== undefined) { window.clearTimeout(typingStopTimerRef.current) typingStopTimerRef.current = undefined } const chatId = typingChatIdRef.current if (chatId) { sendTypingState(chatId, false) typingChatIdRef.current = undefined } }, [sendTypingState]) const loadMessages = useCallback( async (chatId: string) => { setChatLoading(chatId, true) setErrorsByChatId((current) => ({ ...current, [chatId]: '' })) try { const response = await fetch( `${API_URL}/chat/conversations/${chatId}/messages`, { credentials: 'include' }, ) if (!response.ok) { throw new Error( await readApiError( response, 'Nachrichten konnten nicht geladen werden', ), ) } const data = await response.json() setMessagesByChatId((current) => ({ ...current, [chatId]: data.messages ?? [], })) window.dispatchEvent(new Event('chat-unread-changed')) } catch (error) { setErrorsByChatId((current) => ({ ...current, [chatId]: error instanceof Error ? error.message : 'Nachrichten konnten nicht geladen werden', })) } finally { setChatLoading(chatId, false) } }, [setChatLoading], ) function updateDraft(chat: PinnedChat, value: string) { const nextValue = convertDockEmoticons(value) setDraftsByChatId((current) => ({ ...current, [chat.id]: nextValue, })) if (!nextValue.trim() || chat.type === 'channel' || chat.isMember === false) { if (typingChatIdRef.current === chat.id) { stopTyping() } return } if (typingChatIdRef.current !== chat.id) { stopTyping() sendTypingState(chat.id, true) typingChatIdRef.current = chat.id } if (typingStopTimerRef.current !== undefined) { window.clearTimeout(typingStopTimerRef.current) } typingStopTimerRef.current = window.setTimeout(stopTyping, 2_000) } async function uploadAttachments( chat: PinnedChat, event: ChangeEvent, ) { const files = Array.from(event.target.files ?? []) event.target.value = '' if (files.length === 0) { return } const oversizedFile = files.find((file) => file.size > 10 * 1024 * 1024) if (oversizedFile) { setErrorsByChatId((current) => ({ ...current, [chat.id]: 'Dateien dürfen höchstens 10 MB groß sein.', })) return } setChatUploading(chat.id, true) setErrorsByChatId((current) => ({ ...current, [chat.id]: '' })) try { const uploadedAttachments: DockChatAttachment[] = [] for (const file of files) { 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() if (data.attachment) { uploadedAttachments.push(data.attachment as DockChatAttachment) } } setPendingAttachmentsByChatId((current) => ({ ...current, [chat.id]: [...(current[chat.id] ?? []), ...uploadedAttachments], })) } catch (error) { setErrorsByChatId((current) => ({ ...current, [chat.id]: error instanceof Error ? error.message : 'Datei konnte nicht hochgeladen werden', })) } finally { setChatUploading(chat.id, false) } } async function sendMessage(chat: PinnedChat, event: FormEvent) { event.preventDefault() const body = draftsByChatId[chat.id]?.trim() ?? '' const pendingAttachments = pendingAttachmentsByChatId[chat.id] ?? [] if ( (!body && pendingAttachments.length === 0) || chat.type === 'channel' || chat.isMember === false ) { return } setChatSending(chat.id, true) setErrorsByChatId((current) => ({ ...current, [chat.id]: '' })) try { const response = await fetch( `${API_URL}/chat/conversations/${chat.id}/messages`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body, attachmentIds: pendingAttachments.map((attachment) => attachment.id), }), }, ) if (!response.ok) { throw new Error( await readApiError(response, 'Nachricht konnte nicht gesendet werden'), ) } const data = await response.json() if (data.message) { setMessagesByChatId((current) => ({ ...current, [chat.id]: mergeMessage(current[chat.id], data.message), })) } setDraftsByChatId((current) => ({ ...current, [chat.id]: '' })) setPendingAttachmentsByChatId((current) => ({ ...current, [chat.id]: [], })) setEmojiOpenChatId((current) => (current === chat.id ? null : current)) stopTyping() } catch (error) { setErrorsByChatId((current) => ({ ...current, [chat.id]: error instanceof Error ? error.message : 'Nachricht konnte nicht gesendet werden', })) } finally { setChatSending(chat.id, false) } } function openLocationPicker(chat: PinnedChat) { setAttachMenuOpenChatId(null) setEmojiOpenChatId(null) setLocationDraft(null) setLocationLabel('') setLocationPickerChatId(chat.id) } function closeLocationPicker() { setLocationPickerChatId(null) setLocationDraft(null) setLocationLabel('') } async function sendLocation() { const chat = locationPickerChatId ? pinnedChats.find((item) => item.id === locationPickerChatId) : undefined if ( !chat || !locationDraft || chat.type === 'channel' || chat.isMember === false ) { return } setChatSending(chat.id, true) setErrorsByChatId((current) => ({ ...current, [chat.id]: '' })) try { const response = await fetch( `${API_URL}/chat/conversations/${chat.id}/messages`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: '', location: { lat: locationDraft.lat, lng: locationDraft.lon, label: locationLabel.trim(), }, }), }, ) if (!response.ok) { throw new Error( await readApiError(response, 'Standort konnte nicht gesendet werden'), ) } const data = await response.json() if (data.message) { setMessagesByChatId((current) => ({ ...current, [chat.id]: mergeMessage(current[chat.id], data.message), })) } closeLocationPicker() } catch (error) { setErrorsByChatId((current) => ({ ...current, [chat.id]: error instanceof Error ? error.message : 'Standort konnte nicht gesendet werden', })) } finally { setChatSending(chat.id, false) } } function removePendingAttachment(chatId: string, attachmentId: string) { setPendingAttachmentsByChatId((current) => ({ ...current, [chatId]: (current[chatId] ?? []).filter( (attachment) => attachment.id !== attachmentId, ), })) } function addEmoji(chat: PinnedChat, emoji: string) { updateDraft(chat, `${draftsByChatId[chat.id] ?? ''}${emoji}`) setAttachMenuOpenChatId(null) setEmojiOpenChatId(null) } function setDockMessageReacting(messageId: string, reacting: boolean) { setReactingMessageIds((current) => { const next = new Set(current) if (reacting) { next.add(messageId) } else { next.delete(messageId) } return next }) } async function toggleMessageReaction( message: DockChatMessage, emoji: string, ) { if (message.type === 'system' || reactingMessageIds.has(message.id)) { return } setDockMessageReacting(message.id, true) setErrorsByChatId((current) => ({ ...current, [message.conversationId]: '', })) try { const response = await fetch( `${API_URL}/chat/messages/${message.id}/reactions`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ emoji }), }, ) if (!response.ok) { throw new Error( await readApiError(response, 'Reaktion konnte nicht gespeichert werden'), ) } const data = await response.json() if (data.message) { const updatedMessage = data.message as DockChatMessage setMessagesByChatId((current) => ({ ...current, [updatedMessage.conversationId]: mergeMessage( current[updatedMessage.conversationId], updatedMessage, ), })) } } catch (error) { setErrorsByChatId((current) => ({ ...current, [message.conversationId]: error instanceof Error ? error.message : 'Reaktion konnte nicht gespeichert werden', })) } finally { setDockMessageReacting(message.id, false) setReactionPickerMessageId((current) => current === message.id ? '' : current, ) } } useEffect(() => { reloadPinnedChats() window.addEventListener(pinnedChatsEventName, reloadPinnedChats) window.addEventListener('storage', reloadPinnedChats) return () => { window.removeEventListener(pinnedChatsEventName, reloadPinnedChats) window.removeEventListener('storage', reloadPinnedChats) } }, [reloadPinnedChats]) useEffect(() => { function handleUserNotification(event: Event) { const user = (event as UserNotificationEvent).detail?.data?.user if (!user?.id || !user.presenceStatus) { return } let changed = false const nextPinnedChats = readPinnedChats(userId).map((chat) => { if ( chat.partnerId !== user.id || chat.presenceStatus === user.presenceStatus ) { return chat } changed = true return { ...chat, presenceStatus: user.presenceStatus } }) if (!changed) { return } writePinnedChats(userId, nextPinnedChats) } window.addEventListener('user-notification', handleUserNotification) return () => { window.removeEventListener('user-notification', handleUserNotification) } }, [userId]) useEffect(() => { if (!openChatId) { return } void loadMessages(openChatId) }, [loadMessages, openChatId]) useEffect(() => { const unsubscribe = subscribe((rawPayload) => { const payload = rawPayload as DockChatSocketEvent if (payload.type === 'conversation.removed' && payload.conversationId) { writePinnedChats( userId, readPinnedChats(userId).filter( (chat) => chat.id !== payload.conversationId, ), ) return } if (payload.type === 'messages.reload' && payload.conversationId) { if (payload.conversationId === openChatId) { void loadMessages(payload.conversationId) } return } if ( payload.type === 'typing.updated' && payload.conversationId && payload.userId && payload.userId !== 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.created' || payload.type === 'message.updated') { setMessagesByChatId((current) => ({ ...current, [message.conversationId]: mergeMessage( current[message.conversationId], message, ), })) if (message.conversationId === openChatId && message.sender.id !== userId) { fetch(`${API_URL}/chat/conversations/${message.conversationId}/read`, { method: 'POST', credentials: 'include', }).catch(() => undefined) window.dispatchEvent(new Event('chat-unread-changed')) } } }) return unsubscribe }, [loadMessages, openChatId, subscribe, userId]) useEffect(() => { const intervalId = window.setInterval(() => { setTypingNow(Date.now()) }, 1_000) return () => { window.clearInterval(intervalId) } }, []) useEffect(() => { return () => { stopTyping() } }, [stopTyping]) useEffect(() => { if (typingChatIdRef.current && typingChatIdRef.current !== openChatId) { stopTyping() } setReactionPickerMessageId('') }, [openChatId, stopTyping]) useLayoutEffect(() => { if (!openChatId) { return } resizeDockTextarea(textareaRefsByChatId.current.get(openChatId) ?? null) }, [openChatDraft, openChatId]) useEffect(() => { messagesEndRef.current?.scrollIntoView({ block: 'end' }) }, [openChatId, openChatMessages.length, openTypingCount]) useEffect(() => { if ( openChatId && visiblePinnedChats.every((chat) => chat.id !== openChatId) ) { setOpenChatId(null) } }, [openChatId, visiblePinnedChats]) if (dockChats.length === 0) { return null } return ( <>
{dockChats.map((chat) => { const isOpen = chat.id === openChatId const messages = messagesByChatId[chat.id] ?? [] const draft = draftsByChatId[chat.id] ?? '' const pendingAttachments = pendingAttachmentsByChatId[chat.id] ?? [] const isLoading = loadingChatIds.has(chat.id) const isSending = sendingChatIds.has(chat.id) const isUploading = uploadingChatIds.has(chat.id) const error = errorsByChatId[chat.id] const canWrite = chat.type !== 'channel' && chat.isMember !== false const typingNames = Object.values(typingUsers[chat.id] ?? {}) .filter((typingUser) => typingUser.expiresAt > typingNow) .map((typingUser) => typingUser.name) if (isOpen) { return (
{isLoading && messages.length === 0 ? (
Nachrichten werden geladen...
) : messages.length === 0 && typingNames.length === 0 ? (
Noch keine Nachrichten.
) : (
{messages.map((message) => { const isOwn = message.sender.id === userId const hasReactions = (message.reactions ?? []).some( (reaction) => reaction.count > 0, ) if (message.type === 'system') { return (
{message.body}
) } return (
{!isOwn && (

{displayName(message.sender)}

)} {message.body && (

{message.body}

)} {message.location && ( )} {message.attachments && message.attachments.length > 0 && (

{message.attachments.length} Anhang {message.attachments.length === 1 ? '' : 'e'}

)}
{formatTime(message.createdAt)} {canWrite && reactionPickerMessageId === message.id && (
{dockReactionEmojis.map((emoji) => ( ))}
)} {(hasReactions || canWrite) && (
void toggleMessageReaction(message, emoji) } /> {canWrite && ( )}
)}
) })} {typingNames.length > 0 && ( )}
)}
{error && (
{error}
)} {canWrite ? (
void sendMessage(chat, event)} className="relative border-t border-gray-200 p-2 dark:border-white/10" > {emojiOpenChatId === chat.id && (
{dockChatEmojis.map((emoji) => ( ))}
)} {pendingAttachments.length > 0 && (
{pendingAttachments.map((attachment) => ( {attachment.fileName} ))}
)}
void uploadAttachments(chat, event)} />
{attachMenuOpenChatId === chat.id && ( <>
)}