import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'; import type { KeyboardEvent, ReactElement, SyntheticEvent } from 'react'; import type { Conversation, Message, PublicUser } from '@altricade/core'; import { PaperclipIcon, MicIcon, VideoIcon, StopIcon, SendIcon, CloseIcon, CheckIcon, DoubleCheckIcon, EditIcon, TrashIcon, UsersIcon, } from '../../../shared/ui'; import { useConversationMessages } from '../model'; import { useRecorder } from '../recorder'; import { MediaMessage, isRoundVideo } from './MediaMessage'; import { MediaViewerProvider } from './MediaViewer'; const QUICK_REACTIONS = ['👍', '❤️', '😂', '🎉']; const GROUP_WINDOW_MS = 5 * 60 * 1000; const formatElapsed = (ms: number): string => { const total = Math.floor(ms / 1000); const minutes = Math.floor(total / 60); const seconds = total % 60; return `${String(minutes)}:${seconds < 10 ? '0' : ''}${String(seconds)}`; }; const clock = (iso: string): string => new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const dayKey = (iso: string): string => new Date(iso).toDateString(); const dayLabel = (iso: string): string => { const then = new Date(iso); const now = new Date(); if (then.toDateString() === now.toDateString()) { return 'Today'; } const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1); if (then.toDateString() === yesterday.toDateString()) { return 'Yesterday'; } return then.toLocaleDateString([], { day: 'numeric', month: 'long' }); }; // Two messages belong to the same visual group when they share a sender and // day and land within the grouping window (order-agnostic). const inSameGroup = (base: Message, other: Message | undefined): boolean => { if (other === undefined) { return false; } return ( other.senderId === base.senderId && dayKey(other.createdAt) === dayKey(base.createdAt) && Math.abs(new Date(base.createdAt).getTime() - new Date(other.createdAt).getTime()) < GROUP_WINDOW_MS ); }; interface Props { conversation: Conversation; me: PublicUser; onlineMap: Record; } const headerTitle = (conversation: Conversation): string => { if (conversation.type === 'group') { return conversation.title ?? 'Group'; } if (conversation.peer === null) { return 'Direct'; } return conversation.peer.displayName; }; const ChatHeader = ({ conversation, online, typing, }: { conversation: Conversation; online: boolean; typing: boolean; }): ReactElement => { const peer = conversation.peer; const isGroup = conversation.type === 'group'; const subtitle = typing ? 'typing…' : isGroup ? 'Group' : online ? 'online' : 'offline'; return (
{peer !== null && peer.avatarUrl !== null ? ( ) : ( {isGroup ? : headerTitle(conversation).charAt(0)} )}
{headerTitle(conversation)} {!isGroup && online && !typing ? : null} {subtitle}
); }; export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement => { const { messages, loading, typingUserIds, peerReadSeq, send, sendMedia, edit, remove, toggleReaction, notifyTyping, } = useConversationMessages(conversation, me); const [text, setText] = useState(''); const [editingId, setEditingId] = useState(null); const [draft, setDraft] = useState(''); const recorder = useRecorder((file) => { void sendMedia(file, ''); }); const previewRef = useRef(null); const scrollRef = useRef(null); const atBottomRef = useRef(true); const isGroup = conversation.type === 'group'; const peerOnline = conversation.peer !== null && onlineMap[conversation.peer.id] === true; // Mirror the live camera stream into the in-composer preview while recording. useEffect(() => { const element = previewRef.current; if (element !== null) { element.srcObject = recorder.previewStream; } }, [recorder.previewStream]); // Jump to the newest message when opening a conversation. useEffect(() => { atBottomRef.current = true; }, [conversation.id]); // Keep the scrollback pinned to the newest message — but only when the reader // is already near the bottom, so scrolling up through history isn't yanked. useLayoutEffect(() => { const node = scrollRef.current; if (node !== null && atBottomRef.current) { node.scrollTop = node.scrollHeight; } }, [messages.length, typingUserIds.length]); const onScroll = (): void => { const node = scrollRef.current; if (node !== null) { atBottomRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 80; } }; const submit = async (event: SyntheticEvent): Promise => { event.preventDefault(); const trimmed = text.trim(); if (trimmed === '') { return; } setText(''); await send(trimmed); }; const onComposerKey = (event: KeyboardEvent): void => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void submit(event); } }; const onAttach = (event: SyntheticEvent): void => { const input = event.currentTarget; const file = input.files?.[0]; if (file !== undefined) { void sendMedia(file, ''); } input.value = ''; }; const startEdit = (message: Message): void => { setEditingId(message.id); setDraft(message.content); }; const commitEdit = async (messageId: string): Promise => { const next = draft.trim(); setEditingId(null); if (next !== '') { await edit(messageId, next); } }; const tick = (message: Message): ReactElement | null => { if (conversation.type !== 'direct' || message.senderId !== me.id || message.deletedAt !== null) { return null; } const read = peerReadSeq >= message.seq; return ( {read ? : } ); }; return (
0} />
{loading ?

Loading…

: null}
{messages.map((message, index) => { const prev = index > 0 ? messages[index - 1] : undefined; const next = index < messages.length - 1 ? messages[index + 1] : undefined; const mine = message.senderId === me.id; const newDay = prev === undefined || dayKey(prev.createdAt) !== dayKey(message.createdAt); const groupStart = newDay || !inSameGroup(message, prev); const groupEnd = !inSameGroup(message, next); const showAvatar = !mine && isGroup; const deleted = message.deletedAt !== null; const editing = editingId === message.id; const media = message.media; // Round video notes render without bubble chrome (like Telegram). const bareMedia = media !== null && !deleted && media.kind === 'video' && isRoundVideo(media.name); const hasMedia = media !== null && !deleted && !bareMedia; const lineClass = [ 'msg-line', mine ? 'mine' : 'theirs', groupStart ? 'group-start' : '', groupEnd ? 'group-end' : '', ] .filter((token) => token !== '') .join(' '); const bubbleClass = [ 'bubble', mine ? 'bubble-mine' : 'bubble-theirs', groupStart ? 'is-start' : '', groupEnd ? 'is-end' : '', bareMedia ? 'bubble-bare' : '', hasMedia ? 'has-media' : '', ] .filter((token) => token !== '') .join(' '); return ( {newDay ? (
{dayLabel(message.createdAt)}
) : null}
{showAvatar ? ( groupEnd ? ( message.sender.avatarUrl !== null ? ( ) : ( {message.sender.displayName.charAt(0)} ) ) : ( ) ) : null}
{showAvatar && groupStart && !deleted ? ( {message.sender.displayName} ) : null}
{editing ? (