import { cn } from "@xtablo/shared"; import { AlertCircle, ArrowUp, Check, CheckCheck, ChevronDown, Clock, Mic, MoreHorizontal, Paperclip, Pause, Pencil, Pin, // Plus, Play, Reply, SmilePlus, Trash2, // Image as ImageIcon, // Smile, Upload, X, } from "lucide-react"; import * as React from "react"; import { createPortal } from "react-dom"; import { formatTimestamp, groupMessages, useAutoResize, useAutoScroll, useTypingIndicator, } from "../hooks"; import type { ChatConfig, ChatLabels, ChatMessageData, ChatUser, MessageGroup, TypingUser, } from "../types"; // ─── Context ────────────────────────────────────────────────────────────────── const ChatContext = React.createContext(null); function useChatContext() { const ctx = React.useContext(ChatContext); if (!ctx) throw new Error("Chat components must be wrapped in "); return ctx; } // ─── ChatProvider ───────────────────────────────────────────────────────────── interface ChatProviderProps { currentUser: ChatUser; dateFormat?: "relative" | "absolute" | "time-only"; messageGroupingInterval?: number; labels?: ChatLabels; onReactionAdd?: (messageId: string, emoji: string) => void; onReactionRemove?: (messageId: string, emoji: string) => void; onReply?: (message: ChatMessageData) => void; onEdit?: (message: ChatMessageData) => void; onDelete?: (messageId: string) => void; onPin?: (messageId: string) => void; children: React.ReactNode; style?: React.CSSProperties; className?: string; } function ChatProvider({ currentUser, dateFormat = "relative", messageGroupingInterval = 120, labels, onReactionAdd, onReactionRemove, onReply, onEdit, onDelete, onPin, children, style, className, }: ChatProviderProps) { const config = React.useMemo( () => ({ currentUser, dateFormat, messageGroupingInterval, labels, onReactionAdd, onReactionRemove, onReply, onEdit, onDelete, onPin, }), [ currentUser, dateFormat, messageGroupingInterval, labels, onReactionAdd, onReactionRemove, onReply, onEdit, onDelete, onPin, ] ); return (
{children}
); } // ─── Quick emoji picker (6 common reactions) ────────────────────────────────── const QUICK_REACTIONS = [ "\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F602}", "\u{1F62E}", "\u{1F64F}", "\u{1F525}", ]; function QuickReactionPicker({ onSelect, onClose, }: { onSelect: (emoji: string) => void; onClose: () => void; }) { return (
{QUICK_REACTIONS.map((emoji) => ( ))}
); } // ─── ChatMessageActions (hover toolbar) ─────────────────────────────────────── interface ChatMessageActionsProps { message: ChatMessageData; isOutgoing: boolean; } function ChatMessageActions({ message, isOutgoing }: ChatMessageActionsProps) { const { onReply, onReactionAdd, onEdit, onDelete, onPin } = useChatContext(); const [showReactions, setShowReactions] = React.useState(false); const [showMore, setShowMore] = React.useState(false); return (
{/* Reply */} {/* React — opens quick picker */}
{showReactions && (
onReactionAdd?.(message.id, emoji)} onClose={() => setShowReactions(false)} />
)}
{/* More — dropdown */}
{showMore && (
setShowMore(false)} > {isOutgoing && ( )} {isOutgoing && ( )}
)}
); } // ─── ChatMessageReply (quoted reply inside bubble) ──────────────────────────── function ChatMessageReply({ replyTo, isOutgoing, }: { replyTo: NonNullable; isOutgoing: boolean; }) { // Outgoing bubbles set text color via --chat-bubble-outgoing-text which may // be white (Lunar, Midnight) or dark (Aurora, Ember). Using `text-inherit` // + opacity lets the reply quote inherit that color and stay visible against // the bubble background regardless of theme. return (
{replyTo.senderName} {replyTo.text}
); } // ─── ChatMessage ────────────────────────────────────────────────────────────── interface ChatMessageProps { message: ChatMessageData; isOutgoing: boolean; position: "solo" | "first" | "middle" | "last"; showSender?: boolean; showAvatar?: boolean; className?: string; } // ─── Voice Message ───────────────────────────────────────────────────────── function ChatVoiceMessage({ voice, isOutgoing, }: { voice: NonNullable; isOutgoing: boolean; }) { const [playing, setPlaying] = React.useState(false); const [progress, setProgress] = React.useState(0); const progressRef = React.useRef(0); React.useEffect(() => { progressRef.current = progress; }, [progress]); const totalMins = Math.floor(voice.duration / 60); const totalSecs = Math.floor(voice.duration % 60); const elapsed = progress * voice.duration; const elapsedMins = Math.floor(elapsed / 60); const elapsedSecs = Math.floor(elapsed % 60); const timeLabel = playing || progress > 0 ? `${elapsedMins}:${elapsedSecs.toString().padStart(2, "0")}` : `${totalMins}:${totalSecs.toString().padStart(2, "0")}`; const progressIndex = Math.floor(progress * voice.waveform.length); React.useEffect(() => { if (!playing) return; const fps = 20; const step = 1 / (voice.duration * fps); const id = setInterval(() => { const next = progressRef.current + step; if (next >= 1) { setProgress(0); setPlaying(false); clearInterval(id); } else { setProgress(next); } }, 1000 / fps); return () => clearInterval(id); }, [playing, voice.duration]); const toggle = () => { if (!playing && progress === 0) setProgress(0); setPlaying((p) => !p); }; return (
{voice.waveform.map((v, i) => { const played = i < progressIndex; return (
); })}
{timeLabel}
); } function ChatMessage({ message, isOutgoing, position, showSender = false, showAvatar = false, className, }: ChatMessageProps) { const timestamp = new Date(message.timestamp); const { currentUser } = useChatContext(); const radiusClass = getBubbleRadius(isOutgoing, position); const [lightboxImage, setLightboxImage] = React.useState(null); return (
{/* Avatar slot — 32px, only for incoming, only on last/solo */} {!isOutgoing ? (
{showAvatar && message.senderAvatar ? ( {message.senderName} ) : showAvatar ? (
{message.senderName.charAt(0).toUpperCase()}
) : null}
) : null} {/* Bubble + reactions column */}
{/* Sender name — only first in group, incoming */} {showSender && !isOutgoing && ( {message.senderName} )} {/* Bubble — relative for hover toolbar positioning */}
{/* Hover actions toolbar — disabled for now */} {/* */}
{/* Quoted reply */} {message.replyTo && ( )} {/* Text content */} {message.text && (

{message.text}

)} {/* Images */} {message.images && message.images.length > 0 && (
{message.images.map((img, idx) => ( ))}
)} {/* Code block */} {message.code && (
{message.code.language}
                  
                    {message.code.code}
                  
                
)} {/* File attachments */} {message.files && message.files.length > 0 && (
{message.files.map((file, idx) => (

{file.name}

{file.size < 1024 ? `${file.size} B` : file.size < 1048576 ? `${(file.size / 1024).toFixed(0)} KB` : `${(file.size / 1048576).toFixed(1)} MB`}

))}
)} {/* Link preview */} {message.linkPreview && ( {message.linkPreview.image && ( )}

{message.linkPreview.title}

{message.linkPreview.description}

{message.linkPreview.url}

)} {/* Voice message */} {message.voice && } {/* Inline timestamp + status + edited label */}
{message.isEdited && edited} {isOutgoing && message.status && }
{/* Pin indicator */} {message.isPinned && (
)}
{/* Reactions bar */} {message.reactions && message.reactions.length > 0 && ( )} {/* Read receipts (group chat) — small stacked avatars */} {message.readBy && message.readBy.length > 0 && ( )}
{/* Image lightbox */} {lightboxImage && typeof document !== "undefined" && createPortal(
setLightboxImage(null)} > e.stopPropagation()} />
, document.body )}
); } // ─── Bubble radius helper ───────────────────────────────────────────────────── function getBubbleRadius( isOutgoing: boolean, position: "solo" | "first" | "middle" | "last" ): string { if (isOutgoing) { switch (position) { case "solo": return "rounded-[18px_18px_4px_18px]"; case "first": return "rounded-[18px_18px_4px_18px]"; case "middle": return "rounded-[18px_4px_4px_18px]"; case "last": return "rounded-[18px_4px_18px_18px]"; } } else { switch (position) { case "solo": return "rounded-[18px_18px_18px_4px]"; case "first": return "rounded-[18px_18px_18px_4px]"; case "middle": return "rounded-[4px_18px_18px_4px]"; case "last": return "rounded-[4px_18px_18px_18px]"; } } } // ─── ChatMessageStatus ──────────────────────────────────────────────────────── function ChatMessageStatus({ status }: { status: NonNullable }) { switch (status) { case "sending": return ; case "sent": return ; case "delivered": return ; case "read": return ; case "failed": return ; } } // ─── ChatMessageReactions (interactive) ─────────────────────────────────────── function ChatMessageReactions({ messageId, reactions, isOutgoing, currentUserId, }: { messageId: string; reactions: NonNullable; isOutgoing: boolean; currentUserId: string; }) { const { onReactionAdd, onReactionRemove } = useChatContext(); return (
{reactions.map((r) => { const hasReacted = r.userIds.includes(currentUserId); return ( ); })} {/* Add reaction button — visible on hover */}
); } // ─── ChatReadReceipts (group chat — stacked mini avatars) ───────────────────── function ChatReadReceipts({ readBy, isOutgoing, }: { readBy: NonNullable; isOutgoing: boolean; }) { const maxVisible = 3; const visible = readBy.slice(0, maxVisible); const overflow = readBy.length - maxVisible; return (
{visible.map((user) => (
{user.avatar ? ( {user.name} ) : ( user.name.charAt(0).toUpperCase() )}
))}
{overflow > 0 && ( +{overflow} )}
); } // ─── ChatMessageGroup ───────────────────────────────────────────────────────── interface ChatMessageGroupProps { group: MessageGroup; className?: string; } function ChatMessageGroup({ group, className }: ChatMessageGroupProps) { const len = group.messages.length; return (
{group.messages.map((msg, i) => { const position: "solo" | "first" | "middle" | "last" = len === 1 ? "solo" : i === 0 ? "first" : i === len - 1 ? "last" : "middle"; return ( ); })}
); } // ─── ChatDateSeparator ──────────────────────────────────────────────────────── interface ChatDateSeparatorProps { label: string; className?: string; } function ChatDateSeparator({ label, className }: ChatDateSeparatorProps) { return (
{label}
); } // ─── ChatSystemMessage ──────────────────────────────────────────────────────── interface ChatSystemMessageProps { message: ChatMessageData; className?: string; } function ChatSystemMessage({ message, className }: ChatSystemMessageProps) { return (
{message.text || message.systemEvent}
); } // ─── ChatTypingIndicator ────────────────────────────────────────────────────── interface ChatTypingIndicatorProps { users: TypingUser[]; className?: string; } function ChatTypingIndicator({ users, className }: ChatTypingIndicatorProps) { const { labels } = useChatContext(); if (users.length === 0) return null; const label = users.length === 1 ? (labels?.typingOne?.replace("{{name}}", users[0]!.name) ?? `${users[0]!.name} is typing`) : users.length === 2 ? (labels?.typingTwo ?.replace("{{name1}}", users[0]!.name) .replace("{{name2}}", users[1]!.name) ?? `${users[0]!.name} and ${users[1]!.name} are typing`) : (labels?.typingMany ?? "Several people are typing"); return (
{/* Avatar */}
{users[0]!.avatar ? ( {users[0]!.name} ) : ( users[0]!.name.charAt(0).toUpperCase() )}
{/* Label */} {label} {/* Dots bubble */}
); } // ─── ChatReplyPreview (bar above composer) ──────────────────────────────────── interface ChatReplyPreviewProps { replyingTo: ChatMessageData; onCancel: () => void; className?: string; } function ChatReplyPreview({ replyingTo, onCancel, className }: ChatReplyPreviewProps) { return (
{replyingTo.senderName} {replyingTo.text}
); } // ─── ChatMessages (scroll container) ────────────────────────────────────────── interface ChatMessagesProps { messages: ChatMessageData[]; typingUsers?: TypingUser[]; className?: string; onLoadMore?: () => Promise; hasMore?: boolean; } function ChatMessages({ messages, typingUsers = [], className }: ChatMessagesProps) { const { currentUser, messageGroupingInterval, labels } = useChatContext(); const { containerRef, scrollToBottom, isAtBottom, unseenCount } = useAutoScroll(messages); const items = React.useMemo( () => groupMessages(messages, currentUser.id, messageGroupingInterval), [messages, currentUser.id, messageGroupingInterval] ); return (
{/* Scrollable area */}
{items.map((item, i) => { switch (item.type) { case "date": return ; case "system": return ; case "group": return ( ); } })} {/* Typing indicator at the bottom */} {typingUsers.length > 0 && }
{/* Scroll-to-bottom FAB with unread badge */}
); } // ─── File preview item ──────────────────────────────────────────────────────── interface FilePreviewItem { file: File; id: string; preview?: string; // data URL for images progress?: number; // 0-100 } function ChatFilePreview({ item, onRemove }: { item: FilePreviewItem; onRemove: () => void }) { const isImage = item.file.type.startsWith("image/"); return (
{isImage && item.preview ? (
{item.file.name}
) : (

{item.file.name}

{(item.file.size / 1024).toFixed(0)} KB

)} {/* Progress bar */} {item.progress !== undefined && item.progress < 100 && (
)} {/* Remove button */}
); } // ─── ChatComposer ───────────────────────────────────────────────────────────── interface ChatComposerProps { onSend?: (text: string) => void; onTyping?: (isTyping: boolean) => void; onFileUpload?: (files: File[]) => void; onVoiceRecord?: () => void; placeholder?: string; disabled?: boolean; replyingTo?: ChatMessageData | null; onCancelReply?: () => void; className?: string; } function ChatComposer({ onSend, onTyping, onFileUpload, onVoiceRecord, placeholder = "Message", disabled = false, replyingTo, onCancelReply, className, }: ChatComposerProps) { const [value, setValue] = React.useState(""); const [files, setFiles] = React.useState([]); const [isDragging, setIsDragging] = React.useState(false); // const [showAttachMenu, setShowAttachMenu] = React.useState(false) const { textareaRef, resize } = useAutoResize({ maxRows: 6 }); const { handleKeyDown: handleTypingKeyDown, stopTyping } = useTypingIndicator({ onTypingChange: onTyping, }); // const fileInputRef = React.useRef(null) // const imageInputRef = React.useRef(null) const hasContent = value.trim().length > 0 || files.length > 0; const addFiles = React.useCallback( (newFiles: FileList | File[]) => { const arr = Array.from(newFiles); const items: FilePreviewItem[] = arr.map((f) => ({ file: f, id: `${f.name}-${Date.now()}-${Math.random()}`, progress: undefined, })); // Generate image previews items.forEach((item) => { if (item.file.type.startsWith("image/")) { const reader = new FileReader(); reader.onload = (e) => { setFiles((prev) => prev.map((f) => f.id === item.id ? { ...f, preview: e.target?.result as string } : f ) ); }; reader.readAsDataURL(item.file); } }); setFiles((prev) => [...prev, ...items]); onFileUpload?.(arr); }, [onFileUpload] ); const removeFile = React.useCallback((id: string) => { setFiles((prev) => prev.filter((f) => f.id !== id)); }, []); const handleSend = React.useCallback(() => { const trimmed = value.trim(); if ((!trimmed && files.length === 0) || disabled) return; if (trimmed) onSend?.(trimmed); setValue(""); setFiles([]); stopTyping(); if (textareaRef.current) textareaRef.current.style.height = "auto"; }, [value, files, disabled, onSend, textareaRef, stopTyping]); const handleKeyDown = React.useCallback( (e: React.KeyboardEvent) => { handleTypingKeyDown(); if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } if (e.key === "Escape" && replyingTo) onCancelReply?.(); }, [handleSend, handleTypingKeyDown, replyingTo, onCancelReply] ); // Paste upload const handlePaste = React.useCallback( (e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; const imageFiles: File[] = []; for (const item of Array.from(items)) { if (item.type.startsWith("image/")) { const file = item.getAsFile(); if (file) imageFiles.push(file); } } if (imageFiles.length > 0) { addFiles(imageFiles); } }, [addFiles] ); // Drag-and-drop handlers (on the composer container) const handleDragOver = React.useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }, []); const handleDragLeave = React.useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); }, []); const handleDrop = React.useCallback( (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); if (e.dataTransfer.files.length > 0) { addFiles(e.dataTransfer.files); } }, [addFiles] ); return (
{/* Drop overlay */} {isDragging && (
Drop files to upload
)} {/* Reply preview bar */} {replyingTo && ( onCancelReply?.()} /> )} {/* File preview strip */} {files.length > 0 && (
{files.map((f) => ( removeFile(f.id)} /> ))}
)} {/* Composer body — frosted glass */}
{/* Input row */}
{/* + button with attachment popout — disabled until file upload is implemented
{showAttachMenu && (
)}
{ if (e.target.files) addFiles(e.target.files); e.target.value = "" }} /> { if (e.target.files) addFiles(e.target.files); e.target.value = "" }} /> */}