teg/frontend/src/components/PinnedChatDock.tsx
2026-07-03 16:56:20 +02:00

1786 lines
58 KiB
TypeScript

import {
ChevronDownIcon,
PaperAirplaneIcon,
XMarkIcon,
} from '@heroicons/react/20/solid'
import {
ArrowTopRightOnSquareIcon,
ChatBubbleLeftRightIcon,
FaceSmileIcon,
HashtagIcon,
MapPinIcon,
PaperClipIcon,
PlusIcon,
UserGroupIcon,
} from '@heroicons/react/24/outline'
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type FormEvent,
} from 'react'
import { useLocation, useNavigate } from 'react-router'
import AddressMapPicker from './AddressMapPicker'
import Avatar, { type PresenceStatus } from './Avatar'
import { useChatSocket } from './ChatSocketProvider'
import LoadingSpinner from './LoadingSpinner'
import Modal, { ModalTitle } from './Modal'
import { scrollbarClassName } from './Scrollbar'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
export type PinnedChat = {
id: string
type: 'direct' | 'group' | 'channel'
title: string
image?: string
isMember?: boolean
partnerId?: string
presenceStatus?: PresenceStatus
}
const pinnedChatsEventName = 'teg:pinned-chats-changed'
type DockChatUser = {
id: string
username: string
displayName: string
email: string
avatar: string
}
type DockChatAttachment = {
id: string
fileName: string
contentType?: string
sizeBytes?: number
url?: string
}
type DockChatLocation = {
lat: number
lng: number
label: string
}
type DockChatMessage = {
id: string
conversationId: string
type: 'user' | 'system'
body: string
createdAt: string
sender: DockChatUser
attachments?: DockChatAttachment[]
location?: DockChatLocation | null
reactions?: DockChatReaction[]
}
type DockChatReactionUser = {
id: string
displayName: string
}
type DockChatReaction = {
emoji: string
users: DockChatReactionUser[]
count: number
}
type DockChatSocketEvent = {
type:
| 'connected'
| 'message.created'
| 'message.updated'
| 'messages.reload'
| 'typing.updated'
| 'conversation.removed'
| 'error'
conversationId?: string
userId?: string
userDisplayName?: string
typing?: boolean
message?: DockChatMessage
error?: string
}
type UserNotificationEvent = CustomEvent<{
data?: {
user?: {
id: string
presenceStatus?: PresenceStatus
}
}
}>
function pinnedChatsStorageKey(userId: string) {
return `teg:pinned-chats:${userId}`
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function resizeDockTextarea(textarea: HTMLTextAreaElement | null) {
if (!textarea) {
return
}
textarea.style.height = 'auto'
textarea.style.height = `${textarea.scrollHeight}px`
}
function normalizePinnedChats(value: unknown): PinnedChat[] {
if (!Array.isArray(value)) {
return []
}
const chats: PinnedChat[] = []
for (const item of value) {
if (!item || typeof item !== 'object') {
continue
}
const chat = item as Partial<PinnedChat>
if (
typeof chat.id !== 'string' ||
typeof chat.title !== 'string' ||
!['direct', 'group', 'channel'].includes(String(chat.type))
) {
continue
}
chats.push({
id: chat.id,
type: chat.type as PinnedChat['type'],
title: chat.title,
image: typeof chat.image === 'string' ? chat.image : undefined,
isMember: typeof chat.isMember === 'boolean' ? chat.isMember : undefined,
partnerId: typeof chat.partnerId === 'string' ? chat.partnerId : undefined,
presenceStatus:
chat.presenceStatus === 'online' ||
chat.presenceStatus === 'away' ||
chat.presenceStatus === 'offline'
? chat.presenceStatus
: undefined,
})
}
return chats
}
export function readPinnedChats(userId: string) {
if (!userId || typeof window === 'undefined') {
return []
}
try {
return normalizePinnedChats(
JSON.parse(
window.localStorage.getItem(pinnedChatsStorageKey(userId)) ?? '[]',
),
)
} catch {
return []
}
}
export function writePinnedChats(userId: string, chats: PinnedChat[]) {
if (!userId || typeof window === 'undefined') {
return
}
window.localStorage.setItem(
pinnedChatsStorageKey(userId),
JSON.stringify(chats.slice(0, 6)),
)
window.dispatchEvent(new CustomEvent(pinnedChatsEventName))
}
export function isPinnedChat(userId: string, chatId: string) {
return readPinnedChats(userId).some((chat) => chat.id === chatId)
}
export function togglePinnedChat(userId: string, chat: PinnedChat) {
const currentChats = readPinnedChats(userId)
const exists = currentChats.some((item) => item.id === chat.id)
writePinnedChats(
userId,
exists
? currentChats.filter((item) => item.id !== chat.id)
: [chat, ...currentChats.filter((item) => item.id !== chat.id)],
)
return !exists
}
function chatHref(chat: PinnedChat) {
return chat.type === 'channel' ? `/chat/channel/${chat.id}` : `/chat/${chat.id}`
}
function isCurrentChatRoute(chat: PinnedChat, pathname: string) {
return pathname === chatHref(chat)
}
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
function displayName(user: DockChatUser) {
return user.displayName || user.username || user.email || 'Unbekannt'
}
function chatStatusText(chat: PinnedChat) {
if (chat.type === 'direct') {
if (chat.presenceStatus === 'online') {
return 'Online'
}
if (chat.presenceStatus === 'away') {
return 'Abwesend'
}
return 'Offline'
}
if (chat.type === 'channel') {
return 'Channel'
}
return chat.isMember === false ? 'Öffentlich' : 'Gruppe'
}
function formatTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
function dockLocationMapsUrl(lat: number, lng: number) {
return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`
}
function DockLocationPreview({
isOwn,
location,
}: {
isOwn: boolean
location: DockChatLocation
}) {
return (
<a
href={dockLocationMapsUrl(location.lat, location.lng)}
target="_blank"
rel="noreferrer noopener"
className={classNames(
isOwn
? 'bg-white/10 hover:bg-white/15'
: 'bg-gray-50 hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10',
'mt-1 flex max-w-full items-center gap-1.5 rounded-lg px-2.5 py-2 text-xs transition',
)}
>
<MapPinIcon className="size-4 shrink-0 opacity-70" />
<span className="min-w-0 truncate font-medium">
{location.label || 'Geteilter Standort'}
</span>
</a>
)
}
function typingStatusText(names: string[]) {
if (names.length === 1) {
return `${names[0]} schreibt...`
}
if (names.length > 1) {
return `${names.length} Personen schreiben...`
}
return ''
}
function DockStatusLine({
chat,
typingNames,
}: {
chat: PinnedChat
typingNames: string[]
}) {
const typingText = typingStatusText(typingNames)
if (typingText) {
return (
<span className="mt-0.5 flex min-w-0 text-xs text-gray-500 dark:text-gray-400">
<span className="truncate">{typingText}</span>
</span>
)
}
return (
<span className="mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400">
{chatStatusText(chat)}
</span>
)
}
function mergeMessage(
messages: DockChatMessage[] | undefined,
message: DockChatMessage,
) {
const currentMessages = messages ?? []
const nextMessages = currentMessages.some((item) => item.id === message.id)
? currentMessages.map((item) => (item.id === message.id ? message : item))
: [...currentMessages, message]
return nextMessages.sort((left, right) => {
const leftTime = new Date(left.createdAt).getTime()
const rightTime = new Date(right.createdAt).getTime()
if (leftTime !== rightTime) {
return leftTime - rightTime
}
return left.id.localeCompare(right.id)
})
}
const dockChatEmojis = [
'\u{1F600}',
'\u{1F602}',
'\u{1F60A}',
'\u{1F60D}',
'\u{1F44D}',
'\u{1F44F}',
'\u{1F64F}',
'\u{1F389}',
'\u2764\uFE0F',
'\u{1F525}',
'\u2705',
'\u{1F914}',
'\u{1F605}',
'\u{1F622}',
'\u{1F62E}',
'\u{1F680}',
]
const dockReactionEmojis = dockChatEmojis.slice(0, 8)
function dockEmojiShortcut(emoji: string) {
switch (emoji) {
case '\u{1F600}':
return ':D'
case '\u{1F60A}':
return ':)'
case '\u{1F622}':
return ":'("
case '\u{1F62E}':
return ':O'
case '\u2764\uFE0F':
return '<3'
case '\u{1F602}':
return 'Freudentränen'
case '\u{1F60D}':
return 'Verliebt'
case '\u{1F44D}':
return 'Daumen hoch'
case '\u{1F44F}':
return 'Applaus'
case '\u{1F64F}':
return 'Danke'
case '\u{1F389}':
return 'Feiern'
case '\u{1F525}':
return 'Feuer'
case '\u2705':
return 'Erledigt'
case '\u{1F914}':
return 'Nachdenklich'
case '\u{1F605}':
return 'Schwitzend lachen'
case '\u{1F680}':
return 'Rakete'
default:
return emoji
}
}
const dockEmoticonReplacements: Array<[RegExp, string]> = [
[/(^|\s)<3(?=\s|$)/g, '$1\u2764\uFE0F'],
[/(^|\s):'\((?=\s|$)/g, '$1\u{1F622}'],
[/(^|\s):-?\)(?=\s|$)/g, '$1\u{1F642}'],
[/(^|\s):-?D(?=\s|$)/g, '$1\u{1F600}'],
[/(^|\s):-?\((?=\s|$)/g, '$1\u{1F641}'],
[/(^|\s);-?\)(?=\s|$)/g, '$1\u{1F609}'],
[/(^|\s):-?[pP](?=\s|$)/g, '$1\u{1F61B}'],
[/(^|\s):-?[oO](?=\s|$)/g, '$1\u{1F62E}'],
[/(^|\s):-?\|(?=\s|$)/g, '$1\u{1F610}'],
[/(^|\s):-?\/(?=\s|$)/g, '$1\u{1F615}'],
[/(^|\s)8-?\)(?=\s|$)/g, '$1\u{1F60E}'],
]
function convertDockEmoticons(text: string) {
let result = text
for (const [pattern, replacement] of dockEmoticonReplacements) {
result = result.replace(pattern, replacement)
}
return result
}
function DockTypingIndicator({
chat,
names,
}: {
chat: PinnedChat
names: string[]
}) {
const name =
names.length === 1
? names[0]
: names.length > 1
? `${names.length} Personen`
: 'Jemand'
return (
<div
role="status"
aria-live="polite"
className="flex items-end gap-2"
>
<Avatar
src={chat.type === 'direct' ? chat.image : undefined}
name={name}
size={8}
presenceStatus={chat.type === 'direct' ? chat.presenceStatus : undefined}
/>
<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 DockMessageReactions({
userId,
isOwn,
reactions,
disabled,
onToggle,
}: {
userId: string
isOwn: boolean
reactions?: DockChatReaction[]
disabled?: boolean
onToggle: (emoji: string) => void
}) {
const visibleReactions = (reactions ?? []).filter(
(reaction) => reaction.count > 0,
)
if (visibleReactions.length === 0) {
return null
}
return (
<div
className={classNames(
isOwn ? 'justify-end' : 'justify-start',
'flex flex-wrap gap-1',
)}
>
{visibleReactions.map((reaction) => {
const reactedByCurrentUser = reaction.users.some(
(reactionUser) => reactionUser.id === userId,
)
const title = reaction.users
.map((reactionUser) =>
reactionUser.id === userId
? 'Du'
: reactionUser.displayName || 'Unbekannt',
)
.join(', ')
return (
<button
key={reaction.emoji}
type="button"
disabled={disabled}
onClick={() => onToggle(reaction.emoji)}
title={title || 'Reaktion'}
className={classNames(
reactedByCurrentUser
? 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/30'
: 'bg-white text-gray-600 ring-gray-200 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10',
'inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs font-medium shadow-sm ring-1 ring-inset transition disabled:cursor-not-allowed disabled:opacity-60',
)}
>
<span className="text-sm leading-none">{reaction.emoji}</span>
{reaction.count > 1 && (
<span className="tabular-nums">{reaction.count}</span>
)}
</button>
)
})}
</div>
)
}
function ChatIcon({ chat }: { chat: PinnedChat }) {
if (chat.image) {
return (
<Avatar
src={chat.image}
name={chat.title}
size={8}
shape="circle"
presenceStatus={chat.type === 'direct' ? chat.presenceStatus : undefined}
/>
)
}
if (chat.type === 'direct') {
return (
<Avatar
name={chat.title}
size={8}
presenceStatus={chat.presenceStatus}
/>
)
}
const Icon =
chat.type === 'channel'
? HashtagIcon
: chat.type === 'group'
? UserGroupIcon
: ChatBubbleLeftRightIcon
return (
<span
className={classNames(
'inline-flex size-8 shrink-0 items-center justify-center',
chat.type === 'channel'
? 'rounded-full bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300'
: chat.type === 'group'
? 'rounded-full bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300'
: 'rounded-lg bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300',
)}
>
<Icon aria-hidden="true" className="size-4" />
</span>
)
}
export default function PinnedChatDock({ userId }: { userId: string }) {
const navigate = useNavigate()
const location = useLocation()
const { send, subscribe } = useChatSocket()
const [pinnedChats, setPinnedChats] = useState(() => readPinnedChats(userId))
const [openChatId, setOpenChatId] = useState<string | null>(null)
const [messagesByChatId, setMessagesByChatId] = useState<
Record<string, DockChatMessage[]>
>({})
const [draftsByChatId, setDraftsByChatId] = useState<Record<string, string>>({})
const [pendingAttachmentsByChatId, setPendingAttachmentsByChatId] = useState<
Record<string, DockChatAttachment[]>
>({})
const [loadingChatIds, setLoadingChatIds] = useState(() => new Set<string>())
const [sendingChatIds, setSendingChatIds] = useState(() => new Set<string>())
const [uploadingChatIds, setUploadingChatIds] = useState(() => new Set<string>())
const [errorsByChatId, setErrorsByChatId] = useState<Record<string, string>>({})
const [emojiOpenChatId, setEmojiOpenChatId] = useState<string | null>(null)
const [reactionPickerMessageId, setReactionPickerMessageId] = useState('')
const [reactingMessageIds, setReactingMessageIds] = useState(
() => new Set<string>(),
)
const [attachMenuOpenChatId, setAttachMenuOpenChatId] = useState<string | null>(null)
const [locationPickerChatId, setLocationPickerChatId] = useState<string | null>(null)
const [locationDraft, setLocationDraft] = useState<{
lat: number
lon: number
} | null>(null)
const [locationLabel, setLocationLabel] = useState('')
const [typingNow, setTypingNow] = useState(() => Date.now())
const [typingUsers, setTypingUsers] = useState<
Record<string, Record<string, { name: string; expiresAt: number }>>
>({})
const attachmentInputRef = useRef<HTMLInputElement | null>(null)
const textareaRefsByChatId = useRef(new Map<string, HTMLTextAreaElement>())
const messagesEndRef = useRef<HTMLDivElement | null>(null)
const typingChatIdRef = useRef<string | undefined>(undefined)
const typingStopTimerRef = useRef<number | undefined>(undefined)
const isChatRoute =
location.pathname === '/chat' || location.pathname.startsWith('/chat/')
const reloadPinnedChats = useCallback(() => {
const chats = readPinnedChats(userId)
setPinnedChats(chats)
setOpenChatId((current) =>
current && chats.some((chat) => chat.id === current) ? current : null,
)
}, [userId])
const openChatMessages = openChatId
? messagesByChatId[openChatId] ?? []
: []
const openChatDraft = openChatId ? draftsByChatId[openChatId] ?? '' : ''
const openTypingCount = openChatId
? Object.values(typingUsers[openChatId] ?? {}).filter(
(typingUser) => typingUser.expiresAt > typingNow,
).length
: 0
const visiblePinnedChats = useMemo(
() =>
pinnedChats.filter(
(chat) => !isCurrentChatRoute(chat, location.pathname),
),
[location.pathname, pinnedChats],
)
const dockChats = useMemo(
() =>
isChatRoute
? visiblePinnedChats.filter((chat) => chat.id === openChatId)
: visiblePinnedChats,
[isChatRoute, openChatId, visiblePinnedChats],
)
const locationChat = locationPickerChatId
? pinnedChats.find((chat) => chat.id === locationPickerChatId)
: undefined
const setChatLoading = useCallback((chatId: string, loading: boolean) => {
setLoadingChatIds((current) => {
const next = new Set(current)
if (loading) {
next.add(chatId)
} else {
next.delete(chatId)
}
return next
})
}, [])
const setChatSending = useCallback((chatId: string, sending: boolean) => {
setSendingChatIds((current) => {
const next = new Set(current)
if (sending) {
next.add(chatId)
} else {
next.delete(chatId)
}
return next
})
}, [])
const setChatUploading = useCallback((chatId: string, uploading: boolean) => {
setUploadingChatIds((current) => {
const next = new Set(current)
if (uploading) {
next.add(chatId)
} else {
next.delete(chatId)
}
return next
})
}, [])
const sendTypingState = useCallback(
(chatId: string, typing: boolean) => {
send({
type: typing ? 'typing.start' : 'typing.stop',
conversationId: chatId,
})
},
[send],
)
const stopTyping = useCallback(() => {
if (typingStopTimerRef.current !== undefined) {
window.clearTimeout(typingStopTimerRef.current)
typingStopTimerRef.current = undefined
}
const chatId = typingChatIdRef.current
if (chatId) {
sendTypingState(chatId, false)
typingChatIdRef.current = undefined
}
}, [sendTypingState])
const loadMessages = useCallback(
async (chatId: string) => {
setChatLoading(chatId, true)
setErrorsByChatId((current) => ({ ...current, [chatId]: '' }))
try {
const response = await fetch(
`${API_URL}/chat/conversations/${chatId}/messages`,
{ credentials: 'include' },
)
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Nachrichten konnten nicht geladen werden',
),
)
}
const data = await response.json()
setMessagesByChatId((current) => ({
...current,
[chatId]: data.messages ?? [],
}))
window.dispatchEvent(new Event('chat-unread-changed'))
} catch (error) {
setErrorsByChatId((current) => ({
...current,
[chatId]:
error instanceof Error
? error.message
: 'Nachrichten konnten nicht geladen werden',
}))
} finally {
setChatLoading(chatId, false)
}
},
[setChatLoading],
)
function updateDraft(chat: PinnedChat, value: string) {
const nextValue = convertDockEmoticons(value)
setDraftsByChatId((current) => ({
...current,
[chat.id]: nextValue,
}))
if (!nextValue.trim() || chat.type === 'channel' || chat.isMember === false) {
if (typingChatIdRef.current === chat.id) {
stopTyping()
}
return
}
if (typingChatIdRef.current !== chat.id) {
stopTyping()
sendTypingState(chat.id, true)
typingChatIdRef.current = chat.id
}
if (typingStopTimerRef.current !== undefined) {
window.clearTimeout(typingStopTimerRef.current)
}
typingStopTimerRef.current = window.setTimeout(stopTyping, 2_000)
}
async function uploadAttachments(
chat: PinnedChat,
event: ChangeEvent<HTMLInputElement>,
) {
const files = Array.from(event.target.files ?? [])
event.target.value = ''
if (files.length === 0) {
return
}
const oversizedFile = files.find((file) => file.size > 10 * 1024 * 1024)
if (oversizedFile) {
setErrorsByChatId((current) => ({
...current,
[chat.id]: 'Dateien dürfen höchstens 10 MB groß sein.',
}))
return
}
setChatUploading(chat.id, true)
setErrorsByChatId((current) => ({ ...current, [chat.id]: '' }))
try {
const uploadedAttachments: DockChatAttachment[] = []
for (const file of files) {
const formData = new FormData()
formData.append('file', file)
const response = await fetch(`${API_URL}/chat/attachments`, {
method: 'POST',
credentials: 'include',
body: formData,
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Datei konnte nicht hochgeladen werden'),
)
}
const data = await response.json()
if (data.attachment) {
uploadedAttachments.push(data.attachment as DockChatAttachment)
}
}
setPendingAttachmentsByChatId((current) => ({
...current,
[chat.id]: [...(current[chat.id] ?? []), ...uploadedAttachments],
}))
} catch (error) {
setErrorsByChatId((current) => ({
...current,
[chat.id]:
error instanceof Error
? error.message
: 'Datei konnte nicht hochgeladen werden',
}))
} finally {
setChatUploading(chat.id, false)
}
}
async function sendMessage(chat: PinnedChat, event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const body = draftsByChatId[chat.id]?.trim() ?? ''
const pendingAttachments = pendingAttachmentsByChatId[chat.id] ?? []
if (
(!body && pendingAttachments.length === 0) ||
chat.type === 'channel' ||
chat.isMember === false
) {
return
}
setChatSending(chat.id, true)
setErrorsByChatId((current) => ({ ...current, [chat.id]: '' }))
try {
const response = await fetch(
`${API_URL}/chat/conversations/${chat.id}/messages`,
{
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
body,
attachmentIds: pendingAttachments.map((attachment) => attachment.id),
}),
},
)
if (!response.ok) {
throw new Error(
await readApiError(response, 'Nachricht konnte nicht gesendet werden'),
)
}
const data = await response.json()
if (data.message) {
setMessagesByChatId((current) => ({
...current,
[chat.id]: mergeMessage(current[chat.id], data.message),
}))
}
setDraftsByChatId((current) => ({ ...current, [chat.id]: '' }))
setPendingAttachmentsByChatId((current) => ({
...current,
[chat.id]: [],
}))
setEmojiOpenChatId((current) => (current === chat.id ? null : current))
stopTyping()
} catch (error) {
setErrorsByChatId((current) => ({
...current,
[chat.id]:
error instanceof Error
? error.message
: 'Nachricht konnte nicht gesendet werden',
}))
} finally {
setChatSending(chat.id, false)
}
}
function openLocationPicker(chat: PinnedChat) {
setAttachMenuOpenChatId(null)
setEmojiOpenChatId(null)
setLocationDraft(null)
setLocationLabel('')
setLocationPickerChatId(chat.id)
}
function closeLocationPicker() {
setLocationPickerChatId(null)
setLocationDraft(null)
setLocationLabel('')
}
async function sendLocation() {
const chat = locationPickerChatId
? pinnedChats.find((item) => item.id === locationPickerChatId)
: undefined
if (
!chat ||
!locationDraft ||
chat.type === 'channel' ||
chat.isMember === false
) {
return
}
setChatSending(chat.id, true)
setErrorsByChatId((current) => ({ ...current, [chat.id]: '' }))
try {
const response = await fetch(
`${API_URL}/chat/conversations/${chat.id}/messages`,
{
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
body: '',
location: {
lat: locationDraft.lat,
lng: locationDraft.lon,
label: locationLabel.trim(),
},
}),
},
)
if (!response.ok) {
throw new Error(
await readApiError(response, 'Standort konnte nicht gesendet werden'),
)
}
const data = await response.json()
if (data.message) {
setMessagesByChatId((current) => ({
...current,
[chat.id]: mergeMessage(current[chat.id], data.message),
}))
}
closeLocationPicker()
} catch (error) {
setErrorsByChatId((current) => ({
...current,
[chat.id]:
error instanceof Error
? error.message
: 'Standort konnte nicht gesendet werden',
}))
} finally {
setChatSending(chat.id, false)
}
}
function removePendingAttachment(chatId: string, attachmentId: string) {
setPendingAttachmentsByChatId((current) => ({
...current,
[chatId]: (current[chatId] ?? []).filter(
(attachment) => attachment.id !== attachmentId,
),
}))
}
function addEmoji(chat: PinnedChat, emoji: string) {
updateDraft(chat, `${draftsByChatId[chat.id] ?? ''}${emoji}`)
setAttachMenuOpenChatId(null)
setEmojiOpenChatId(null)
}
function setDockMessageReacting(messageId: string, reacting: boolean) {
setReactingMessageIds((current) => {
const next = new Set(current)
if (reacting) {
next.add(messageId)
} else {
next.delete(messageId)
}
return next
})
}
async function toggleMessageReaction(
message: DockChatMessage,
emoji: string,
) {
if (message.type === 'system' || reactingMessageIds.has(message.id)) {
return
}
setDockMessageReacting(message.id, true)
setErrorsByChatId((current) => ({
...current,
[message.conversationId]: '',
}))
try {
const response = await fetch(
`${API_URL}/chat/messages/${message.id}/reactions`,
{
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji }),
},
)
if (!response.ok) {
throw new Error(
await readApiError(response, 'Reaktion konnte nicht gespeichert werden'),
)
}
const data = await response.json()
if (data.message) {
const updatedMessage = data.message as DockChatMessage
setMessagesByChatId((current) => ({
...current,
[updatedMessage.conversationId]: mergeMessage(
current[updatedMessage.conversationId],
updatedMessage,
),
}))
}
} catch (error) {
setErrorsByChatId((current) => ({
...current,
[message.conversationId]:
error instanceof Error
? error.message
: 'Reaktion konnte nicht gespeichert werden',
}))
} finally {
setDockMessageReacting(message.id, false)
setReactionPickerMessageId((current) =>
current === message.id ? '' : current,
)
}
}
useEffect(() => {
reloadPinnedChats()
window.addEventListener(pinnedChatsEventName, reloadPinnedChats)
window.addEventListener('storage', reloadPinnedChats)
return () => {
window.removeEventListener(pinnedChatsEventName, reloadPinnedChats)
window.removeEventListener('storage', reloadPinnedChats)
}
}, [reloadPinnedChats])
useEffect(() => {
function handleUserNotification(event: Event) {
const user = (event as UserNotificationEvent).detail?.data?.user
if (!user?.id || !user.presenceStatus) {
return
}
let changed = false
const nextPinnedChats = readPinnedChats(userId).map((chat) => {
if (
chat.partnerId !== user.id ||
chat.presenceStatus === user.presenceStatus
) {
return chat
}
changed = true
return { ...chat, presenceStatus: user.presenceStatus }
})
if (!changed) {
return
}
writePinnedChats(userId, nextPinnedChats)
}
window.addEventListener('user-notification', handleUserNotification)
return () => {
window.removeEventListener('user-notification', handleUserNotification)
}
}, [userId])
useEffect(() => {
if (!openChatId) {
return
}
void loadMessages(openChatId)
}, [loadMessages, openChatId])
useEffect(() => {
const unsubscribe = subscribe((rawPayload) => {
const payload = rawPayload as DockChatSocketEvent
if (payload.type === 'conversation.removed' && payload.conversationId) {
writePinnedChats(
userId,
readPinnedChats(userId).filter(
(chat) => chat.id !== payload.conversationId,
),
)
return
}
if (payload.type === 'messages.reload' && payload.conversationId) {
if (payload.conversationId === openChatId) {
void loadMessages(payload.conversationId)
}
return
}
if (
payload.type === 'typing.updated' &&
payload.conversationId &&
payload.userId &&
payload.userId !== userId
) {
setTypingUsers((current) => {
const conversationUsers = {
...(current[payload.conversationId as string] ?? {}),
}
if (payload.typing) {
conversationUsers[payload.userId as string] = {
name: payload.userDisplayName || 'Jemand',
expiresAt: Date.now() + 4_000,
}
} else {
delete conversationUsers[payload.userId as string]
}
return {
...current,
[payload.conversationId as string]: conversationUsers,
}
})
return
}
const message = payload.message
if (!message) {
return
}
if (payload.type === 'message.created' || payload.type === 'message.updated') {
setMessagesByChatId((current) => ({
...current,
[message.conversationId]: mergeMessage(
current[message.conversationId],
message,
),
}))
if (message.conversationId === openChatId && message.sender.id !== userId) {
fetch(`${API_URL}/chat/conversations/${message.conversationId}/read`, {
method: 'POST',
credentials: 'include',
}).catch(() => undefined)
window.dispatchEvent(new Event('chat-unread-changed'))
}
}
})
return unsubscribe
}, [loadMessages, openChatId, subscribe, userId])
useEffect(() => {
const intervalId = window.setInterval(() => {
setTypingNow(Date.now())
}, 1_000)
return () => {
window.clearInterval(intervalId)
}
}, [])
useEffect(() => {
return () => {
stopTyping()
}
}, [stopTyping])
useEffect(() => {
if (typingChatIdRef.current && typingChatIdRef.current !== openChatId) {
stopTyping()
}
setReactionPickerMessageId('')
}, [openChatId, stopTyping])
useLayoutEffect(() => {
if (!openChatId) {
return
}
resizeDockTextarea(textareaRefsByChatId.current.get(openChatId) ?? null)
}, [openChatDraft, openChatId])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ block: 'end' })
}, [openChatId, openChatMessages.length, openTypingCount])
useEffect(() => {
if (
openChatId &&
visiblePinnedChats.every((chat) => chat.id !== openChatId)
) {
setOpenChatId(null)
}
}, [openChatId, visiblePinnedChats])
if (dockChats.length === 0) {
return null
}
return (
<>
<div className="pointer-events-none fixed right-4 bottom-0 left-4 z-[8500] flex flex-wrap items-end justify-end gap-2 lg:left-76">
{dockChats.map((chat) => {
const isOpen = chat.id === openChatId
const messages = messagesByChatId[chat.id] ?? []
const draft = draftsByChatId[chat.id] ?? ''
const pendingAttachments = pendingAttachmentsByChatId[chat.id] ?? []
const isLoading = loadingChatIds.has(chat.id)
const isSending = sendingChatIds.has(chat.id)
const isUploading = uploadingChatIds.has(chat.id)
const error = errorsByChatId[chat.id]
const canWrite = chat.type !== 'channel' && chat.isMember !== false
const typingNames = Object.values(typingUsers[chat.id] ?? {})
.filter((typingUser) => typingUser.expiresAt > typingNow)
.map((typingUser) => typingUser.name)
if (isOpen) {
return (
<section
key={chat.id}
className="pointer-events-auto flex h-[28rem] w-80 max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-t-xl border border-gray-200 bg-white shadow-xl shadow-gray-900/15 dark:border-white/10 dark:bg-gray-900 dark:shadow-black/25"
>
<div className="flex items-center gap-2 border-b border-gray-200 px-3 py-2 dark:border-white/10">
<button
type="button"
onClick={() => setOpenChatId(null)}
className="min-w-0 flex flex-1 items-center gap-2 text-left"
>
<ChatIcon chat={chat} />
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-gray-900 dark:text-white">
{chat.title}
</span>
<DockStatusLine chat={chat} typingNames={typingNames} />
</span>
</button>
<button
type="button"
onClick={() => navigate(chatHref(chat))}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-indigo-600 dark:hover:bg-white/10 dark:hover:text-indigo-300"
>
<span className="sr-only">Chat vollständig öffnen</span>
<ArrowTopRightOnSquareIcon aria-hidden="true" className="size-4" />
</button>
<button
type="button"
onClick={() => setOpenChatId(null)}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-white/10 dark:hover:text-white"
>
<span className="sr-only">Chat minimieren</span>
<ChevronDownIcon aria-hidden="true" className="size-4" />
</button>
<button
type="button"
onClick={() =>
writePinnedChats(
userId,
pinnedChats.filter((item) => item.id !== chat.id),
)
}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-white/10 dark:hover:text-white"
>
<span className="sr-only">Fixierung entfernen</span>
<XMarkIcon aria-hidden="true" className="size-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto bg-gray-50 px-3 py-3 dark:bg-gray-950/40">
{isLoading && messages.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400">
Nachrichten werden geladen...
</div>
) : messages.length === 0 && typingNames.length === 0 ? (
<div className="flex h-full items-center justify-center text-center text-sm text-gray-500 dark:text-gray-400">
Noch keine Nachrichten.
</div>
) : (
<div className="space-y-2">
{messages.map((message) => {
const isOwn = message.sender.id === userId
const hasReactions = (message.reactions ?? []).some(
(reaction) => reaction.count > 0,
)
if (message.type === 'system') {
return (
<div
key={message.id}
className="mx-auto max-w-[85%] rounded-full bg-white px-3 py-1 text-center text-xs text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10"
>
{message.body}
</div>
)
}
return (
<div
key={message.id}
className={classNames(
'group flex flex-col',
isOwn ? 'items-end' : 'items-start',
)}
>
<div
className={classNames(
'max-w-[85%] rounded-2xl px-3 py-2 text-sm shadow-sm',
isOwn
? 'rounded-br-md bg-indigo-600 text-white'
: 'rounded-bl-md bg-white text-gray-900 ring-1 ring-gray-200 dark:bg-gray-800 dark:text-white dark:ring-white/10',
)}
>
{!isOwn && (
<p className="mb-0.5 truncate text-xs font-semibold text-indigo-600">
{displayName(message.sender)}
</p>
)}
{message.body && (
<p className="whitespace-pre-wrap break-words">
{message.body}
</p>
)}
{message.location && (
<DockLocationPreview
isOwn={isOwn}
location={message.location}
/>
)}
{message.attachments &&
message.attachments.length > 0 && (
<p
className={classNames(
'mt-1 text-xs',
isOwn ? 'text-indigo-100' : 'text-gray-500',
)}
>
{message.attachments.length} Anhang
{message.attachments.length === 1 ? '' : 'e'}
</p>
)}
</div>
<span className="mt-0.5 px-1 text-[11px] text-gray-400">
{formatTime(message.createdAt)}
</span>
{canWrite && reactionPickerMessageId === message.id && (
<div
className={classNames(
isOwn ? 'self-end' : 'self-start',
'mt-1 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',
)}
>
{dockReactionEmojis.map((emoji) => (
<button
key={emoji}
type="button"
disabled={reactingMessageIds.has(message.id)}
title={dockEmojiShortcut(emoji)}
aria-label={`Mit ${dockEmojiShortcut(emoji)} reagieren`}
onClick={() =>
void toggleMessageReaction(message, emoji)
}
className="inline-flex size-7 items-center justify-center rounded text-base leading-none hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
{emoji}
</button>
))}
</div>
)}
{(hasReactions || canWrite) && (
<div
className={classNames(
isOwn ? 'justify-end' : 'justify-start',
hasReactions
? 'flex'
: 'hidden group-hover:flex',
'mt-1 max-w-[85%] items-center gap-1',
)}
>
<DockMessageReactions
userId={userId}
isOwn={isOwn}
reactions={message.reactions}
disabled={
!canWrite || reactingMessageIds.has(message.id)
}
onToggle={(emoji) =>
void toggleMessageReaction(message, emoji)
}
/>
{canWrite && (
<button
type="button"
disabled={reactingMessageIds.has(message.id)}
onClick={() =>
setReactionPickerMessageId((current) =>
current === message.id ? '' : message.id,
)
}
title="Reagieren"
className="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-white text-gray-500 shadow-sm ring-1 ring-gray-200 transition hover:bg-gray-50 hover:text-indigo-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-800 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
>
<FaceSmileIcon
aria-hidden="true"
className="size-3.5 stroke-[1.8]"
/>
</button>
)}
</div>
)}
</div>
)
})}
{typingNames.length > 0 && (
<DockTypingIndicator chat={chat} names={typingNames} />
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
{error && (
<div className="border-t border-red-100 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-500/20 dark:bg-red-950/30 dark:text-red-200">
{error}
</div>
)}
{canWrite ? (
<form
onSubmit={(event) => void sendMessage(chat, event)}
className="relative border-t border-gray-200 p-2 dark:border-white/10"
>
{emojiOpenChatId === chat.id && (
<div className="absolute bottom-full left-2 z-10 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">
{dockChatEmojis.map((emoji) => (
<button
key={emoji}
type="button"
title={dockEmojiShortcut(emoji)}
aria-label={`Emoji ${dockEmojiShortcut(emoji)}`}
onClick={() => addEmoji(chat, emoji)}
className="inline-flex size-8 items-center justify-center rounded text-lg leading-none hover:bg-gray-100 dark:hover:bg-white/10"
>
{emoji}
</button>
))}
</div>
)}
{pendingAttachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{pendingAttachments.map((attachment) => (
<span
key={attachment.id}
className="inline-flex max-w-full items-center gap-1 rounded-full bg-indigo-50 px-2 py-1 text-xs font-medium text-indigo-700 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-400/20"
>
<PaperClipIcon className="size-3.5 shrink-0" />
<span className="truncate">{attachment.fileName}</span>
<button
type="button"
onClick={() =>
removePendingAttachment(chat.id, attachment.id)
}
className="ml-0.5 rounded-full text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-100"
>
<span className="sr-only">Anhang entfernen</span>
<XMarkIcon aria-hidden="true" className="size-3.5" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-1.5">
<input
ref={attachmentInputRef}
type="file"
multiple
className="sr-only"
onChange={(event) => void uploadAttachments(chat, event)}
/>
<div className="relative">
<button
type="button"
disabled={isUploading}
onClick={() => {
setEmojiOpenChatId(null)
setAttachMenuOpenChatId((current) =>
current === chat.id ? null : chat.id,
)
}}
title="Anhängen"
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-gray-600 hover:bg-gray-100 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-300 dark:hover:bg-white/10 dark:hover:text-white"
>
<span className="sr-only">Anhängen</span>
{isUploading ? (
<span className="size-4 animate-spin rounded-full border-2 border-gray-300 border-t-indigo-600 dark:border-white/20 dark:border-t-indigo-300" />
) : (
<PlusIcon
aria-hidden="true"
className="size-5 stroke-[1.8]"
/>
)}
</button>
{attachMenuOpenChatId === chat.id && (
<>
<button
type="button"
aria-hidden="true"
tabIndex={-1}
onClick={() => setAttachMenuOpenChatId(null)}
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={() => {
setAttachMenuOpenChatId(null)
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 stroke-[1.8] text-gray-500 dark:text-gray-300" />
Datei
</button>
<button
type="button"
onClick={() => openLocationPicker(chat)}
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 stroke-[1.8] text-gray-500 dark:text-gray-300" />
Standort
</button>
</div>
</>
)}
</div>
<button
type="button"
onClick={() => {
setAttachMenuOpenChatId(null)
setEmojiOpenChatId((current) =>
current === chat.id ? null : chat.id,
)
}}
title="Emoji auswählen"
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-white/10 dark:hover:text-white"
>
<span className="sr-only">Emoji auswählen</span>
<FaceSmileIcon
aria-hidden="true"
className="size-5 stroke-[1.8]"
/>
</button>
<textarea
ref={(element) => {
if (element) {
textareaRefsByChatId.current.set(chat.id, element)
resizeDockTextarea(element)
} else {
textareaRefsByChatId.current.delete(chat.id)
}
}}
value={draft}
onChange={(event) => updateDraft(chat, event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
event.currentTarget.form?.requestSubmit()
}
}}
rows={1}
maxLength={4000}
placeholder="Nachricht schreiben"
className={[
'max-h-32 min-h-9 min-w-0 flex-1 resize-none overflow-y-auto rounded-lg border-0 bg-gray-100 px-3 py-2 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',
scrollbarClassName,
].join(' ')}
/>
<button
type="submit"
disabled={
(!draft.trim() && pendingAttachments.length === 0) ||
isSending
}
className="inline-flex size-9 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>
{isSending ? (
<LoadingSpinner size="sm" className="text-white" />
) : (
<PaperAirplaneIcon className="size-4" aria-hidden="true" />
)}
</button>
</div>
</form>
) : (
<div className="border-t border-gray-200 px-3 py-2 text-xs text-gray-500 dark:border-white/10 dark:text-gray-400">
Dieser Chat ist im Mini-Fenster nur lesbar.
</div>
)}
</section>
)
}
return (
<div
key={chat.id}
className="pointer-events-auto flex w-64 max-w-[calc(100vw-2rem)] items-center gap-2 rounded-t-xl border border-gray-200 bg-white px-3 py-2 shadow-lg shadow-gray-900/10 dark:border-white/10 dark:bg-gray-900 dark:shadow-black/20"
>
<button
type="button"
onClick={() => setOpenChatId(chat.id)}
className="min-w-0 flex flex-1 items-center gap-2 text-left"
>
<ChatIcon chat={chat} />
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-gray-900 dark:text-white">
{chat.title}
</span>
<DockStatusLine chat={chat} typingNames={typingNames} />
</span>
</button>
<button
type="button"
onClick={() =>
writePinnedChats(
userId,
pinnedChats.filter((item) => item.id !== chat.id),
)
}
className="inline-flex size-7 shrink-0 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-white/10 dark:hover:text-white"
>
<span className="sr-only">Fixierung entfernen</span>
<XMarkIcon aria-hidden="true" className="size-4" />
</button>
</div>
)
})}
</div>
<Modal
open={Boolean(locationPickerChatId)}
setOpen={(open) => {
if (!open) {
closeLocationPicker()
}
}}
zIndexClassName="z-[10000]"
backdropClassName="bg-gray-950/60 backdrop-blur-sm dark:bg-gray-950/60"
panelClassName="flex max-h-[90vh] max-w-lg flex-col rounded-2xl bg-white dark:bg-gray-900"
>
<div className="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-white/10">
<ModalTitle>Standort senden</ModalTitle>
<button
type="button"
onClick={closeLocationPicker}
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={Boolean(locationPickerChatId)}
focusKey={locationPickerChatId ?? '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={closeLocationPicker}
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 ||
!locationChat ||
sendingChatIds.has(locationChat.id)
}
onClick={() => void 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>
</Modal>
</>
)
}