veza/apps/web/src/features/chat/components/ChatMessage.tsx
senke 4df02d1f80 feat(ui): complete chat interface overhaul
- Replaced all basic Tailwind components with Premium Glassmorphism design
- Implemented neon accents and custom scrollbars
- Added typing indicators and file upload UI polish
- Integrated Chat Page with the new Layout system
2026-01-11 03:20:52 +01:00

161 lines
No EOL
6.4 KiB
TypeScript

import React, { useState, lazy, Suspense } from 'react';
import { ChatMessage } from '../store/chatStore';
import { useAuthStore } from '@/features/auth/store/authStore';
import { cn } from '@/lib/utils';
import { Smile, MoreHorizontal, Check, CheckCheck } from 'lucide-react';
import { useChat } from '../hooks/useChat';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { Theme } from 'emoji-picker-react';
const EmojiPicker = lazy(() => import('emoji-picker-react').then(module => ({ default: module.default })));
interface ChatMessageProps {
message: ChatMessage;
}
export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
message,
}) => {
const { user } = useAuthStore();
const { addReaction } = useChat();
const isMe = user?.id === message.sender_id;
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const handleEmojiClick = (emojiData: { emoji: string }) => {
addReaction(message.id, emojiData.emoji);
setShowEmojiPicker(false);
};
return (
<div
className={cn(
'group flex flex-col gap-1 max-w-[80%] mb-4 relative',
isMe ? 'ml-auto items-end' : 'mr-auto items-start',
)}
>
<div className="flex items-center gap-2 px-1 mb-0.5">
<span className={cn(
"font-mono text-[10px] uppercase tracking-wider",
isMe ? "text-kodo-cyan" : "text-kodo-magenta"
)}>
{isMe ? 'You' : message.sender_username || 'Unknown_Signal'}
</span>
<span className="text-[9px] text-kodo-secondary/60">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="relative flex items-end gap-2 group/bubble">
{/* Emoji Button (Left for Me) */}
{isMe && (
<button
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className="opacity-0 group-hover/bubble:opacity-100 p-1.5 hover:bg-white/10 rounded-full transition-all text-kodo-secondary hover:text-kodo-cyan"
>
<Smile size={14} />
</button>
)}
{/* Message Bubble */}
<div
className={cn(
'px-4 py-2.5 rounded-2xl text-sm backdrop-blur-md shadow-lg transition-all',
isMe
? 'bg-kodo-cyan/10 border border-kodo-cyan/20 text-white rounded-tr-sm shadow-[0_0_15px_rgba(102,252,241,0.05)]'
: 'bg-white/5 border border-white/10 text-gray-100 rounded-tl-sm hover:bg-white/10',
)}
>
{/* Attachments */}
{message.attachments && message.attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{message.attachments.map((att, i) => (
<div key={i} className="max-w-full overflow-hidden rounded-lg border border-white/10 bg-black/20">
{att.file_type.startsWith('image') ? (
<img
src={att.file_url}
alt={att.file_name}
className="max-h-60 object-contain cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => window.open(att.file_url, '_blank')}
/>
) : (
<a
href={att.file_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 hover:bg-white/5 transition-colors"
>
<div className="w-8 h-8 rounded bg-white/10 flex items-center justify-center">
<MoreHorizontal size={16} className="text-kodo-cyan" />
</div>
<span className="truncate max-w-[150px] text-xs font-mono">{att.file_name}</span>
</a>
)}
</div>
))}
</div>
)}
<p className="whitespace-pre-wrap break-words leading-relaxed">{message.content}</p>
</div>
{/* Emoji Button (Right for Others) */}
{!isMe && (
<button
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className="opacity-0 group-hover/bubble:opacity-100 p-1.5 hover:bg-white/10 rounded-full transition-all text-kodo-secondary hover:text-kodo-cyan"
>
<Smile size={14} />
</button>
)}
{/* Emoji Picker Popover */}
{showEmojiPicker && (
<div className={cn(
"absolute z-50 bottom-full mb-2",
isMe ? "right-0" : "left-0"
)}>
<div className="fixed inset-0" onClick={() => setShowEmojiPicker(false)} />
<div className="relative shadow-2xl rounded-xl overflow-hidden border border-white/10 animate-scaleIn">
<Suspense fallback={<div className="w-[300px] h-[400px] bg-kodo-ink flex items-center justify-center"><LoadingSpinner size="sm" /></div>}>
<EmojiPicker
onEmojiClick={handleEmojiClick}
theme={Theme.DARK}
lazyLoadEmojis={true}
width={300}
height={400}
/>
</Suspense>
</div>
</div>
)}
</div>
{/* Footer (Reactions + Status) */}
<div className="flex items-center justify-between w-full px-1 mt-1">
<div className="flex flex-wrap gap-1">
{message.reactions && Object.entries(message.reactions).map(([emoji, users]) => (
<button
key={emoji}
onClick={() => addReaction(message.id, emoji)}
className={cn(
"flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] border transition-all animate-scaleIn",
users.includes(user?.id || '')
? "bg-kodo-cyan/20 border-kodo-cyan/40 text-kodo-cyan shadow-[0_0_10px_rgba(102,252,241,0.2)]"
: "bg-white/5 border-white/10 text-kodo-secondary hover:bg-white/10 hover:border-white/20"
)}
>
<span>{emoji}</span>
{users.length > 1 && <span className="font-bold">{users.length}</span>}
</button>
))}
</div>
{isMe && (
<div className="text-kodo-secondary/40 ml-auto">
<CheckCheck size={12} />
</div>
)}
</div>
</div>
);
};