diff --git a/apps/web/src/features/chat/components/ChatInput.tsx b/apps/web/src/features/chat/components/ChatInput.tsx index 0f68d9468..a5ea64394 100644 --- a/apps/web/src/features/chat/components/ChatInput.tsx +++ b/apps/web/src/features/chat/components/ChatInput.tsx @@ -1,15 +1,18 @@ import React, { useState, useCallback, useRef, useEffect, lazy, Suspense } from 'react'; -import { Send, Smile, Paperclip, X, Image as ImageIcon, File } from 'lucide-react'; +import { Send, Smile, Paperclip, X, Image as ImageIcon, File, Mic } from 'lucide-react'; import { useChat } from '../hooks/useChat'; import { useChatStore } from '../store/chatStore'; -// PERF: Lazy load EmojiPicker (composant volumineux ~200KB) -const EmojiPicker = lazy(() => import('emoji-picker-react').then(module => ({ default: module.default }))); import { Theme } from 'emoji-picker-react'; import { useDropzone } from 'react-dropzone'; import { apiClient } from '@/services/api/client'; import { MessageAttachment } from '../types'; import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { logger } from '@/utils/logger'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +// Lazy load +const EmojiPicker = lazy(() => import('emoji-picker-react').then(module => ({ default: module.default }))); export const ChatInput: React.FC = () => { const [message, setMessage] = useState(''); @@ -27,10 +30,7 @@ export const ChatInput: React.FC = () => { setMessage(''); setAttachments([]); - // Stop typing indicator immediately - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - } + if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); setTyping(false); } }; @@ -43,17 +43,14 @@ export const ChatInput: React.FC = () => { const uploadPromises = acceptedFiles.map(async (file) => { const formData = new FormData(); formData.append('file', file); - - // Use existing upload endpoint const response = await apiClient.post('/uploads', formData, { headers: { 'Content-Type': 'multipart/form-data' } }); - const data = response.data; return { file_name: file.name, file_type: file.type, - file_url: data.url, // Assuming backend returns { url: "..." } + file_url: data.url, file_size: file.size, } as MessageAttachment; }); @@ -62,8 +59,7 @@ export const ChatInput: React.FC = () => { setAttachments((prev) => [...prev, ...newAttachments]); } catch (error) { logger.error('Failed to upload files', { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, + error: error instanceof Error ? error.message : String(error) }); } finally { setIsUploading(false); @@ -72,7 +68,7 @@ export const ChatInput: React.FC = () => { const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, - noClick: true, // We want custom click on button + noClick: true, }); const handleEmojiClick = (emojiData: { emoji: string }) => { @@ -80,60 +76,52 @@ export const ChatInput: React.FC = () => { setShowEmojiPicker(false); }; - const handleFileButtonClick = () => { - fileInputRef.current?.click(); - }; - - const handleFileChange = (e: React.ChangeEvent) => { - if (e.target.files) { - onDrop(Array.from(e.target.files)); - } - }; - const removeAttachment = (index: number) => { setAttachments((prev) => prev.filter((_, i) => i !== index)); }; - // Typing indicator logic useEffect(() => { if (message.length > 0) { setTyping(true); - - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - } - + if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); typingTimeoutRef.current = setTimeout(() => { setTyping(false); - }, 3000); // Stop typing after 3 seconds of inactivity + }, 3000); } else { setTyping(false); } }, [message, setTyping]); return ( -
- +
+ - {/* File Previews */} + {/* Upload Overlay */} + {isDragActive && ( +
+
+
+ +
+

Initiate Data Transfer

+
+
+ )} + + {/* Attachments Preview */} {attachments.length > 0 && ( -
+
{attachments.map((att, i) => ( -
+
{att.file_type.startsWith('image') ? ( - + ) : ( - + )} - {att.file_name} + {att.file_name} @@ -142,46 +130,43 @@ export const ChatInput: React.FC = () => {
)} - {isDragActive && ( -
-

Déposez vos fichiers ici

-
- )} - -
+
- +
- + {showEmojiPicker && ( -
+
setShowEmojiPicker(false)} /> -
-
}> +
+
}>
@@ -190,32 +175,39 @@ export const ChatInput: React.FC = () => {
- setMessage(e.target.value)} - placeholder="Écrire un message..." - className="flex-1 p-2 bg-white border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all" - disabled={!currentConversationId || isUploading} - /> +
+ setMessage(e.target.value)} + placeholder="Broadcast message..." + className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder:text-kodo-secondary/50 focus:outline-none focus:border-kodo-cyan/50 focus:ring-1 focus:ring-kodo-cyan/50 transition-all font-mono text-sm" + disabled={!currentConversationId || isUploading} + /> + {message.length === 0 && !isUploading && ( +
+ +
+ )} +
- +
); -}; - -// Helper for class names since Lucide and ShadUI might be used -function cn(...classes: (string | boolean | undefined)[]) { - return classes.filter(Boolean).join(' '); -} +}; \ No newline at end of file diff --git a/apps/web/src/features/chat/components/ChatMessage.tsx b/apps/web/src/features/chat/components/ChatMessage.tsx index eb3c900f4..e40d12fa6 100644 --- a/apps/web/src/features/chat/components/ChatMessage.tsx +++ b/apps/web/src/features/chat/components/ChatMessage.tsx @@ -2,12 +2,12 @@ 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 } from 'lucide-react'; +import { Smile, MoreHorizontal, Check, CheckCheck } from 'lucide-react'; import { useChat } from '../hooks/useChat'; -// PERF: Lazy load EmojiPicker (composant volumineux ~200KB) -const EmojiPicker = lazy(() => import('emoji-picker-react').then(module => ({ default: module.default }))); -import { Theme } from 'emoji-picker-react'; 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; @@ -29,49 +29,52 @@ export const ChatMessageComponent: React.FC = ({ return (
-
- - {isMe - ? 'Moi' - : message.sender_username || `Utilisateur ${message.sender_id.slice(0, 8)}`} +
+ + {isMe ? 'You' : message.sender_username || 'Unknown_Signal'} - + {new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
-
+
+ {/* Emoji Button (Left for Me) */} {isMe && ( )} + {/* Message Bubble */}
{/* Attachments */} {message.attachments && message.attachments.length > 0 && (
{message.attachments.map((att, i) => ( -
+
{att.file_type.startsWith('image') ? ( {att.file_name} window.open(att.file_url, '_blank')} /> ) : ( @@ -79,10 +82,12 @@ export const ChatMessageComponent: React.FC = ({ href={att.file_url} target="_blank" rel="noopener noreferrer" - className="flex items-center gap-2 p-2 bg-gray-100 text-gray-800 rounded hover:bg-gray-200" + className="flex items-center gap-3 p-3 hover:bg-white/5 transition-colors" > - - {att.file_name} +
+ +
+ {att.file_name} )}
@@ -90,27 +95,34 @@ export const ChatMessageComponent: React.FC = ({
)} -

{message.content}

+

{message.content}

+ {/* Emoji Button (Right for Others) */} {!isMe && ( )} + {/* Emoji Picker Popover */} {showEmojiPicker && ( -
+
setShowEmojiPicker(false)} /> -
-
}> +
+
}>
@@ -118,30 +130,32 @@ export const ChatMessageComponent: React.FC = ({ )}
- {/* Reactions Display */} - {message.reactions && Object.keys(message.reactions).length > 0 && ( -
- {Object.entries(message.reactions).map(([emoji, users]) => ( + {/* Footer (Reactions + Status) */} +
+
+ {message.reactions && Object.entries(message.reactions).map(([emoji, users]) => ( ))}
- )} + + {isMe && ( +
+ +
+ )} +
); -}; +}; \ No newline at end of file diff --git a/apps/web/src/features/chat/components/ChatRoom.tsx b/apps/web/src/features/chat/components/ChatRoom.tsx index 61b3db0b7..180e47ae9 100644 --- a/apps/web/src/features/chat/components/ChatRoom.tsx +++ b/apps/web/src/features/chat/components/ChatRoom.tsx @@ -4,67 +4,68 @@ import { ChatMessageComponent } from './ChatMessage'; import { useChat } from '../hooks/useChat'; import { MessageSearch } from './MessageSearch'; import { TypingIndicator } from './TypingIndicator'; -import { Search, X } from 'lucide-react'; +import { Search, X, Wifi } from 'lucide-react'; import { Button } from '@/components/ui/button'; - -// FE-PAGE-005: Complete Chat page implementation +import { cn } from '@/lib/utils'; interface ChatRoomProps { conversationId: string; } export const ChatRoom: React.FC = ({ conversationId }) => { - const { messages } = useChatStore(); + const { messages, wsStatus } = useChatStore(); const { fetchHistory } = useChat(); const messagesEndRef = useRef(null); const [showSearch, setShowSearch] = useState(false); const [highlightedMessageId, setHighlightedMessageId] = useState(null); const currentMessages = messages[conversationId] || []; - - // FE-BUG-002: Use a ref to track if we've already tried fetching to avoid infinite loops on failure const fetchingRef = useRef<{ [key: string]: boolean }>({}); useEffect(() => { if (conversationId && !messages[conversationId] && !fetchingRef.current[conversationId]) { fetchingRef.current[conversationId] = true; fetchHistory(conversationId).finally(() => { - // We keep it true to avoid re-fetching the same ID in this session - // if it returned nothing, or we could reset it if we want to allow retry. - // For now, let's just make sure it doesn't loop. + // Fetch complete }); } }, [conversationId, messages[conversationId], fetchHistory]); useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [currentMessages]); + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [currentMessages.length, conversationId]); // Scroll on new messages or channel switch const handleMessageSelect = (messageId: string) => { setHighlightedMessageId(messageId); - // Scroll to message const messageElement = document.getElementById(`message-${messageId}`); if (messageElement) { messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); - // Remove highlight after 3 seconds setTimeout(() => setHighlightedMessageId(null), 3000); } }; if (!conversationId) { return ( -
- Sélectionnez une conversation pour commencer +
+
+ +
+

Awaiting Frequency Selection

); } return ( -
- {/* FE-PAGE-005: Message Search Bar */} -
+
+ {/* Search Header Overlay */} +
{showSearch ? ( -
+
= ({ conversationId }) => { variant="ghost" size="sm" onClick={() => setShowSearch(false)} + className="hover:bg-white/10" >
) : ( -
+
)}
-
- {currentMessages.length === 0 ? ( -
- Aucun message. Soyez le premier à envoyer un message ! +
+ {/* Welcome Message for Empty Room */} + {currentMessages.length === 0 && ( +
+
+ +
+
+

Channel Established

+

Begin transmission on this frequency.

+
- ) : ( - currentMessages.map((msg) => ( + )} + + {/* Message Stream */} + {currentMessages.map((msg, index) => { + const isMe = false; // TODO: Check with current user ID from store + const isSequence = index > 0 && currentMessages[index - 1].sender_id === msg.sender_id; + + return (
- )) - )} - {/* FE-PAGE-005: Typing Indicator */} + ); + })} + -
+
); -}; +}; \ No newline at end of file diff --git a/apps/web/src/features/chat/components/ChatSidebar.tsx b/apps/web/src/features/chat/components/ChatSidebar.tsx index 9a9b1be01..7ed671285 100644 --- a/apps/web/src/features/chat/components/ChatSidebar.tsx +++ b/apps/web/src/features/chat/components/ChatSidebar.tsx @@ -4,7 +4,7 @@ import { useAuthStore } from '@/features/auth/store/authStore'; import { apiClient } from '@/services/api/client'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { cn } from '@/lib/utils'; -import { Loader2, Plus, Trash2, LogOut } from 'lucide-react'; +import { Loader2, Plus, Trash2, LogOut, MessageSquare, Hash, User, MoreVertical } from 'lucide-react'; import { CreateRoomDialog } from './CreateRoomDialog'; import { useToast } from '@/hooks/useToast'; import { Button } from '@/components/ui/button'; @@ -14,13 +14,10 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { MoreVertical } from 'lucide-react'; import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'; -// FE-PAGE-005: Complete Chat page implementation - Room Management - interface ConversationItemProps { - conversation: { id: string; name: string; type: string }; + conversation: { id: string; name: string; type: string; unread_count?: number }; onSelect: (id: string) => void; isSelected: boolean; } @@ -90,44 +87,81 @@ const ConversationItem: React.FC = ({
onSelect(conversation.id)} className={cn( - 'flex items-center justify-between p-3 rounded-lg cursor-pointer transition-colors group', - isSelected ? 'bg-blue-100 text-blue-800' : 'hover:bg-gray-100', + 'group relative flex items-center justify-between p-3 rounded-xl cursor-pointer transition-all duration-300 border border-transparent', + isSelected + ? 'bg-kodo-cyan/10 border-kodo-cyan/30 shadow-[0_0_15px_rgba(102,252,241,0.1)]' + : 'hover:bg-white/5 hover:border-white/5' )} > - - {conversation.name || `Conversation ${conversation.id.substring(0, 8)}`} - +
+
+ {conversation.type === 'direct' ? : } +
+ +
+ + {conversation.name || `Channel ${conversation.id.substring(0, 4)}`} + + {conversation.type !== 'direct' && ( + + {conversation.type} + + )} +
+
+ + {conversation.unread_count && conversation.unread_count > 0 ? ( + + {conversation.unread_count} + + ) : null} + e.stopPropagation()}> - - + + - Leave Room + Leave Channel {conversation.type !== 'direct' && ( - + - Delete Room + Delete Channel )} + + {/* Active Indicator Line */} + {isSelected && ( +
+ )}
+ setShowLeaveDialog(false)} onConfirm={confirmLeave} - title="Leave Room" - description="Are you sure you want to leave this room? You will no longer receive messages from this conversation." - confirmLabel="Leave" + title="Leave Channel" + description="Disconnect from this secure frequency? Incoming transmission will cease." + confirmLabel="Disconnect" cancelLabel="Cancel" variant="default" isLoading={leaveRoomMutation.isPending} @@ -136,9 +170,9 @@ const ConversationItem: React.FC = ({ open={showDeleteDialog} onClose={() => setShowDeleteDialog(false)} onConfirm={confirmDelete} - title="Delete Room" - description="Are you sure you want to delete this room? This action cannot be undone. All messages and participants will be removed." - confirmLabel="Delete" + title="Delete Channel" + description="Permanently purge this channel from the network? This action is irreversible." + confirmLabel="Purge" cancelLabel="Cancel" variant="destructive" isLoading={deleteRoomMutation.isPending} @@ -158,7 +192,6 @@ export const ChatSidebar: React.FC = () => { } = useChatStore(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - // Fetch conversations from backend const { data, isLoading, error } = useQuery({ queryKey: ['chatConversations', userId], queryFn: async () => { @@ -172,7 +205,6 @@ export const ChatSidebar: React.FC = () => { useEffect(() => { if (data) { data.forEach((conv: any) => { - // Only call addConversation if not already in store to avoid re-render trigger if (!conversations.some(c => c.id === conv.id)) { addConversation({ id: conv.id, @@ -188,30 +220,40 @@ export const ChatSidebar: React.FC = () => { if (isLoading) { return ( -
- +
+
); } if (error) { return ( -
- Erreur:{' '} - {(error as any).message || 'Impossible de charger les conversations'} +
+

Signal Lost

); } return ( -
-
-

Conversations

+
+
+
+

+ + Active Channels +

+ + {conversations.length} + +
-
+ +
{conversations.length === 0 ? ( -
- Aucune conversation. Créez-en une ! +
+ No active frequencies detected. +
+ Initialize a new channel.
) : ( conversations.map((conv) => ( @@ -224,14 +266,15 @@ export const ChatSidebar: React.FC = () => { )) )}
-
+ +
{ />
); -}; +}; \ No newline at end of file diff --git a/apps/web/src/features/chat/pages/ChatPage.tsx b/apps/web/src/features/chat/pages/ChatPage.tsx index 0d2f01e73..b7c135143 100644 --- a/apps/web/src/features/chat/pages/ChatPage.tsx +++ b/apps/web/src/features/chat/pages/ChatPage.tsx @@ -6,18 +6,16 @@ import { useChatStore } from '../store/chatStore'; import { useAuthStore } from '@/features/auth/store/authStore'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/services/api/client'; -import { useChat } from '../hooks/useChat'; -import { Loader2 } from 'lucide-react'; +import { Loader2, AlertCircle } from 'lucide-react'; import { env } from '@/config/env'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; -// This page needs to fetch the WS token first export const ChatPage: React.FC = () => { const { user, isAuthenticated } = useAuthStore(); - const userId = user?.id; // Derived + const userId = user?.id; const { setWsToken, currentConversationId, wsStatus } = useChatStore(); - const { disconnect: _disconnect } = useChat(); // disconnect available but unused here - // CRITIQUE FIX #52: Fetch WS Token avec cache pour éviter les requêtes multiples const { data: wsTokenResponse, isLoading: isTokenLoading, @@ -29,18 +27,15 @@ export const ChatPage: React.FC = () => { const response = await apiClient.post('/chat/token', {}); return response.data; }, - enabled: isAuthenticated && !!userId && wsStatus === 'disconnected', // Only fetch if authenticated and not connected + enabled: isAuthenticated && !!userId && wsStatus === 'disconnected', refetchOnWindowFocus: false, retry: false, - staleTime: 5 * 60 * 1000, // CRITIQUE FIX #52: Cache le token pendant 5 minutes pour éviter les requêtes multiples - gcTime: 10 * 60 * 1000, // Garder en cache pendant 10 minutes + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, }); useEffect(() => { if (wsTokenResponse?.token) { - // FE-BUG-001: Check if values actually changed to avoid infinite loop - // useChat already has an internal useEffect that calls connect() when wsToken/wsUrl change - // Use env.WS_URL instead of API response ws_url which is just a relative path const needsUpdate = wsTokenResponse.token !== useChatStore.getState().wsToken || env.WS_URL !== useChatStore.getState().wsUrl; @@ -48,42 +43,77 @@ export const ChatPage: React.FC = () => { setWsToken(wsTokenResponse.token, env.WS_URL); } } - }, [wsTokenResponse, setWsToken]); // connect removed from dependencies to avoid loop via useChat internal status changes + }, [wsTokenResponse, setWsToken]); if (!isAuthenticated) { return ( -
- Vous devez être connecté pour utiliser le chat. +
+
+ +

Access Restricted

+

Authorization required to access secure communication channels.

+ +
); } if (isTokenLoading || wsStatus === 'connecting') { return ( -
- -

Chargement du chat...

+
+
+
+
+
+
+
+

+ Establishing Uplink... +

); } if (tokenError) { return ( -
- Erreur:{' '} - {(tokenError as any).message || - 'Impossible de récupérer le token du chat'} +
+ +

Connection Failure

+

+ {(tokenError as any).message || 'Unable to retrieve secure token via Handshake Protocol.'} +

); } return ( -
- -
- - +
+ {/* Sidebar Container */} +
+ +
+ + {/* Chat Area Container */} +
+ {/* Background Grid Effect */} +
+ +
+ +
+ +
+ +
); -}; +}; \ No newline at end of file