feat: Add chat management actions including clear and delete conversation, and enhance session token refresh logic.
This commit is contained in:
66
src/components/message/ChatDropdownMenu.tsx
Normal file
66
src/components/message/ChatDropdownMenu.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
export interface DropdownMenuItem {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface ChatDropdownMenuProps {
|
||||
items: DropdownMenuItem[];
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChatDropdownMenu({ items, isOpen, onClose }: ChatDropdownMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute right-0 top-full mt-1 bg-white border border-[#00293d]/10 rounded-[10px] shadow-lg z-50 min-w-[200px] py-1 overflow-hidden"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2.5 font-serif font-normal text-[14px] leading-[19px] transition-colors cursor-pointer ${
|
||||
item.danger
|
||||
? 'text-red-600 hover:bg-red-50'
|
||||
: 'text-[#00293D] hover:bg-[#00293d]/5'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { ChatDropdownMenu, type DropdownMenuItem } from './ChatDropdownMenu';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
name: string;
|
||||
@@ -12,6 +14,8 @@ interface ChatHeaderProps {
|
||||
expertise?: string[];
|
||||
isTyping?: boolean;
|
||||
typingText?: string;
|
||||
onClearChat?: () => void;
|
||||
onDeleteConversation?: () => void;
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
@@ -24,7 +28,42 @@ export function ChatHeader({
|
||||
expertise = [],
|
||||
isTyping = false,
|
||||
typingText,
|
||||
onClearChat,
|
||||
onDeleteConversation,
|
||||
}: ChatHeaderProps) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
|
||||
const menuItems: DropdownMenuItem[] = [
|
||||
{
|
||||
label: 'Clear Chat',
|
||||
onClick: () => onClearChat?.(),
|
||||
},
|
||||
{
|
||||
label: isMuted ? 'Unmute Notifications' : 'Mute Notifications',
|
||||
onClick: () => setIsMuted((prev) => !prev),
|
||||
},
|
||||
{
|
||||
label: 'Report User',
|
||||
onClick: () => {
|
||||
// Placeholder — no backend endpoint yet
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete Conversation',
|
||||
onClick: () => onDeleteConversation?.(),
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
|
||||
const handleToggleMenu = useCallback(() => {
|
||||
setIsMenuOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleCloseMenu = useCallback(() => {
|
||||
setIsMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="border-b border-[#00293d]/10 pb-4">
|
||||
{/* Top row - Name, status, actions */}
|
||||
@@ -46,14 +85,24 @@ export function ChatHeader({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
|
||||
<Image
|
||||
src="/assets/icons/three-dots-horizontal-icon.svg"
|
||||
alt="More options"
|
||||
width={24}
|
||||
height={24}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleToggleMenu}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/three-dots-horizontal-icon.svg"
|
||||
alt="More options"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</button>
|
||||
<ChatDropdownMenu
|
||||
items={menuItems}
|
||||
isOpen={isMenuOpen}
|
||||
onClose={handleCloseMenu}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
|
||||
<Image
|
||||
src="/assets/icons/star-outline-icon.svg"
|
||||
|
||||
@@ -423,14 +423,6 @@ export function MessageInput({ onSend, onSendGif, onSendFile, onTypingStart, onT
|
||||
>
|
||||
{isUploading ? 'Uploading...' : 'Send'}
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
|
||||
<Image
|
||||
src="/assets/icons/three-dots-icon.svg"
|
||||
alt="More options"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Image from 'next/image';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { ChatHeader } from './ChatHeader';
|
||||
import { MessageInput } from './MessageInput';
|
||||
import { ChatDropdownMenu, type DropdownMenuItem } from './ChatDropdownMenu';
|
||||
import { useMessaging } from '@/hooks/useMessaging';
|
||||
import { connectionRequestsService, type ConnectionRequest } from '@/services/connection-requests.service';
|
||||
import { uploadService } from '@/services/upload.service';
|
||||
@@ -107,9 +108,15 @@ export function MessagingPage() {
|
||||
loadMoreMessages,
|
||||
hasMoreMessages,
|
||||
startConversation,
|
||||
markAllAsRead,
|
||||
deleteConversation,
|
||||
clearMessages,
|
||||
} = useMessaging();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isPageMenuOpen, setIsPageMenuOpen] = useState(false);
|
||||
const [isAllMuted, setIsAllMuted] = useState(false);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const [connectedAgents, setConnectedAgents] = useState<ConnectedAgent[]>([]);
|
||||
@@ -339,6 +346,36 @@ export function MessagingPage() {
|
||||
stopTyping();
|
||||
}, [stopTyping]);
|
||||
|
||||
// Handle delete conversation with confirmation
|
||||
const handleDeleteConversation = useCallback(async () => {
|
||||
if (!currentConversation) return;
|
||||
setConfirmDeleteId(currentConversation.id);
|
||||
}, [currentConversation]);
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
if (!confirmDeleteId) return;
|
||||
try {
|
||||
await deleteConversation(confirmDeleteId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete conversation:', error);
|
||||
}
|
||||
setConfirmDeleteId(null);
|
||||
}, [confirmDeleteId, deleteConversation]);
|
||||
|
||||
// Page header menu items
|
||||
const pageMenuItems: DropdownMenuItem[] = [
|
||||
{
|
||||
label: 'Mark All as Read',
|
||||
onClick: () => {
|
||||
markAllAsRead().catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: isAllMuted ? 'Unmute All Notifications' : 'Mute All Notifications',
|
||||
onClick: () => setIsAllMuted((prev) => !prev),
|
||||
},
|
||||
];
|
||||
|
||||
// Handle scroll events
|
||||
const handleScroll = () => {
|
||||
if (messagesContainerRef.current) {
|
||||
@@ -420,6 +457,35 @@ export function MessagingPage() {
|
||||
return (
|
||||
<>
|
||||
<style>{customScrollbarStyles}</style>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{confirmDeleteId && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-[15px] p-6 max-w-[400px] w-full mx-4 shadow-xl">
|
||||
<h3 className="font-fractul font-bold text-[16px] text-[#00293D] mb-2">
|
||||
Delete Conversation
|
||||
</h3>
|
||||
<p className="font-serif font-normal text-[14px] text-[#00293D]/70 mb-6">
|
||||
Are you sure you want to delete this conversation? This action cannot be undone and all messages will be permanently removed.
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
className="px-4 py-2 font-serif font-normal text-[14px] text-[#00293D] rounded-[10px] border border-[#00293d]/10 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 font-serif font-normal text-[14px] text-white bg-red-600 rounded-[10px] hover:bg-red-700 transition-colors cursor-pointer"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-[#00293d]/10 rounded-[20px] bg-white overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 p-4 border-b border-[#00293d]/10">
|
||||
@@ -456,14 +522,24 @@ export function MessagingPage() {
|
||||
|
||||
{/* Header actions */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
|
||||
<Image
|
||||
src="/assets/icons/three-dots-horizontal-icon.svg"
|
||||
alt="More options"
|
||||
width={24}
|
||||
height={24}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsPageMenuOpen((prev) => !prev)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/three-dots-horizontal-icon.svg"
|
||||
alt="More options"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</button>
|
||||
<ChatDropdownMenu
|
||||
items={pageMenuItems}
|
||||
isOpen={isPageMenuOpen}
|
||||
onClose={() => setIsPageMenuOpen(false)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
|
||||
<Image
|
||||
src="/assets/icons/compose-icon.svg"
|
||||
@@ -634,6 +710,8 @@ export function MessagingPage() {
|
||||
{...selectedUserInfo}
|
||||
isTyping={typingUserNames.length > 0}
|
||||
typingText={typingUserNames.length > 0 ? `${typingUserNames.join(', ')} is typing...` : undefined}
|
||||
onClearChat={clearMessages}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
/>
|
||||
|
||||
{/* Messages area wrapper */}
|
||||
|
||||
@@ -40,6 +40,9 @@ interface UseMessagingReturn {
|
||||
startTyping: () => void;
|
||||
stopTyping: () => void;
|
||||
markAsRead: () => Promise<void>;
|
||||
markAllAsRead: () => Promise<void>;
|
||||
deleteConversation: (conversationId: string) => Promise<void>;
|
||||
clearMessages: () => void;
|
||||
updateUserStatus: (userId: string, isOnline: boolean) => void;
|
||||
hasMoreMessages: boolean;
|
||||
}
|
||||
@@ -314,6 +317,40 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
|
||||
}
|
||||
}, [isConnected]);
|
||||
|
||||
// Mark all conversations as read
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
const unreadConversations = conversations.filter((c) => c.unreadCount > 0);
|
||||
await Promise.all(
|
||||
unreadConversations.map((c) => messagesService.markAsRead(c.id))
|
||||
);
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => ({ ...c, unreadCount: 0 }))
|
||||
);
|
||||
}, [conversations]);
|
||||
|
||||
// Delete a conversation
|
||||
const deleteConversation = useCallback(
|
||||
async (conversationId: string) => {
|
||||
await messagesService.deleteConversation(conversationId);
|
||||
|
||||
// Remove from list
|
||||
setConversations((prev) => prev.filter((c) => c.id !== conversationId));
|
||||
|
||||
// Clear current if it was the deleted one
|
||||
if (currentConversationIdRef.current === conversationId) {
|
||||
currentConversationIdRef.current = null;
|
||||
setCurrentConversation(null);
|
||||
setMessages([]);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Clear messages from current conversation view (local only)
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
}, []);
|
||||
|
||||
// Update user online status (called from status change events)
|
||||
const updateUserStatus = useCallback((userId: string, isOnline: boolean) => {
|
||||
setConversations((prev) =>
|
||||
@@ -478,6 +515,9 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
|
||||
startTyping,
|
||||
stopTyping,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
deleteConversation,
|
||||
clearMessages,
|
||||
updateUserStatus,
|
||||
hasMoreMessages,
|
||||
};
|
||||
|
||||
@@ -74,6 +74,13 @@ class MessagesService {
|
||||
await api.patch(`/messages/conversations/${conversationId}/read`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a conversation
|
||||
*/
|
||||
async deleteConversation(conversationId: string): Promise<void> {
|
||||
await api.delete(`/messages/conversations/${conversationId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total unread message count
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user