322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
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 { GroupInfoPanel } from '../components/GroupInfoPanel';
|
|
import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons';
|
|
import { InCallPanel } from '../components/InCallPanel';
|
|
import { MessageBubble } from '../components/MessageBubble';
|
|
import { TypingIndicator } from '../components/TypingIndicator';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { useConversationsContext } from '../context/ConversationsContext';
|
|
import { useConversationMessages } from '../lib/useConversationMessages';
|
|
import { useMessageReactions } from '../lib/useMessageReactions';
|
|
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 } = 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 } = 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 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 scrollRef = useRef<HTMLDivElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
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);
|
|
setText('');
|
|
setAttachments([]);
|
|
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);
|
|
}
|
|
}
|
|
|
|
function handleFilesChosen(list: FileList | null) {
|
|
if (!list) return;
|
|
const next: File[] = [];
|
|
for (let i = 0; i < list.length; i++) {
|
|
const f = list[i];
|
|
if (!f) continue;
|
|
if (!f.type.startsWith('image/')) continue;
|
|
if (f.size > 10 * 1024 * 1024) {
|
|
setSendError('Datei zu groß (max 10 MB)');
|
|
continue;
|
|
}
|
|
next.push(f);
|
|
}
|
|
setAttachments((prev) => [...prev, ...next].slice(0, 4));
|
|
}
|
|
|
|
const isGroup = conversation?.type === 'group';
|
|
|
|
return (
|
|
<div className="relative flex h-full flex-col">
|
|
<ConversationHeader
|
|
conversation={conversation}
|
|
peerPresence={peerPresence}
|
|
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
|
|
/>
|
|
|
|
{isGroup && conversation && (
|
|
<GroupInfoPanel
|
|
open={infoPanelOpen}
|
|
onClose={() => setInfoPanelOpen(false)}
|
|
conversation={conversation}
|
|
/>
|
|
)}
|
|
|
|
{conversation && <InCallPanel conversation={conversation} />}
|
|
|
|
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto px-6 py-4">
|
|
{loading ? (
|
|
<div className="flex items-center gap-2 text-xs text-neutral-500">
|
|
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
|
|
</div>
|
|
) : error ? (
|
|
<Banner>{error}</Banner>
|
|
) : messages.length === 0 ? (
|
|
<p className="text-center text-sm text-neutral-500">…</p>
|
|
) : (
|
|
<ul className="space-y-0.5">
|
|
{messages.map((m, idx) => {
|
|
const prev = messages[idx - 1];
|
|
const grouped = idx > 0 && prev?.senderId === m.senderId;
|
|
return (
|
|
<li key={m.id}>
|
|
<MessageBubble
|
|
message={m}
|
|
mine={m.senderId === myId}
|
|
groupedWithPrev={grouped}
|
|
conversationId={id ?? ''}
|
|
reactions={reactionsByMessage.get(m.id) ?? []}
|
|
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
|
|
showSeen={m.id === lastSeenMessageId}
|
|
/>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
<TypingIndicator
|
|
typingUserIds={typingUserIds}
|
|
members={conversation?.members ?? []}
|
|
/>
|
|
|
|
<form onSubmit={handleSend} className="border-t border-white/5 p-4">
|
|
{sendError && (
|
|
<div className="mb-2">
|
|
<Banner>{sendError}</Banner>
|
|
</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="flex items-end gap-2">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => handleFilesChosen(e.target.files)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
aria-label="Bild anhängen"
|
|
title="Bild anhängen"
|
|
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg border border-white/10 bg-white/5 text-neutral-300 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
|
>
|
|
<PlusIcon className="h-4 w-4" />
|
|
</button>
|
|
<textarea
|
|
value={text}
|
|
onChange={(e) => {
|
|
setText(e.target.value);
|
|
if (e.target.value.length > 0) notifyTyping();
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
void handleSend();
|
|
}
|
|
}}
|
|
rows={1}
|
|
placeholder="…"
|
|
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-white/10 bg-ink-900/60 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={sending || (text.trim().length === 0 && attachments.length === 0)}
|
|
aria-busy={sending}
|
|
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 text-white transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{sending ? <SpinnerIcon className="h-4 w-4" /> : <ArrowRightIcon className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Banner({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div
|
|
role="alert"
|
|
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
|
>
|
|
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
|
<p className="min-w-0 flex-1 break-words">{children}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) {
|
|
const [url, setUrl] = useState<string | null>(null);
|
|
useEffect(() => {
|
|
const u = URL.createObjectURL(file);
|
|
setUrl(u);
|
|
return () => URL.revokeObjectURL(u);
|
|
}, [file]);
|
|
return (
|
|
<div className="relative overflow-hidden rounded-lg border border-white/10 bg-ink-900/60">
|
|
{url ? (
|
|
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
|
) : (
|
|
<div className="h-20 w-20" />
|
|
)}
|
|
<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-ink-950/80 text-neutral-200 transition hover:bg-rose-500/70 hover:text-white"
|
|
>
|
|
<XIcon className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|