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>
This commit is contained in:
753
src/pages/Chat.tsx
Normal file
753
src/pages/Chat.tsx
Normal file
@@ -0,0 +1,753 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user