Build out chat more
This commit is contained in:
@@ -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;
|
||||
470
inventory/src/components/chat/ChatRoom.tsx
Normal file
470
inventory/src/components/chat/ChatRoom.tsx
Normal file
@@ -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<Room | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(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 <Hash className="h-4 w-4 text-blue-500" />;
|
||||
case 'p':
|
||||
return <Lock className="h-4 w-4 text-orange-500" />;
|
||||
case 'd':
|
||||
return <MessageSquare className="h-4 w-4 text-green-500" />;
|
||||
default:
|
||||
return <Hash className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="mt-2 space-y-2">
|
||||
{urls.map((urlData, index) => (
|
||||
<div key={index} className="border rounded-lg p-3 bg-gray-50">
|
||||
<div className="flex items-start gap-2">
|
||||
<ExternalLink className="h-4 w-4 mt-1 text-blue-500 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
href={urlData.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm break-all"
|
||||
>
|
||||
{urlData.url}
|
||||
</a>
|
||||
{urlData.meta?.pageTitle && (
|
||||
<div className="font-medium text-sm mt-1">{urlData.meta.pageTitle}</div>
|
||||
)}
|
||||
{urlData.meta?.ogDescription && (
|
||||
<div className="text-xs text-muted-foreground mt-1">{urlData.meta.ogDescription}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAttachments = (attachments: any[]) => {
|
||||
if (!attachments || attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
{attachments.map((attachment, index) => {
|
||||
const isImage = attachment.typegroup === 'image';
|
||||
const filePath = `${config.chatUrl}/files/by-id/${attachment.mongo_id}`;
|
||||
|
||||
return (
|
||||
<div key={index} className="border rounded-lg p-3 bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
{isImage ? (
|
||||
<Image className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-blue-500" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{attachment.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{(attachment.size / 1024).toFixed(1)} KB
|
||||
{attachment.identify?.size && (
|
||||
<span> • {attachment.identify.size.width}×{attachment.identify.size.height}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{isImage && attachment.identify?.size && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={filePath}
|
||||
alt={attachment.name}
|
||||
className="max-w-xs max-h-48 rounded border"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
`<span class="bg-blue-100 text-blue-800 px-1 rounded">@${mention.username}</span>`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return <span dangerouslySetInnerHTML={{ __html: renderedText }} />;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div key={message.id} className={`${isConsecutive ? 'mt-1' : 'mt-4'}`}>
|
||||
{!isConsecutive && (
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-sm font-medium text-blue-600">
|
||||
{(message.u.name || message.u.username).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-medium text-sm">
|
||||
{message.u.name || message.u.username}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTime(message.ts)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`${isConsecutive ? 'ml-10' : 'ml-10'} text-sm`}>
|
||||
<div className="break-words">
|
||||
{message.mentions && message.mentions.length > 0
|
||||
? renderMentions(message.msg, message.mentions)
|
||||
: message.msg
|
||||
}
|
||||
</div>
|
||||
{message.urls && renderURLPreviews(message.urls)}
|
||||
{message.attachments && renderAttachments(message.attachments)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!roomId) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardContent className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">Select a room to view messages</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && messages.length === 0) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardContent className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Loading messages...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="h-full border-red-200 bg-red-50">
|
||||
<CardContent className="flex items-center justify-center h-full">
|
||||
<p className="text-red-700">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="border-b p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{room && getRoomIcon(room.type)}
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{room?.display_name || room?.fname || room?.name || 'Unnamed Room'}
|
||||
</CardTitle>
|
||||
{room?.description && (
|
||||
<p className="text-sm text-muted-foreground">{room.description}</p>
|
||||
)}
|
||||
{room?.participants && room.participants.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{room.participants.map(p => p.name || p.username).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSearch(!showSearch)}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
<Badge variant="secondary">{room?.msgs || 0} messages</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSearch && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Input
|
||||
placeholder="Search messages..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && searchMessages()}
|
||||
/>
|
||||
<Button onClick={searchMessages} size="sm">Search</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 p-0 overflow-hidden">
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="h-full overflow-y-auto p-4"
|
||||
>
|
||||
{hasMore && messages.length > 0 && (
|
||||
<div className="text-center mb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadMoreMessages}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Load older messages
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground">
|
||||
No messages in this room
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{messages.map((message, index) => renderMessage(message, index))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
251
inventory/src/components/chat/RoomList.tsx
Normal file
251
inventory/src/components/chat/RoomList.tsx
Normal file
@@ -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<Room[]>([]);
|
||||
const [filteredRooms, setFilteredRooms] = useState<Room[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 <Hash className="h-4 w-4 text-blue-500" />;
|
||||
case 'p':
|
||||
return <Lock className="h-4 w-4 text-orange-500" />;
|
||||
case 'd':
|
||||
return <MessageSquare className="h-4 w-4 text-green-500" />;
|
||||
default:
|
||||
return <Users className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Rooms</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Select a user from the dropdown above to view their rooms.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Loading Rooms...</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">Fetching rooms for selected user...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="h-full border-red-200 bg-red-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-800">Error Loading Rooms</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">Rooms ({filteredRooms.length})</CardTitle>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search rooms..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 p-0 overflow-hidden">
|
||||
<div className="h-full overflow-y-auto">
|
||||
{filteredRooms.length === 0 ? (
|
||||
<div className="p-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{searchFilter ? 'No rooms match your search.' : 'No rooms found for this user.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 p-2">
|
||||
{filteredRooms.map((room) => (
|
||||
<div
|
||||
key={room.id}
|
||||
onClick={() => 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' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{getRoomIcon(room.type)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{room.display_name || room.fname || room.name || 'Unnamed Room'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-xs ${
|
||||
room.type === 'c' ? 'bg-blue-100 text-blue-800' :
|
||||
room.type === 'p' ? 'bg-orange-100 text-orange-800' :
|
||||
room.type === 'd' ? 'bg-green-100 text-green-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{getRoomTypeLabel(room.type)}
|
||||
</Badge>
|
||||
{room.userscount && room.userscount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{room.userscount} member{room.userscount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{room.msgs > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{room.msgs} msgs
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{room.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1 truncate">
|
||||
{room.description}
|
||||
</div>
|
||||
)}
|
||||
{room.last_message_date && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{formatLastMessageDate(room.last_message_date)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
113
inventory/src/components/chat/SearchResults.tsx
Normal file
113
inventory/src/components/chat/SearchResults.tsx
Normal file
@@ -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 <Hash className="h-3 w-3 text-blue-500" />;
|
||||
case 'p':
|
||||
return <Lock className="h-3 w-3 text-orange-500" />;
|
||||
case 'd':
|
||||
return <MessageSquare className="h-3 w-3 text-green-500" />;
|
||||
default:
|
||||
return <Hash className="h-3 w-3 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
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) ? (
|
||||
<span key={index} className="bg-yellow-200 font-medium">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="absolute top-full left-0 right-0 z-10 mt-2 max-h-96 overflow-y-auto">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm">
|
||||
Search Results for "{query}" ({results.length})
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{results.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No messages found matching your search.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{results.map((result) => (
|
||||
<div
|
||||
key={result.id}
|
||||
className="border rounded-lg p-3 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => {
|
||||
onRoomSelect(result.room_id.toString());
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{getRoomIcon(result.room_type)}
|
||||
<span className="text-sm font-medium">
|
||||
{result.room_fname || result.room_name || 'Unnamed Room'}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{result.u.name || result.u.username}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatTime(result.ts)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{highlightText(result.msg, query)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<User[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dbStats, setDbStats] = useState<DbStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Global search state
|
||||
const [globalSearchQuery, setGlobalSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -78,7 +127,7 @@ export function Chat() {
|
||||
<CardContent>
|
||||
<p className="text-red-700">{error}</p>
|
||||
<p className="text-sm text-red-600 mt-2">
|
||||
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.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -88,80 +137,97 @@ export function Chat() {
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Chat Archive</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Read-only archive of Rocket.Chat conversations
|
||||
</p>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">Chat</h1>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Global Search */}
|
||||
{selectedUserId && (
|
||||
<div className="relative">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Search all messages..."
|
||||
value={globalSearchQuery}
|
||||
onChange={(e) => setGlobalSearchQuery(e.target.value)}
|
||||
onKeyPress={handleSearchKeyPress}
|
||||
className="w-64"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleGlobalSearch}
|
||||
disabled={searching || globalSearchQuery.length < 2}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
{searching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showSearchResults && (
|
||||
<SearchResults
|
||||
results={searchResults}
|
||||
query={globalSearchQuery}
|
||||
onClose={() => setShowSearchResults(false)}
|
||||
onRoomSelect={handleRoomSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select value={selectedUserId} onValueChange={handleUserChange}>
|
||||
<SelectTrigger className="w-64">
|
||||
<SelectValue placeholder="View as user..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
|
||||
<SelectTrigger className="w-64">
|
||||
<SelectValue placeholder="View as user..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{user.name || user.username}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{user.username}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Database Stats */}
|
||||
{dbStats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Active Users</p>
|
||||
<p className="text-2xl font-bold">{dbStats.active_users}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Messages</p>
|
||||
<p className="text-2xl font-bold">{dbStats.total_messages.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-purple-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Rooms</p>
|
||||
<p className="text-2xl font-bold">{dbStats.total_rooms}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat Test Component */}
|
||||
<ChatTest selectedUserId={selectedUserId} />
|
||||
{/* Chat Interface */}
|
||||
{selectedUserId ? (
|
||||
<div className="grid grid-cols-12 gap-6 h-[700px]">
|
||||
{/* Room List Sidebar */}
|
||||
<div className="col-span-4 h-[85vh] overflow-y-auto">
|
||||
<RoomList
|
||||
selectedUserId={selectedUserId}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onRoomSelect={handleRoomSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat Messages Area */}
|
||||
<div className="col-span-8 h-[85vh] overflow-y-auto">
|
||||
<ChatRoom
|
||||
roomId={selectedRoomId || ''}
|
||||
selectedUserId={selectedUserId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">
|
||||
Select a user to view their chat rooms and messages.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,5 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user