mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 07:57:55 +00:00
feat: add P2P messages inbox and OKX-style navigation
- Add P2PMessages inbox page listing all trade conversations - Update P2PDashboard top nav with icon+label buttons (Orders, Ads, Messages) - Add unread message count badge with realtime subscription - Add /p2p/messages route - Add i18n translations for all 6 locales
This commit is contained in:
@@ -42,6 +42,7 @@ const P2PTrade = lazy(() => import('./pages/P2PTrade'));
|
||||
const P2POrders = lazy(() => import('./pages/P2POrders'));
|
||||
const P2PDispute = lazy(() => import('./pages/P2PDispute'));
|
||||
const P2PMerchantDashboard = lazy(() => import('./pages/P2PMerchantDashboard'));
|
||||
const P2PMessages = lazy(() => import('./pages/P2PMessages'));
|
||||
const DEXDashboard = lazy(() => import('./components/dex/DEXDashboard').then(m => ({ default: m.DEXDashboard })));
|
||||
const Presale = lazy(() => import('./pages/Presale'));
|
||||
const PresaleList = lazy(() => import('./pages/launchpad/PresaleList'));
|
||||
@@ -199,6 +200,11 @@ function App() {
|
||||
<P2PLayout><P2PDispute /></P2PLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/p2p/messages" element={
|
||||
<ProtectedRoute allowTelegramSession>
|
||||
<P2PLayout><P2PMessages /></P2PLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/p2p/merchant" element={
|
||||
<ProtectedRoute allowTelegramSession>
|
||||
<P2PLayout><P2PMerchantDashboard /></P2PLayout>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { PlusCircle, Home, ClipboardList, TrendingUp, CheckCircle2, Clock, Store, Zap, Blocks } from 'lucide-react';
|
||||
import { PlusCircle, Home, ClipboardList, TrendingUp, CheckCircle2, Clock, Store, Zap, Blocks, MessageSquare } from 'lucide-react';
|
||||
import { AdList } from './AdList';
|
||||
import { CreateAd } from './CreateAd';
|
||||
import { NotificationBell } from './NotificationBell';
|
||||
@@ -36,10 +36,75 @@ export function P2PDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { userId } = useP2PIdentity();
|
||||
|
||||
const [unreadMessages, setUnreadMessages] = useState(0);
|
||||
|
||||
const handleBalanceUpdated = () => {
|
||||
setBalanceRefreshKey(prev => prev + 1);
|
||||
};
|
||||
|
||||
// Fetch unread message count
|
||||
useEffect(() => {
|
||||
const fetchUnread = async () => {
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
// Get all trade IDs where user is participant
|
||||
const { data: trades } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('id')
|
||||
.or(`seller_id.eq.${userId},buyer_id.eq.${userId}`);
|
||||
|
||||
if (!trades || trades.length === 0) return;
|
||||
|
||||
const tradeIds = trades.map(t => t.id);
|
||||
|
||||
const { count } = await supabase
|
||||
.from('p2p_messages')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.in('trade_id', tradeIds)
|
||||
.neq('sender_id', userId)
|
||||
.eq('is_read', false);
|
||||
|
||||
setUnreadMessages(count || 0);
|
||||
} catch (error) {
|
||||
console.error('Fetch unread count error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUnread();
|
||||
|
||||
// Realtime subscription for new messages
|
||||
const channel = supabase
|
||||
.channel('p2p-unread-count')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'p2p_messages',
|
||||
},
|
||||
() => {
|
||||
fetchUnread();
|
||||
}
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'p2p_messages',
|
||||
},
|
||||
() => {
|
||||
fetchUnread();
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
// Fetch user stats
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
@@ -93,29 +158,39 @@ export function P2PDashboard() {
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
{t('p2p.backToHome')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1 sm:gap-3">
|
||||
<NotificationBell />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/p2p/merchant')}
|
||||
className="border-gray-700 hover:bg-gray-800"
|
||||
>
|
||||
<Store className="w-4 h-4 mr-2" />
|
||||
{t('p2p.merchant')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
<button
|
||||
onClick={() => navigate('/p2p/orders')}
|
||||
className="border-gray-700 hover:bg-gray-800"
|
||||
className="relative flex flex-col items-center gap-0.5 px-3 py-1.5 rounded-lg hover:bg-gray-800/60 transition-colors"
|
||||
>
|
||||
<ClipboardList className="w-4 h-4 mr-2" />
|
||||
{t('p2p.myTrades')}
|
||||
<ClipboardList className="w-5 h-5 text-gray-300" />
|
||||
<span className="text-[10px] text-gray-400">{t('p2pNav.orders')}</span>
|
||||
{userStats.activeTrades > 0 && (
|
||||
<Badge className="ml-2 bg-yellow-500 text-black">
|
||||
<Badge className="absolute -top-1 -right-1 bg-yellow-500 text-black text-[10px] px-1.5 min-w-[18px] h-[18px] flex items-center justify-center">
|
||||
{userStats.activeTrades}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/p2p/merchant')}
|
||||
className="relative flex flex-col items-center gap-0.5 px-3 py-1.5 rounded-lg hover:bg-gray-800/60 transition-colors"
|
||||
>
|
||||
<Store className="w-5 h-5 text-gray-300" />
|
||||
<span className="text-[10px] text-gray-400">{t('p2pNav.ads')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/p2p/messages')}
|
||||
className="relative flex flex-col items-center gap-0.5 px-3 py-1.5 rounded-lg hover:bg-gray-800/60 transition-colors"
|
||||
>
|
||||
<MessageSquare className="w-5 h-5 text-gray-300" />
|
||||
<span className="text-[10px] text-gray-400">{t('p2pNav.messages')}</span>
|
||||
{unreadMessages > 0 && (
|
||||
<Badge className="absolute -top-1 -right-1 bg-green-500 text-white text-[10px] px-1.5 min-w-[18px] h-[18px] flex items-center justify-center">
|
||||
{unreadMessages}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1553,6 +1553,19 @@ export default {
|
||||
'p2pChat.newMessage': 'رسالة جديدة',
|
||||
'p2pChat.newImage': 'صورة جديدة',
|
||||
|
||||
// P2P Navigation
|
||||
'p2pNav.orders': 'الطلبات',
|
||||
'p2pNav.ads': 'الإعلانات',
|
||||
'p2pNav.messages': 'الرسائل',
|
||||
|
||||
// P2P Messages Inbox
|
||||
'p2pMessages.title': 'الرسائل',
|
||||
'p2pMessages.subtitle': 'محادثات التداول الخاصة بك',
|
||||
'p2pMessages.noConversations': 'لا توجد محادثات بعد',
|
||||
'p2pMessages.noConversationsHint': 'ابدأ صفقة لبدء المحادثة',
|
||||
'p2pMessages.you': 'أنت',
|
||||
'p2pMessages.sentImage': 'أرسل صورة',
|
||||
|
||||
'p2pFilters.filters': 'الفلاتر',
|
||||
'p2pFilters.filterOrders': 'فلترة الطلبات',
|
||||
'p2pFilters.reset': 'إعادة تعيين',
|
||||
|
||||
@@ -1543,6 +1543,19 @@ export default {
|
||||
'p2pChat.newMessage': 'نامەی نوێ',
|
||||
'p2pChat.newImage': 'وێنەی نوێ',
|
||||
|
||||
// P2P Navigation
|
||||
'p2pNav.orders': 'داواکاریەکان',
|
||||
'p2pNav.ads': 'ڕیکلامەکان',
|
||||
'p2pNav.messages': 'پەیامەکان',
|
||||
|
||||
// P2P Messages Inbox
|
||||
'p2pMessages.title': 'پەیامەکان',
|
||||
'p2pMessages.subtitle': 'گفتوگۆکانی بازرگانیت',
|
||||
'p2pMessages.noConversations': 'هێشتا گفتوگۆ نییە',
|
||||
'p2pMessages.noConversationsHint': 'بۆ دەستپێکردنی گفتوگۆ بازرگانییەک بکە',
|
||||
'p2pMessages.you': 'تۆ',
|
||||
'p2pMessages.sentImage': 'وێنەیەکی ناردووە',
|
||||
|
||||
'p2pFilters.filters': 'فلتەرەکان',
|
||||
'p2pFilters.filterOrders': 'داواکارییەکان فلتەر بکە',
|
||||
'p2pFilters.reset': 'ڕیسێت',
|
||||
|
||||
@@ -1907,6 +1907,19 @@ export default {
|
||||
'p2pChat.newMessage': 'New Message',
|
||||
'p2pChat.newImage': 'New Image',
|
||||
|
||||
// P2P Navigation
|
||||
'p2pNav.orders': 'Orders',
|
||||
'p2pNav.ads': 'Ads',
|
||||
'p2pNav.messages': 'Messages',
|
||||
|
||||
// P2P Messages Inbox
|
||||
'p2pMessages.title': 'Messages',
|
||||
'p2pMessages.subtitle': 'Your trade conversations',
|
||||
'p2pMessages.noConversations': 'No conversations yet',
|
||||
'p2pMessages.noConversationsHint': 'Start a trade to begin chatting',
|
||||
'p2pMessages.you': 'You',
|
||||
'p2pMessages.sentImage': 'Sent an image',
|
||||
|
||||
// OrderFilters
|
||||
'p2pFilters.filters': 'Filters',
|
||||
'p2pFilters.filterOrders': 'Filter Orders',
|
||||
|
||||
@@ -1577,6 +1577,19 @@ export default {
|
||||
'p2pChat.newMessage': 'پیام جدید',
|
||||
'p2pChat.newImage': 'تصویر جدید',
|
||||
|
||||
// P2P Navigation
|
||||
'p2pNav.orders': 'سفارشات',
|
||||
'p2pNav.ads': 'آگهیها',
|
||||
'p2pNav.messages': 'پیامها',
|
||||
|
||||
// P2P Messages Inbox
|
||||
'p2pMessages.title': 'پیامها',
|
||||
'p2pMessages.subtitle': 'گفتگوهای معاملاتی شما',
|
||||
'p2pMessages.noConversations': 'هنوز گفتگویی وجود ندارد',
|
||||
'p2pMessages.noConversationsHint': 'برای شروع گفتگو یک معامله آغاز کنید',
|
||||
'p2pMessages.you': 'شما',
|
||||
'p2pMessages.sentImage': 'تصویری ارسال شد',
|
||||
|
||||
// OrderFilters
|
||||
'p2pFilters.filters': 'فیلترها',
|
||||
'p2pFilters.filterOrders': 'فیلتر سفارشات',
|
||||
|
||||
@@ -1565,6 +1565,19 @@ export default {
|
||||
'p2pChat.newMessage': 'Peyama Nû',
|
||||
'p2pChat.newImage': 'Wêneya Nû',
|
||||
|
||||
// P2P Navigation
|
||||
'p2pNav.orders': 'Ferman',
|
||||
'p2pNav.ads': 'Reklam',
|
||||
'p2pNav.messages': 'Peyam',
|
||||
|
||||
// P2P Messages Inbox
|
||||
'p2pMessages.title': 'Peyam',
|
||||
'p2pMessages.subtitle': 'Sohbetên bazirganiya we',
|
||||
'p2pMessages.noConversations': 'Hêj sohbet tune',
|
||||
'p2pMessages.noConversationsHint': 'Ji bo destpêkirina sohbetê bazirganiyekê bikin',
|
||||
'p2pMessages.you': 'Tu',
|
||||
'p2pMessages.sentImage': 'Wêneyek şand',
|
||||
|
||||
// OrderFilters
|
||||
'p2pFilters.filters': 'Fîltre',
|
||||
'p2pFilters.filterOrders': 'Siparîşan Fîltre Bike',
|
||||
|
||||
@@ -1559,6 +1559,19 @@ export default {
|
||||
'p2pChat.newMessage': 'Yeni Mesaj',
|
||||
'p2pChat.newImage': 'Yeni Resim',
|
||||
|
||||
// P2P Navigation
|
||||
'p2pNav.orders': 'Siparişler',
|
||||
'p2pNav.ads': 'İlanlar',
|
||||
'p2pNav.messages': 'Mesajlar',
|
||||
|
||||
// P2P Messages Inbox
|
||||
'p2pMessages.title': 'Mesajlar',
|
||||
'p2pMessages.subtitle': 'Trade sohbetleriniz',
|
||||
'p2pMessages.noConversations': 'Henüz sohbet yok',
|
||||
'p2pMessages.noConversationsHint': 'Sohbet başlatmak için bir trade başlatın',
|
||||
'p2pMessages.you': 'Sen',
|
||||
'p2pMessages.sentImage': 'Bir resim gönderdi',
|
||||
|
||||
// OrderFilters
|
||||
'p2pFilters.filters': 'Filtreler',
|
||||
'p2pFilters.filterOrders': 'Siparişleri Filtrele',
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
ArrowLeft,
|
||||
MessageSquare,
|
||||
Loader2,
|
||||
Image as ImageIcon,
|
||||
} from 'lucide-react';
|
||||
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
interface TradeConversation {
|
||||
tradeId: string;
|
||||
counterpartyId: string;
|
||||
counterpartyWallet: string;
|
||||
lastMessage: string;
|
||||
lastMessageType: 'text' | 'image' | 'system';
|
||||
lastMessageAt: string;
|
||||
lastMessageSenderId: string;
|
||||
unreadCount: number;
|
||||
tradeStatus: string;
|
||||
}
|
||||
|
||||
export default function P2PMessages() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { userId } = useP2PIdentity();
|
||||
|
||||
const [conversations, setConversations] = useState<TradeConversation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchConversations = useCallback(async () => {
|
||||
if (!userId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Get all trades where user is buyer or seller
|
||||
const { data: trades, error: tradesError } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('id, seller_id, buyer_id, buyer_wallet, offer_id, status')
|
||||
.or(`seller_id.eq.${userId},buyer_id.eq.${userId}`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (tradesError) throw tradesError;
|
||||
if (!trades || trades.length === 0) {
|
||||
setConversations([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const tradeIds = trades.map(t => t.id);
|
||||
|
||||
// 2. Get all messages for these trades
|
||||
const { data: messages, error: msgsError } = await supabase
|
||||
.from('p2p_messages')
|
||||
.select('id, trade_id, sender_id, message, message_type, is_read, created_at')
|
||||
.in('trade_id', tradeIds)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (msgsError) throw msgsError;
|
||||
|
||||
// 3. Get offer data for seller_wallet resolution
|
||||
const offerIds = [...new Set(trades.map(t => t.offer_id))];
|
||||
const { data: offers } = await supabase
|
||||
.from('p2p_fiat_offers')
|
||||
.select('id, seller_wallet')
|
||||
.in('id', offerIds);
|
||||
|
||||
const offerMap = new Map(offers?.map(o => [o.id, o.seller_wallet]) || []);
|
||||
|
||||
// 4. Group messages by trade_id and build conversation list
|
||||
const convos: TradeConversation[] = [];
|
||||
|
||||
for (const trade of trades) {
|
||||
const tradeMessages = messages?.filter(m => m.trade_id === trade.id) || [];
|
||||
if (tradeMessages.length === 0) continue; // Skip trades with no messages
|
||||
|
||||
const lastMsg = tradeMessages[0]; // Already sorted desc
|
||||
const unreadCount = tradeMessages.filter(
|
||||
m => m.sender_id !== userId && !m.is_read
|
||||
).length;
|
||||
|
||||
// Resolve counterparty
|
||||
const isBuyer = trade.buyer_id === userId;
|
||||
const counterpartyId = isBuyer ? trade.seller_id : trade.buyer_id;
|
||||
const counterpartyWallet = isBuyer
|
||||
? (offerMap.get(trade.offer_id) || '???')
|
||||
: trade.buyer_wallet;
|
||||
|
||||
convos.push({
|
||||
tradeId: trade.id,
|
||||
counterpartyId,
|
||||
counterpartyWallet,
|
||||
lastMessage: lastMsg.message,
|
||||
lastMessageType: lastMsg.message_type,
|
||||
lastMessageAt: lastMsg.created_at,
|
||||
lastMessageSenderId: lastMsg.sender_id,
|
||||
unreadCount,
|
||||
tradeStatus: trade.status,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by last message time (newest first)
|
||||
convos.sort((a, b) =>
|
||||
new Date(b.lastMessageAt).getTime() - new Date(a.lastMessageAt).getTime()
|
||||
);
|
||||
|
||||
setConversations(convos);
|
||||
} catch (error) {
|
||||
console.error('Fetch conversations error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConversations();
|
||||
}, [fetchConversations]);
|
||||
|
||||
// Realtime subscription for new messages
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel('p2p-messages-inbox')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'p2p_messages',
|
||||
},
|
||||
() => {
|
||||
// Refetch conversations when any new message arrives
|
||||
fetchConversations();
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [userId, fetchConversations]);
|
||||
|
||||
const formatRelativeTime = (dateString: string) => {
|
||||
const now = new Date();
|
||||
const date = new Date(dateString);
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMin < 1) return t('p2p.justNow');
|
||||
if (diffMin < 60) return `${diffMin}m`;
|
||||
if (diffHr < 24) return `${diffHr}h`;
|
||||
if (diffDay < 7) return `${diffDay}d`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const truncateWallet = (wallet: string) => {
|
||||
if (wallet.length <= 10) return wallet;
|
||||
return `${wallet.slice(0, 6)}...${wallet.slice(-4)}`;
|
||||
};
|
||||
|
||||
const getMessagePreview = (convo: TradeConversation) => {
|
||||
const isOwn = convo.lastMessageSenderId === userId;
|
||||
const prefix = isOwn ? `${t('p2pMessages.you')}: ` : '';
|
||||
|
||||
if (convo.lastMessageType === 'image') {
|
||||
return `${prefix}${t('p2pMessages.sentImage')}`;
|
||||
}
|
||||
|
||||
const msg = convo.lastMessage.length > 40
|
||||
? convo.lastMessage.slice(0, 40) + '...'
|
||||
: convo.lastMessage;
|
||||
return `${prefix}${msg}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-3xl">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/p2p')}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">{t('p2pMessages.title')}</h1>
|
||||
<p className="text-sm text-gray-400">{t('p2pMessages.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
) : conversations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-500">
|
||||
<MessageSquare className="w-12 h-12 mb-3" />
|
||||
<p className="text-lg font-medium">{t('p2pMessages.noConversations')}</p>
|
||||
<p className="text-sm">{t('p2pMessages.noConversationsHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{conversations.map((convo) => (
|
||||
<button
|
||||
key={convo.tradeId}
|
||||
onClick={() => navigate(`/p2p/trade/${convo.tradeId}`)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800/60 transition-colors text-left"
|
||||
>
|
||||
<Avatar className="h-10 w-10 shrink-0">
|
||||
<AvatarFallback className="bg-gray-700 text-sm">
|
||||
{convo.counterpartyWallet.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-white truncate">
|
||||
{truncateWallet(convo.counterpartyWallet)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 shrink-0">
|
||||
{formatRelativeTime(convo.lastMessageAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 mt-0.5">
|
||||
<span className={`text-xs truncate ${convo.unreadCount > 0 ? 'text-gray-300 font-medium' : 'text-gray-500'}`}>
|
||||
{convo.lastMessageType === 'image' && (
|
||||
<ImageIcon className="w-3 h-3 inline mr-1" />
|
||||
)}
|
||||
{getMessagePreview(convo)}
|
||||
</span>
|
||||
{convo.unreadCount > 0 && (
|
||||
<Badge className="bg-green-500 text-white text-[10px] px-1.5 py-0 min-w-[18px] h-[18px] flex items-center justify-center shrink-0">
|
||||
{convo.unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user