2706 lines
95 KiB
TypeScript
2706 lines
95 KiB
TypeScript
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>
|
||
)
|
||
}
|