Files
limitless-ui/src/pages/Chat.tsx
Claude cf068ce4ec chore: initialize @limitless/ui git repo + add AppShell
This brings the long-untracked @limitless/ui source tree under version
control. Until now /srv/k8s/templates/limitless-ui has been a plain
file: dependency consumed by gscChronos / gscCRM / gscAdmin, with
copies scattered across web/gsc{Portal,WWW,Aether,Register}/ and
apps/gsc{Meet,Share}/. None were git-tracked.

Treating /srv/k8s/templates/limitless-ui as the canonical going
forward; secondary copies should be replaced with this version
in their consumers' Dockerfiles when they next get touched.

Changes in this initial commit beyond the snapshot:
- Add src/layout/AppShell.tsx — runtime-loaded chrome (header,
  sidebar, footer) backed by gsc-shell-api. Public surface:
    AppShell, ShellProvider, useShell, ShellConfig types
  Framework-agnostic (no Next.js dep). Apps pass appKey + apiUrl +
  getToken; AppShell composes the existing PageShell / Navbar /
  Sidebar / Footer primitives with API data.
- Re-export AppShell from src/index.ts.
- Fix build script: `tsc -p tsconfig.json --noEmit false`. The bare
  `tsc` command was a no-op because tsconfig.json sets noEmit:true
  for typecheck speed. Existing dist/ only existed because of an
  earlier emit; clean rebuilds were silently broken.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:42:57 +02:00

754 lines
25 KiB
TypeScript

