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')));
|
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({
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
@@ -254,12 +283,30 @@ 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">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -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">
|
||||||
|
<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()}
|
{(message.u.name || message.u.username).charAt(0).toUpperCase()}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
const directMessages: Room[] = [];
|
||||||
|
|
||||||
|
rooms.forEach(room => {
|
||||||
|
switch (room.type) {
|
||||||
case 'p':
|
case 'p':
|
||||||
return <Lock className="h-4 w-4 text-orange-500" />;
|
if (room.teamid) {
|
||||||
case 'd':
|
teams.push(room);
|
||||||
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 {
|
} 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 (
|
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,61 +260,70 @@ 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) => (
|
{(() => {
|
||||||
|
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
|
<div
|
||||||
key={room.id}
|
key={room.id}
|
||||||
onClick={() => onRoomSelect(room.id.toString())}
|
onClick={() => onRoomSelect(room.id.toString())}
|
||||||
className={`
|
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
|
hover:bg-gray-100
|
||||||
${selectedRoomId === room.id.toString() ? 'bg-blue-50 border-l-4 border-blue-500' : ''}
|
${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">
|
<div className="grid grid-cols-4 items-center gap-2 min-w-0 flex-1">
|
||||||
{getRoomIcon(room.type)}
|
{renderRoomIcon(room)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1 col-span-3">
|
||||||
<div className="font-medium text-sm truncate">
|
<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'}
|
{room.display_name || room.fname || room.name || 'Unnamed Room'}
|
||||||
</div>
|
</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 && (
|
{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
|
{room.msgs} msgs
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{room.description && (
|
{room.description && (
|
||||||
<div className="text-xs text-muted-foreground mt-1 truncate">
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
{room.description}
|
{room.description}
|
||||||
</div>
|
</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>
|
</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>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -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) ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
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