xtablo-source/packages/chat-ui/src/components/chat.tsx
2026-04-19 18:02:27 +02:00

1378 lines
47 KiB
TypeScript

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<ChatConfig | null>(null);
function useChatContext() {
const ctx = React.useContext(ChatContext);
if (!ctx) throw new Error("Chat components must be wrapped in <ChatProvider>");
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<ChatConfig>(
() => ({
currentUser,
dateFormat,
messageGroupingInterval,
labels,
onReactionAdd,
onReactionRemove,
onReply,
onEdit,
onDelete,
onPin,
}),
[
currentUser,
dateFormat,
messageGroupingInterval,
labels,
onReactionAdd,
onReactionRemove,
onReply,
onEdit,
onDelete,
onPin,
]
);
return (
<ChatContext.Provider value={config}>
<div style={style} className={className}>
{children}
</div>
</ChatContext.Provider>
);
}
// ─── 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 (
<div
className="chat-toolbar-enter flex items-center gap-0.5 rounded-[10px] border border-border bg-card p-1 shadow-md"
onMouseLeave={onClose}
>
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
onClick={() => {
onSelect(emoji);
onClose();
}}
className="flex size-8 items-center justify-center rounded-lg text-lg transition-transform hover:scale-125 hover:bg-accent"
aria-label={`React with ${emoji}`}
>
{emoji}
</button>
))}
</div>
);
}
// ─── 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 (
<div
className={cn(
"chat-toolbar-enter absolute -top-3 z-10 flex items-center gap-0.5 rounded-lg border border-border bg-card p-0.5 opacity-0 shadow-md transition-opacity group-hover/message:opacity-100",
isOutgoing ? "right-0" : "left-10"
)}
>
{/* Reply */}
<button
onClick={() => onReply?.(message)}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="Reply"
>
<Reply className="size-3.5" />
</button>
{/* React — opens quick picker */}
<div className="relative">
<button
onClick={() => setShowReactions(!showReactions)}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="Add reaction"
>
<SmilePlus className="size-3.5" />
</button>
{showReactions && (
<div className="absolute bottom-full left-1/2 z-20 mb-2 -translate-x-1/2">
<QuickReactionPicker
onSelect={(emoji) => onReactionAdd?.(message.id, emoji)}
onClose={() => setShowReactions(false)}
/>
</div>
)}
</div>
{/* More — dropdown */}
<div className="relative">
<button
onClick={() => setShowMore(!showMore)}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="More actions"
>
<MoreHorizontal className="size-3.5" />
</button>
{showMore && (
<div
className={cn(
"chat-toolbar-enter absolute top-full z-20 mt-1 w-40 overflow-hidden rounded-lg border border-border bg-card py-1 shadow-md",
isOutgoing ? "right-0" : "left-0"
)}
onMouseLeave={() => setShowMore(false)}
>
{isOutgoing && (
<button
onClick={() => {
onEdit?.(message);
setShowMore(false);
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Pencil className="size-3.5" />
Edit
</button>
)}
<button
onClick={() => {
onPin?.(message.id);
setShowMore(false);
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Pin className="size-3.5" />
{message.isPinned ? "Unpin" : "Pin"}
</button>
{isOutgoing && (
<button
onClick={() => {
onDelete?.(message.id);
setShowMore(false);
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-[13px] text-destructive transition-colors hover:bg-red-500/10"
>
<Trash2 className="size-3.5" />
Delete
</button>
)}
</div>
)}
</div>
</div>
);
}
// ─── ChatMessageReply (quoted reply inside bubble) ────────────────────────────
function ChatMessageReply({
replyTo,
isOutgoing,
}: {
replyTo: NonNullable<ChatMessageData["replyTo"]>;
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 (
<div
className={cn(
"mb-1.5 flex items-start gap-2 rounded-lg border-l-2 px-2.5 py-1.5",
isOutgoing
? "border-primary-foreground/30 bg-primary-foreground/10"
: "border-primary bg-accent"
)}
>
<div className="min-w-0 flex-1">
<span
className={cn(
"block text-[12px] font-semibold",
isOutgoing ? "text-inherit opacity-80" : "text-primary"
)}
>
{replyTo.senderName}
</span>
<span
className={cn(
"block truncate text-[12px]",
isOutgoing ? "text-inherit opacity-60" : "text-muted-foreground"
)}
>
{replyTo.text}
</span>
</div>
</div>
);
}
// ─── 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<ChatMessageData["voice"]>;
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 (
<div className="mt-1.5 flex items-center gap-3">
<button
onClick={toggle}
className="flex w-9 h-9 shrink-0 items-center justify-center rounded-full transition-colors"
style={{ background: isOutgoing ? "rgba(255,255,255,0.20)" : "var(--color-primary)" }}
aria-label={playing ? "Pause voice message" : "Play voice message"}
>
{playing ? (
<Pause className="w-4 h-4" style={{ color: "white" }} fill="white" />
) : (
<Play className="w-4 h-4 ml-0.5" style={{ color: "white" }} fill="white" />
)}
</button>
<div className="flex flex-1 items-center gap-[2px] h-8">
{voice.waveform.map((v, i) => {
const played = i < progressIndex;
return (
<div
key={i}
className="w-[3px] rounded-full transition-opacity"
style={{
height: `${v * 100}%`,
background: isOutgoing ? "white" : "var(--color-primary)",
opacity: played ? 1 : 0.6 + v * 0.4,
...(isOutgoing && !played ? { opacity: 0.4 + v * 0.3 } : {}),
}}
/>
);
})}
</div>
<span className="text-[12px] shrink-0 opacity-60 tabular-nums">{timeLabel}</span>
</div>
);
}
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<string | null>(null);
return (
<div
className={cn(
"chat-message group/message relative flex items-end gap-2",
isOutgoing ? "flex-row-reverse" : "flex-row",
position === "first" || position === "solo" ? "mt-4" : "mt-0.5",
className
)}
>
{/* Avatar slot — 32px, only for incoming, only on last/solo */}
{!isOutgoing ? (
<div className="w-8 shrink-0">
{showAvatar && message.senderAvatar ? (
<img
src={message.senderAvatar}
alt={message.senderName}
className="size-8 rounded-full object-cover"
/>
) : showAvatar ? (
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-[11px] font-semibold text-muted-foreground">
{message.senderName.charAt(0).toUpperCase()}
</div>
) : null}
</div>
) : null}
{/* Bubble + reactions column */}
<div className="flex max-w-[75%] flex-col">
{/* Sender name — only first in group, incoming */}
{showSender && !isOutgoing && (
<span className="mb-0.5 ml-3 text-[14px] font-semibold leading-tight tracking-[-0.01em] text-muted-foreground">
{message.senderName}
</span>
)}
{/* Bubble — relative for hover toolbar positioning */}
<div className="relative">
{/* Hover actions toolbar — disabled for now */}
{/* <ChatMessageActions message={message} isOutgoing={isOutgoing} /> */}
<div
className={cn(
"chat-bubble relative px-3.5 py-2",
isOutgoing ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
radiusClass
)}
>
{/* Quoted reply */}
{message.replyTo && (
<ChatMessageReply replyTo={message.replyTo} isOutgoing={isOutgoing} />
)}
{/* Text content */}
{message.text && (
<p className="whitespace-pre-wrap break-words text-[15px] leading-[1.35] tracking-[-0.01em]">
{message.text}
</p>
)}
{/* Images */}
{message.images && message.images.length > 0 && (
<div
className={cn(
"mt-1.5 flex flex-wrap gap-1.5",
message.images.length === 1 ? "" : ""
)}
>
{message.images.map((img, idx) => (
<button
key={idx}
onClick={() => setLightboxImage(img.url)}
className="cursor-pointer rounded-lg overflow-hidden"
aria-label="View image"
>
<img
src={img.url}
alt={img.alt || "Image"}
width={img.width}
height={img.height}
className="max-h-[200px] max-w-full rounded-lg object-cover transition-opacity hover:opacity-90"
loading="lazy"
/>
</button>
))}
</div>
)}
{/* Code block */}
{message.code && (
<div className="chat-content-card mt-1.5 overflow-hidden">
<div className="flex items-center justify-between bg-muted px-3 py-1.5">
<span className="text-[11px] font-medium text-muted-foreground/60">
{message.code.language}
</span>
</div>
<pre className="overflow-x-auto bg-muted px-3 py-2">
<code className="text-[13px] leading-relaxed text-foreground font-mono">
{message.code.code}
</code>
</pre>
</div>
)}
{/* File attachments */}
{message.files && message.files.length > 0 && (
<div className="mt-1.5 flex flex-col gap-1.5">
{message.files.map((file, idx) => (
<div key={idx} className="chat-content-card flex items-center gap-2.5 px-3 py-2">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-accent">
<Paperclip className="size-3.5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium text-foreground">
{file.name}
</p>
<p className="text-[11px] text-muted-foreground/60">
{file.size < 1024
? `${file.size} B`
: file.size < 1048576
? `${(file.size / 1024).toFixed(0)} KB`
: `${(file.size / 1048576).toFixed(1)} MB`}
</p>
</div>
</div>
))}
</div>
)}
{/* Link preview */}
{message.linkPreview && (
<a
href={message.linkPreview.url}
target="_blank"
rel="noopener noreferrer"
className="chat-content-card mt-1.5 block hover:opacity-90 transition-opacity"
>
{message.linkPreview.image && (
<img
src={message.linkPreview.image}
alt=""
className="h-32 w-full object-cover"
loading="lazy"
/>
)}
<div className="px-3 py-2">
<p className="text-[13px] font-semibold text-foreground">
{message.linkPreview.title}
</p>
<p className="mt-0.5 text-[12px] text-muted-foreground">
{message.linkPreview.description}
</p>
<p className="mt-1 text-[11px] text-primary">{message.linkPreview.url}</p>
</div>
</a>
)}
{/* Voice message */}
{message.voice && <ChatVoiceMessage voice={message.voice} isOutgoing={isOutgoing} />}
{/* Inline timestamp + status + edited label */}
<div
className={cn(
"mt-1 flex items-center gap-1",
isOutgoing ? "justify-end" : "justify-start"
)}
>
{message.isEdited && <span className="text-[10px] italic opacity-50">edited</span>}
<time className="text-[11px] tracking-[0.02em] opacity-60">
{formatTimestamp(timestamp)}
</time>
{isOutgoing && message.status && <ChatMessageStatus status={message.status} />}
</div>
</div>
{/* Pin indicator */}
{message.isPinned && (
<div className="absolute -top-1.5 -right-1.5">
<Pin className="size-3 rotate-45 text-orange-500" />
</div>
)}
</div>
{/* Reactions bar */}
{message.reactions && message.reactions.length > 0 && (
<ChatMessageReactions
messageId={message.id}
reactions={message.reactions}
isOutgoing={isOutgoing}
currentUserId={currentUser.id}
/>
)}
{/* Read receipts (group chat) — small stacked avatars */}
{message.readBy && message.readBy.length > 0 && (
<ChatReadReceipts readBy={message.readBy} isOutgoing={isOutgoing} />
)}
</div>
{/* Image lightbox */}
{lightboxImage &&
typeof document !== "undefined" &&
createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={() => setLightboxImage(null)}
>
<button
onClick={() => setLightboxImage(null)}
className="absolute top-4 right-4 flex size-10 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
aria-label="Close lightbox"
>
<X className="size-5" />
</button>
<img
src={lightboxImage}
alt=""
className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>,
document.body
)}
</div>
);
}
// ─── 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<ChatMessageData["status"]> }) {
switch (status) {
case "sending":
return <Clock className="size-3 animate-pulse opacity-50" />;
case "sent":
return <Check className="size-3 opacity-60" />;
case "delivered":
return <CheckCheck className="size-3.5 opacity-60" />;
case "read":
return <CheckCheck className="chat-status-read size-3.5 text-primary" />;
case "failed":
return <AlertCircle className="size-3.5 cursor-pointer text-destructive" />;
}
}
// ─── ChatMessageReactions (interactive) ───────────────────────────────────────
function ChatMessageReactions({
messageId,
reactions,
isOutgoing,
currentUserId,
}: {
messageId: string;
reactions: NonNullable<ChatMessageData["reactions"]>;
isOutgoing: boolean;
currentUserId: string;
}) {
const { onReactionAdd, onReactionRemove } = useChatContext();
return (
<div className={cn("mt-1 flex flex-wrap gap-1", isOutgoing ? "justify-end" : "justify-start")}>
{reactions.map((r) => {
const hasReacted = r.userIds.includes(currentUserId);
return (
<button
key={r.emoji}
onClick={() => {
if (hasReacted) {
onReactionRemove?.(messageId, r.emoji);
} else {
onReactionAdd?.(messageId, r.emoji);
}
}}
className={cn(
"chat-reaction-pop flex h-[26px] items-center gap-1 rounded-full border px-2 text-xs tabular-nums transition-all hover:scale-105",
hasReacted ? "border-primary/30 bg-accent" : "border-border bg-card hover:bg-accent"
)}
aria-label={`${r.emoji} ${r.count} reaction${r.count !== 1 ? "s" : ""}`}
>
<span className="text-sm">{r.emoji}</span>
<span
className={cn(
"text-[12px] font-medium",
hasReacted ? "text-primary" : "text-muted-foreground"
)}
>
{r.count}
</span>
</button>
);
})}
{/* Add reaction button — visible on hover */}
<button
onClick={() => {
// Toggle first available reaction for demo; in prod this opens a picker
onReactionAdd?.(messageId, "\u{1F44D}");
}}
className="flex size-[26px] items-center justify-center rounded-full border border-dashed border-border text-muted-foreground/60 opacity-0 transition-all hover:border-primary hover:text-primary group-hover/message:opacity-100"
aria-label="Add reaction"
>
<SmilePlus className="size-3" />
</button>
</div>
);
}
// ─── ChatReadReceipts (group chat — stacked mini avatars) ─────────────────────
function ChatReadReceipts({
readBy,
isOutgoing,
}: {
readBy: NonNullable<ChatMessageData["readBy"]>;
isOutgoing: boolean;
}) {
const maxVisible = 3;
const visible = readBy.slice(0, maxVisible);
const overflow = readBy.length - maxVisible;
return (
<div className={cn("mt-1 flex items-center", isOutgoing ? "justify-end" : "justify-start")}>
<div className="flex -space-x-1.5">
{visible.map((user) => (
<div
key={user.userId}
className="flex size-4 items-center justify-center rounded-full border border-background bg-muted text-[7px] font-bold text-muted-foreground"
title={user.name}
>
{user.avatar ? (
<img
src={user.avatar}
alt={user.name}
className="size-full rounded-full object-cover"
/>
) : (
user.name.charAt(0).toUpperCase()
)}
</div>
))}
</div>
{overflow > 0 && (
<span className="ml-1 text-[10px] text-muted-foreground/60">+{overflow}</span>
)}
</div>
);
}
// ─── ChatMessageGroup ─────────────────────────────────────────────────────────
interface ChatMessageGroupProps {
group: MessageGroup;
className?: string;
}
function ChatMessageGroup({ group, className }: ChatMessageGroupProps) {
const len = group.messages.length;
return (
<div
className={cn(
"chat-message-group",
group.isOutgoing ? "items-end" : "items-start",
className
)}
>
{group.messages.map((msg, i) => {
const position: "solo" | "first" | "middle" | "last" =
len === 1 ? "solo" : i === 0 ? "first" : i === len - 1 ? "last" : "middle";
return (
<ChatMessage
key={msg.id}
message={msg}
isOutgoing={group.isOutgoing}
position={position}
showSender={i === 0}
showAvatar={position === "solo" || position === "last"}
/>
);
})}
</div>
);
}
// ─── ChatDateSeparator ────────────────────────────────────────────────────────
interface ChatDateSeparatorProps {
label: string;
className?: string;
}
function ChatDateSeparator({ label, className }: ChatDateSeparatorProps) {
return (
<div className={cn("chat-date-separator my-6 flex items-center gap-4", className)}>
<div className="h-px flex-1 bg-border" />
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/60">
{label}
</span>
<div className="h-px flex-1 bg-border" />
</div>
);
}
// ─── ChatSystemMessage ────────────────────────────────────────────────────────
interface ChatSystemMessageProps {
message: ChatMessageData;
className?: string;
}
function ChatSystemMessage({ message, className }: ChatSystemMessageProps) {
return (
<div className={cn("chat-system-message my-4 flex justify-center", className)}>
<span className="text-[13px] font-medium tracking-[0.01em] text-muted-foreground">
{message.text || message.systemEvent}
</span>
</div>
);
}
// ─── 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 (
<div className={cn("chat-message mt-4 flex items-end gap-2", className)}>
{/* Avatar */}
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-semibold text-muted-foreground">
{users[0]!.avatar ? (
<img
src={users[0]!.avatar}
alt={users[0]!.name}
className="size-full rounded-full object-cover"
/>
) : (
users[0]!.name.charAt(0).toUpperCase()
)}
</div>
<div className="flex flex-col">
{/* Label */}
<span className="mb-0.5 ml-3 text-[12px] text-muted-foreground/60">{label}</span>
{/* Dots bubble */}
<div className="flex w-16 items-center justify-center gap-1 rounded-[18px_18px_18px_4px] bg-muted px-4 py-3">
<span
className="chat-typing-dot size-[7px] rounded-full bg-muted-foreground"
style={{ animationDelay: "0ms" }}
/>
<span
className="chat-typing-dot size-[7px] rounded-full bg-muted-foreground"
style={{ animationDelay: "160ms" }}
/>
<span
className="chat-typing-dot size-[7px] rounded-full bg-muted-foreground"
style={{ animationDelay: "320ms" }}
/>
</div>
</div>
</div>
);
}
// ─── ChatReplyPreview (bar above composer) ────────────────────────────────────
interface ChatReplyPreviewProps {
replyingTo: ChatMessageData;
onCancel: () => void;
className?: string;
}
function ChatReplyPreview({ replyingTo, onCancel, className }: ChatReplyPreviewProps) {
return (
<div
className={cn("flex items-center gap-3 border-t border-border bg-card px-4 py-2", className)}
>
<div className="h-8 w-0.5 shrink-0 rounded-full bg-primary" />
<div className="min-w-0 flex-1">
<span className="block text-[12px] font-semibold text-primary">
{replyingTo.senderName}
</span>
<span className="block truncate text-[13px] text-muted-foreground">{replyingTo.text}</span>
</div>
<button
onClick={onCancel}
className="flex size-6 shrink-0 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
aria-label="Cancel reply"
>
<X className="size-3.5" />
</button>
</div>
);
}
// ─── ChatMessages (scroll container) ──────────────────────────────────────────
interface ChatMessagesProps {
messages: ChatMessageData[];
typingUsers?: TypingUser[];
className?: string;
onLoadMore?: () => Promise<void>;
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 (
<div className={cn("chat-messages relative flex flex-1 flex-col overflow-hidden", className)}>
{/* Scrollable area */}
<div
ref={containerRef}
className="flex-1 overflow-y-auto px-4 py-4"
role="log"
aria-live="polite"
>
<div className="w-full">
{items.map((item, i) => {
switch (item.type) {
case "date":
return <ChatDateSeparator key={`date-${item.label}-${i}`} label={item.label} />;
case "system":
return <ChatSystemMessage key={item.message.id} message={item.message} />;
case "group":
return (
<ChatMessageGroup
key={`group-${item.group.messages[0]!.id}`}
group={item.group}
/>
);
}
})}
{/* Typing indicator at the bottom */}
{typingUsers.length > 0 && <ChatTypingIndicator users={typingUsers} />}
</div>
</div>
{/* Scroll-to-bottom FAB with unread badge */}
<button
onClick={() => scrollToBottom("smooth")}
className={cn(
"absolute bottom-4 right-4 z-5 flex size-10 items-center justify-center rounded-full border border-border bg-background shadow-md transition-all duration-200",
isAtBottom ? "pointer-events-none translate-y-2 opacity-0" : "translate-y-0 opacity-100"
)}
aria-label={labels?.scrollToBottom ?? "Scroll to bottom"}
>
<ChevronDown className="size-[18px] text-muted-foreground" />
{/* Unread badge */}
{unseenCount > 0 && (
<span className="absolute -top-1 -right-1 flex size-[18px] items-center justify-center rounded-full bg-primary text-[11px] font-bold text-white tabular-nums">
{unseenCount > 99 ? "99+" : unseenCount}
</span>
)}
</button>
</div>
);
}
// ─── 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 (
<div className="relative shrink-0 rounded-lg border border-border bg-card">
{isImage && item.preview ? (
<div className="relative size-14 overflow-hidden rounded-lg">
<img src={item.preview} alt={item.file.name} className="size-full object-cover" />
</div>
) : (
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex size-8 items-center justify-center rounded-md bg-accent">
<Paperclip className="size-3.5 text-primary" />
</div>
<div className="min-w-0">
<p className="max-w-[120px] truncate text-[12px] font-medium text-foreground">
{item.file.name}
</p>
<p className="text-[10px] text-muted-foreground/60">
{(item.file.size / 1024).toFixed(0)} KB
</p>
</div>
</div>
)}
{/* Progress bar */}
{item.progress !== undefined && item.progress < 100 && (
<div className="absolute bottom-0 left-0 h-[3px] w-full bg-border">
<div
className="h-full bg-primary transition-all"
style={{ width: `${item.progress}%` }}
/>
</div>
)}
{/* Remove button */}
<button
onClick={onRemove}
className="absolute -top-1.5 -right-1.5 flex size-5 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground"
aria-label="Remove file"
>
<X className="size-3" />
</button>
</div>
);
}
// ─── 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<FilePreviewItem[]>([]);
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<HTMLInputElement>(null)
// const imageInputRef = React.useRef<HTMLInputElement>(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 (
<div
className={cn("chat-composer sticky bottom-0 z-10 relative", className)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drop overlay */}
{isDragging && (
<div className="chat-drop-overlay">
<div className="flex flex-col items-center gap-2">
<Upload className="size-8 text-primary" />
<span className="text-[14px] font-medium text-primary">Drop files to upload</span>
</div>
</div>
)}
{/* Reply preview bar */}
{replyingTo && (
<ChatReplyPreview replyingTo={replyingTo} onCancel={() => onCancelReply?.()} />
)}
{/* File preview strip */}
{files.length > 0 && (
<div className="flex gap-3 overflow-x-auto border-t border-border bg-card px-4 pt-3 pb-2 backdrop-blur-[20px]">
{files.map((f) => (
<ChatFilePreview key={f.id} item={f} onRemove={() => removeFile(f.id)} />
))}
</div>
)}
{/* Composer body — frosted glass */}
<div className="bg-card px-3 py-2">
<div>
{/* Input row */}
<div className="flex items-end gap-2">
{/* + button with attachment popout — disabled until file upload is implemented
<div className="relative">
<button
onClick={() => setShowAttachMenu(!showAttachMenu)}
className={cn(
"flex size-9 items-center justify-center rounded-full border border-border bg-card text-muted-foreground/60 transition-all hover:bg-accent hover:text-muted-foreground",
showAttachMenu && "rotate-45 bg-accent text-primary"
)}
aria-label="Attachments"
>
<Plus className="size-5" />
</button>
{showAttachMenu && (
<div className="chat-toolbar-enter absolute bottom-full left-0 mb-2 w-44 overflow-hidden rounded-xl border border-border bg-card py-1 shadow-md">
<button
onClick={() => { fileInputRef.current?.click(); setShowAttachMenu(false) }}
className="flex w-full items-center gap-2.5 px-3 py-2 text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Paperclip className="size-4" />
Attach file
</button>
<button
onClick={() => { imageInputRef.current?.click(); setShowAttachMenu(false) }}
className="flex w-full items-center gap-2.5 px-3 py-2 text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<ImageIcon className="size-4" />
Photo or video
</button>
<button
onClick={() => setShowAttachMenu(false)}
className="flex w-full items-center gap-2.5 px-3 py-2 text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Smile className="size-4" />
Emoji
</button>
</div>
)}
</div>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = "" }} />
<input ref={imageInputRef} type="file" accept="image/*" multiple className="hidden" onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = "" }} />
*/}
<div className="relative flex flex-1 items-end rounded-[22px] border border-border bg-card">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
setValue(e.target.value);
resize();
}}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={placeholder}
disabled={disabled}
rows={1}
className="flex-1 resize-none bg-transparent py-[10px] pl-4 pr-12 text-[15px] leading-[22px] tracking-[-0.01em] text-foreground placeholder:text-muted-foreground/60 focus:outline-none disabled:opacity-50"
style={{ overflow: "hidden", maxHeight: "160px" }}
/>
{!hasContent && onVoiceRecord ? (
<button
onClick={onVoiceRecord}
disabled={disabled}
className="absolute bottom-[6px] right-[6px] flex size-8 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:text-primary"
aria-label="Record voice message"
>
<Mic className="size-4" strokeWidth={2.5} />
</button>
) : (
<button
onClick={handleSend}
disabled={!hasContent || disabled}
className={cn(
"absolute bottom-[6px] right-[6px] flex size-8 items-center justify-center rounded-full transition-all duration-200",
hasContent
? "bg-primary text-white hover:scale-105 active:scale-95"
: "bg-transparent text-muted-foreground/60"
)}
aria-label="Send message"
>
<ArrowUp className="size-4" strokeWidth={2.5} />
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
}
// ─── Exports ──────────────────────────────────────────────────────────────────
export {
ChatProvider,
ChatMessage,
ChatMessageGroup,
ChatDateSeparator,
ChatSystemMessage,
ChatMessages,
ChatComposer,
ChatMessageStatus,
ChatMessageReactions,
ChatMessageActions,
ChatMessageReply,
ChatTypingIndicator,
ChatReplyPreview,
ChatReadReceipts,
};
export type {
ChatProviderProps,
ChatMessageProps,
ChatMessageGroupProps,
ChatDateSeparatorProps,
ChatSystemMessageProps,
ChatMessagesProps,
ChatComposerProps,
ChatMessageActionsProps,
ChatTypingIndicatorProps,
ChatReplyPreviewProps,
};