import React, { useState, useRef, useEffect, useCallback } from 'react';
// Types
export interface ChatUser {
id: string;
name: string;
avatar?: string;
status?: 'online' | 'offline' | 'away' | 'busy';
lastSeen?: Date;
}
export interface ChatMessage {
id: string;
senderId: string;
content: string;
timestamp: Date;
type?: 'text' | 'image' | 'file' | 'system';
fileUrl?: string;
fileName?: string;
isRead?: boolean;
}
export interface ChatConversation {
id: string;
participants: ChatUser[];
lastMessage?: ChatMessage;
unreadCount?: number;
isGroup?: boolean;
groupName?: string;
groupAvatar?: string;
}
// Chat Layout
export interface ChatLayoutProps {
/** Sidebar content (conversations list) */
sidebar?: React.ReactNode;
/** Main chat area */
children: React.ReactNode;
/** Show sidebar on mobile */
showSidebar?: boolean;
/** Toggle sidebar handler */
onToggleSidebar?: () => void;
/** Additional CSS classes */
className?: string;
}
export const ChatLayout: React.FC<ChatLayoutProps> = ({
sidebar,
children,
showSidebar = true,
onToggleSidebar,
className = '',
}) => {
return (
<div className={`ll-chat-layout ${className}`}>
<div className={`ll-chat-sidebar ${showSidebar ? 'll-chat-sidebar-open' : ''}`}>
{sidebar}
</div>
<div className="ll-chat-main">
{onToggleSidebar && (
<button className="ll-chat-sidebar-toggle" onClick={onToggleSidebar}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />
</svg>
</button>
)}
{children}
</div>
</div>
);
};
// Chat Sidebar / Conversations List
export interface ChatConversationsListProps {
/** Conversations to display */
conversations: ChatConversation[];
/** Current user */
currentUser?: ChatUser;
/** Active conversation ID */
activeId?: string;
/** Conversation click handler */
onConversationClick?: (conversation: ChatConversation) => void;
/** Search value */
searchValue?: string;
/** Search change handler */
onSearchChange?: (value: string) => void;
/** New chat handler */
onNewChat?: () => void;
/** Additional CSS classes */
className?: string;
}
export const ChatConversationsList: React.FC<ChatConversationsListProps> = ({
conversations,
currentUser,
activeId,
onConversationClick,
searchValue = '',
onSearchChange,
onNewChat,
className = '',
}) => {
const getConversationName = (conversation: ChatConversation) => {
if (conversation.isGroup && conversation.groupName) {
return conversation.groupName;
}
const otherParticipant = conversation.participants.find(
(p) => p.id !== currentUser?.id
);
return otherParticipant?.name || 'Unknown';
};
const getConversationAvatar = (conversation: ChatConversation) => {
if (conversation.isGroup && conversation.groupAvatar) {
return conversation.groupAvatar;
}
const otherParticipant = conversation.participants.find(
(p) => p.id !== currentUser?.id
);
return otherParticipant?.avatar;
};
const getConversationStatus = (conversation: ChatConversation) => {
if (conversation.isGroup) return undefined;
const otherParticipant = conversation.participants.find(
(p) => p.id !== currentUser?.id
);
return otherParticipant?.status;
};
const formatTime = (date?: Date) => {
if (!date) return '';
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (days === 1) {
return 'Yesterday';
} else if (days < 7) {
return date.toLocaleDateString([], { weekday: 'short' });
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
};
const filteredConversations = conversations.filter((conv) => {
if (!searchValue) return true;
const name = getConversationName(conv).toLowerCase();
return name.includes(searchValue.toLowerCase());
});
return (
<div className={`ll-chat-conversations ${className}`}>
{currentUser && (
<div className="ll-chat-user-header">
<div className="ll-chat-user-avatar">
{currentUser.avatar ? (
<img src={currentUser.avatar} alt={currentUser.name} />
) : (
<span>{currentUser.name.charAt(0).toUpperCase()}</span>
)}
<span className={`ll-chat-status ll-chat-status-${currentUser.status || 'offline'}`} />
</div>
<div className="ll-chat-user-info">
<span className="ll-chat-user-name">{currentUser.name}</span>
<span className="ll-chat-user-status-text">{currentUser.status || 'Offline'}</span>
</div>
{onNewChat && (
<button className="ll-chat-new-btn" onClick={onNewChat} title="New chat">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z" />
</svg>
</button>
)}
</div>
)}
{onSearchChange && (
<div className="ll-chat-search">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
<input
type="text"
placeholder="Search conversations..."
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
)}
<div className="ll-chat-conversations-list">
{filteredConversations.map((conversation) => {
const name = getConversationName(conversation);
const avatar = getConversationAvatar(conversation);
const status = getConversationStatus(conversation);
return (
<div
key={conversation.id}
className={`ll-chat-conversation-item ${
activeId === conversation.id ? 'll-chat-conversation-active' : ''
} ${conversation.unreadCount ? 'll-chat-conversation-unread' : ''}`}
onClick={() => onConversationClick?.(conversation)}
>
<div className="ll-chat-conversation-avatar">
{avatar ? (
<img src={avatar} alt={name} />
) : (
<span>{name.charAt(0).toUpperCase()}</span>
)}
{status && (
<span className={`ll-chat-status ll-chat-status-${status}`} />
)}
</div>
<div className="ll-chat-conversation-content">
<div className="ll-chat-conversation-header">
<span className="ll-chat-conversation-name">{name}</span>
{conversation.lastMessage && (
<span className="ll-chat-conversation-time">
{formatTime(conversation.lastMessage.timestamp)}
</span>
)}
</div>
{conversation.lastMessage && (
<div className="ll-chat-conversation-preview">
{conversation.lastMessage.content}
</div>
)}
</div>
{conversation.unreadCount && conversation.unreadCount > 0 && (
<span className="ll-chat-conversation-badge">
{conversation.unreadCount > 99 ? '99+' : conversation.unreadCount}
</span>
)}
</div>
);
})}
{filteredConversations.length === 0 && (
<div className="ll-chat-conversations-empty">
No conversations found
</div>
)}
</div>
</div>
);
};
// Chat Window
export interface ChatWindowProps {
/** Messages to display */
messages: ChatMessage[];
/** Current user ID */
currentUserId: string;
/** Participants map (id -> user) */
participants: Record<string, ChatUser>;
/** Conversation info */
conversation?: ChatConversation;
/** Loading state */
loading?: boolean;
/** Typing indicator users */
typingUsers?: ChatUser[];
/** Message input value */
inputValue?: string;
/** Input change handler */
onInputChange?: (value: string) => void;
/** Send message handler */
onSendMessage?: (content: string) => void;
/** Back button handler */
onBack?: () => void;
/** Info button handler */
onInfo?: () => void;
/** Load more handler (infinite scroll) */
onLoadMore?: () => void;
/** Additional CSS classes */
className?: string;
}
export const ChatWindow: React.FC<ChatWindowProps> = ({
messages,
currentUserId,
participants,
conversation,
loading = false,
typingUsers = [],
inputValue = '',
onInputChange,
onSendMessage,
onBack,
onInfo,
onLoadMore,
className = '',
}) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [localInput, setLocalInput] = useState(inputValue);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
useEffect(() => {
setLocalInput(inputValue);
}, [inputValue]);
const handleInputChange = (value: string) => {
setLocalInput(value);
onInputChange?.(value);
};
const handleSend = () => {
if (localInput.trim()) {
onSendMessage?.(localInput.trim());
setLocalInput('');
onInputChange?.('');
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const formatTime = (date: Date) => {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const formatDate = (date: Date) => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return 'Today';
} else if (date.toDateString() === yesterday.toDateString()) {
return 'Yesterday';
} else {
return date.toLocaleDateString([], {
weekday: 'long',
month: 'long',
day: 'numeric',
});
}
};
const getOtherParticipant = () => {
if (!conversation) return null;
return conversation.participants.find((p) => p.id !== currentUserId);
};
const otherUser = getOtherParticipant();
// Group messages by date
const groupedMessages: { date: string; messages: ChatMessage[] }[] = [];
let currentDate = '';
messages.forEach((msg) => {
const dateStr = msg.timestamp.toDateString();
if (dateStr !== currentDate) {
currentDate = dateStr;
groupedMessages.push({ date: formatDate(msg.timestamp), messages: [] });
}
groupedMessages[groupedMessages.length - 1].messages.push(msg);
});
return (
<div className={`ll-chat-window ${className}`}>
<div className="ll-chat-window-header">
{onBack && (
<button className="ll-chat-window-back" onClick={onBack}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
)}
{conversation && (
<div className="ll-chat-window-info">
<div className="ll-chat-window-avatar">
{conversation.isGroup ? (
conversation.groupAvatar ? (
<img src={conversation.groupAvatar} alt={conversation.groupName} />
) : (
<span>{conversation.groupName?.charAt(0).toUpperCase()}</span>
)
) : otherUser?.avatar ? (
<img src={otherUser.avatar} alt={otherUser.name} />
) : (
<span>{otherUser?.name.charAt(0).toUpperCase()}</span>
)}
{!conversation.isGroup && otherUser?.status && (
<span className={`ll-chat-status ll-chat-status-${otherUser.status}`} />
)}
</div>
<div className="ll-chat-window-details">
<span className="ll-chat-window-name">
{conversation.isGroup ? conversation.groupName : otherUser?.name}
</span>
<span className="ll-chat-window-status">
{conversation.isGroup
? `${conversation.participants.length} participants`
: otherUser?.status === 'online'
? 'Online'
: otherUser?.lastSeen
? `Last seen ${formatTime(otherUser.lastSeen)}`
: 'Offline'}
</span>
</div>
</div>
)}
<div className="ll-chat-window-actions">
{onInfo && (
<button className="ll-chat-window-action" onClick={onInfo}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z" />
</svg>
</button>
)}
</div>
</div>
<div className="ll-chat-window-messages" ref={messagesContainerRef}>
{loading && (
<div className="ll-chat-loading">
<div className="ll-chat-loading-spinner" />
</div>
)}
{onLoadMore && messages.length > 0 && (
<button className="ll-chat-load-more" onClick={onLoadMore}>
Load earlier messages
</button>
)}
{groupedMessages.map((group, groupIndex) => (
<div key={groupIndex} className="ll-chat-message-group">
<div className="ll-chat-date-separator">
<span>{group.date}</span>
</div>
{group.messages.map((message, msgIndex) => {
const isOwn = message.senderId === currentUserId;
const sender = participants[message.senderId];
const showAvatar =
!isOwn &&
(msgIndex === 0 ||
group.messages[msgIndex - 1]?.senderId !== message.senderId);
return (
<div
key={message.id}
className={`ll-chat-message ${isOwn ? 'll-chat-message-own' : 'll-chat-message-other'}`}
>
{!isOwn && showAvatar && (
<div className="ll-chat-message-avatar">
{sender?.avatar ? (
<img src={sender.avatar} alt={sender.name} />
) : (
<span>{sender?.name?.charAt(0).toUpperCase() || '?'}</span>
)}
</div>
)}
{!isOwn && !showAvatar && <div className="ll-chat-message-avatar-placeholder" />}
<div className="ll-chat-message-content">
{!isOwn && showAvatar && conversation?.isGroup && (
<span className="ll-chat-message-sender">{sender?.name}</span>
)}
{message.type === 'image' && message.fileUrl && (
<img
src={message.fileUrl}
alt="Shared image"
className="ll-chat-message-image"
/>
)}
{message.type === 'file' && message.fileUrl && (
<a href={message.fileUrl} className="ll-chat-message-file" download>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" />
</svg>
<span>{message.fileName || 'File'}</span>
</a>
)}
{(message.type === 'text' || !message.type) && (
<div className="ll-chat-message-bubble">
{message.content}
</div>
)}
{message.type === 'system' && (
<div className="ll-chat-message-system">
{message.content}
</div>
)}
<span className="ll-chat-message-time">
{formatTime(message.timestamp)}
{isOwn && message.isRead && (
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M18 7l-1.41-1.41-6.34 6.34 1.41 1.41L18 7zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12-1.42-1.41zM.41 13.41L6 19l1.41-1.41L1.83 12 .41 13.41z" />
</svg>
)}
</span>
</div>
</div>
);
})}
</div>
))}
{typingUsers.length > 0 && (
<div className="ll-chat-typing">
<div className="ll-chat-typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
<span>
{typingUsers.map((u) => u.name).join(', ')} {typingUsers.length === 1 ? 'is' : 'are'} typing...
</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="ll-chat-window-input">
<button className="ll-chat-input-action" title="Attach file">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
</svg>
</button>
<textarea
placeholder="Type a message..."
value={localInput}
onChange={(e) => handleInputChange(e.target.value)}
onKeyPress={handleKeyPress}
rows={1}
/>
<button className="ll-chat-input-action" title="Emoji">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
</svg>
</button>
<button
className="ll-chat-send-btn"
onClick={handleSend}
disabled={!localInput.trim()}
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
</button>
</div>
</div>
);
};
// Chat User Info Panel
export interface ChatUserInfoProps {
/** User to display */
user: ChatUser;
/** Shared media (optional) */
sharedMedia?: { type: string; url: string; name?: string }[];
/** Close handler */
onClose?: () => void;
/** Block user handler */
onBlock?: () => void;
/** Delete conversation handler */
onDeleteConversation?: () => void;
/** Additional CSS classes */
className?: string;
}
export const ChatUserInfo: React.FC<ChatUserInfoProps> = ({
user,
sharedMedia = [],
onClose,
onBlock,
onDeleteConversation,
className = '',
}) => {
return (
<div className={`ll-chat-user-info-panel ${className}`}>
<div className="ll-chat-user-info-header">
<h3>Contact Info</h3>
{onClose && (
<button className="ll-chat-user-info-close" onClick={onClose}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</button>
)}
</div>
<div className="ll-chat-user-info-content">
<div className="ll-chat-user-info-avatar">
{user.avatar ? (
<img src={user.avatar} alt={user.name} />
) : (
<span>{user.name.charAt(0).toUpperCase()}</span>
)}
</div>
<h4 className="ll-chat-user-info-name">{user.name}</h4>
<span className={`ll-chat-user-info-status ll-chat-user-info-status-${user.status || 'offline'}`}>
{user.status || 'Offline'}
</span>
{sharedMedia.length > 0 && (
<div className="ll-chat-shared-media">
<h5>Shared Media</h5>
<div className="ll-chat-shared-media-grid">
{sharedMedia.slice(0, 9).map((media, index) => (
<a key={index} href={media.url} className="ll-chat-shared-media-item">
{media.type === 'image' ? (
<img src={media.url} alt={media.name || 'Shared media'} />
) : (
<div className="ll-chat-shared-file">
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" />
</svg>
</div>
)}
</a>
))}
</div>
</div>
)}
<div className="ll-chat-user-info-actions">
{onBlock && (
<button className="ll-chat-user-info-action ll-chat-user-info-action-danger" onClick={onBlock}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z" />
</svg>
Block User
</button>
)}
{onDeleteConversation && (
<button className="ll-chat-user-info-action ll-chat-user-info-action-danger" onClick={onDeleteConversation}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
Delete Conversation
</button>
)}
</div>
</div>
</div>
);
};
// Empty Chat State
export interface ChatEmptyStateProps {
/** Title */
title?: string;
/** Description */
description?: string;
/** Action button text */
actionText?: string;
/** Action handler */
onAction?: () => void;
/** Additional CSS classes */
className?: string;
}
export const ChatEmptyState: React.FC<ChatEmptyStateProps> = ({
title = 'No conversation selected',
description = 'Select a conversation from the list or start a new chat',
actionText,
onAction,
className = '',
}) => {
return (
<div className={`ll-chat-empty-state ${className}`}>
<div className="ll-chat-empty-icon">
<svg viewBox="0 0 24 24" width="64" height="64" fill="currentColor">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z" />
</svg>
</div>
<h3>{title}</h3>
<p>{description}</p>
{actionText && onAction && (
<button className="ll-chat-empty-action" onClick={onAction}>
{actionText}
</button>
)}
</div>
);
};
// Hook for chat state management
export interface UseChatOptions {
initialMessages?: ChatMessage[];
currentUserId: string;
}
export const useChat = ({ initialMessages = [], currentUserId }: UseChatOptions) => {
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [inputValue, setInputValue] = useState('');
const sendMessage = useCallback((content: string) => {
const newMessage: ChatMessage = {
id: Date.now().toString(),
senderId: currentUserId,
content,
timestamp: new Date(),
type: 'text',
};
setMessages((prev) => [...prev, newMessage]);
return newMessage;
}, [currentUserId]);
const addMessage = useCallback((message: ChatMessage) => {
setMessages((prev) => [...prev, message]);
}, []);
const markAsRead = useCallback((messageIds: string[]) => {
setMessages((prev) =>
prev.map((msg) =>
messageIds.includes(msg.id) ? { ...msg, isRead: true } : msg
)
);
}, []);
return {
messages,
inputValue,
setInputValue,
sendMessage,
addMessage,
markAsRead,
setMessages,
};
};