Chat fixes and layout tweaks
This commit is contained in:
@@ -132,18 +132,124 @@ router.get('/files/by-id/:mongoId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Serve avatars
|
||||
// Serve user avatars by mongo_id
|
||||
router.get('/avatar/:mongoId', async (req, res) => {
|
||||
try {
|
||||
const { mongoId } = req.params;
|
||||
|
||||
console.log(`[Avatar Debug] Looking up avatar for user mongo_id: ${mongoId}`);
|
||||
|
||||
// First try to find avatar by user's avataretag
|
||||
const userResult = await global.pool.query(`
|
||||
SELECT avataretag, username FROM users WHERE mongo_id = $1
|
||||
`, [mongoId]);
|
||||
|
||||
let avatarPath = null;
|
||||
|
||||
if (userResult.rows.length > 0) {
|
||||
const username = userResult.rows[0].username;
|
||||
const avataretag = userResult.rows[0].avataretag;
|
||||
|
||||
// Try method 1: Look up by avataretag -> etag (for users with avataretag set)
|
||||
if (avataretag) {
|
||||
console.log(`[Avatar Debug] Found user ${username} with avataretag: ${avataretag}`);
|
||||
|
||||
const avatarResult = await global.pool.query(`
|
||||
SELECT url, path FROM avatars WHERE etag = $1
|
||||
`, [avataretag]);
|
||||
|
||||
if (avatarResult.rows.length > 0) {
|
||||
const dbPath = avatarResult.rows[0].path || avatarResult.rows[0].url;
|
||||
console.log(`[Avatar Debug] Found avatar record with path: ${dbPath}`);
|
||||
|
||||
if (dbPath) {
|
||||
const pathParts = dbPath.split('/');
|
||||
for (let i = 0; i < pathParts.length; i++) {
|
||||
if (pathParts[i].includes('AmazonS3:Avatars') && i + 1 < pathParts.length) {
|
||||
const avatarMongoId = pathParts[i + 1];
|
||||
avatarPath = path.join(__dirname, 'db-convert/db/files/avatars', avatarMongoId);
|
||||
console.log(`[Avatar Debug] Extracted avatar mongo_id: ${avatarMongoId}, full path: ${avatarPath}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[Avatar Debug] No avatar record found for etag: ${avataretag}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Try method 2: Look up by userid directly (for users without avataretag)
|
||||
if (!avatarPath) {
|
||||
console.log(`[Avatar Debug] Trying direct userid lookup for user ${username} (${mongoId})`);
|
||||
|
||||
const avatarResult = await global.pool.query(`
|
||||
SELECT url, path FROM avatars WHERE userid = $1
|
||||
`, [mongoId]);
|
||||
|
||||
if (avatarResult.rows.length > 0) {
|
||||
const dbPath = avatarResult.rows[0].path || avatarResult.rows[0].url;
|
||||
console.log(`[Avatar Debug] Found avatar record by userid with path: ${dbPath}`);
|
||||
|
||||
if (dbPath) {
|
||||
const pathParts = dbPath.split('/');
|
||||
for (let i = 0; i < pathParts.length; i++) {
|
||||
if (pathParts[i].includes('AmazonS3:Avatars') && i + 1 < pathParts.length) {
|
||||
const avatarMongoId = pathParts[i + 1];
|
||||
avatarPath = path.join(__dirname, 'db-convert/db/files/avatars', avatarMongoId);
|
||||
console.log(`[Avatar Debug] Extracted avatar mongo_id: ${avatarMongoId}, full path: ${avatarPath}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[Avatar Debug] No avatar record found for userid: ${mongoId}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[Avatar Debug] No user found for mongo_id: ${mongoId}`);
|
||||
}
|
||||
|
||||
// Fallback: try direct lookup by user mongo_id
|
||||
if (!avatarPath) {
|
||||
avatarPath = path.join(__dirname, 'db-convert/db/files/avatars', mongoId);
|
||||
console.log(`[Avatar Debug] Using fallback path: ${avatarPath}`);
|
||||
}
|
||||
|
||||
// Set proper content type for images
|
||||
res.set('Content-Type', 'image/jpeg'); // Most avatars are likely JPEG
|
||||
|
||||
// Send the file
|
||||
res.sendFile(avatarPath, (err) => {
|
||||
if (err) {
|
||||
// If avatar doesn't exist, send a default 404 or generate initials
|
||||
console.log(`[Avatar Debug] Avatar file not found at path: ${avatarPath}, error:`, err.message);
|
||||
if (!res.headersSent) {
|
||||
res.status(404).json({ error: 'Avatar not found' });
|
||||
}
|
||||
} else {
|
||||
console.log(`[Avatar Debug] Successfully served avatar from: ${avatarPath}`);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error serving avatar:', error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve avatars statically as fallback
|
||||
router.use('/files/avatars', express.static(path.join(__dirname, 'db-convert/db/files/avatars')));
|
||||
|
||||
// Get all active users for the "view as" dropdown
|
||||
// Get all users for the "view as" dropdown (active and inactive)
|
||||
router.get('/users', async (req, res) => {
|
||||
try {
|
||||
const result = await global.pool.query(`
|
||||
SELECT id, username, name, type, active, status, lastlogin,
|
||||
statustext, utcoffset, statusconnection
|
||||
statustext, utcoffset, statusconnection, mongo_id, avataretag
|
||||
FROM users
|
||||
WHERE active = true AND type = 'user'
|
||||
WHERE type = 'user'
|
||||
ORDER BY
|
||||
active DESC, -- Active users first
|
||||
CASE
|
||||
WHEN status = 'online' THEN 1
|
||||
WHEN status = 'away' THEN 2
|
||||
@@ -188,6 +294,7 @@ router.get('/users/:userId/rooms', async (req, res) => {
|
||||
const currentUsername = userResult.rows[0].username;
|
||||
|
||||
// Get rooms where the user is a member with proper naming from subscription table
|
||||
// Include archived and closed rooms but sort them at the bottom
|
||||
const result = await global.pool.query(`
|
||||
SELECT DISTINCT
|
||||
r.id,
|
||||
@@ -201,6 +308,9 @@ router.get('/users/:userId/rooms', async (req, res) => {
|
||||
r.uids,
|
||||
r.userscount,
|
||||
r.description,
|
||||
r.teamid,
|
||||
r.archived,
|
||||
s.open,
|
||||
-- Use the subscription's name for direct messages (excludes current user)
|
||||
-- For channels/groups, use room's fname or name
|
||||
CASE
|
||||
@@ -210,15 +320,32 @@ router.get('/users/:userId/rooms', async (req, res) => {
|
||||
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
|
||||
ORDER BY
|
||||
s.open DESC NULLS LAST, -- Open rooms first
|
||||
r.archived NULLS FIRST, -- Non-archived first (nulls treated as false)
|
||||
r.lm DESC NULLS LAST
|
||||
LIMIT 50
|
||||
`, [currentUserMongoId]);
|
||||
|
||||
// Enhance rooms with participant information for direct messages
|
||||
const enhancedRooms = await Promise.all(result.rows.map(async (room) => {
|
||||
if (room.type === 'd' && room.uids) {
|
||||
// Get participant info (excluding current user) for direct messages
|
||||
const participantResult = await global.pool.query(`
|
||||
SELECT u.username, u.name, u.mongo_id, u.avataretag
|
||||
FROM users u
|
||||
WHERE u.mongo_id = ANY($1::text[])
|
||||
AND u.mongo_id != $2
|
||||
`, [room.uids, currentUserMongoId]);
|
||||
|
||||
room.participants = participantResult.rows;
|
||||
}
|
||||
return room;
|
||||
}));
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
rooms: result.rows
|
||||
rooms: enhancedRooms
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching user rooms:', error);
|
||||
@@ -233,11 +360,12 @@ router.get('/users/:userId/rooms', async (req, res) => {
|
||||
// Get room details including participants
|
||||
router.get('/rooms/:roomId', async (req, res) => {
|
||||
const { roomId } = req.params;
|
||||
const { userId } = req.query; // Accept current user ID as query parameter
|
||||
|
||||
try {
|
||||
const result = await global.pool.query(`
|
||||
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
|
||||
r.lm as last_message_date, r.usernames, r.uids, r.userscount, r.teamid
|
||||
FROM room r
|
||||
WHERE r.id = $1
|
||||
`, [roomId]);
|
||||
@@ -251,8 +379,38 @@ router.get('/rooms/:roomId', async (req, res) => {
|
||||
|
||||
const room = result.rows[0];
|
||||
|
||||
// Get room participants for direct messages
|
||||
if (room.type === 'd' && room.uids) {
|
||||
// For direct messages, get the proper display name based on current user
|
||||
if (room.type === 'd' && room.uids && userId) {
|
||||
// Get current user's mongo_id
|
||||
const userResult = await global.pool.query(`
|
||||
SELECT mongo_id FROM users WHERE id = $1
|
||||
`, [userId]);
|
||||
|
||||
if (userResult.rows.length > 0) {
|
||||
const currentUserMongoId = userResult.rows[0].mongo_id;
|
||||
|
||||
// Get display name from subscription table for this user
|
||||
// Use room mongo_id to match with subscription.rid
|
||||
const roomMongoResult = await global.pool.query(`
|
||||
SELECT mongo_id FROM room WHERE id = $1
|
||||
`, [roomId]);
|
||||
|
||||
if (roomMongoResult.rows.length > 0) {
|
||||
const roomMongoId = roomMongoResult.rows[0].mongo_id;
|
||||
|
||||
const subscriptionResult = await global.pool.query(`
|
||||
SELECT fname, name FROM subscription
|
||||
WHERE rid = $1 AND u->>'_id' = $2
|
||||
`, [roomMongoId, currentUserMongoId]);
|
||||
|
||||
if (subscriptionResult.rows.length > 0) {
|
||||
const sub = subscriptionResult.rows[0];
|
||||
room.display_name = sub.fname || sub.name || 'Unknown User';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all participants for additional info
|
||||
const participantResult = await global.pool.query(`
|
||||
SELECT username, name
|
||||
FROM users
|
||||
@@ -260,6 +418,9 @@ router.get('/rooms/:roomId', async (req, res) => {
|
||||
`, [room.uids]);
|
||||
|
||||
room.participants = participantResult.rows;
|
||||
} else {
|
||||
// For channels/groups, use room's fname or name
|
||||
room.display_name = room.fname || room.name || 'Unnamed Room';
|
||||
}
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -2,9 +2,11 @@ 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Loader2, Hash, Lock, MessageSquare, ChevronUp, Search, ExternalLink, FileText, Image, Download, MessageCircle, Users2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import config from '@/config';
|
||||
import { convertEmojiShortcodes } from '@/utils/emojiUtils';
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
@@ -44,6 +46,7 @@ interface Room {
|
||||
last_message_date: string;
|
||||
display_name: string;
|
||||
description?: string;
|
||||
teamid?: string;
|
||||
participants?: { username: string; name: string }[];
|
||||
}
|
||||
|
||||
@@ -72,7 +75,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
|
||||
const fetchRoom = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.chatUrl}/rooms/${roomId}`);
|
||||
const response = await fetch(`${config.chatUrl}/rooms/${roomId}?userId=${selectedUserId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
@@ -194,12 +197,17 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
}
|
||||
}, [roomId, selectedUserId]);
|
||||
|
||||
const getRoomIcon = (roomType: string) => {
|
||||
switch (roomType) {
|
||||
const getRoomIcon = (room: Room) => {
|
||||
switch (room.type) {
|
||||
case 'c':
|
||||
return <Hash className="h-4 w-4 text-blue-500" />;
|
||||
case 'p':
|
||||
return <Lock className="h-4 w-4 text-orange-500" />;
|
||||
// Distinguish between teams and discussions based on teamid
|
||||
if (room.teamid) {
|
||||
return <Users2 className="h-4 w-4 text-purple-500" />; // Teams
|
||||
} else {
|
||||
return <MessageCircle className="h-4 w-4 text-orange-500" />; // Discussions
|
||||
}
|
||||
case 'd':
|
||||
return <MessageSquare className="h-4 w-4 text-green-500" />;
|
||||
default:
|
||||
@@ -219,6 +227,30 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessageText = (text: string, urls?: any[]) => {
|
||||
if (!text) return '';
|
||||
|
||||
// First, convert emoji shortcodes to actual emoji
|
||||
let processedText = convertEmojiShortcodes(text);
|
||||
|
||||
// Then, handle markdown links [text](url) and convert them to HTML
|
||||
processedText = processedText.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^\s\)]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:underline">$1</a>'
|
||||
);
|
||||
|
||||
// If we have URL previews, replace standalone URLs (that aren't already in markdown) with just the preview
|
||||
if (urls && urls.length > 0) {
|
||||
urls.forEach((urlData) => {
|
||||
// Only replace standalone URLs that aren't part of markdown links
|
||||
const standaloneUrlRegex = new RegExp(`(?<!\\]\\()${urlData.url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?!\\))`, 'g');
|
||||
processedText = processedText.replace(standaloneUrlRegex, '');
|
||||
});
|
||||
}
|
||||
|
||||
return <span dangerouslySetInnerHTML={{ __html: processedText }} />;
|
||||
};
|
||||
|
||||
const renderURLPreviews = (urls: any[]) => {
|
||||
if (!urls || urls.length === 0) return null;
|
||||
|
||||
@@ -235,11 +267,8 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm break-all"
|
||||
>
|
||||
{urlData.url}
|
||||
{urlData.meta?.pageTitle || 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>
|
||||
)}
|
||||
@@ -254,12 +283,30 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
const renderAttachments = (attachments: any[]) => {
|
||||
if (!attachments || attachments.length === 0) return null;
|
||||
|
||||
// Filter out thumbnail attachments (they're usually lower quality versions)
|
||||
const filteredAttachments = attachments.filter(attachment =>
|
||||
!attachment.name?.toLowerCase().startsWith('thumb-')
|
||||
);
|
||||
|
||||
if (filteredAttachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
{attachments.map((attachment, index) => {
|
||||
{filteredAttachments.map((attachment, index) => {
|
||||
const isImage = attachment.typegroup === 'image';
|
||||
const filePath = `${config.chatUrl}/files/by-id/${attachment.mongo_id}`;
|
||||
|
||||
const handleDownload = () => {
|
||||
// Create a temporary anchor element to trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = filePath;
|
||||
link.download = attachment.name || 'download';
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={index} className="border rounded-lg p-3 bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -277,7 +324,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Button variant="ghost" size="sm" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -286,8 +333,9 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
<img
|
||||
src={filePath}
|
||||
alt={attachment.name}
|
||||
className="max-w-xs max-h-48 rounded border"
|
||||
className="max-w-xs max-h-48 rounded border cursor-pointer"
|
||||
loading="lazy"
|
||||
onClick={() => window.open(filePath, '_blank')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -301,7 +349,10 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
const renderMentions = (text: string, mentions: any[]) => {
|
||||
if (!mentions || mentions.length === 0) return text;
|
||||
|
||||
let renderedText = text;
|
||||
// First, convert emoji shortcodes to actual emoji
|
||||
let renderedText = convertEmojiShortcodes(text);
|
||||
|
||||
// Then process mentions
|
||||
mentions.forEach((mention) => {
|
||||
if (mention.username) {
|
||||
const mentionPattern = new RegExp(`@${mention.username}`, 'g');
|
||||
@@ -325,9 +376,15 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
<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">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage
|
||||
src={`${config.chatUrl}/avatar/${message.u._id}`}
|
||||
alt={message.u.name || message.u.username}
|
||||
/>
|
||||
<AvatarFallback className="text-sm font-medium">
|
||||
{(message.u.name || message.u.username).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium text-sm">
|
||||
{message.u.name || message.u.username}
|
||||
</span>
|
||||
@@ -340,7 +397,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
<div className="break-words">
|
||||
{message.mentions && message.mentions.length > 0
|
||||
? renderMentions(message.msg, message.mentions)
|
||||
: message.msg
|
||||
: renderMessageText(message.msg, message.urls)
|
||||
}
|
||||
</div>
|
||||
{message.urls && renderURLPreviews(message.urls)}
|
||||
@@ -388,15 +445,19 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
<CardHeader className="border-b p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{room && getRoomIcon(room.type)}
|
||||
{room && getRoomIcon(room)}
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{room?.display_name || room?.fname || room?.name || 'Unnamed Room'}
|
||||
{room?.type === 'd'
|
||||
? `Direct message with ${room?.display_name || 'Unknown User'}`
|
||||
: room?.display_name || room?.fname || room?.name || 'Unnamed Room'
|
||||
}
|
||||
</CardTitle>
|
||||
{room?.description && (
|
||||
{room?.description && room?.type !== 'd' && (
|
||||
<p className="text-sm text-muted-foreground">{room.description}</p>
|
||||
)}
|
||||
{room?.participants && room.participants.length > 0 && (
|
||||
{/* Only show participants for non-direct messages since DM names are already in the title */}
|
||||
{room?.participants && room.participants.length > 0 && room?.type !== 'd' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{room.participants.map(p => p.name || p.username).join(', ')}
|
||||
</p>
|
||||
@@ -412,7 +473,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
<Badge variant="secondary">{room?.msgs || 0} messages</Badge>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Loader2, Hash, Lock, Users, MessageSquare, Search, MessageCircle, Users2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import config from '@/config';
|
||||
|
||||
@@ -15,6 +16,15 @@ interface Room {
|
||||
display_name: string;
|
||||
userscount?: number;
|
||||
description?: string;
|
||||
teamid?: string;
|
||||
archived?: boolean;
|
||||
open?: boolean;
|
||||
participants?: {
|
||||
username: string;
|
||||
name: string;
|
||||
mongo_id: string;
|
||||
avataretag?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface RoomListProps {
|
||||
@@ -75,48 +85,119 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL
|
||||
}
|
||||
}, [searchFilter, rooms]);
|
||||
|
||||
const getRoomIcon = (roomType: string) => {
|
||||
switch (roomType) {
|
||||
case 'c':
|
||||
return <Hash className="h-4 w-4 text-blue-500" />;
|
||||
const groupRoomsByType = (rooms: Room[]) => {
|
||||
const teams: Room[] = [];
|
||||
const discussions: Room[] = [];
|
||||
const channels: Room[] = [];
|
||||
const directMessages: Room[] = [];
|
||||
|
||||
rooms.forEach(room => {
|
||||
switch (room.type) {
|
||||
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`;
|
||||
if (room.teamid) {
|
||||
teams.push(room);
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
discussions.push(room);
|
||||
}
|
||||
break;
|
||||
case 'c':
|
||||
channels.push(room);
|
||||
break;
|
||||
case 'd':
|
||||
directMessages.push(room);
|
||||
break;
|
||||
default:
|
||||
channels.push(room); // fallback for unknown types
|
||||
}
|
||||
});
|
||||
|
||||
// Sort each group by message count descending
|
||||
const sortByMessages = (a: Room, b: Room) => (b.msgs || 0) - (a.msgs || 0);
|
||||
|
||||
teams.sort(sortByMessages);
|
||||
discussions.sort(sortByMessages);
|
||||
channels.sort(sortByMessages);
|
||||
directMessages.sort(sortByMessages);
|
||||
|
||||
return { teams, discussions, channels, directMessages };
|
||||
};
|
||||
|
||||
const renderRoomIcon = (room: Room) => {
|
||||
// For direct messages, show participant avatars
|
||||
if (room.type === 'd' && room.participants && room.participants.length > 0) {
|
||||
if (room.participants.length === 1) {
|
||||
// Single participant - show their avatar
|
||||
const participant = room.participants[0];
|
||||
return (
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage
|
||||
src={`${config.chatUrl}/avatar/${participant.mongo_id}`}
|
||||
alt={participant.name || participant.username}
|
||||
/>
|
||||
<AvatarFallback className="text-lg">
|
||||
{(participant.name || participant.username).charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
} else {
|
||||
// Multiple participants - show overlapping avatars
|
||||
return (
|
||||
<div className="relative flex items-center h-10 w-10">
|
||||
{room.participants.slice(0, 3).map((participant, index) => (
|
||||
<Avatar
|
||||
key={participant.mongo_id}
|
||||
className={`h-8 w-8 border-2 border-white ${index > 0 ? '-ml-4' : ''}`}
|
||||
style={{ zIndex: 30 - index }}
|
||||
>
|
||||
<AvatarImage
|
||||
src={`${config.chatUrl}/avatar/${participant.mongo_id}`}
|
||||
alt={participant.name || participant.username}
|
||||
/>
|
||||
<AvatarFallback className="text-lg">
|
||||
{(participant.name || participant.username).charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// For other room types, use icons
|
||||
switch (room.type) {
|
||||
case 'c':
|
||||
return (
|
||||
<div className="h-10 w-10 bg-blue-50 rounded-full flex items-center justify-center">
|
||||
<Hash className="h-6 w-6 text-blue-500" />
|
||||
</div>
|
||||
);
|
||||
case 'p':
|
||||
// Distinguish between teams and discussions based on teamid
|
||||
if (room.teamid) {
|
||||
return (
|
||||
<div className="h-10 w-10 bg-purple-50 rounded-full flex items-center justify-center">
|
||||
<Users2 className="h-6 w-6 text-purple-500" />
|
||||
</div>
|
||||
); // Teams
|
||||
} else {
|
||||
return (
|
||||
<div className="h-10 w-10 bg-orange-50 rounded-full flex items-center justify-center">
|
||||
<MessageCircle className="h-6 w-6 text-orange-500" />
|
||||
</div>
|
||||
); // Discussions
|
||||
}
|
||||
case 'd':
|
||||
return (
|
||||
<div className="h-12 w-12 bg-green-50 rounded-full flex items-center justify-center">
|
||||
<MessageSquare className="h-6 w-6 text-green-500" />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="h-12 w-12 bg-gray-50 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-gray-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -167,16 +248,7 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL
|
||||
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>
|
||||
<CardTitle className="text-lg">Rooms</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 p-0 overflow-hidden">
|
||||
@@ -188,61 +260,70 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 p-2">
|
||||
{filteredRooms.map((room) => (
|
||||
<div className="space-y-3 px-2 pb-4">
|
||||
{(() => {
|
||||
const { teams, discussions, channels, directMessages } = groupRoomsByType(filteredRooms);
|
||||
|
||||
const renderRoomGroup = (title: string, rooms: Room[], icon: React.ReactNode) => {
|
||||
if (rooms.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={title} className="space-y-0.5">
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100">
|
||||
{icon}
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{title} ({rooms.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{rooms.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
|
||||
flex items-center justify-between py-0.5 px-3 rounded-lg cursor-pointer transition-colors
|
||||
hover:bg-gray-100
|
||||
${selectedRoomId === room.id.toString() ? 'bg-blue-50 border-l-4 border-blue-500' : ''}
|
||||
${(room.open === false || room.archived === true) ? 'opacity-60' : ''}
|
||||
`}
|
||||
>
|
||||
<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">
|
||||
<div className="grid grid-cols-4 items-center gap-2 min-w-0 flex-1">
|
||||
{renderRoomIcon(room)}
|
||||
<div className="min-w-0 flex-1 col-span-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`font-medium text-sm truncate ${(room.open === false || room.archived === true) ? 'text-muted-foreground' : ''}`}>
|
||||
{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">
|
||||
<span className="text-xs text-muted-foreground ml-2 flex-shrink-0">
|
||||
{room.msgs} msgs
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{room.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1 truncate">
|
||||
<div className="text-xs text-muted-foreground 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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderRoomGroup('Teams', teams, <Users2 className="h-3 w-3 text-purple-500" />)}
|
||||
{renderRoomGroup('Discussions', discussions, <MessageCircle className="h-3 w-3 text-orange-500" />)}
|
||||
{renderRoomGroup('Channels', channels, <Hash className="h-3 w-3 text-blue-500" />)}
|
||||
{renderRoomGroup('Direct Messages', directMessages, <MessageSquare className="h-3 w-3 text-green-500" />)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
import { convertEmojiShortcodes } from '@/utils/emojiUtils';
|
||||
|
||||
interface SearchResult {
|
||||
id: number;
|
||||
@@ -40,10 +41,13 @@ export function SearchResults({ results, query, onClose, onRoomSelect }: SearchR
|
||||
};
|
||||
|
||||
const highlightText = (text: string, query: string) => {
|
||||
if (!query) return text;
|
||||
if (!query) return convertEmojiShortcodes(text);
|
||||
|
||||
// First convert emoji shortcodes
|
||||
const textWithEmoji = convertEmojiShortcodes(text);
|
||||
|
||||
const regex = new RegExp(`(${query})`, 'gi');
|
||||
const parts = text.split(regex);
|
||||
const parts = textWithEmoji.split(regex);
|
||||
|
||||
return parts.map((part, index) =>
|
||||
regex.test(part) ? (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Loader2, Search } from 'lucide-react';
|
||||
import { RoomList } from '@/components/chat/RoomList';
|
||||
import { ChatRoom } from '@/components/chat/ChatRoom';
|
||||
@@ -19,6 +20,8 @@ interface User {
|
||||
lastlogin?: string;
|
||||
statustext?: string;
|
||||
statusconnection?: string;
|
||||
mongo_id?: string;
|
||||
avataretag?: string;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
@@ -186,11 +189,19 @@ export function Chat() {
|
||||
{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">
|
||||
|
||||
<span>{user.name || user.username}</span>
|
||||
</div>
|
||||
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage
|
||||
src={user.mongo_id ? `${config.chatUrl}/avatar/${user.mongo_id}` : undefined}
|
||||
alt={user.name || user.username}
|
||||
/>
|
||||
<AvatarFallback className="text-xs">
|
||||
{(user.name || user.username).charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className={user.active ? '' : 'text-muted-foreground'}>
|
||||
{user.name || user.username}
|
||||
{!user.active && <span className="text-xs ml-1">(inactive)</span>}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
213
inventory/src/utils/emojiUtils.ts
Normal file
213
inventory/src/utils/emojiUtils.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
// Emoji shortcode to Unicode mapping
|
||||
// Based on common emoji shortcodes used in chat platforms
|
||||
const emojiMap: Record<string, string> = {
|
||||
// Smileys & Emotion
|
||||
'joy': '😂',
|
||||
'heart_eyes': '😍',
|
||||
'sob': '😭',
|
||||
'blush': '😊',
|
||||
'kissing_heart': '😘',
|
||||
'smiling': '☺️',
|
||||
'weary': '😩',
|
||||
'pensive': '😔',
|
||||
'smirk': '😏',
|
||||
'grin': '😁',
|
||||
'wink': '😉',
|
||||
'relieved': '😌',
|
||||
'flushed': '😳',
|
||||
'cry': '😢',
|
||||
'sunglasses': '😎',
|
||||
'sweat_smile': '😅',
|
||||
'sleeping': '😴',
|
||||
'smile': '😄',
|
||||
'purple_heart': '💜',
|
||||
'broken_heart': '💔',
|
||||
'expressionless': '😑',
|
||||
'sparkling_heart': '💖',
|
||||
'blue_heart': '💙',
|
||||
'confused': '😕',
|
||||
'stuck_out_tongue_winking_eye': '😜',
|
||||
'disappointed': '😞',
|
||||
'yum': '😋',
|
||||
'neutral_face': '😐',
|
||||
'sleepy': '😪',
|
||||
'cupid': '💘',
|
||||
'heartpulse': '💗',
|
||||
'revolving_hearts': '💞',
|
||||
'speak_no_evil': '🙊',
|
||||
'see_no_evil': '🙈',
|
||||
'rage': '😡',
|
||||
'smiley': '😃',
|
||||
'tired_face': '😫',
|
||||
'stuck_out_tongue_closed_eyes': '😝',
|
||||
'muscle': '💪',
|
||||
'skull': '💀',
|
||||
'sunny': '☀️',
|
||||
'yellow_heart': '💛',
|
||||
'triumph': '😤',
|
||||
'new_moon_with_face': '🌚',
|
||||
'laughing': '😆',
|
||||
'sweat': '😓',
|
||||
'heavy_check_mark': '✔️',
|
||||
'heart_eyes_cat': '😻',
|
||||
'grinning': '😀',
|
||||
'mask': '😷',
|
||||
'green_heart': '💚',
|
||||
'persevere': '😣',
|
||||
'heartbeat': '💓',
|
||||
'angry': '😠',
|
||||
'grimacing': '😬',
|
||||
'gun': '🔫',
|
||||
'thumbsdown': '👎',
|
||||
'dancer': '💃',
|
||||
'musical_note': '🎵',
|
||||
'no_mouth': '😶',
|
||||
'dizzy': '💫',
|
||||
'fist': '✊',
|
||||
'unamused': '😒',
|
||||
'cold_sweat': '😰',
|
||||
'gem': '💎',
|
||||
'pizza': '🍕',
|
||||
'joy_cat': '😹',
|
||||
'sun_with_face': '🌞',
|
||||
|
||||
// Hearts
|
||||
'heart': '❤️',
|
||||
'two_hearts': '💕',
|
||||
'kiss': '💋',
|
||||
|
||||
// Hand gestures
|
||||
'thumbsup': '👍',
|
||||
'thumbs_up': '👍',
|
||||
'thumbs_down': '👎',
|
||||
'ok_hand': '👌',
|
||||
'pray': '🙏',
|
||||
'raised_hands': '🙌',
|
||||
'clap': '👏',
|
||||
'point_right': '👉',
|
||||
'point_left': '👈',
|
||||
'point_up': '☝️',
|
||||
'point_down': '👇',
|
||||
'raised_hand': '✋',
|
||||
'wave': '👋',
|
||||
'v': '✌️',
|
||||
'oncoming_fist': '👊',
|
||||
'facepunch': '👊',
|
||||
'punch': '👊',
|
||||
|
||||
// Objects & symbols
|
||||
'fire': '🔥',
|
||||
'tada': '🎉',
|
||||
'camera': '📷',
|
||||
'notes': '🎶',
|
||||
'sparkles': '✨',
|
||||
'star2': '🌟',
|
||||
'crown': '👑',
|
||||
'headphones': '🎧',
|
||||
'white_check_mark': '✅',
|
||||
'arrow_right': '➡️',
|
||||
'arrow_left': '⬅️',
|
||||
'arrow_forward': '▶️',
|
||||
'arrow_backward': '◀️',
|
||||
'arrow_right_hook': '↪️',
|
||||
'leftwards_arrow_with_hook': '↩️',
|
||||
'red_circle': '🔴',
|
||||
'boom': '💥',
|
||||
'collision': '💥',
|
||||
'copyright': '©️',
|
||||
'thought_balloon': '💭',
|
||||
'recycle': '♻️',
|
||||
|
||||
// Nature
|
||||
'cherry_blossom': '🌸',
|
||||
'rose': '🌹',
|
||||
'scream': '😱',
|
||||
|
||||
// Body parts
|
||||
'eyes': '👀',
|
||||
'tongue': '👅',
|
||||
|
||||
// Misc
|
||||
'poop': '💩',
|
||||
'poo': '💩',
|
||||
'shit': '💩',
|
||||
'hankey': '💩',
|
||||
'innocent': '😇',
|
||||
'kissing_closed_eyes': '😚',
|
||||
'stuck_out_tongue': '😛',
|
||||
'disappointed_relieved': '😥',
|
||||
'confounded': '😖',
|
||||
'raising_hand': '🙋',
|
||||
'no_good': '🙅',
|
||||
'ok_woman': '🙆',
|
||||
'information_desk_person': '💁',
|
||||
'man_tipping_hand': '💁♂️',
|
||||
'woman_tipping_hand': '💁♀️',
|
||||
'man_gesturing_no': '🙅♂️',
|
||||
'woman_gesturing_no': '🙅♀️',
|
||||
'man_gesturing_ok': '🙆♂️',
|
||||
'woman_gesturing_ok': '🙆♀️',
|
||||
'man_raising_hand': '🙋♂️',
|
||||
'woman_raising_hand': '🙋♀️',
|
||||
|
||||
// Common variations and aliases
|
||||
'slightly_smiling_face': '🙂',
|
||||
'upside_down_face': '🙃',
|
||||
'thinking_face': '🤔',
|
||||
'shrug': '🤷',
|
||||
'facepalm': '🤦',
|
||||
'man_shrugging': '🤷♂️',
|
||||
'woman_shrugging': '🤷♀️',
|
||||
'man_facepalming': '🤦♂️',
|
||||
'woman_facepalming': '🤦♀️',
|
||||
'hugging_face': '🤗',
|
||||
'money_mouth_face': '🤑',
|
||||
'nerd_face': '🤓',
|
||||
'face_with_rolling_eyes': '🙄',
|
||||
'zipper_mouth_face': '🤐',
|
||||
'nauseated_face': '🤢',
|
||||
'vomiting_face': '🤮',
|
||||
'sneezing_face': '🤧',
|
||||
'lying_face': '🤥',
|
||||
'drooling_face': '🤤',
|
||||
'sleeping_face': '😴',
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert emoji shortcodes (like :thumbsup: or :joy:) to Unicode emoji characters
|
||||
*/
|
||||
export function convertEmojiShortcodes(text: string): string {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return text;
|
||||
}
|
||||
|
||||
return text.replace(/:([a-zA-Z0-9_+-]+):/g, (match, shortcode) => {
|
||||
const emoji = emojiMap[shortcode.toLowerCase()];
|
||||
return emoji || match; // Return the emoji if found, otherwise return the original text
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string contains emoji shortcodes
|
||||
*/
|
||||
export function hasEmojiShortcodes(text: string): boolean {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /:([a-zA-Z0-9_+-]+):/.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available emoji shortcodes
|
||||
*/
|
||||
export function getAvailableEmojis(): string[] {
|
||||
return Object.keys(emojiMap).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji for a specific shortcode
|
||||
*/
|
||||
export function getEmoji(shortcode: string): string | null {
|
||||
return emojiMap[shortcode.toLowerCase()] || null;
|
||||
}
|
||||
Reference in New Issue
Block a user