1303c8e26f
- backup/restore dialog + user profile popover components - image compression, video blur, wake lock utilities - message cache + conversation messages hook refinements - call context, active speakers, screen share dialog tweaks - audio + screen share settings persistence - refreshed app icons (smaller sizes) across all platforms
1177 lines
44 KiB
TypeScript
1177 lines
44 KiB
TypeScript
import { parseMessagePayload } from '@chat-app/shared/chat';
|
|
import { extractErrorCode } from '@chat-app/shared/i18n';
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useParams } from 'react-router-dom';
|
|
|
|
import { ConversationHeader } from '../components/ConversationHeader';
|
|
import { EmojiPicker } from '../components/EmojiPicker';
|
|
import { ForwardDialog } from '../components/ForwardDialog';
|
|
import { GroupInfoPanel } from '../components/GroupInfoPanel';
|
|
import {
|
|
AlertIcon,
|
|
ArrowRightIcon,
|
|
ChevronDownIcon,
|
|
ChevronUpIcon,
|
|
PlusIcon,
|
|
ReplyIcon,
|
|
SearchIcon,
|
|
SmileIcon,
|
|
SpinnerIcon,
|
|
XIcon,
|
|
} from '../components/icons';
|
|
import { InCallPanel } from '../components/InCallPanel';
|
|
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
|
import { MentionAutocomplete } from '../components/MentionAutocomplete';
|
|
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
|
import { UserProfilePopover } from '../components/UserProfilePopover';
|
|
import { TypingIndicator } from '../components/TypingIndicator';
|
|
import { VoiceRecorder } from '../components/VoiceRecorder';
|
|
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { useCall } from '../context/CallContext';
|
|
import { useConversationsContext } from '../context/ConversationsContext';
|
|
import { compressImages } from '../lib/imageCompress';
|
|
import { searchCachedMessages } from '../lib/messageCache';
|
|
import type { OutboxItem } from '../lib/messageOutbox';
|
|
import { useConversationMessages } from '../lib/useConversationMessages';
|
|
import { useMessageReactions } from '../lib/useMessageReactions';
|
|
import { useGroupReceipts } from '../lib/useGroupReceipts';
|
|
import { markDelivered, useMessageDeliveries } from '../lib/useMessageDeliveries';
|
|
import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads';
|
|
import { usePeerPresence } from '../lib/usePeerPresence';
|
|
import { useTypingChannel } from '../lib/useTypingChannel';
|
|
|
|
const STICK_THRESHOLD = 80;
|
|
|
|
export function ConversationPage() {
|
|
const { t } = useTranslation(['app', 'errors']);
|
|
const { id } = useParams<{ id: string }>();
|
|
const { session, device } = useAuth();
|
|
const { conversations, setActiveConversation, markRead, unread } = useConversationsContext();
|
|
|
|
const conversation = useMemo(
|
|
() => conversations.find((c) => c.id === id) ?? null,
|
|
[conversations, id],
|
|
);
|
|
const peerId = conversation?.peer?.userId;
|
|
const peerPresence = usePeerPresence(peerId);
|
|
|
|
const { messages, loading, error, send, pending, retryPending, cancelPending } =
|
|
useConversationMessages({
|
|
conversationId: id,
|
|
userId: session?.user.id,
|
|
deviceId: device?.id,
|
|
});
|
|
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
|
|
const { byMessage: reactionsByMessage, toggle: toggleReaction } = useMessageReactions(
|
|
messageIds,
|
|
session?.user.id,
|
|
);
|
|
|
|
const myId = session?.user.id;
|
|
|
|
// Peer read tracking — only for 1:1 DMs.
|
|
const ownMessageIds = useMemo(
|
|
() => messages.filter((m) => m.senderId === myId).map((m) => m.id),
|
|
[messages, myId],
|
|
);
|
|
const { peerReadSet } = useMessageReads(ownMessageIds, peerId);
|
|
const { peerDeliveredSet } = useMessageDeliveries(ownMessageIds, peerId);
|
|
|
|
// Group receipts: only meaningful when conversation is a group. We feed it
|
|
// ownMessageIds since we only render delivery state on the sender side.
|
|
const isGroup = conversation?.type === 'group';
|
|
const { deliveredByMessage: groupDelivered, readByMessage: groupRead } =
|
|
useGroupReceipts(ownMessageIds, myId, !!isGroup);
|
|
const groupRecipientCount = useMemo(() => {
|
|
if (!isGroup || !conversation) return 0;
|
|
return conversation.members.filter((m) => m.userId !== myId).length;
|
|
}, [isGroup, conversation, myId]);
|
|
|
|
// Mark every peer-authored message as delivered on our side. Idempotent,
|
|
// so rerunning for already-acknowledged ids is a no-op server-side.
|
|
const deliveredTrackedRef = useRef<Set<string>>(new Set());
|
|
useEffect(() => {
|
|
if (!myId || messages.length === 0) return;
|
|
const toMark: string[] = [];
|
|
for (const m of messages) {
|
|
if (m.senderId === myId) continue;
|
|
if (deliveredTrackedRef.current.has(m.id)) continue;
|
|
deliveredTrackedRef.current.add(m.id);
|
|
toMark.push(m.id);
|
|
}
|
|
if (toMark.length > 0) {
|
|
void markDelivered(toMark).catch((err: unknown) => {
|
|
console.warn('markDelivered failed', err);
|
|
});
|
|
}
|
|
}, [messages, myId]);
|
|
|
|
const lastSeenMessageId = useMemo(() => {
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const m = messages[i];
|
|
if (m && m.senderId === myId && peerReadSet.has(m.id)) return m.id;
|
|
}
|
|
return null;
|
|
}, [messages, peerReadSet, myId]);
|
|
|
|
// Typing channel.
|
|
const { typingUserIds, notifyTyping, notifyStopTyping } = useTypingChannel(id, myId);
|
|
|
|
// Mark incoming messages as read (server-side, visible to peer if both sides
|
|
// have receipts on). Runs whenever new messages arrive or id changes.
|
|
useEffect(() => {
|
|
if (!id || messages.length === 0 || !myId) return;
|
|
const incoming = messages.filter((m) => m.senderId !== myId).map((m) => m.id);
|
|
if (incoming.length === 0) return;
|
|
void markMessagesReadRemote(incoming).catch((err: unknown) => {
|
|
console.error('markMessagesRead failed', err);
|
|
});
|
|
}, [id, messages, myId]);
|
|
|
|
const [text, setText] = useState('');
|
|
const [sending, setSending] = useState(false);
|
|
const [sendError, setSendError] = useState<string | null>(null);
|
|
const [stickToBottom, setStickToBottom] = useState(true);
|
|
const [attachments, setAttachments] = useState<File[]>([]);
|
|
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
|
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
|
|
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searchIdx, setSearchIdx] = useState(0);
|
|
const [searchSenderId, setSearchSenderId] = useState<string>('');
|
|
const [searchAttachmentsOnly, setSearchAttachmentsOnly] = useState(false);
|
|
const [searchDateFrom, setSearchDateFrom] = useState<string>('');
|
|
const [searchDateTo, setSearchDateTo] = useState<string>('');
|
|
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
|
const [displayCount, setDisplayCount] = useState<number>(150);
|
|
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
|
// Snapshot of the "first-unread-message" id captured once the very first
|
|
// render of this conversation lands. Stays fixed until the user switches
|
|
// away so the divider doesn't jump around while new messages arrive.
|
|
const firstUnreadRef = useRef<string | null>(null);
|
|
const firstUnreadComputedRef = useRef<boolean>(false);
|
|
const [profilePopover, setProfilePopover] = useState<
|
|
{ userId: string; x: number; y: number } | null
|
|
>(null);
|
|
const [mentionState, setMentionState] = useState<
|
|
{ query: string; start: number } | null
|
|
>(null);
|
|
const [emojiOpen, setEmojiOpen] = useState(false);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const composerRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
// Drop reply-to / clear search state when switching conversation.
|
|
useEffect(() => {
|
|
setReplyTo(null);
|
|
setForwardTarget(null);
|
|
setSearchOpen(false);
|
|
setSearchQuery('');
|
|
setDisplayCount(150);
|
|
firstUnreadRef.current = null;
|
|
firstUnreadComputedRef.current = false;
|
|
}, [id]);
|
|
|
|
// On first message-list populate for this conversation, pin the divider
|
|
// above the oldest-unread message. We only compute once — subsequent
|
|
// inserts push the divider "further back" visually, which matches
|
|
// Discord's behaviour.
|
|
useEffect(() => {
|
|
if (firstUnreadComputedRef.current) return;
|
|
if (!id || messages.length === 0) return;
|
|
const count = unread[id] ?? 0;
|
|
firstUnreadComputedRef.current = true;
|
|
if (count === 0 || count > messages.length) {
|
|
firstUnreadRef.current = null;
|
|
return;
|
|
}
|
|
const boundary = messages[messages.length - count];
|
|
firstUnreadRef.current = boundary ? boundary.id : null;
|
|
}, [id, messages, unread]);
|
|
|
|
// Expand window when the "load older" sentinel scrolls into view. Doubles
|
|
// effective window on each trigger so scrolling up quickly converges to
|
|
// rendering everything.
|
|
useEffect(() => {
|
|
const el = loadMoreSentinelRef.current;
|
|
if (!el) return;
|
|
if (displayCount >= messages.length) return;
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0]?.isIntersecting) {
|
|
setDisplayCount((n) => Math.min(messages.length, n * 2));
|
|
}
|
|
},
|
|
{ root: scrollRef.current, rootMargin: '200px 0px' },
|
|
);
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, [displayCount, messages.length]);
|
|
|
|
const messageById = useMemo(() => {
|
|
const m = new Map<string, DecryptedMessage>();
|
|
for (const msg of messages) m.set(msg.id, msg);
|
|
return m;
|
|
}, [messages]);
|
|
|
|
const senderNameFor = useCallback(
|
|
(senderId: string): string => {
|
|
if (senderId === myId) return t('app:chats.you', { defaultValue: 'Du' });
|
|
const profile =
|
|
conversation?.members.find((mm) => mm.userId === senderId)?.profile ??
|
|
(senderId !== myId ? conversation?.peer ?? null : null);
|
|
return profile?.displayName ?? '?';
|
|
},
|
|
[conversation, myId, t],
|
|
);
|
|
|
|
const buildQuoted = useCallback(
|
|
(replyToId: string | null): QuotedRef | null => {
|
|
if (!replyToId) return null;
|
|
const target = messageById.get(replyToId);
|
|
if (!target) {
|
|
return {
|
|
id: replyToId,
|
|
senderName: '…',
|
|
snippet: t('app:chats.quote_unavailable', { defaultValue: 'Nachricht nicht verfügbar' }),
|
|
isAttachment: false,
|
|
deleted: true,
|
|
};
|
|
}
|
|
const parsed = parseMessagePayload(target.plaintext);
|
|
const text = parsed.kind === 'text' ? parsed.text : '';
|
|
const hasAttachment = parsed.kind === 'text' && parsed.attachments.length > 0;
|
|
return {
|
|
id: target.id,
|
|
senderName: senderNameFor(target.senderId),
|
|
snippet: text.length > 120 ? text.slice(0, 120) + '…' : text,
|
|
isAttachment: hasAttachment,
|
|
deleted: !!target.deletedAt,
|
|
};
|
|
},
|
|
[messageById, senderNameFor, t],
|
|
);
|
|
|
|
const jumpToMessage = useCallback((targetId: string) => {
|
|
const el = scrollRef.current?.querySelector<HTMLElement>(
|
|
'[data-message-id="' + CSS.escape(targetId) + '"]',
|
|
);
|
|
if (!el) return;
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
setHighlightedId(targetId);
|
|
window.setTimeout(() => setHighlightedId((cur) => (cur === targetId ? null : cur)), 1600);
|
|
}, []);
|
|
|
|
const handleReply = useCallback((m: DecryptedMessage) => {
|
|
setReplyTo(m);
|
|
composerRef.current?.focus();
|
|
}, []);
|
|
|
|
const handleForward = useCallback((m: DecryptedMessage) => {
|
|
setForwardTarget(m);
|
|
}, []);
|
|
|
|
// Search matches: messages matching query + filters. Empty query is allowed
|
|
// when filters are active, so users can e.g. show "all attachments from
|
|
// alice in the last week" without a text query.
|
|
const searchActive = useMemo(
|
|
() =>
|
|
searchQuery.trim().length > 0 ||
|
|
searchSenderId !== '' ||
|
|
searchAttachmentsOnly ||
|
|
searchDateFrom !== '' ||
|
|
searchDateTo !== '',
|
|
[searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo],
|
|
);
|
|
|
|
// FTS5-backed supplementary results: covers cached messages that aren't in
|
|
// the currently-loaded window (`messages`). Runs only when there's a text
|
|
// query — filters alone stay in-memory because they depend on already-
|
|
// decrypted payload state.
|
|
const [ftsExtras, setFtsExtras] = useState<DecryptedMessage[]>([]);
|
|
useEffect(() => {
|
|
if (!id) {
|
|
setFtsExtras([]);
|
|
return;
|
|
}
|
|
const q = searchQuery.trim();
|
|
if (q.length < 2) {
|
|
setFtsExtras([]);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
void searchCachedMessages(id, q, 200).then((rows) => {
|
|
if (cancelled) return;
|
|
setFtsExtras(rows);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [id, searchQuery]);
|
|
|
|
const searchMatches = useMemo(() => {
|
|
if (!searchActive) return [] as DecryptedMessage[];
|
|
const q = searchQuery.trim().toLowerCase();
|
|
const fromTs = searchDateFrom ? new Date(searchDateFrom).getTime() : null;
|
|
// Date inputs cover whole days — bump 'to' to end-of-day.
|
|
const toTs = searchDateTo
|
|
? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1
|
|
: null;
|
|
// Union the live `messages` array with any FTS5-only rows not yet
|
|
// loaded into memory, keyed by id so we don't double-count.
|
|
const seen = new Set<string>();
|
|
const pool: DecryptedMessage[] = [];
|
|
for (const m of messages) {
|
|
if (!seen.has(m.id)) {
|
|
seen.add(m.id);
|
|
pool.push(m);
|
|
}
|
|
}
|
|
for (const m of ftsExtras) {
|
|
if (!seen.has(m.id)) {
|
|
seen.add(m.id);
|
|
pool.push(m);
|
|
}
|
|
}
|
|
pool.sort(
|
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
|
);
|
|
return pool.filter((m) => {
|
|
if (searchSenderId && m.senderId !== searchSenderId) return false;
|
|
const created = new Date(m.createdAt).getTime();
|
|
if (fromTs !== null && created < fromTs) return false;
|
|
if (toTs !== null && created > toTs) return false;
|
|
if (!m.plaintext) return false;
|
|
const parsed = parseMessagePayload(m.plaintext);
|
|
if (parsed.kind !== 'text') return false;
|
|
if (searchAttachmentsOnly && parsed.attachments.length === 0) return false;
|
|
if (q && !parsed.text.toLowerCase().includes(q)) return false;
|
|
return true;
|
|
});
|
|
}, [
|
|
messages,
|
|
ftsExtras,
|
|
searchActive,
|
|
searchQuery,
|
|
searchSenderId,
|
|
searchAttachmentsOnly,
|
|
searchDateFrom,
|
|
searchDateTo,
|
|
]);
|
|
|
|
// Reset/clamp the active match index when the match set changes.
|
|
useEffect(() => {
|
|
if (searchMatches.length === 0) {
|
|
setSearchIdx(0);
|
|
return;
|
|
}
|
|
setSearchIdx((cur) => Math.min(cur, searchMatches.length - 1));
|
|
}, [searchMatches.length]);
|
|
|
|
// Auto-jump to current match.
|
|
useEffect(() => {
|
|
if (!searchOpen || searchMatches.length === 0) return;
|
|
const target = searchMatches[searchIdx];
|
|
if (target) jumpToMessage(target.id);
|
|
}, [searchOpen, searchMatches, searchIdx, jumpToMessage]);
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
setActiveConversation(id);
|
|
return () => {
|
|
setActiveConversation(null);
|
|
};
|
|
}, [id, setActiveConversation]);
|
|
|
|
useEffect(() => {
|
|
if (id && messages.length > 0) markRead(id);
|
|
}, [id, messages.length, markRead]);
|
|
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (!el || !stickToBottom) return;
|
|
el.scrollTop = el.scrollHeight;
|
|
}, [messages.length, stickToBottom]);
|
|
|
|
useEffect(() => {
|
|
setStickToBottom(true);
|
|
const el = scrollRef.current;
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
}, [id]);
|
|
|
|
const handleScroll = useCallback(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
setStickToBottom(distanceFromBottom < STICK_THRESHOLD);
|
|
}, []);
|
|
|
|
async function handleSend(e?: React.FormEvent) {
|
|
e?.preventDefault();
|
|
if ((!text.trim() && attachments.length === 0) || sending) return;
|
|
setSending(true);
|
|
setSendError(null);
|
|
try {
|
|
await send(text, attachments, replyTo?.id ?? null);
|
|
setText('');
|
|
setAttachments([]);
|
|
setReplyTo(null);
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
setStickToBottom(true);
|
|
notifyStopTyping();
|
|
} catch (err: unknown) {
|
|
const code = extractErrorCode(err);
|
|
setSendError(
|
|
code
|
|
? t('errors:' + code, { defaultValue: t('errors:generic') })
|
|
: err instanceof Error
|
|
? err.message
|
|
: t('errors:generic'),
|
|
);
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
}
|
|
|
|
async function ingestFiles(files: File[]) {
|
|
// Pre-compression so heavy phone photos (typically 4-8MB) don't bust the
|
|
// 10MB limit and don't waste storage/bandwidth. Non-image + animated
|
|
// files are passed through unchanged.
|
|
const compressed = await compressImages(files);
|
|
const next: File[] = [];
|
|
for (const f of compressed) {
|
|
if (f.size > 10 * 1024 * 1024) {
|
|
setSendError('Datei zu groß (max 10 MB)');
|
|
continue;
|
|
}
|
|
next.push(f);
|
|
}
|
|
setAttachments((prev) => [...prev, ...next].slice(0, 4));
|
|
}
|
|
|
|
function handleFilesChosen(list: FileList | null) {
|
|
if (!list) return;
|
|
void ingestFiles(Array.from(list));
|
|
}
|
|
|
|
const { state: callState } = useCall();
|
|
// Hide the chat header while this conversation hosts an active call — the
|
|
// call topbar inside the dock already shows the channel name + duration,
|
|
// and fullscreen cinema needs the whole slot.
|
|
const callHereActive =
|
|
(callState.kind === 'connected' ||
|
|
callState.kind === 'connecting' ||
|
|
callState.kind === 'outgoing') &&
|
|
callState.conversationId === id;
|
|
const incomingHere =
|
|
callState.kind === 'incoming' && callState.conversationId === id;
|
|
|
|
return (
|
|
<div
|
|
className="relative flex h-full flex-col"
|
|
onDragEnter={(e) => {
|
|
if (e.dataTransfer?.types.includes('Files')) {
|
|
e.preventDefault();
|
|
setIsDraggingFile(true);
|
|
}
|
|
}}
|
|
onDragOver={(e) => {
|
|
if (e.dataTransfer?.types.includes('Files')) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
}
|
|
}}
|
|
onDragLeave={(e) => {
|
|
// leave fires on child enter too; only clear when leaving the page container.
|
|
if (e.currentTarget === e.target) setIsDraggingFile(false);
|
|
}}
|
|
onDrop={(e) => {
|
|
if (!e.dataTransfer?.files?.length) return;
|
|
e.preventDefault();
|
|
setIsDraggingFile(false);
|
|
void ingestFiles(Array.from(e.dataTransfer.files));
|
|
}}
|
|
>
|
|
{!callHereActive && (
|
|
<ConversationHeader
|
|
conversation={conversation}
|
|
peerPresence={peerPresence}
|
|
onSearchClick={() => setSearchOpen((v) => !v)}
|
|
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
|
|
/>
|
|
)}
|
|
|
|
{!callHereActive && searchOpen && (
|
|
<SearchBar
|
|
query={searchQuery}
|
|
onQueryChange={setSearchQuery}
|
|
matches={searchMatches.length}
|
|
activeIdx={searchIdx}
|
|
senderId={searchSenderId}
|
|
onSenderChange={setSearchSenderId}
|
|
attachmentsOnly={searchAttachmentsOnly}
|
|
onAttachmentsOnlyChange={setSearchAttachmentsOnly}
|
|
dateFrom={searchDateFrom}
|
|
onDateFromChange={setSearchDateFrom}
|
|
dateTo={searchDateTo}
|
|
onDateToChange={setSearchDateTo}
|
|
members={conversation?.members ?? []}
|
|
onPrev={() =>
|
|
setSearchIdx((cur) =>
|
|
searchMatches.length === 0 ? 0 : (cur - 1 + searchMatches.length) % searchMatches.length,
|
|
)
|
|
}
|
|
onNext={() =>
|
|
setSearchIdx((cur) => (searchMatches.length === 0 ? 0 : (cur + 1) % searchMatches.length))
|
|
}
|
|
onClose={() => {
|
|
setSearchOpen(false);
|
|
setSearchQuery('');
|
|
setSearchSenderId('');
|
|
setSearchAttachmentsOnly(false);
|
|
setSearchDateFrom('');
|
|
setSearchDateTo('');
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{isGroup && conversation && (
|
|
<GroupInfoPanel
|
|
open={infoPanelOpen}
|
|
onClose={() => setInfoPanelOpen(false)}
|
|
conversation={conversation}
|
|
/>
|
|
)}
|
|
|
|
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
|
|
{conversation && <InCallPanel conversation={conversation} />}
|
|
|
|
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto bg-surface-3 px-6 py-4">
|
|
{loading ? (
|
|
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
|
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
|
</div>
|
|
) : error ? (
|
|
<Banner>{error}</Banner>
|
|
) : messages.length === 0 ? (
|
|
<p className="text-center text-sm text-fg-muted">…</p>
|
|
) : (
|
|
<ul className="space-y-0.5">
|
|
{displayCount < messages.length && (
|
|
<li>
|
|
<div
|
|
ref={loadMoreSentinelRef}
|
|
className="flex items-center justify-center py-2 text-xs text-fg-muted"
|
|
>
|
|
Lade ältere Nachrichten…
|
|
</div>
|
|
</li>
|
|
)}
|
|
{messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => {
|
|
const idx = Math.max(0, messages.length - displayCount) + sliceIdx;
|
|
// A "run" is consecutive bubbles from the same sender with
|
|
// nothing between them. Call-event separators break the run —
|
|
// a bubble whose immediate next neighbour is a call_event must
|
|
// anchor the avatar, even if another bubble from the same
|
|
// sender appears after the separator.
|
|
const prevRaw = messages[idx - 1];
|
|
const nextRaw = messages[idx + 1];
|
|
const prevIsCallEvent =
|
|
!!prevRaw && parseMessagePayload(prevRaw.plaintext).kind === 'call_event';
|
|
const nextIsCallEvent =
|
|
!!nextRaw && parseMessagePayload(nextRaw.plaintext).kind === 'call_event';
|
|
const grouped =
|
|
!!prevRaw && prevRaw.senderId === m.senderId && !prevIsCallEvent;
|
|
// Anchor avatar on the LAST message of a run so it aligns with
|
|
// the bubble's tail (bottom corner). Tail is bottom-left for
|
|
// mine, bottom-right for peer — see rounded-[…_4px_…] above.
|
|
const isLastOfRun =
|
|
!nextRaw || nextRaw.senderId !== m.senderId || nextIsCallEvent;
|
|
// DM fallback: if member lookup fails (e.g. transient sync), fall
|
|
// back to conversation.peer so the peer's avatar still resolves.
|
|
const memberProfile =
|
|
conversation?.members.find((mm) => mm.userId === m.senderId)?.profile ?? null;
|
|
const senderProfile =
|
|
memberProfile ??
|
|
(m.senderId !== myId ? (conversation?.peer ?? null) : null);
|
|
return (
|
|
<li key={m.id}>
|
|
{firstUnreadRef.current === m.id && (
|
|
<div
|
|
aria-label="Neue Nachrichten"
|
|
className="my-2 flex items-center gap-3 px-2"
|
|
>
|
|
<span className="h-px flex-1 bg-rose-500/60" />
|
|
<span className="rounded-full bg-rose-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-rose-500">
|
|
Neue Nachrichten
|
|
</span>
|
|
<span className="h-px flex-1 bg-rose-500/60" />
|
|
</div>
|
|
)}
|
|
<MessageBubble
|
|
message={m}
|
|
mine={m.senderId === myId}
|
|
groupedWithPrev={grouped}
|
|
isLastOfRun={isLastOfRun}
|
|
senderDisplayName={senderProfile?.displayName}
|
|
senderAvatarUrl={senderProfile?.avatarUrl}
|
|
conversationId={id ?? ''}
|
|
reactions={reactionsByMessage.get(m.id) ?? []}
|
|
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
|
|
showSeen={m.id === lastSeenMessageId}
|
|
{...(m.senderId === myId
|
|
? {
|
|
deliveryState: computeDeliveryState({
|
|
messageId: m.id,
|
|
isGroup: !!isGroup,
|
|
recipientCount: groupRecipientCount,
|
|
peerReadSet,
|
|
peerDeliveredSet,
|
|
groupRead,
|
|
groupDelivered,
|
|
}),
|
|
}
|
|
: {})}
|
|
quoted={buildQuoted(m.replyToId)}
|
|
onJumpToMessage={jumpToMessage}
|
|
onReply={handleReply}
|
|
onForward={handleForward}
|
|
onAvatarClick={(uid, ev) => {
|
|
ev.stopPropagation();
|
|
setProfilePopover({
|
|
userId: uid,
|
|
x: ev.clientX,
|
|
y: ev.clientY,
|
|
});
|
|
}}
|
|
highlighted={highlightedId === m.id}
|
|
/>
|
|
</li>
|
|
);
|
|
})}
|
|
{pending.map((p) => (
|
|
<li key={p.id}>
|
|
<PendingBubble
|
|
item={p}
|
|
onRetry={() => retryPending(p.id)}
|
|
onCancel={() => cancelPending(p.id)}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
<TypingIndicator
|
|
typingUserIds={typingUserIds}
|
|
members={conversation?.members ?? []}
|
|
/>
|
|
|
|
{isDraggingFile && (
|
|
<div
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center rounded-lg border-2 border-dashed border-accent/60 bg-accent/10 backdrop-blur-sm"
|
|
>
|
|
<div className="rounded-xl border border-accent/40 bg-surface-3/90 px-4 py-3 text-sm font-semibold text-fg shadow-xl">
|
|
Datei hier ablegen zum Anhängen
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleSend} className="border-t border-line bg-surface-3 p-4">
|
|
{sendError && (
|
|
<div className="mb-2">
|
|
<Banner>{sendError}</Banner>
|
|
</div>
|
|
)}
|
|
|
|
{replyTo && (
|
|
<div className="mb-2 flex items-stretch gap-2 rounded-lg border border-line bg-surface-2 p-2 text-sm">
|
|
<span aria-hidden="true" className="w-1 shrink-0 rounded-full bg-accent" />
|
|
<ReplyIcon className="mt-0.5 h-4 w-4 shrink-0 text-accent" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-xs font-semibold text-fg">
|
|
{t('app:chats.replying_to', {
|
|
name: senderNameFor(replyTo.senderId),
|
|
defaultValue: 'Antwort an ' + senderNameFor(replyTo.senderId),
|
|
})}
|
|
</p>
|
|
<p className="truncate text-xs italic text-fg-muted">
|
|
{(() => {
|
|
if (!replyTo.plaintext) return '…';
|
|
const p = parseMessagePayload(replyTo.plaintext);
|
|
if (p.kind !== 'text') return '';
|
|
if (!p.text && p.attachments.length > 0) return '📎';
|
|
return p.text;
|
|
})()}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setReplyTo(null)}
|
|
aria-label={t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
|
className="shrink-0 cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
|
>
|
|
<XIcon className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{attachments.length > 0 && (
|
|
<div className="mb-2 flex flex-wrap gap-2">
|
|
{attachments.map((file, idx) => (
|
|
<AttachmentPreview
|
|
key={idx}
|
|
file={file}
|
|
onRemove={() =>
|
|
setAttachments((prev) => prev.filter((_, i) => i !== idx))
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="relative flex items-end gap-2">
|
|
{isGroup && mentionState && conversation && (
|
|
<MentionAutocomplete
|
|
members={conversation.members}
|
|
query={mentionState.query}
|
|
excludeUserId={myId}
|
|
onSelect={(username) => {
|
|
// Replace `@{query}` at `start..caret` with `@{username} `.
|
|
const start = mentionState.start;
|
|
const before = text.slice(0, start);
|
|
const afterCaret = text.slice(start + 1 + mentionState.query.length);
|
|
const inserted = '@' + username + ' ';
|
|
const next = before + inserted + afterCaret;
|
|
setText(next);
|
|
setMentionState(null);
|
|
// Restore caret position after inserted mention.
|
|
const caret = (before + inserted).length;
|
|
requestAnimationFrame(() => {
|
|
const el = composerRef.current;
|
|
if (!el) return;
|
|
el.focus();
|
|
el.setSelectionRange(caret, caret);
|
|
});
|
|
}}
|
|
onClose={() => setMentionState(null)}
|
|
/>
|
|
)}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => handleFilesChosen(e.target.files)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
aria-label="Datei anhängen"
|
|
title="Datei anhängen"
|
|
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
|
>
|
|
<PlusIcon className="h-4 w-4" />
|
|
</button>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
data-emoji-trigger
|
|
onClick={() => setEmojiOpen((v) => !v)}
|
|
aria-label="Emoji einfügen"
|
|
title="Emoji einfügen"
|
|
aria-expanded={emojiOpen}
|
|
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-surface-2 text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
|
>
|
|
<SmileIcon className="h-4 w-4" />
|
|
</button>
|
|
<EmojiPicker
|
|
open={emojiOpen}
|
|
onPick={(emoji) => {
|
|
const el = composerRef.current;
|
|
const caret = el?.selectionStart ?? text.length;
|
|
const next = text.slice(0, caret) + emoji + text.slice(caret);
|
|
setText(next);
|
|
requestAnimationFrame(() => {
|
|
if (!el) return;
|
|
el.focus();
|
|
const pos = caret + emoji.length;
|
|
el.setSelectionRange(pos, pos);
|
|
});
|
|
}}
|
|
onClose={() => setEmojiOpen(false)}
|
|
/>
|
|
</div>
|
|
<VoiceRecorder
|
|
disabled={sending}
|
|
onComplete={async (file) => {
|
|
try {
|
|
await send('', [file], replyTo?.id ?? null);
|
|
setReplyTo(null);
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : 'send failed';
|
|
setSendError(msg);
|
|
}
|
|
}}
|
|
/>
|
|
<textarea
|
|
ref={composerRef}
|
|
value={text}
|
|
onChange={(e) => {
|
|
const next = e.target.value;
|
|
setText(next);
|
|
if (next.length > 0) notifyTyping();
|
|
// Detect an in-progress @mention: find the last '@' before the
|
|
// caret, with no whitespace between it and the caret. If
|
|
// present, open the autocomplete with the partial query.
|
|
const caret = e.target.selectionStart ?? next.length;
|
|
const before = next.slice(0, caret);
|
|
const atIdx = before.lastIndexOf('@');
|
|
if (
|
|
atIdx >= 0 &&
|
|
(atIdx === 0 || /\s/.test(before[atIdx - 1] ?? ''))
|
|
) {
|
|
const q = before.slice(atIdx + 1);
|
|
if (!/\s/.test(q)) {
|
|
setMentionState({ query: q, start: atIdx });
|
|
return;
|
|
}
|
|
}
|
|
setMentionState(null);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
void handleSend();
|
|
}
|
|
}}
|
|
onPaste={(e) => {
|
|
const items = e.clipboardData?.items;
|
|
if (!items) return;
|
|
const pics: File[] = [];
|
|
for (const it of Array.from(items)) {
|
|
if (it.kind === 'file') {
|
|
const f = it.getAsFile();
|
|
if (f && f.type.startsWith('image/')) pics.push(f);
|
|
}
|
|
}
|
|
if (pics.length > 0) {
|
|
e.preventDefault();
|
|
void ingestFiles(pics);
|
|
}
|
|
}}
|
|
rows={1}
|
|
placeholder="Nachricht schreiben…"
|
|
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={sending || (text.trim().length === 0 && attachments.length === 0)}
|
|
aria-busy={sending}
|
|
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{sending ? <SpinnerIcon className="h-4 w-4" /> : <ArrowRightIcon className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<ForwardDialog
|
|
open={forwardTarget !== null}
|
|
message={forwardTarget}
|
|
currentConversationId={id ?? null}
|
|
onClose={() => setForwardTarget(null)}
|
|
/>
|
|
|
|
{profilePopover && (
|
|
<UserProfilePopover
|
|
userId={profilePopover.userId}
|
|
profile={
|
|
conversation?.members.find((m) => m.userId === profilePopover.userId)?.profile ??
|
|
conversation?.peer ??
|
|
null
|
|
}
|
|
x={profilePopover.x}
|
|
y={profilePopover.y}
|
|
onClose={() => setProfilePopover(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface SearchBarProps {
|
|
query: string;
|
|
onQueryChange: (q: string) => void;
|
|
matches: number;
|
|
activeIdx: number;
|
|
senderId: string;
|
|
onSenderChange: (id: string) => void;
|
|
attachmentsOnly: boolean;
|
|
onAttachmentsOnlyChange: (v: boolean) => void;
|
|
dateFrom: string;
|
|
onDateFromChange: (v: string) => void;
|
|
dateTo: string;
|
|
onDateToChange: (v: string) => void;
|
|
members: { userId: string; profile: { displayName?: string | null } | null }[];
|
|
onPrev: () => void;
|
|
onNext: () => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
function SearchBar({
|
|
query,
|
|
onQueryChange,
|
|
matches,
|
|
activeIdx,
|
|
senderId,
|
|
onSenderChange,
|
|
attachmentsOnly,
|
|
onAttachmentsOnlyChange,
|
|
dateFrom,
|
|
onDateFromChange,
|
|
dateTo,
|
|
onDateToChange,
|
|
members,
|
|
onPrev,
|
|
onNext,
|
|
onClose,
|
|
}: SearchBarProps) {
|
|
const { t } = useTranslation(['app']);
|
|
return (
|
|
<div className="flex flex-col gap-2 border-b border-line bg-surface-2 px-4 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<SearchIcon className="h-4 w-4 shrink-0 text-fg-muted" />
|
|
<input
|
|
type="search"
|
|
autoFocus
|
|
value={query}
|
|
onChange={(e) => onQueryChange(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') onClose();
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (e.shiftKey) onPrev();
|
|
else onNext();
|
|
}
|
|
}}
|
|
placeholder={t('app:chats.search_in_conv', { defaultValue: 'In Unterhaltung suchen…' })}
|
|
className="min-w-0 flex-1 bg-transparent text-sm text-fg placeholder-fg-muted outline-none"
|
|
/>
|
|
<span className="shrink-0 text-xs tabular-nums text-fg-muted">
|
|
{matches === 0
|
|
? query.trim().length > 0
|
|
? t('app:chats.search_none', { defaultValue: 'Keine Treffer' })
|
|
: ''
|
|
: activeIdx + 1 + ' / ' + matches}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={onPrev}
|
|
disabled={matches === 0}
|
|
aria-label="Previous"
|
|
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
<ChevronUpIcon className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onNext}
|
|
disabled={matches === 0}
|
|
aria-label="Next"
|
|
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
<ChevronDownIcon className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
|
>
|
|
<XIcon className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-fg-muted">
|
|
<select
|
|
value={senderId}
|
|
onChange={(e) => onSenderChange(e.target.value)}
|
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
|
|
>
|
|
<option value="">Alle Sender</option>
|
|
{members.map((m) => (
|
|
<option key={m.userId} value={m.userId}>
|
|
{m.profile?.displayName ?? m.userId.slice(0, 6)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<label className="flex cursor-pointer items-center gap-1 rounded-md border border-line bg-surface-3 px-2 py-1 text-fg">
|
|
<input
|
|
type="checkbox"
|
|
checked={attachmentsOnly}
|
|
onChange={(e) => onAttachmentsOnlyChange(e.target.checked)}
|
|
className="accent-accent"
|
|
/>
|
|
<span>Nur Anhänge</span>
|
|
</label>
|
|
<label className="flex items-center gap-1">
|
|
<span>Von</span>
|
|
<input
|
|
type="date"
|
|
value={dateFrom}
|
|
onChange={(e) => onDateFromChange(e.target.value)}
|
|
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
|
|
/>
|
|
</label>
|
|
<label className="flex items-center gap-1">
|
|
<span>Bis</span>
|
|
<input
|
|
type="date"
|
|
value={dateTo}
|
|
onChange={(e) => onDateToChange(e.target.value)}
|
|
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Walks `messages` from `idx + step` skipping call_event entries until a
|
|
// regular bubble is found or the array boundary is reached. Used to decide
|
|
// run-grouping for avatar placement so call separators don't bleed into
|
|
// sender continuity.
|
|
function computeDeliveryState(args: {
|
|
messageId: string;
|
|
isGroup: boolean;
|
|
recipientCount: number;
|
|
peerReadSet: Set<string>;
|
|
peerDeliveredSet: Set<string>;
|
|
groupRead: Map<string, Set<string>>;
|
|
groupDelivered: Map<string, Set<string>>;
|
|
}): 'sent' | 'delivered' | 'read' {
|
|
// DM: single peer ack flips state.
|
|
if (!args.isGroup) {
|
|
if (args.peerReadSet.has(args.messageId)) return 'read';
|
|
if (args.peerDeliveredSet.has(args.messageId)) return 'delivered';
|
|
return 'sent';
|
|
}
|
|
// Group: state advances only when ALL recipients have acknowledged. With
|
|
// 0 recipients (admin-only group), we keep 'sent' so we don't show
|
|
// misleading completed ticks.
|
|
if (args.recipientCount === 0) return 'sent';
|
|
const reads = args.groupRead.get(args.messageId);
|
|
if (reads && reads.size >= args.recipientCount) return 'read';
|
|
const delivered = args.groupDelivered.get(args.messageId);
|
|
if (delivered && delivered.size >= args.recipientCount) return 'delivered';
|
|
return 'sent';
|
|
}
|
|
|
|
function PendingBubble({
|
|
item,
|
|
onRetry,
|
|
onCancel,
|
|
}: {
|
|
item: OutboxItem;
|
|
onRetry: () => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const failed = item.attempts >= 8;
|
|
return (
|
|
<div className="flex justify-end py-0.5">
|
|
<div
|
|
className={
|
|
'max-w-[72%] rounded-[18px] px-3.5 py-2 text-sm ' +
|
|
(failed
|
|
? 'border border-rose-500/40 bg-rose-500/10 text-rose-700 dark:text-rose-100'
|
|
: 'border border-dashed border-accent/50 bg-accent/10 text-fg opacity-80')
|
|
}
|
|
>
|
|
<p className="whitespace-pre-wrap break-words">{item.text}</p>
|
|
<div className="mt-1 flex items-center justify-end gap-2 text-[10px] uppercase tracking-wider text-fg-muted">
|
|
{failed ? (
|
|
<>
|
|
<span>{item.lastError ?? 'Senden fehlgeschlagen'}</span>
|
|
<button
|
|
type="button"
|
|
onClick={onRetry}
|
|
className="cursor-pointer rounded px-1.5 py-0.5 font-semibold text-accent hover:underline"
|
|
>
|
|
Erneut
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="cursor-pointer rounded px-1.5 py-0.5 font-semibold text-rose-600 hover:underline dark:text-rose-300"
|
|
>
|
|
Verwerfen
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<SpinnerIcon className="h-3 w-3 animate-spin" />
|
|
<span>
|
|
{item.attempts === 0 ? 'Senden…' : 'Wiederhole (' + item.attempts + ')'}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Banner({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div
|
|
role="alert"
|
|
className="flex items-start gap-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-700 dark:text-rose-100"
|
|
>
|
|
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
|
|
<p className="min-w-0 flex-1 break-words">{children}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) {
|
|
const isImage = file.type.startsWith('image/');
|
|
const [url, setUrl] = useState<string | null>(null);
|
|
useEffect(() => {
|
|
if (!isImage) return;
|
|
const u = URL.createObjectURL(file);
|
|
setUrl(u);
|
|
return () => URL.revokeObjectURL(u);
|
|
}, [file, isImage]);
|
|
return (
|
|
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2">
|
|
{isImage && url ? (
|
|
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
|
) : (
|
|
<div className="flex h-20 w-32 flex-col justify-center gap-0.5 px-2 text-[10px]">
|
|
<span className="truncate font-semibold text-fg" title={file.name}>
|
|
{file.name || 'Datei'}
|
|
</span>
|
|
<span className="text-fg-muted">
|
|
{file.type || 'unbekannt'}
|
|
</span>
|
|
<span className="text-fg-muted">
|
|
{(file.size / 1024).toFixed(0)} KB
|
|
</span>
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={onRemove}
|
|
aria-label="Entfernen"
|
|
className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white transition hover:bg-rose-500/80"
|
|
>
|
|
<XIcon className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|