teg/frontend/src/pages/chat/ChatPage.tsx
2026-06-15 08:27:12 +02:00

2706 lines
95 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ChatUser> & { id: string }
}
}>
function classNames(...classes: Array<string | false | null | undefined>) {
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 (
<div
role="status"
aria-live="polite"
className="flex items-end gap-2"
>
<Avatar
src={user?.avatar}
name={name}
size={8}
presenceStatus={user?.presenceStatus}
/>
<div className="rounded-2xl rounded-bl-sm bg-white px-3.5 py-3 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-white/10">
<span className="sr-only">{name} schreibt gerade.</span>
<span aria-hidden="true" className="flex h-3 items-center gap-1">
{[-0.3, -0.15, 0].map((delay) => (
<span
key={delay}
className="size-1.5 animate-bounce rounded-full bg-gray-400 motion-reduce:animate-none dark:bg-gray-300"
style={{ animationDelay: `${delay}s` }}
/>
))}
</span>
</div>
</div>
)
}
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 (
<a
href={preview.url}
target="_blank"
rel="noreferrer noopener"
className={classNames(
ownMessage
? 'border-white/20 bg-white/10 hover:bg-white/15'
: 'border-gray-200 bg-gray-50 hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:hover:bg-white/10',
'mb-2 block w-72 max-w-full overflow-hidden rounded-lg border transition',
)}
>
{preview.imageUrl && (
<img
src={preview.imageUrl}
alt=""
loading="lazy"
className="h-32 w-full object-cover"
/>
)}
<div className="p-2.5">
{preview.siteName && (
<p
className={classNames(
ownMessage ? 'text-indigo-100' : 'text-gray-400',
'truncate text-[0.625rem] font-medium uppercase tracking-wide',
)}
>
{preview.siteName}
</p>
)}
<p className="line-clamp-2 text-sm font-semibold">
{preview.title || preview.url}
</p>
{preview.description && (
<p
className={classNames(
ownMessage ? 'text-indigo-100' : 'text-gray-500 dark:text-gray-400',
'mt-0.5 line-clamp-2 text-xs',
)}
>
{preview.description}
</p>
)}
</div>
</a>
)
}
// 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 (
<a
href={locationMapsUrl(location.lat, location.lng)}
target="_blank"
rel="noreferrer noopener"
className={classNames(
ownMessage
? 'border-white/20 bg-white/10 hover:bg-white/15'
: 'border-gray-200 bg-gray-50 hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:hover:bg-white/10',
'mt-1 block w-64 max-w-full overflow-hidden rounded-lg border transition',
)}
>
<div
className="relative w-full overflow-hidden bg-gray-200 dark:bg-gray-700"
style={{ height: `${viewportHeight}px` }}
>
<img
src={tileUrl}
alt="Kartenausschnitt"
width={256}
height={256}
loading="lazy"
className="absolute left-0 h-64 w-64 max-w-none"
style={{ top: `-${cropTop}px` }}
/>
<MapPinSolidIcon
className="absolute size-7 -translate-x-1/2 -translate-y-full text-red-600 drop-shadow-md"
style={{ left: `${pointX}px`, top: `${pointY - cropTop}px` }}
/>
</div>
<div className="flex items-center gap-1.5 px-2.5 py-2">
<MapPinIcon className="size-4 shrink-0 opacity-70" />
<span className="min-w-0 truncate text-xs font-medium">
{location.label || 'Geteilter Standort'}
</span>
</div>
</a>
)
}
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 <span key={`${index}-${part}`}>{part}</span>
}
return (
<a
key={`${index}-${part}`}
href={part}
target="_blank"
rel="noreferrer noopener"
className={classNames(
ownMessage
? 'text-white underline decoration-indigo-200'
: 'text-indigo-600 underline dark:text-indigo-300',
'break-all underline-offset-2 hover:no-underline',
)}
>
{part}
</a>
)
})
}
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<HTMLDivElement | null>(null)
const attachmentInputRef = useRef<HTMLInputElement | null>(null)
const lastPreviewUrlRef = useRef('')
const chatSocketRef = useRef<WebSocket | null>(null)
const selectedConversationIdRef = useRef<string | undefined>(undefined)
const typingConversationIdRef = useRef<string | undefined>(undefined)
const typingStopTimerRef = useRef<number | undefined>(undefined)
const pendingMessageRef = useRef<{
clientId: string
body: string
attachments: ChatAttachment[]
replyTo?: ChatMessage | null
forwardedFrom?: ChatMessage | null
} | null>(null)
const [conversations, setConversations] = useState<ChatConversation[]>([])
const [users, setUsers] = useState<ChatUser[]>([])
const [messages, setMessages] = useState<ChatMessage[]>([])
const [activeTab, setActiveTab] = useState<'chats' | 'channels'>(() =>
channelId ? 'channels' : 'chats',
)
const [search, setSearch] = useState('')
const [searchResults, setSearchResults] = useState<ChatSearchResult[]>([])
const [searchingMessages, setSearchingMessages] = useState(false)
const [userSearch, setUserSearch] = useState('')
const [messageBody, setMessageBody] = useState('')
const [pendingAttachments, setPendingAttachments] = useState<ChatAttachment[]>([])
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null)
const [forwardMessage, setForwardMessage] = useState<ChatMessage | null>(null)
const [forwardDraft, setForwardDraft] = useState<ChatMessage | null>(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<ChatLinkPreview | null>(null)
const [dismissedPreviewUrl, setDismissedPreviewUrl] = useState('')
const [socketConnected, setSocketConnected] = useState(false)
const [statusNow, setStatusNow] = useState(() => Date.now())
const [typingUsers, setTypingUsers] = useState<
Record<string, Record<string, { name: string; expiresAt: number }>>
>({})
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<HTMLFormElement>) {
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<HTMLInputElement>) {
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 (
<div className="flex h-full min-h-0 bg-gray-50 dark:bg-gray-950">
<aside
className={classNames(
selectedConversation ? 'hidden md:flex' : 'flex',
'w-full shrink-0 flex-col border-r border-gray-200 bg-white md:w-80 dark:border-white/10 dark:bg-gray-900',
)}
>
<div className="border-b border-gray-200 p-4 dark:border-white/10">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-gray-950 dark:text-white">
Nachrichten
</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">
Chats und Integrations-Channels
</p>
</div>
{visibleTab === 'chats' && (
<button
type="button"
onClick={() => setNewChatOpen(true)}
className="inline-flex size-9 items-center justify-center rounded-full bg-indigo-600 text-white shadow-sm transition hover:bg-indigo-500"
>
<span className="sr-only">Neue Direktnachricht</span>
<PlusIcon className="size-5" aria-hidden="true" />
</button>
)}
</div>
<div className="mt-4 grid grid-cols-2 rounded-xl bg-gray-100 p-1 dark:bg-white/5">
{(['chats', 'channels'] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => selectTab(tab)}
className={classNames(
visibleTab === tab
? 'bg-white text-indigo-700 shadow-sm dark:bg-gray-800 dark:text-indigo-300'
: 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white',
'flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition',
)}
>
{tab === 'chats' ? 'Chats' : 'Channels'}
{unreadByTab[tab] > 0 && (
<span className="inline-flex min-w-5 items-center justify-center rounded-full bg-indigo-600 px-1.5 py-0.5 text-[0.6875rem] font-semibold leading-none text-white">
{unreadByTab[tab] > 99 ? '99+' : unreadByTab[tab]}
</span>
)}
</button>
))}
</div>
<div className="relative mt-4">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400"
aria-hidden="true"
/>
<input
value={search}
onChange={(event) => handleSearchChange(event.target.value)}
placeholder={
visibleTab === 'channels'
? 'Channels und Meldungen durchsuchen'
: 'Chats und Nachrichten durchsuchen'
}
className="block w-full rounded-xl border-0 bg-gray-100 py-2 pr-3 pl-9 text-sm text-gray-900 outline-none ring-1 ring-transparent placeholder:text-gray-500 focus:ring-indigo-500 dark:bg-white/5 dark:text-white dark:placeholder:text-gray-500"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{loadingConversations ? (
<div className="flex h-full items-center justify-center">
<LoadingSpinner label="Chats werden geladen..." />
</div>
) : filteredConversations.length === 0 ? (
<div className="px-6 py-16 text-center">
<ChatBubbleLeftRightIcon className="mx-auto size-10 text-gray-300 dark:text-gray-600" />
<p className="mt-3 text-sm font-medium text-gray-900 dark:text-white">
{search.trim()
? 'Keine Treffer'
: visibleTab === 'channels'
? 'Noch keine Channels'
: 'Noch keine Chats'}
</p>
<p className="mt-1 text-xs text-gray-500">
{search.trim()
? searchingMessages
? 'Nachrichten werden durchsucht...'
: visibleTab === 'channels'
? 'Kein Channel und keine Meldung passt zur Suche.'
: 'Kein Chat und keine Nachricht passt zur Suche.'
: visibleTab === 'channels'
? 'Channels werden in der Administration eingerichtet.'
: 'Öffne ein Team oder starte eine Direktnachricht.'}
</p>
</div>
) : (
<ul className="space-y-1">
{filteredConversations.map((conversation) => {
const partner = conversationPartner(
conversation,
currentUser.id,
)
const active = conversation.id === selectedConversation?.id
const searchResult = searchResultByConversation.get(
conversation.id,
)
const conversationTypingUsers = Object.values(
typingUsers[conversation.id] ?? {},
)
return (
<li key={conversation.id}>
<button
type="button"
onClick={() => openConversation(conversation)}
className={classNames(
active
? 'bg-indigo-50 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:ring-indigo-400/20'
: 'hover:bg-gray-50 dark:hover:bg-white/5',
'flex w-full items-center gap-3 rounded-xl p-3 text-left transition',
)}
>
{conversation.type === 'channel' ? (
conversation.image ? (
<Avatar
src={conversation.image}
name={conversation.name}
size={10}
shape="rounded"
/>
) : (
<span className="inline-flex size-10 shrink-0 items-center justify-center rounded-xl bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300">
<HashtagIcon className="size-5" aria-hidden="true" />
</span>
)
) : conversation.type !== 'direct' ? (
<span className="inline-flex size-10 shrink-0 items-center justify-center rounded-xl bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300">
<UserGroupIcon className="size-5" aria-hidden="true" />
</span>
) : (
<Avatar
src={partner?.avatar}
name={partner ? displayName(partner) : 'Chat'}
size={10}
presenceStatus={partner?.presenceStatus}
/>
)}
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{conversationTitle(conversation, currentUser.id)}
</span>
<span className="shrink-0 text-[0.6875rem] text-gray-400">
<span className="flex items-center gap-1">
{conversation.muted && (
<BellSlashIcon className="size-3.5" />
)}
{formatConversationTime(
conversation.lastMessageAt,
)}
{conversation.unreadCount > 0 && (
<span className="inline-flex min-w-5 items-center justify-center rounded-full bg-indigo-600 px-1.5 py-0.5 text-[0.6875rem] font-semibold leading-none text-white">
{conversation.unreadCount > 99
? '99+'
: conversation.unreadCount}
</span>
)}
</span>
</span>
</span>
<span className="mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400">
{conversationTypingUsers.length > 0
? conversationTypingUsers.length === 1
? `${conversationTypingUsers[0].name} schreibt...`
: `${conversationTypingUsers.length} Personen schreiben...`
: search.trim() && searchResult
? `${searchResult.senderDisplayName}: ${searchResult.body}`
: lastMessagePreviewText(conversation.lastMessage) ||
(conversation.type === 'channel'
? 'Schreibgeschützter Channel'
: conversation.type !== 'direct'
? `${conversation.participants.length} Mitglieder`
: userStatusText(partner, statusNow))}
</span>
</span>
</button>
</li>
)
})}
</ul>
)}
</div>
</aside>
<section
className={classNames(
selectedConversation ? 'flex' : 'hidden md:flex',
'min-w-0 flex-1 flex-col bg-white dark:bg-gray-900',
)}
>
{selectedConversation ? (
<>
<header className="flex h-17 shrink-0 items-center gap-3 border-b border-gray-200 px-4 dark:border-white/10">
<button
type="button"
onClick={() => navigate('/chat')}
className="inline-flex size-9 items-center justify-center rounded-full text-gray-500 hover:bg-gray-100 md:hidden dark:hover:bg-white/10"
>
<span className="sr-only">Zurück zur Chatliste</span>
<ArrowLeftIcon className="size-5" aria-hidden="true" />
</button>
{selectedConversation.type === 'channel' ? (
selectedConversation.image ? (
<Avatar
src={selectedConversation.image}
name={selectedConversation.name}
size={10}
shape="rounded"
/>
) : (
<span className="inline-flex size-10 items-center justify-center rounded-xl bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300">
<HashtagIcon className="size-5" aria-hidden="true" />
</span>
)
) : selectedConversation.type !== 'direct' ? (
<span className="inline-flex size-10 items-center justify-center rounded-xl bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300">
<UserGroupIcon className="size-5" aria-hidden="true" />
</span>
) : (
<Avatar
src={selectedPartner?.avatar}
name={selectedPartner ? displayName(selectedPartner) : 'Chat'}
size={10}
presenceStatus={selectedPartner?.presenceStatus}
/>
)}
{selectedConversation.type === 'channel' ? (
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold text-gray-950 dark:text-white">
{conversationTitle(selectedConversation, currentUser.id)}
</h2>
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
Schreibgeschützter Channel
</p>
</div>
) : selectedConversation.type !== 'direct' ? (
<button
type="button"
onClick={() => setMembersOpen(true)}
title="Mitglieder anzeigen"
className="min-w-0 flex-1 text-left"
>
<h2 className="truncate text-sm font-semibold text-gray-950 dark:text-white">
{conversationTitle(selectedConversation, currentUser.id)}
</h2>
<p className="truncate text-xs text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400">
{selectedTypingText ||
`${selectedConversation.participants.length} Mitglieder anzeigen`}
</p>
</button>
) : (
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold text-gray-950 dark:text-white">
{conversationTitle(selectedConversation, currentUser.id)}
</h2>
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
{selectedTypingText ||
userStatusText(selectedPartner, statusNow)}
</p>
</div>
)}
{selectedConversation.type !== 'direct' && (
<button
type="button"
onClick={() => void toggleConversationMute(selectedConversation)}
title={
selectedConversation.muted
? 'Benachrichtigungen aktivieren'
: selectedConversation.type === 'channel'
? 'Channel stummschalten'
: 'Chat stummschalten'
}
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-gray-500 hover:bg-gray-100 hover:text-indigo-600 dark:hover:bg-white/10"
>
{selectedConversation.muted ? (
<BellSlashIcon className="size-5" />
) : (
<BellIcon className="size-5" />
)}
</button>
)}
</header>
<div className="min-h-0 flex-1 overflow-y-auto bg-gray-50 px-4 py-6 dark:bg-gray-950/60">
{loadingMessages ? (
<div className="flex h-full items-center justify-center">
<LoadingSpinner label="Nachrichten werden geladen..." />
</div>
) : messages.length === 0 ? (
<div className="mx-auto flex h-full max-w-3xl flex-col">
<div className="flex min-h-0 flex-1 items-center justify-center text-center">
<div>
<ChatBubbleLeftRightIcon className="mx-auto size-12 text-gray-300 dark:text-gray-600" />
<p className="mt-3 text-sm font-semibold text-gray-900 dark:text-white">
{selectedConversation.type === 'channel'
? 'Noch keine Meldungen'
: 'Hier beginnt der Chat'}
</p>
<p className="mt-1 text-sm text-gray-500">
{selectedConversation.type === 'channel'
? 'Neue Meldungen erscheinen hier automatisch.'
: 'Schreibe die erste Nachricht.'}
</p>
</div>
</div>
{showDirectTypingIndicator && (
<DirectTypingIndicator user={selectedPartner} />
)}
<div ref={messagesEndRef} />
</div>
) : (
<div className="mx-auto max-w-3xl space-y-4">
{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 (
<Fragment key={message.id}>
{showDateSeparator && (
<div
role="separator"
aria-label={dateLabel}
className="flex items-center gap-3 py-1"
>
<span className="h-px flex-1 bg-gray-200 dark:bg-white/10" />
<span className="rounded-full bg-white px-3 py-1 text-[0.6875rem] font-semibold text-gray-500 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:ring-white/10">
{dateLabel}
</span>
<span className="h-px flex-1 bg-gray-200 dark:bg-white/10" />
</div>
)}
<div
className={classNames(
ownMessage ? 'justify-end' : 'justify-start',
'flex items-end gap-2',
)}
>
{!ownMessage && (
<Avatar
src={message.sender.avatar}
name={displayName(message.sender)}
size={8}
presenceStatus={
selectedConversation.type === 'channel'
? undefined
: message.sender.presenceStatus
}
/>
)}
<div className="group relative max-w-[min(34rem,82%)]">
<div
className={classNames(
ownMessage
? 'rounded-br-sm bg-indigo-600 text-white'
: 'rounded-bl-sm bg-white text-gray-900 ring-1 ring-gray-200 dark:bg-gray-800 dark:text-white dark:ring-white/10',
'rounded-2xl px-3.5 py-2.5 shadow-sm',
)}
>
{!ownMessage && selectedConversation.type !== 'direct' && (
<p className="mb-1 text-xs font-semibold text-indigo-600 dark:text-indigo-300">
{displayName(message.sender)}
</p>
)}
{message.forwardedFrom && (
<div
className={classNames(
ownMessage
? 'border-indigo-200 bg-white/10'
: 'border-indigo-400 bg-gray-50 dark:bg-white/5',
'mb-2 rounded-lg border-l-2 px-2.5 py-2 text-xs',
)}
>
<p className="flex items-center gap-1 font-semibold">
<ArrowRightIcon className="size-3.5" />
Weitergeleitet von{' '}
{message.forwardedFrom.senderDisplayName}
</p>
<p className="mt-1 line-clamp-4 whitespace-pre-wrap opacity-80">
{message.forwardedFrom.body
? renderMessageText(
message.forwardedFrom.body,
ownMessage,
)
: 'Dateianhang'}
</p>
</div>
)}
{message.replyTo && (
<div
className={classNames(
ownMessage
? 'border-indigo-200 bg-white/10'
: 'border-indigo-400 bg-gray-50 dark:bg-white/5',
'mb-2 rounded-lg border-l-2 px-2.5 py-1.5 text-xs',
)}
>
<p className="font-semibold">
{message.replyTo.senderDisplayName}
</p>
<p className="mt-0.5 line-clamp-2 opacity-80">
{message.replyTo.body || 'Dateianhang'}
</p>
</div>
)}
{message.linkPreview && (
<MessageLinkPreview
preview={message.linkPreview}
ownMessage={ownMessage}
/>
)}
{message.body && (
<p className="whitespace-pre-wrap break-words text-sm">
{renderMessageText(message.body, ownMessage)}
</p>
)}
{message.location && (
<LocationPreview
location={message.location}
ownMessage={ownMessage}
/>
)}
{message.attachments?.length > 0 && (
<div className={classNames(message.body && 'mt-2', 'space-y-1.5')}>
{message.attachments.map((attachment) =>
attachment.contentType.startsWith('image/') ? (
<a
key={attachment.id}
href={`${API_URL}${attachment.url}`}
target="_blank"
rel="noreferrer"
className="block overflow-hidden rounded-lg"
>
<img
src={`${API_URL}${attachment.url}`}
alt={attachment.fileName}
loading="lazy"
className="max-h-64 w-full max-w-xs rounded-lg object-cover"
/>
</a>
) : (
<a
key={attachment.id}
href={`${API_URL}${attachment.url}`}
target="_blank"
rel="noreferrer"
className={classNames(
ownMessage
? 'bg-white/10 hover:bg-white/15'
: 'bg-gray-50 hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10',
'flex items-center gap-2 rounded-lg px-2.5 py-2 text-left transition',
)}
>
<DocumentIcon className="size-5 shrink-0" />
<span className="min-w-0">
<span className="block truncate text-xs font-medium">
{attachment.fileName}
</span>
<span className="block text-[0.625rem] opacity-70">
{formatFileSize(attachment.sizeBytes)}
</span>
</span>
</a>
),
)}
</div>
)}
<p
className={classNames(
ownMessage
? 'text-indigo-100'
: 'text-gray-400',
'mt-1 text-right text-[0.625rem]',
)}
>
{formatTime(message.createdAt)}
</p>
</div>
<div
className={classNames(
ownMessage ? 'right-0' : 'left-0',
'absolute -top-8 hidden items-center gap-1 rounded-lg bg-white p-1 shadow-md ring-1 ring-black/5 group-hover:flex dark:bg-gray-800 dark:ring-white/10',
)}
>
{selectedConversation.type !== 'channel' && (
<button
type="button"
onClick={() => setReplyTo(message)}
title="Nachricht zitieren"
className="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-indigo-600 dark:hover:bg-white/10"
>
<ArrowUturnLeftIcon className="size-4" />
</button>
)}
<button
type="button"
onClick={() => setForwardMessage(message)}
title="Nachricht weiterleiten"
className="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-indigo-600 dark:hover:bg-white/10"
>
<ArrowRightIcon className="size-4" />
</button>
</div>
</div>
</div>
</Fragment>
)
})}
{showDirectTypingIndicator && (
<DirectTypingIndicator user={selectedPartner} />
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
{error && (
<div className="border-t border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300">
{error}
</div>
)}
{selectedConversation.type === 'channel' ? (
<div className="flex shrink-0 items-center gap-3 border-t border-gray-200 bg-white px-4 py-3 text-sm text-gray-500 dark:border-white/10 dark:bg-gray-900 dark:text-gray-400">
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300">
<HashtagIcon className="size-5" aria-hidden="true" />
</span>
Dieser Channel ist schreibgeschützt. Meldungen werden von der
angebundenen Integration veröffentlicht.
</div>
) : (
<form
onSubmit={sendMessage}
className="relative shrink-0 border-t border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-gray-900"
>
{forwardDraft && (
<div className="mb-3 flex items-start justify-between gap-3 rounded-lg border-l-2 border-indigo-500 bg-indigo-50 px-3 py-2 text-xs dark:bg-indigo-500/10">
<div className="min-w-0">
<p className="flex items-center gap-1 font-semibold text-indigo-700 dark:text-indigo-300">
<ArrowRightIcon className="size-3.5" />
Weiterleiten von {displayName(forwardDraft.sender)}
</p>
<p className="mt-0.5 line-clamp-3 whitespace-pre-wrap text-gray-600 dark:text-gray-300">
{forwardDraft.body || 'Dateianhang'}
</p>
</div>
<button
type="button"
onClick={() => setForwardDraft(null)}
title="Weiterleitung entfernen"
className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
<XMarkIcon className="size-4" />
</button>
</div>
)}
{replyTo && (
<div className="mb-3 flex items-start justify-between gap-3 rounded-lg border-l-2 border-indigo-500 bg-indigo-50 px-3 py-2 text-xs dark:bg-indigo-500/10">
<div className="min-w-0">
<p className="font-semibold text-indigo-700 dark:text-indigo-300">
Antwort auf {displayName(replyTo.sender)}
</p>
<p className="mt-0.5 truncate text-gray-600 dark:text-gray-300">
{replyTo.body || 'Dateianhang'}
</p>
</div>
<button
type="button"
onClick={() => setReplyTo(null)}
className="text-gray-400 hover:text-gray-700"
>
<XMarkIcon className="size-4" />
</button>
</div>
)}
{pendingAttachments.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{pendingAttachments.map((attachment) => (
<span
key={attachment.id}
className="inline-flex max-w-xs items-center gap-2 rounded-lg bg-gray-100 px-2.5 py-1.5 text-xs text-gray-700 dark:bg-white/10 dark:text-gray-200"
>
<DocumentIcon className="size-4 shrink-0" />
<span className="truncate">{attachment.fileName}</span>
<button
type="button"
onClick={() =>
setPendingAttachments((current) =>
current.filter((item) => item.id !== attachment.id),
)
}
className="text-gray-400 hover:text-red-500"
>
<XMarkIcon className="size-3.5" />
</button>
</span>
))}
</div>
)}
{draftPreview && (
<div className="mb-3 flex items-start gap-3 rounded-lg border border-gray-200 bg-gray-50 p-2.5 dark:border-white/10 dark:bg-white/5">
{draftPreview.imageUrl && (
<img
src={draftPreview.imageUrl}
alt=""
loading="lazy"
className="size-14 shrink-0 rounded-md object-cover"
/>
)}
<div className="min-w-0 flex-1">
{draftPreview.siteName && (
<p className="truncate text-[0.625rem] font-medium uppercase tracking-wide text-gray-400">
{draftPreview.siteName}
</p>
)}
<p className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{draftPreview.title || draftPreview.url}
</p>
{draftPreview.description && (
<p className="line-clamp-1 text-xs text-gray-500 dark:text-gray-400">
{draftPreview.description}
</p>
)}
</div>
<button
type="button"
onClick={() => {
setDismissedPreviewUrl(draftPreview.url)
setDraftPreview(null)
}}
title="Vorschau ausblenden"
className="shrink-0 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
<XMarkIcon className="size-4" />
</button>
</div>
)}
{emojiOpen && (
<div className="absolute bottom-full left-4 mb-2 grid grid-cols-8 gap-1 rounded-xl bg-white p-2 shadow-xl ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10">
{chatEmojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
setMessageBody((current) => current + emoji)
setEmojiOpen(false)
}}
className="rounded p-1.5 text-xl hover:bg-gray-100 dark:hover:bg-white/10"
>
{emoji}
</button>
))}
</div>
)}
<div className="flex items-end gap-2">
<input
ref={attachmentInputRef}
type="file"
className="sr-only"
onChange={(event) => void uploadAttachment(event)}
/>
<div className="relative">
<button
type="button"
disabled={uploadingAttachment}
onClick={() => {
setEmojiOpen(false)
setAttachMenuOpen((current) => !current)
}}
title="Anhängen"
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full text-gray-500 hover:bg-gray-100 hover:text-indigo-600 disabled:opacity-50 dark:hover:bg-white/10"
>
{uploadingAttachment ? (
<LoadingSpinner size="sm" />
) : (
<PlusIcon className="size-5" />
)}
</button>
{attachMenuOpen && (
<>
<button
type="button"
aria-hidden="true"
tabIndex={-1}
onClick={() => setAttachMenuOpen(false)}
className="fixed inset-0 z-10 cursor-default"
/>
<div className="absolute bottom-full left-0 z-20 mb-2 w-44 overflow-hidden rounded-xl bg-white py-1 shadow-xl ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10">
<button
type="button"
onClick={() => {
setAttachMenuOpen(false)
attachmentInputRef.current?.click()
}}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10"
>
<PaperClipIcon className="size-5 text-gray-400" />
Datei
</button>
<button
type="button"
onClick={() => {
setAttachMenuOpen(false)
setLocationDraft(null)
setLocationLabel('')
setLocationPickerOpen(true)
}}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10"
>
<MapPinIcon className="size-5 text-gray-400" />
Standort
</button>
</div>
</>
)}
</div>
<button
type="button"
onClick={() => {
setAttachMenuOpen(false)
setEmojiOpen((current) => !current)
}}
title="Emoji auswählen"
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full text-gray-500 hover:bg-gray-100 hover:text-indigo-600 dark:hover:bg-white/10"
>
<FaceSmileIcon className="size-5" />
</button>
<textarea
value={messageBody}
onChange={(event) =>
handleMessageBodyChange(event.target.value)
}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
event.currentTarget.form?.requestSubmit()
}
}}
rows={1}
maxLength={4000}
placeholder={
socketConnected
? forwardDraft
? 'Kommentar hinzufügen...'
: 'Nachricht schreiben...'
: 'Chat-Verbindung wird hergestellt...'
}
className="max-h-32 min-h-11 flex-1 resize-none rounded-2xl border-0 bg-gray-100 px-4 py-3 text-sm text-gray-900 outline-none ring-1 ring-transparent placeholder:text-gray-500 focus:ring-indigo-500 dark:bg-white/5 dark:text-white"
/>
<button
type="submit"
disabled={
(!messageBody.trim() &&
pendingAttachments.length === 0 &&
!forwardDraft) ||
sending ||
!socketConnected
}
className="inline-flex size-11 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-white shadow-sm transition hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="sr-only">Nachricht senden</span>
{sending ? (
<LoadingSpinner size="sm" className="text-white" />
) : (
<PaperAirplaneIcon className="size-5" aria-hidden="true" />
)}
</button>
</div>
</form>
)}
</>
) : (
<div className="flex h-full items-center justify-center bg-gray-50 px-6 text-center dark:bg-gray-950/60">
<div>
<ChatBubbleLeftRightIcon className="mx-auto size-14 text-gray-300 dark:text-gray-600" />
<h2 className="mt-4 text-base font-semibold text-gray-900 dark:text-white">
{visibleTab === 'channels'
? 'Wähle einen Channel aus'
: 'Wähle einen Chat aus'}
</h2>
<p className="mt-1 max-w-sm text-sm text-gray-500">
{visibleTab === 'channels'
? 'Öffne links einen Channel, um eingehende Meldungen zu lesen.'
: 'Öffne links einen Teamchat oder starte eine Unterhaltung mit einer Person.'}
</p>
</div>
</div>
)}
</section>
<Dialog
open={newChatOpen}
onClose={setNewChatOpen}
className="relative z-[10000]"
>
<DialogBackdrop className="fixed inset-0 bg-gray-950/60 backdrop-blur-sm" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel className="flex max-h-[min(42rem,90vh)] w-full max-w-md flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10">
<div className="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-white/10">
<DialogTitle className="text-base font-semibold text-gray-950 dark:text-white">
Neue Direktnachricht
</DialogTitle>
<button
type="button"
onClick={() => setNewChatOpen(false)}
className="inline-flex size-8 items-center justify-center rounded-full text-gray-500 hover:bg-gray-100 dark:hover:bg-white/10"
>
<span className="sr-only">Schließen</span>
<XMarkIcon className="size-5" aria-hidden="true" />
</button>
</div>
<div className="border-b border-gray-200 p-4 dark:border-white/10">
<div className="relative">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400"
aria-hidden="true"
/>
<input
autoFocus
value={userSearch}
onChange={(event) => setUserSearch(event.target.value)}
placeholder="Person suchen"
className="block w-full rounded-xl border-0 bg-gray-100 py-2.5 pr-3 pl-9 text-sm text-gray-900 outline-none ring-1 ring-transparent focus:ring-indigo-500 dark:bg-white/5 dark:text-white"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{filteredUsers.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-gray-500">
Keine Personen gefunden.
</p>
) : (
<ul className="space-y-1">
{filteredUsers.map((user) => (
<li key={user.id}>
<button
type="button"
disabled={startingDirectChat}
onClick={() => void startDirectChat(user.id)}
className="flex w-full items-center gap-3 rounded-xl p-3 text-left transition hover:bg-gray-50 disabled:opacity-50 dark:hover:bg-white/5"
>
<Avatar
src={user.avatar}
name={displayName(user)}
size={10}
presenceStatus={user.presenceStatus}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-gray-900 dark:text-white">
{displayName(user)}
</span>
<span className="block truncate text-xs text-gray-500">
{user.presenceStatus === 'offline'
? user.unit || user.email
: user.presenceStatus === 'away'
? 'Abwesend'
: userStatusText(user, statusNow)}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</div>
</DialogPanel>
</div>
</Dialog>
<Dialog
open={Boolean(forwardMessage)}
onClose={() => setForwardMessage(null)}
className="relative z-[10000]"
>
<DialogBackdrop className="fixed inset-0 bg-gray-950/60 backdrop-blur-sm" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel className="flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10">
<div className="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-white/10">
<DialogTitle className="text-base font-semibold text-gray-950 dark:text-white">
Nachricht weiterleiten
</DialogTitle>
<button
type="button"
onClick={() => setForwardMessage(null)}
className="rounded-full p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-white/10"
>
<XMarkIcon className="size-5" />
</button>
</div>
{forwardMessage && (
<div className="border-b border-gray-200 bg-gray-50 px-5 py-3 text-sm text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
<p className="line-clamp-3">
{forwardMessage.body || 'Dateianhang'}
</p>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{conversations
.filter(
(conversation) =>
conversation.type !== 'channel' &&
conversation.id !== forwardMessage?.conversationId,
)
.map((conversation) => (
<button
key={conversation.id}
type="button"
disabled={sending}
onClick={() => forwardToConversation(conversation)}
className="flex w-full items-center gap-3 rounded-xl p-3 text-left hover:bg-gray-50 disabled:opacity-50 dark:hover:bg-white/5"
>
<span className="inline-flex size-9 items-center justify-center rounded-lg bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300">
{conversation.type !== 'direct' ? (
<UserGroupIcon className="size-5" />
) : (
<ArrowRightIcon className="size-5" />
)}
</span>
<span className="truncate text-sm font-medium text-gray-900 dark:text-white">
{conversationTitle(conversation, currentUser.id)}
</span>
</button>
))}
{conversations.every(
(conversation) =>
conversation.type === 'channel' ||
conversation.id === forwardMessage?.conversationId,
) && (
<p className="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
Kein anderer Chat zum Weiterleiten verfügbar.
</p>
)}
</div>
</DialogPanel>
</div>
</Dialog>
<Dialog
open={membersOpen}
onClose={() => setMembersOpen(false)}
className="relative z-[10000]"
>
<DialogBackdrop className="fixed inset-0 bg-gray-950/60 backdrop-blur-sm" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel className="flex max-h-[min(40rem,85vh)] w-full max-w-md flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10">
<div className="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-white/10">
<DialogTitle className="text-base font-semibold text-gray-950 dark:text-white">
Mitglieder
{selectedConversation
? ` · ${selectedConversation.participants.length}`
: ''}
</DialogTitle>
<button
type="button"
onClick={() => setMembersOpen(false)}
className="inline-flex size-8 items-center justify-center rounded-full text-gray-500 hover:bg-gray-100 dark:hover:bg-white/10"
>
<span className="sr-only">Schließen</span>
<XMarkIcon className="size-5" aria-hidden="true" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<ul className="space-y-1">
{(selectedConversation?.participants ?? []).map((member) => (
<li
key={member.id}
className="flex items-center gap-3 rounded-xl p-3"
>
<Avatar
src={member.avatar}
name={displayName(member)}
size={10}
presenceStatus={member.presenceStatus}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-gray-900 dark:text-white">
{displayName(member)}
{member.id === currentUser.id ? ' (Du)' : ''}
</span>
<span className="block truncate text-xs text-gray-500">
{member.unit || member.email}
</span>
</span>
</li>
))}
</ul>
</div>
</DialogPanel>
</div>
</Dialog>
<Dialog
open={locationPickerOpen}
onClose={() => setLocationPickerOpen(false)}
className="relative z-[10000]"
>
<DialogBackdrop className="fixed inset-0 bg-gray-950/60 backdrop-blur-sm" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10">
<div className="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-white/10">
<DialogTitle className="text-base font-semibold text-gray-950 dark:text-white">
Standort senden
</DialogTitle>
<button
type="button"
onClick={() => setLocationPickerOpen(false)}
className="inline-flex size-8 items-center justify-center rounded-full text-gray-500 hover:bg-gray-100 dark:hover:bg-white/10"
>
<span className="sr-only">Schließen</span>
<XMarkIcon className="size-5" aria-hidden="true" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
Tippe in die Karte oder ziehe den Pin auf die gewünschte Position.
</p>
<AddressMapPicker
value={locationDraft}
visible={locationPickerOpen}
focusKey={locationPickerOpen ? 'open' : 'closed'}
onChange={setLocationDraft}
/>
<input
value={locationLabel}
onChange={(event) => setLocationLabel(event.target.value)}
placeholder="Beschreibung (optional), z. B. Treffpunkt"
maxLength={200}
className="mt-3 block w-full rounded-xl border-0 bg-gray-100 px-3 py-2.5 text-sm text-gray-900 outline-none ring-1 ring-transparent focus:ring-indigo-500 dark:bg-white/5 dark:text-white"
/>
{locationDraft && (
<p className="mt-2 text-xs text-gray-400">
{locationDraft.lat.toFixed(5)}, {locationDraft.lon.toFixed(5)}
</p>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-gray-200 px-5 py-3 dark:border-white/10">
<button
type="button"
onClick={() => setLocationPickerOpen(false)}
className="rounded-lg px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/10"
>
Abbrechen
</button>
<button
type="button"
disabled={!locationDraft || sending || !socketConnected}
onClick={sendLocation}
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<PaperAirplaneIcon className="size-4" />
Standort senden
</button>
</div>
</DialogPanel>
</div>
</Dialog>
</div>
)
}