Chat fixes and layout tweaks

This commit is contained in:
2025-06-15 00:55:49 -04:00
parent e793cb0cc5
commit 12a0f540b3
6 changed files with 673 additions and 142 deletions

View File

@@ -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'))); 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) => { router.get('/users', async (req, res) => {
try { try {
const result = await global.pool.query(` const result = await global.pool.query(`
SELECT id, username, name, type, active, status, lastlogin, SELECT id, username, name, type, active, status, lastlogin,
statustext, utcoffset, statusconnection statustext, utcoffset, statusconnection, mongo_id, avataretag
FROM users FROM users
WHERE active = true AND type = 'user' WHERE type = 'user'
ORDER BY ORDER BY
active DESC, -- Active users first
CASE CASE
WHEN status = 'online' THEN 1 WHEN status = 'online' THEN 1
WHEN status = 'away' THEN 2 WHEN status = 'away' THEN 2
@@ -188,6 +294,7 @@ router.get('/users/:userId/rooms', async (req, res) => {
const currentUsername = userResult.rows[0].username; const currentUsername = userResult.rows[0].username;
// Get rooms where the user is a member with proper naming from subscription table // 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(` const result = await global.pool.query(`
SELECT DISTINCT SELECT DISTINCT
r.id, r.id,
@@ -201,6 +308,9 @@ router.get('/users/:userId/rooms', async (req, res) => {
r.uids, r.uids,
r.userscount, r.userscount,
r.description, r.description,
r.teamid,
r.archived,
s.open,
-- Use the subscription's name for direct messages (excludes current user) -- Use the subscription's name for direct messages (excludes current user)
-- For channels/groups, use room's fname or name -- For channels/groups, use room's fname or name
CASE CASE
@@ -210,15 +320,32 @@ router.get('/users/:userId/rooms', async (req, res) => {
FROM room r FROM room r
JOIN subscription s ON s.rid = r.mongo_id JOIN subscription s ON s.rid = r.mongo_id
WHERE s.u->>'_id' = $1 WHERE s.u->>'_id' = $1
AND r.archived IS NOT TRUE ORDER BY
AND s.open = true s.open DESC NULLS LAST, -- Open rooms first
ORDER BY r.lm DESC NULLS LAST r.archived NULLS FIRST, -- Non-archived first (nulls treated as false)
r.lm DESC NULLS LAST
LIMIT 50 LIMIT 50
`, [currentUserMongoId]); `, [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({ res.json({
status: 'success', status: 'success',
rooms: result.rows rooms: enhancedRooms
}); });
} catch (error) { } catch (error) {
console.error('Error fetching user rooms:', 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 // Get room details including participants
router.get('/rooms/:roomId', async (req, res) => { router.get('/rooms/:roomId', async (req, res) => {
const { roomId } = req.params; const { roomId } = req.params;
const { userId } = req.query; // Accept current user ID as query parameter
try { try {
const result = await global.pool.query(` const result = await global.pool.query(`
SELECT r.id, r.name, r.fname, r.t as type, r.msgs, r.description, 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 FROM room r
WHERE r.id = $1 WHERE r.id = $1
`, [roomId]); `, [roomId]);
@@ -251,8 +379,38 @@ router.get('/rooms/:roomId', async (req, res) => {
const room = result.rows[0]; const room = result.rows[0];
// Get room participants for direct messages // For direct messages, get the proper display name based on current user
if (room.type === 'd' && room.uids) { 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(` const participantResult = await global.pool.query(`
SELECT username, name SELECT username, name
FROM users FROM users
@@ -260,6 +418,9 @@ router.get('/rooms/:roomId', async (req, res) => {
`, [room.uids]); `, [room.uids]);
room.participants = participantResult.rows; 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({ res.json({

View File

@@ -2,9 +2,11 @@ import React, { useState, useEffect, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; 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 { Input } from '@/components/ui/input';
import config from '@/config'; import config from '@/config';
import { convertEmojiShortcodes } from '@/utils/emojiUtils';
interface Message { interface Message {
id: number; id: number;
@@ -44,6 +46,7 @@ interface Room {
last_message_date: string; last_message_date: string;
display_name: string; display_name: string;
description?: string; description?: string;
teamid?: string;
participants?: { username: string; name: string }[]; participants?: { username: string; name: string }[];
} }
@@ -72,7 +75,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
const fetchRoom = async () => { const fetchRoom = async () => {
try { try {
const response = await fetch(`${config.chatUrl}/rooms/${roomId}`); const response = await fetch(`${config.chatUrl}/rooms/${roomId}?userId=${selectedUserId}`);
const data = await response.json(); const data = await response.json();
if (data.status === 'success') { if (data.status === 'success') {
@@ -194,12 +197,17 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
} }
}, [roomId, selectedUserId]); }, [roomId, selectedUserId]);
const getRoomIcon = (roomType: string) => { const getRoomIcon = (room: Room) => {
switch (roomType) { switch (room.type) {
case 'c': case 'c':
return <Hash className="h-4 w-4 text-blue-500" />; return <Hash className="h-4 w-4 text-blue-500" />;
case 'p': 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': case 'd':
return <MessageSquare className="h-4 w-4 text-green-500" />; return <MessageSquare className="h-4 w-4 text-green-500" />;
default: 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[]) => { const renderURLPreviews = (urls: any[]) => {
if (!urls || urls.length === 0) return null; if (!urls || urls.length === 0) return null;
@@ -235,11 +267,8 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm break-all" className="text-blue-600 hover:underline text-sm break-all"
> >
{urlData.url} {urlData.meta?.pageTitle || urlData.url}
</a> </a>
{urlData.meta?.pageTitle && (
<div className="font-medium text-sm mt-1">{urlData.meta.pageTitle}</div>
)}
{urlData.meta?.ogDescription && ( {urlData.meta?.ogDescription && (
<div className="text-xs text-muted-foreground mt-1">{urlData.meta.ogDescription}</div> <div className="text-xs text-muted-foreground mt-1">{urlData.meta.ogDescription}</div>
)} )}
@@ -251,14 +280,32 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
); );
}; };
const renderAttachments = (attachments: any[]) => { const renderAttachments = (attachments: any[]) => {
if (!attachments || attachments.length === 0) return null; 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 ( return (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{attachments.map((attachment, index) => { {filteredAttachments.map((attachment, index) => {
const isImage = attachment.typegroup === 'image'; const isImage = attachment.typegroup === 'image';
const filePath = `${config.chatUrl}/files/by-id/${attachment.mongo_id}`; 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 ( return (
<div key={index} className="border rounded-lg p-3 bg-gray-50"> <div key={index} className="border rounded-lg p-3 bg-gray-50">
@@ -277,7 +324,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
)} )}
</div> </div>
</div> </div>
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm" onClick={handleDownload}>
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -286,8 +333,9 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
<img <img
src={filePath} src={filePath}
alt={attachment.name} 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" loading="lazy"
onClick={() => window.open(filePath, '_blank')}
/> />
</div> </div>
)} )}
@@ -301,7 +349,10 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
const renderMentions = (text: string, mentions: any[]) => { const renderMentions = (text: string, mentions: any[]) => {
if (!mentions || mentions.length === 0) return text; 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) => { mentions.forEach((mention) => {
if (mention.username) { if (mention.username) {
const mentionPattern = new RegExp(`@${mention.username}`, 'g'); 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'}`}> <div key={message.id} className={`${isConsecutive ? 'mt-1' : 'mt-4'}`}>
{!isConsecutive && ( {!isConsecutive && (
<div className="flex items-center gap-2 mb-1"> <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">
{(message.u.name || message.u.username).charAt(0).toUpperCase()} <AvatarImage
</div> 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()}
</AvatarFallback>
</Avatar>
<span className="font-medium text-sm"> <span className="font-medium text-sm">
{message.u.name || message.u.username} {message.u.name || message.u.username}
</span> </span>
@@ -340,7 +397,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
<div className="break-words"> <div className="break-words">
{message.mentions && message.mentions.length > 0 {message.mentions && message.mentions.length > 0
? renderMentions(message.msg, message.mentions) ? renderMentions(message.msg, message.mentions)
: message.msg : renderMessageText(message.msg, message.urls)
} }
</div> </div>
{message.urls && renderURLPreviews(message.urls)} {message.urls && renderURLPreviews(message.urls)}
@@ -388,15 +445,19 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
<CardHeader className="border-b p-4"> <CardHeader className="border-b p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{room && getRoomIcon(room.type)} {room && getRoomIcon(room)}
<div> <div>
<CardTitle className="text-lg"> <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> </CardTitle>
{room?.description && ( {room?.description && room?.type !== 'd' && (
<p className="text-sm text-muted-foreground">{room.description}</p> <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"> <p className="text-xs text-muted-foreground">
{room.participants.map(p => p.name || p.username).join(', ')} {room.participants.map(p => p.name || p.username).join(', ')}
</p> </p>
@@ -412,7 +473,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
</Button> </Button>
<Badge variant="secondary">{room?.msgs || 0} messages</Badge>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; 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 { Input } from '@/components/ui/input';
import config from '@/config'; import config from '@/config';
@@ -15,6 +16,15 @@ interface Room {
display_name: string; display_name: string;
userscount?: number; userscount?: number;
description?: string; description?: string;
teamid?: string;
archived?: boolean;
open?: boolean;
participants?: {
username: string;
name: string;
mongo_id: string;
avataretag?: string;
}[];
} }
interface RoomListProps { interface RoomListProps {
@@ -75,48 +85,119 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL
} }
}, [searchFilter, rooms]); }, [searchFilter, rooms]);
const getRoomIcon = (roomType: string) => { const groupRoomsByType = (rooms: Room[]) => {
switch (roomType) { const teams: Room[] = [];
case 'c': const discussions: Room[] = [];
return <Hash className="h-4 w-4 text-blue-500" />; const channels: Room[] = [];
case 'p': const directMessages: Room[] = [];
return <Lock className="h-4 w-4 text-orange-500" />;
case 'd': rooms.forEach(room => {
return <MessageSquare className="h-4 w-4 text-green-500" />; switch (room.type) {
default: case 'p':
return <Users className="h-4 w-4 text-gray-500" />; if (room.teamid) {
} teams.push(room);
} else {
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 getRoomTypeLabel = (roomType: string) => { const renderRoomIcon = (room: Room) => {
switch (roomType) { // For direct messages, show participant avatars
case 'c': if (room.type === 'd' && room.participants && room.participants.length > 0) {
return 'Channel'; if (room.participants.length === 1) {
case 'p': // Single participant - show their avatar
return 'Private'; const participant = room.participants[0];
case 'd': return (
return 'Direct'; <Avatar className="h-10 w-10">
default: <AvatarImage
return 'Unknown'; 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>
);
}
} }
};
const formatLastMessageDate = (dateString: string) => { // For other room types, use icons
if (!dateString) return ''; switch (room.type) {
case 'c':
const date = new Date(dateString); return (
const now = new Date(); <div className="h-10 w-10 bg-blue-50 rounded-full flex items-center justify-center">
const diffTime = Math.abs(now.getTime() - date.getTime()); <Hash className="h-6 w-6 text-blue-500" />
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); </div>
);
if (diffDays === 1) { case 'p':
return 'Today'; // Distinguish between teams and discussions based on teamid
} else if (diffDays === 2) { if (room.teamid) {
return 'Yesterday'; return (
} else if (diffDays <= 7) { <div className="h-10 w-10 bg-purple-50 rounded-full flex items-center justify-center">
return `${diffDays - 1} days ago`; <Users2 className="h-6 w-6 text-purple-500" />
} else { </div>
return date.toLocaleDateString(); ); // 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 ( return (
<Card className="h-full flex flex-col"> <Card className="h-full flex flex-col">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-lg">Rooms ({filteredRooms.length})</CardTitle> <CardTitle className="text-lg">Rooms</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> </CardHeader>
<CardContent className="flex-1 p-0 overflow-hidden"> <CardContent className="flex-1 p-0 overflow-hidden">
@@ -188,60 +260,69 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-1 p-2"> <div className="space-y-3 px-2 pb-4">
{filteredRooms.map((room) => ( {(() => {
<div const { teams, discussions, channels, directMessages } = groupRoomsByType(filteredRooms);
key={room.id}
onClick={() => onRoomSelect(room.id.toString())} const renderRoomGroup = (title: string, rooms: Room[], icon: React.ReactNode) => {
className={` if (rooms.length === 0) return null;
flex items-center justify-between p-3 rounded-lg cursor-pointer transition-colors
hover:bg-gray-100 return (
${selectedRoomId === room.id.toString() ? 'bg-blue-50 border-l-4 border-blue-500' : ''} <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}
<div className="flex items-center gap-3 min-w-0 flex-1"> <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{getRoomIcon(room.type)} {title} ({rooms.length})
<div className="min-w-0 flex-1"> </h3>
<div className="font-medium text-sm truncate">
{room.display_name || room.fname || room.name || 'Unnamed Room'}
</div> </div>
<div className="flex items-center gap-2 mt-1"> <div className="space-y-0.5">
<Badge {rooms.map((room) => (
variant="secondary" <div
className={`text-xs ${ key={room.id}
room.type === 'c' ? 'bg-blue-100 text-blue-800' : onClick={() => onRoomSelect(room.id.toString())}
room.type === 'p' ? 'bg-orange-100 text-orange-800' : className={`
room.type === 'd' ? 'bg-green-100 text-green-800' : flex items-center justify-between py-0.5 px-3 rounded-lg cursor-pointer transition-colors
'bg-gray-100 text-gray-800' 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' : ''}
{getRoomTypeLabel(room.type)} `}
</Badge> >
{room.userscount && room.userscount > 0 && ( <div className="grid grid-cols-4 items-center gap-2 min-w-0 flex-1">
<span className="text-xs text-muted-foreground"> {renderRoomIcon(room)}
{room.userscount} member{room.userscount !== 1 ? 's' : ''} <div className="min-w-0 flex-1 col-span-3">
</span> <div className="flex items-center justify-between">
)} <div className={`font-medium text-sm truncate ${(room.open === false || room.archived === true) ? 'text-muted-foreground' : ''}`}>
{room.msgs > 0 && ( {room.display_name || room.fname || room.name || 'Unnamed Room'}
<span className="text-xs text-muted-foreground"> </div>
{room.msgs} msgs {room.msgs > 0 && (
</span> <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 truncate">
{room.description}
</div>
)}
</div>
</div>
</div>
))}
</div> </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> };
))}
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>
)} )}
</div> </div>

View File

@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Hash, Lock, MessageSquare, X } from 'lucide-react'; import { Hash, Lock, MessageSquare, X } from 'lucide-react';
import { convertEmojiShortcodes } from '@/utils/emojiUtils';
interface SearchResult { interface SearchResult {
id: number; id: number;
@@ -40,10 +41,13 @@ export function SearchResults({ results, query, onClose, onRoomSelect }: SearchR
}; };
const highlightText = (text: string, query: string) => { 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 regex = new RegExp(`(${query})`, 'gi');
const parts = text.split(regex); const parts = textWithEmoji.split(regex);
return parts.map((part, index) => return parts.map((part, index) =>
regex.test(part) ? ( regex.test(part) ? (

View File

@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Loader2, Search } from 'lucide-react'; import { Loader2, Search } from 'lucide-react';
import { RoomList } from '@/components/chat/RoomList'; import { RoomList } from '@/components/chat/RoomList';
import { ChatRoom } from '@/components/chat/ChatRoom'; import { ChatRoom } from '@/components/chat/ChatRoom';
@@ -19,6 +20,8 @@ interface User {
lastlogin?: string; lastlogin?: string;
statustext?: string; statustext?: string;
statusconnection?: string; statusconnection?: string;
mongo_id?: string;
avataretag?: string;
} }
interface SearchResult { interface SearchResult {
@@ -186,11 +189,19 @@ export function Chat() {
{users.map((user) => ( {users.map((user) => (
<SelectItem key={user.id} value={user.id.toString()}> <SelectItem key={user.id} value={user.id.toString()}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-1"> <Avatar className="h-6 w-6">
<AvatarImage
<span>{user.name || user.username}</span> src={user.mongo_id ? `${config.chatUrl}/avatar/${user.mongo_id}` : undefined}
</div> 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> </div>
</SelectItem> </SelectItem>
))} ))}

View 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;
}