1786 lines
58 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|