From e793cb0cc56d0b12fe47f26ddd9b2562f607167b Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 14 Jun 2025 14:27:50 -0400 Subject: [PATCH] Build out chat more --- inventory-server/chat/routes.js | 439 +++++++++++++++- inventory/src/components/chat/ChatRoom.tsx | 470 ++++++++++++++++++ inventory/src/components/chat/RoomList.tsx | 251 ++++++++++ .../src/components/chat/SearchResults.tsx | 113 +++++ inventory/src/pages/Chat.tsx | 258 ++++++---- inventory/tsconfig.json | 3 +- 6 files changed, 1417 insertions(+), 117 deletions(-) create mode 100644 inventory/src/components/chat/ChatRoom.tsx create mode 100644 inventory/src/components/chat/RoomList.tsx create mode 100644 inventory/src/components/chat/SearchResults.tsx diff --git a/inventory-server/chat/routes.js b/inventory-server/chat/routes.js index bcf802e..b0d366a 100644 --- a/inventory-server/chat/routes.js +++ b/inventory-server/chat/routes.js @@ -1,14 +1,156 @@ const express = require('express'); +const path = require('path'); const router = express.Router(); +// Serve uploaded files with proper mapping from database paths to actual file locations +router.get('/files/uploads/*', async (req, res) => { + try { + // Extract the path from the URL (everything after /files/uploads/) + const requestPath = req.params[0]; + + // The URL path will be like: ufs/AmazonS3:Uploads/274Mf9CyHNG72oF86/filename.jpg + // We need to extract the mongo_id (274Mf9CyHNG72oF86) from this path + const pathParts = requestPath.split('/'); + let mongoId = null; + + // Find the mongo_id in the path structure + for (let i = 0; i < pathParts.length; i++) { + if (pathParts[i].includes('AmazonS3:Uploads') && i + 1 < pathParts.length) { + mongoId = pathParts[i + 1]; + break; + } + // Sometimes the mongo_id might be the last part of ufs/AmazonS3:Uploads/mongoId + if (pathParts[i] === 'AmazonS3:Uploads' && i + 1 < pathParts.length) { + mongoId = pathParts[i + 1]; + break; + } + } + + if (!mongoId) { + // Try to get mongo_id from database by matching the full path + const result = await global.pool.query(` + SELECT mongo_id, name, type + FROM uploads + WHERE path = $1 OR url = $1 + LIMIT 1 + `, [`/ufs/AmazonS3:Uploads/${requestPath}`, `/ufs/AmazonS3:Uploads/${requestPath}`]); + + if (result.rows.length > 0) { + mongoId = result.rows[0].mongo_id; + } + } + + if (!mongoId) { + return res.status(404).json({ error: 'File not found' }); + } + + // The actual file is stored with just the mongo_id as filename + const filePath = path.join(__dirname, 'db-convert/db/files/uploads', mongoId); + + // Get file info from database for proper content-type + const fileInfo = await global.pool.query(` + SELECT name, type + FROM uploads + WHERE mongo_id = $1 + LIMIT 1 + `, [mongoId]); + + if (fileInfo.rows.length === 0) { + return res.status(404).json({ error: 'File metadata not found' }); + } + + const { name, type } = fileInfo.rows[0]; + + // Set proper content type + if (type) { + res.set('Content-Type', type); + } + + // Set content disposition with original filename + if (name) { + res.set('Content-Disposition', `inline; filename="${name}"`); + } + + // Send the file + res.sendFile(filePath, (err) => { + if (err) { + console.error('Error serving file:', err); + if (!res.headersSent) { + res.status(404).json({ error: 'File not found on disk' }); + } + } + }); + + } catch (error) { + console.error('Error serving upload:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Also serve files directly by mongo_id for simpler access +router.get('/files/by-id/:mongoId', async (req, res) => { + try { + const { mongoId } = req.params; + + // Get file info from database + const fileInfo = await global.pool.query(` + SELECT name, type + FROM uploads + WHERE mongo_id = $1 + LIMIT 1 + `, [mongoId]); + + if (fileInfo.rows.length === 0) { + return res.status(404).json({ error: 'File not found' }); + } + + const { name, type } = fileInfo.rows[0]; + const filePath = path.join(__dirname, 'db-convert/db/files/uploads', mongoId); + + // Set proper content type and filename + if (type) { + res.set('Content-Type', type); + } + + if (name) { + res.set('Content-Disposition', `inline; filename="${name}"`); + } + + // Send the file + res.sendFile(filePath, (err) => { + if (err) { + console.error('Error serving file:', err); + if (!res.headersSent) { + res.status(404).json({ error: 'File not found on disk' }); + } + } + }); + + } catch (error) { + console.error('Error serving upload by ID:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Serve avatars +router.use('/files/avatars', express.static(path.join(__dirname, 'db-convert/db/files/avatars'))); + // Get all active users for the "view as" dropdown router.get('/users', async (req, res) => { try { const result = await global.pool.query(` - SELECT id, username, name, type, active + SELECT id, username, name, type, active, status, lastlogin, + statustext, utcoffset, statusconnection FROM users WHERE active = true AND type = 'user' - ORDER BY name ASC + ORDER BY + CASE + WHEN status = 'online' THEN 1 + WHEN status = 'away' THEN 2 + WHEN status = 'busy' THEN 3 + ELSE 4 + END, + name ASC `); res.json({ @@ -25,21 +167,54 @@ router.get('/users', async (req, res) => { } }); -// Get rooms for a specific user +// Get rooms for a specific user with enhanced room names for direct messages router.get('/users/:userId/rooms', async (req, res) => { const { userId } = req.params; try { - // Get rooms where the user is a member + // Get the current user's mongo_id for filtering + const userResult = await global.pool.query(` + SELECT mongo_id, username FROM users WHERE id = $1 + `, [userId]); + + if (userResult.rows.length === 0) { + return res.status(404).json({ + status: 'error', + error: 'User not found' + }); + } + + const currentUserMongoId = userResult.rows[0].mongo_id; + const currentUsername = userResult.rows[0].username; + + // Get rooms where the user is a member with proper naming from subscription table const result = await global.pool.query(` - SELECT DISTINCT r.id, r.name, r.fname, r.t as type, r.msgs, r.lm as last_message_date - FROM room r, subscription s - WHERE s.rid = r.mongo_id - AND s.u->>'_id' = (SELECT mongo_id FROM users WHERE id = $1) + SELECT DISTINCT + r.id, + r.mongo_id as room_mongo_id, + r.name, + r.fname, + r.t as type, + r.msgs, + r.lm as last_message_date, + r.usernames, + r.uids, + r.userscount, + r.description, + -- Use the subscription's name for direct messages (excludes current user) + -- For channels/groups, use room's fname or name + CASE + WHEN r.t = 'd' THEN COALESCE(s.fname, s.name, 'Unknown User') + ELSE COALESCE(r.fname, r.name, 'Unnamed Room') + END as display_name + FROM room r + JOIN subscription s ON s.rid = r.mongo_id + WHERE s.u->>'_id' = $1 AND r.archived IS NOT TRUE + AND s.open = true ORDER BY r.lm DESC NULLS LAST LIMIT 50 - `, [userId]); + `, [currentUserMongoId]); res.json({ status: 'success', @@ -55,24 +230,87 @@ router.get('/users/:userId/rooms', async (req, res) => { } }); -// Get messages for a specific room -router.get('/rooms/:roomId/messages', async (req, res) => { +// Get room details including participants +router.get('/rooms/:roomId', async (req, res) => { const { roomId } = req.params; - const { limit = 50, offset = 0 } = req.query; try { const result = await global.pool.query(` - SELECT m.id, m.msg, m.ts, m.u, m._updatedat - FROM message m - JOIN room r ON m.rid = r.mongo_id + SELECT r.id, r.name, r.fname, r.t as type, r.msgs, r.description, + r.lm as last_message_date, r.usernames, r.uids, r.userscount + FROM room r WHERE r.id = $1 - ORDER BY m.ts DESC - LIMIT $2 OFFSET $3 - `, [roomId, limit, offset]); + `, [roomId]); + + if (result.rows.length === 0) { + return res.status(404).json({ + status: 'error', + error: 'Room not found' + }); + } + + const room = result.rows[0]; + + // Get room participants for direct messages + if (room.type === 'd' && room.uids) { + const participantResult = await global.pool.query(` + SELECT username, name + FROM users + WHERE mongo_id = ANY($1::text[]) + `, [room.uids]); + + room.participants = participantResult.rows; + } res.json({ status: 'success', - messages: result.rows + room: room + }); + } catch (error) { + console.error('Error fetching room details:', error); + res.status(500).json({ + status: 'error', + error: 'Failed to fetch room details', + details: error.message + }); + } +}); + +// Get messages for a specific room (fast, without attachments) +router.get('/rooms/:roomId/messages', async (req, res) => { + const { roomId } = req.params; + const { limit = 50, offset = 0, before } = req.query; + + try { + // Fast query - just get messages without expensive attachment joins + let query = ` + SELECT m.id, m.msg, m.ts, m.u, m._updatedat, m.urls, m.mentions, m.md + FROM message m + JOIN room r ON m.rid = r.mongo_id + WHERE r.id = $1 + `; + + const params = [roomId]; + + if (before) { + query += ` AND m.ts < $${params.length + 1}`; + params.push(before); + } + + query += ` ORDER BY m.ts DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`; + params.push(limit, offset); + + const result = await global.pool.query(query, params); + + // Add empty attachments array for now - attachments will be loaded separately if needed + const messages = result.rows.map(msg => ({ + ...msg, + attachments: [] + })); + + res.json({ + status: 'success', + messages: messages.reverse() // Reverse to show oldest first }); } catch (error) { console.error('Error fetching messages:', error); @@ -84,4 +322,167 @@ router.get('/rooms/:roomId/messages', async (req, res) => { } }); +// Get attachments for specific messages (called separately for performance) +router.post('/messages/attachments', async (req, res) => { + const { messageIds } = req.body; + + if (!messageIds || !Array.isArray(messageIds) || messageIds.length === 0) { + return res.json({ status: 'success', attachments: {} }); + } + + try { + // Get room mongo_id from first message to limit search scope + const roomQuery = await global.pool.query(` + SELECT r.mongo_id as room_mongo_id + FROM message m + JOIN room r ON m.rid = r.mongo_id + WHERE m.id = $1 + LIMIT 1 + `, [messageIds[0]]); + + if (roomQuery.rows.length === 0) { + return res.json({ status: 'success', attachments: {} }); + } + + const roomMongoId = roomQuery.rows[0].room_mongo_id; + + // Get messages and their upload timestamps + const messagesQuery = await global.pool.query(` + SELECT m.id, m.ts, m.u->>'_id' as user_id + FROM message m + WHERE m.id = ANY($1::int[]) + `, [messageIds]); + + if (messagesQuery.rows.length === 0) { + return res.json({ status: 'success', attachments: {} }); + } + + // Build a map of user_id -> array of message timestamps for efficient lookup + const userTimeMap = {}; + const messageMap = {}; + messagesQuery.rows.forEach(msg => { + if (!userTimeMap[msg.user_id]) { + userTimeMap[msg.user_id] = []; + } + userTimeMap[msg.user_id].push(msg.ts); + messageMap[msg.id] = { ts: msg.ts, user_id: msg.user_id }; + }); + + // Get attachments for this room and these users + const uploadsQuery = await global.pool.query(` + SELECT mongo_id, name, size, type, url, path, typegroup, identify, + userid, uploadedat + FROM uploads + WHERE rid = $1 + AND userid = ANY($2::text[]) + ORDER BY uploadedat + `, [roomMongoId, Object.keys(userTimeMap)]); + + // Match attachments to messages based on timestamp proximity (within 5 minutes) + const attachmentsByMessage = {}; + + uploadsQuery.rows.forEach(upload => { + const uploadTime = new Date(upload.uploadedat).getTime(); + + // Find the closest message from this user within 5 minutes + let closestMessageId = null; + let closestTimeDiff = Infinity; + + Object.entries(messageMap).forEach(([msgId, msgData]) => { + if (msgData.user_id === upload.userid) { + const msgTime = new Date(msgData.ts).getTime(); + const timeDiff = Math.abs(uploadTime - msgTime); + + if (timeDiff < 300000 && timeDiff < closestTimeDiff) { // 5 minutes = 300000ms + closestMessageId = msgId; + closestTimeDiff = timeDiff; + } + } + }); + + if (closestMessageId) { + if (!attachmentsByMessage[closestMessageId]) { + attachmentsByMessage[closestMessageId] = []; + } + + attachmentsByMessage[closestMessageId].push({ + id: upload.id, + mongo_id: upload.mongo_id, + name: upload.name, + size: upload.size, + type: upload.type, + url: upload.url, + path: upload.path, + typegroup: upload.typegroup, + identify: upload.identify + }); + } + }); + + res.json({ + status: 'success', + attachments: attachmentsByMessage + }); + + } catch (error) { + console.error('Error fetching message attachments:', error); + res.status(500).json({ + status: 'error', + error: 'Failed to fetch attachments', + details: error.message + }); + } +}); + +// Search messages in accessible rooms for a user +router.get('/users/:userId/search', async (req, res) => { + const { userId } = req.params; + const { q, limit = 20 } = req.query; + + if (!q || q.length < 2) { + return res.status(400).json({ + status: 'error', + error: 'Search query must be at least 2 characters' + }); + } + + try { + const userResult = await global.pool.query(` + SELECT mongo_id FROM users WHERE id = $1 + `, [userId]); + + if (userResult.rows.length === 0) { + return res.status(404).json({ + status: 'error', + error: 'User not found' + }); + } + + const currentUserMongoId = userResult.rows[0].mongo_id; + + const result = await global.pool.query(` + SELECT m.id, m.msg, m.ts, m.u, r.id as room_id, r.name as room_name, r.fname as room_fname, r.t as room_type + FROM message m + JOIN room r ON m.rid = r.mongo_id + JOIN subscription s ON s.rid = r.mongo_id AND s.u->>'_id' = $1 + WHERE m.msg ILIKE $2 + AND r.archived IS NOT TRUE + ORDER BY m.ts DESC + LIMIT $3 + `, [currentUserMongoId, `%${q}%`, limit]); + + res.json({ + status: 'success', + results: result.rows + }); + } catch (error) { + console.error('Error searching messages:', error); + res.status(500).json({ + status: 'error', + error: 'Failed to search messages', + details: error.message + }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/inventory/src/components/chat/ChatRoom.tsx b/inventory/src/components/chat/ChatRoom.tsx new file mode 100644 index 0000000..aa07a01 --- /dev/null +++ b/inventory/src/components/chat/ChatRoom.tsx @@ -0,0 +1,470 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Loader2, Hash, Lock, MessageSquare, ChevronUp, Search, ExternalLink, FileText, Image, Download } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import config from '@/config'; + +interface Message { + id: number; + msg: string; + ts: string; + u: { + _id: string; + username: string; + name?: string; + }; + _updatedat: string; + urls?: any[]; + mentions?: any[]; + md?: any[]; + attachments?: { + id: number; + mongo_id: string; + name: string; + size: number; + type: string; + url: string; + path: string; + typegroup: string; + identify?: { + size?: { width: number; height: number }; + format?: string; + }; + }[]; +} + +interface Room { + id: number; + name: string; + fname: string; + type: string; + msgs: number; + last_message_date: string; + display_name: string; + description?: string; + participants?: { username: string; name: string }[]; +} + +interface ChatRoomProps { + roomId: string; + selectedUserId: string; +} + +export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) { + const [room, setRoom] = useState(null); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [showSearch, setShowSearch] = useState(false); + + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + const fetchRoom = async () => { + try { + const response = await fetch(`${config.chatUrl}/rooms/${roomId}`); + const data = await response.json(); + + if (data.status === 'success') { + setRoom(data.room); + } else { + throw new Error(data.error || 'Failed to fetch room'); + } + } catch (err) { + console.error('Error fetching room:', err); + setError(err instanceof Error ? err.message : 'An error occurred'); + } + }; + + const fetchMessages = async (before?: string, append = false) => { + if (!append) setLoading(true); + else setLoadingMore(true); + + try { + const params = new URLSearchParams({ + limit: '50', + offset: append ? messages.length.toString() : '0' + }); + + if (before) { + params.set('before', before); + } + + const response = await fetch(`${config.chatUrl}/rooms/${roomId}/messages?${params}`); + const data = await response.json(); + + if (data.status === 'success') { + const newMessages = data.messages; + + if (append) { + // Prepend older messages + setMessages(prev => [...newMessages, ...prev]); + setHasMore(newMessages.length === 50); + } else { + setMessages(newMessages); + setHasMore(newMessages.length === 50); + // Scroll to bottom on initial load + setTimeout(scrollToBottom, 100); + } + + // Load attachments for these messages in the background (non-blocking) + if (newMessages.length > 0) { + loadAttachments(newMessages.map((m: Message) => m.id)); + } + } else { + throw new Error(data.error || 'Failed to fetch messages'); + } + } catch (err) { + console.error('Error fetching messages:', err); + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setLoading(false); + setLoadingMore(false); + } + }; + + const loadAttachments = async (messageIds: number[]) => { + try { + const response = await fetch(`${config.chatUrl}/messages/attachments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ messageIds }), + }); + + const data = await response.json(); + + if (data.status === 'success' && data.attachments) { + // Update messages with their attachments + setMessages(prevMessages => + prevMessages.map(msg => ({ + ...msg, + attachments: data.attachments[msg.id] || [] + })) + ); + } + } catch (err) { + console.error('Error loading attachments:', err); + // Don't show error to user for attachments - messages are already displayed + } + }; + + const loadMoreMessages = () => { + if (messages.length > 0 && hasMore && !loadingMore) { + const oldestMessage = messages[0]; + fetchMessages(oldestMessage.ts, true); + } + }; + + const searchMessages = async () => { + if (!searchQuery || searchQuery.length < 2) return; + + try { + const response = await fetch( + `${config.chatUrl}/users/${selectedUserId}/search?q=${encodeURIComponent(searchQuery)}&limit=20` + ); + const data = await response.json(); + + if (data.status === 'success') { + setSearchResults(data.results); + } + } catch (err) { + console.error('Error searching messages:', err); + } + }; + + useEffect(() => { + if (roomId && selectedUserId) { + setMessages([]); + setError(null); + setHasMore(true); + fetchRoom(); + fetchMessages(); + } + }, [roomId, selectedUserId]); + + const getRoomIcon = (roomType: string) => { + switch (roomType) { + case 'c': + return ; + case 'p': + return ; + case 'd': + return ; + default: + return ; + } + }; + + const formatTime = (timestamp: string) => { + const date = new Date(timestamp); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + + if (isToday) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } else { + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + }; + + const renderURLPreviews = (urls: any[]) => { + if (!urls || urls.length === 0) return null; + + return ( +
+ {urls.map((urlData, index) => ( +
+
+ +
+ + {urlData.url} + + {urlData.meta?.pageTitle && ( +
{urlData.meta.pageTitle}
+ )} + {urlData.meta?.ogDescription && ( +
{urlData.meta.ogDescription}
+ )} +
+
+
+ ))} +
+ ); + }; + + const renderAttachments = (attachments: any[]) => { + if (!attachments || attachments.length === 0) return null; + + return ( +
+ {attachments.map((attachment, index) => { + const isImage = attachment.typegroup === 'image'; + const filePath = `${config.chatUrl}/files/by-id/${attachment.mongo_id}`; + + return ( +
+
+ {isImage ? ( + + ) : ( + + )} +
+
{attachment.name}
+
+ {(attachment.size / 1024).toFixed(1)} KB + {attachment.identify?.size && ( + • {attachment.identify.size.width}×{attachment.identify.size.height} + )} +
+
+ +
+ {isImage && attachment.identify?.size && ( +
+ {attachment.name} +
+ )} +
+ ); + })} +
+ ); + }; + + const renderMentions = (text: string, mentions: any[]) => { + if (!mentions || mentions.length === 0) return text; + + let renderedText = text; + mentions.forEach((mention) => { + if (mention.username) { + const mentionPattern = new RegExp(`@${mention.username}`, 'g'); + renderedText = renderedText.replace( + mentionPattern, + `@${mention.username}` + ); + } + }); + + return ; + }; + + const renderMessage = (message: Message, index: number) => { + const prevMessage = index > 0 ? messages[index - 1] : null; + const isConsecutive = prevMessage && + prevMessage.u.username === message.u.username && + new Date(message.ts).getTime() - new Date(prevMessage.ts).getTime() < 300000; // 5 minutes + + return ( +
+ {!isConsecutive && ( +
+
+ {(message.u.name || message.u.username).charAt(0).toUpperCase()} +
+ + {message.u.name || message.u.username} + + + {formatTime(message.ts)} + +
+ )} +
+
+ {message.mentions && message.mentions.length > 0 + ? renderMentions(message.msg, message.mentions) + : message.msg + } +
+ {message.urls && renderURLPreviews(message.urls)} + {message.attachments && renderAttachments(message.attachments)} +
+
+ ); + }; + + if (!roomId) { + return ( + + +

Select a room to view messages

+
+
+ ); + } + + if (loading && messages.length === 0) { + return ( + + +
+ + Loading messages... +
+
+
+ ); + } + + if (error) { + return ( + + +

{error}

+
+
+ ); + } + + return ( + + +
+
+ {room && getRoomIcon(room.type)} +
+ + {room?.display_name || room?.fname || room?.name || 'Unnamed Room'} + + {room?.description && ( +

{room.description}

+ )} + {room?.participants && room.participants.length > 0 && ( +

+ {room.participants.map(p => p.name || p.username).join(', ')} +

+ )} +
+
+ +
+ + {room?.msgs || 0} messages +
+
+ + {showSearch && ( +
+ setSearchQuery(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && searchMessages()} + /> + +
+ )} +
+ + +
+ {hasMore && messages.length > 0 && ( +
+ +
+ )} + + {messages.length === 0 ? ( +
+ No messages in this room +
+ ) : ( +
+ {messages.map((message, index) => renderMessage(message, index))} +
+ )} + +
+
+ + + ); +} \ No newline at end of file diff --git a/inventory/src/components/chat/RoomList.tsx b/inventory/src/components/chat/RoomList.tsx new file mode 100644 index 0000000..b257175 --- /dev/null +++ b/inventory/src/components/chat/RoomList.tsx @@ -0,0 +1,251 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, Hash, Lock, Users, MessageSquare, Search } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import config from '@/config'; + +interface Room { + id: number; + name: string; + fname: string; + type: string; + msgs: number; + last_message_date: string; + display_name: string; + userscount?: number; + description?: string; +} + +interface RoomListProps { + selectedUserId: string; + selectedRoomId: string | null; + onRoomSelect: (roomId: string) => void; +} + +export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomListProps) { + const [rooms, setRooms] = useState([]); + const [filteredRooms, setFilteredRooms] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [searchFilter, setSearchFilter] = useState(''); + + useEffect(() => { + if (!selectedUserId) { + setRooms([]); + setFilteredRooms([]); + return; + } + + const fetchUserRooms = async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch(`${config.chatUrl}/users/${selectedUserId}/rooms`); + const data = await response.json(); + + if (data.status === 'success') { + setRooms(data.rooms); + setFilteredRooms(data.rooms); + } else { + throw new Error(data.error || 'Failed to fetch rooms'); + } + } catch (err) { + console.error('Error fetching user rooms:', err); + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setLoading(false); + } + }; + + fetchUserRooms(); + }, [selectedUserId]); + + useEffect(() => { + if (!searchFilter) { + setFilteredRooms(rooms); + } else { + const filtered = rooms.filter(room => + (room.display_name?.toLowerCase() || '').includes(searchFilter.toLowerCase()) || + (room.name?.toLowerCase() || '').includes(searchFilter.toLowerCase()) || + (room.fname?.toLowerCase() || '').includes(searchFilter.toLowerCase()) + ); + setFilteredRooms(filtered); + } + }, [searchFilter, rooms]); + + const getRoomIcon = (roomType: string) => { + switch (roomType) { + case 'c': + return ; + case 'p': + return ; + case 'd': + return ; + default: + return ; + } + }; + + const getRoomTypeLabel = (roomType: string) => { + switch (roomType) { + case 'c': + return 'Channel'; + case 'p': + return 'Private'; + case 'd': + return 'Direct'; + default: + return 'Unknown'; + } + }; + + const formatLastMessageDate = (dateString: string) => { + if (!dateString) return ''; + + const date = new Date(dateString); + const now = new Date(); + const diffTime = Math.abs(now.getTime() - date.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 1) { + return 'Today'; + } else if (diffDays === 2) { + return 'Yesterday'; + } else if (diffDays <= 7) { + return `${diffDays - 1} days ago`; + } else { + return date.toLocaleDateString(); + } + }; + + if (!selectedUserId) { + return ( + + + Rooms + + +

+ Select a user from the dropdown above to view their rooms. +

+
+
+ ); + } + + if (loading) { + return ( + + + Loading Rooms... + + +
+ + Fetching rooms for selected user... +
+
+
+ ); + } + + if (error) { + return ( + + + Error Loading Rooms + + +

{error}

+
+
+ ); + } + + return ( + + + Rooms ({filteredRooms.length}) +
+ + setSearchFilter(e.target.value)} + className="pl-8" + /> +
+
+ + +
+ {filteredRooms.length === 0 ? ( +
+

+ {searchFilter ? 'No rooms match your search.' : 'No rooms found for this user.'} +

+
+ ) : ( +
+ {filteredRooms.map((room) => ( +
onRoomSelect(room.id.toString())} + className={` + flex items-center justify-between p-3 rounded-lg cursor-pointer transition-colors + hover:bg-gray-100 + ${selectedRoomId === room.id.toString() ? 'bg-blue-50 border-l-4 border-blue-500' : ''} + `} + > +
+ {getRoomIcon(room.type)} +
+
+ {room.display_name || room.fname || room.name || 'Unnamed Room'} +
+
+ + {getRoomTypeLabel(room.type)} + + {room.userscount && room.userscount > 0 && ( + + {room.userscount} member{room.userscount !== 1 ? 's' : ''} + + )} + {room.msgs > 0 && ( + + {room.msgs} msgs + + )} +
+ {room.description && ( +
+ {room.description} +
+ )} + {room.last_message_date && ( +
+ {formatLastMessageDate(room.last_message_date)} +
+ )} +
+
+
+ ))} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/chat/SearchResults.tsx b/inventory/src/components/chat/SearchResults.tsx new file mode 100644 index 0000000..35c0d8f --- /dev/null +++ b/inventory/src/components/chat/SearchResults.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Hash, Lock, MessageSquare, X } from 'lucide-react'; + +interface SearchResult { + id: number; + msg: string; + ts: string; + u: { + username: string; + name?: string; + }; + room_id: number; + room_name: string; + room_fname: string; + room_type: string; +} + +interface SearchResultsProps { + results: SearchResult[]; + query: string; + onClose: () => void; + onRoomSelect: (roomId: string) => void; +} + +export function SearchResults({ results, query, onClose, onRoomSelect }: SearchResultsProps) { + const getRoomIcon = (roomType: string) => { + switch (roomType) { + case 'c': + return ; + case 'p': + return ; + case 'd': + return ; + default: + return ; + } + }; + + const highlightText = (text: string, query: string) => { + if (!query) return text; + + const regex = new RegExp(`(${query})`, 'gi'); + const parts = text.split(regex); + + return parts.map((part, index) => + regex.test(part) ? ( + + {part} + + ) : ( + part + ) + ); + }; + + const formatTime = (timestamp: string) => { + const date = new Date(timestamp); + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + }); + }; + + return ( + + + + Search Results for "{query}" ({results.length}) + + + + + {results.length === 0 ? ( +

No messages found matching your search.

+ ) : ( +
+ {results.map((result) => ( +
{ + onRoomSelect(result.room_id.toString()); + onClose(); + }} + > +
+ {getRoomIcon(result.room_type)} + + {result.room_fname || result.room_name || 'Unnamed Room'} + + + {result.u.name || result.u.username} + + + {formatTime(result.ts)} + +
+
+ {highlightText(result.msg, query)} +
+
+ ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/pages/Chat.tsx b/inventory/src/pages/Chat.tsx index a61eaf2..e5466fe 100644 --- a/inventory/src/pages/Chat.tsx +++ b/inventory/src/pages/Chat.tsx @@ -1,9 +1,12 @@ import React, { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Badge } from '@/components/ui/badge'; -import { Loader2, MessageCircle, Users, Database } from 'lucide-react'; -import { ChatTest } from '@/components/chat/ChatTest'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Loader2, Search } from 'lucide-react'; +import { RoomList } from '@/components/chat/RoomList'; +import { ChatRoom } from '@/components/chat/ChatRoom'; +import { SearchResults } from '@/components/chat/SearchResults'; import config from '@/config'; interface User { @@ -12,54 +15,100 @@ interface User { name: string; type: string; active: boolean; + status?: string; + lastlogin?: string; + statustext?: string; + statusconnection?: string; } -interface DbStats { - active_users: number; - total_messages: number; - total_rooms: number; +interface SearchResult { + id: number; + msg: string; + ts: string; + u: { + username: string; + name?: string; + }; + room_id: number; + room_name: string; + room_fname: string; + room_type: string; } export function Chat() { const [users, setUsers] = useState([]); const [selectedUserId, setSelectedUserId] = useState(''); + const [selectedRoomId, setSelectedRoomId] = useState(null); const [loading, setLoading] = useState(true); - const [dbStats, setDbStats] = useState(null); const [error, setError] = useState(null); + + // Global search state + const [globalSearchQuery, setGlobalSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [showSearchResults, setShowSearchResults] = useState(false); + const [searching, setSearching] = useState(false); useEffect(() => { - const fetchInitialData = async () => { + const fetchUsers = async () => { try { - // Test database connection - const dbResponse = await fetch(`${config.chatUrl}/test-db`); - const dbData = await dbResponse.json(); + const response = await fetch(`${config.chatUrl}/users`); + const data = await response.json(); - if (dbData.status === 'success') { - setDbStats(dbData.stats); + if (data.status === 'success') { + setUsers(data.users); } else { - throw new Error(dbData.error || 'Database connection failed'); - } - - // Fetch users - const usersResponse = await fetch(`${config.chatUrl}/users`); - const usersData = await usersResponse.json(); - - if (usersData.status === 'success') { - setUsers(usersData.users); - } else { - throw new Error(usersData.error || 'Failed to fetch users'); + throw new Error(data.error || 'Failed to fetch users'); } } catch (err) { - console.error('Error fetching initial data:', err); + console.error('Error fetching users:', err); setError(err instanceof Error ? err.message : 'An error occurred'); } finally { setLoading(false); } }; - fetchInitialData(); + fetchUsers(); }, []); + const handleUserChange = (userId: string) => { + setSelectedUserId(userId); + setSelectedRoomId(null); // Reset room selection when user changes + setGlobalSearchQuery(''); // Clear search when user changes + setShowSearchResults(false); + }; + + const handleRoomSelect = (roomId: string) => { + setSelectedRoomId(roomId); + setShowSearchResults(false); // Close search results when room is selected + }; + + const handleGlobalSearch = async () => { + if (!globalSearchQuery || globalSearchQuery.length < 2 || !selectedUserId) return; + + setSearching(true); + try { + const response = await fetch( + `${config.chatUrl}/users/${selectedUserId}/search?q=${encodeURIComponent(globalSearchQuery)}&limit=20` + ); + const data = await response.json(); + + if (data.status === 'success') { + setSearchResults(data.results); + setShowSearchResults(true); + } + } catch (err) { + console.error('Error searching messages:', err); + } finally { + setSearching(false); + } + }; + + const handleSearchKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleGlobalSearch(); + } + }; + if (loading) { return (
@@ -78,7 +127,7 @@ export function Chat() {

{error}

- Make sure the chat server is running on port 3014 and the database is accessible. + Make sure the chat server is running and the database is accessible.

@@ -88,80 +137,97 @@ export function Chat() { return (
-
-
-
-

Chat Archive

-

- Read-only archive of Rocket.Chat conversations -

-
+ {/* Header */} +
+

Chat

+ +
+ {/* Global Search */} + {selectedUserId && ( +
+
+ setGlobalSearchQuery(e.target.value)} + onKeyPress={handleSearchKeyPress} + className="w-64" + /> + +
+ + {showSearchResults && ( + setShowSearchResults(false)} + onRoomSelect={handleRoomSelect} + /> + )} +
+ )} -
- + + + + + {users.map((user) => ( + +
+
+ {user.name || user.username} - - {user.username} -
- - ))} - - -
+ +
+ + ))} + +
- - {/* Database Stats */} - {dbStats && ( -
- - -
- -
-

Active Users

-

{dbStats.active_users}

-
-
-
-
- - - -
- -
-

Total Messages

-

{dbStats.total_messages.toLocaleString()}

-
-
-
-
- - - -
- -
-

Total Rooms

-

{dbStats.total_rooms}

-
-
-
-
-
- )}
- {/* Chat Test Component */} - + {/* Chat Interface */} + {selectedUserId ? ( +
+ {/* Room List Sidebar */} +
+ +
+ + {/* Chat Messages Area */} +
+ +
+
+ ) : ( + + +

+ Select a user to view their chat rooms and messages. +

+
+
+ )}
); } \ No newline at end of file diff --git a/inventory/tsconfig.json b/inventory/tsconfig.json index c20738e..e5a8655 100644 --- a/inventory/tsconfig.json +++ b/inventory/tsconfig.json @@ -20,6 +20,5 @@ "@/*": ["./src/*"] } }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + "include": ["src"] }