From e0f28a0e164242dac1ea00e2d3a393ecdb55e9b7 Mon Sep 17 00:00:00 2001 From: senke Date: Thu, 5 Feb 2026 20:07:09 +0100 Subject: [PATCH] refactor(chat): decompose ChatSidebar into sub-components Co-authored-by: Cursor --- .../features/chat/components/ChatSidebar.tsx | 473 +----------------- .../components/chat-sidebar/ChatSidebar.tsx | 100 ++++ .../chat-sidebar/ChatSidebarEmpty.tsx | 20 + .../chat-sidebar/ChatSidebarHeader.tsx | 28 ++ .../chat-sidebar/ChatSidebarSkeleton.tsx | 20 + .../chat-sidebar/ConversationItem.tsx | 219 ++++++++ .../chat/components/chat-sidebar/index.ts | 8 + .../chat/components/chat-sidebar/types.ts | 10 + .../chat-sidebar/useChatConversations.ts | 39 ++ .../chat-sidebar/useConversationActions.ts | 82 +++ 10 files changed, 530 insertions(+), 469 deletions(-) create mode 100644 apps/web/src/features/chat/components/chat-sidebar/ChatSidebar.tsx create mode 100644 apps/web/src/features/chat/components/chat-sidebar/ChatSidebarEmpty.tsx create mode 100644 apps/web/src/features/chat/components/chat-sidebar/ChatSidebarHeader.tsx create mode 100644 apps/web/src/features/chat/components/chat-sidebar/ChatSidebarSkeleton.tsx create mode 100644 apps/web/src/features/chat/components/chat-sidebar/ConversationItem.tsx create mode 100644 apps/web/src/features/chat/components/chat-sidebar/index.ts create mode 100644 apps/web/src/features/chat/components/chat-sidebar/types.ts create mode 100644 apps/web/src/features/chat/components/chat-sidebar/useChatConversations.ts create mode 100644 apps/web/src/features/chat/components/chat-sidebar/useConversationActions.ts diff --git a/apps/web/src/features/chat/components/ChatSidebar.tsx b/apps/web/src/features/chat/components/ChatSidebar.tsx index e706b30c8..85603bad1 100644 --- a/apps/web/src/features/chat/components/ChatSidebar.tsx +++ b/apps/web/src/features/chat/components/ChatSidebar.tsx @@ -1,469 +1,4 @@ -import React, { useEffect, useState } from 'react'; -import { useChatStore } from '../store/chatStore'; -import { useUser } from '@/features/auth/hooks/useUser'; -import { apiClient } from '@/services/api/client'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { cn } from '@/lib/utils'; -import { - Loader2, - Plus, - Trash2, - LogOut, - MessageSquare, - Hash, - User, - MoreVertical, -} from 'lucide-react'; -import { CreateRoomDialog } from './CreateRoomDialog'; -import { useToast } from '@/hooks/useToast'; -import { Button } from '@/components/ui/button'; -import { ErrorDisplay } from '@/components/ui/ErrorDisplay'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'; - -const safeString = (val: any) => { - if (val === null || val === undefined) return ''; - if (typeof val === 'string') return val; - try { - return String(val); - } catch (e) { - return 'Invalid Value'; - } -}; - -interface ConversationItemProps { - conversation: { - id: string; - name: string; - type: string; - unread_count?: number; - }; - onSelect: (id: string) => void; - isSelected: boolean; -} - -const ConversationItem: React.FC = ({ - conversation, - onSelect, - isSelected, -}) => { - const { data: user } = useUser(); - const queryClient = useQueryClient(); - const toast = useToast(); - const { setCurrentConversation } = useChatStore(); - const [showLeaveDialog, setShowLeaveDialog] = useState(false); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [mutationError, setMutationError] = useState(null); - const [retryCount, setRetryCount] = useState(0); - const [lastMutationType, setLastMutationType] = useState< - 'leave' | 'delete' | null - >(null); - const [lastRoomId, setLastRoomId] = useState(null); - - // Action 4.4.1.5: Add optimistic update - const leaveRoomMutation = useMutation({ - mutationFn: async (roomId: string) => { - await apiClient.delete( - `/conversations/${roomId}/participants/${user?.id}`, - ); - }, - onMutate: async (roomId: string) => { - // Cancel outgoing refetches - await queryClient.cancelQueries({ - queryKey: ['chatConversations', user?.id], - }); - - // Snapshot previous value - const previousConversations = queryClient.getQueryData( - ['chatConversations', user?.id], - ); - - // Optimistically remove conversation from list - if (previousConversations) { - queryClient.setQueryData( - ['chatConversations', user?.id], - previousConversations.filter((c) => c.id !== roomId), - ); - } - - return { previousConversations }; - }, - onError: (error: any, _roomId, context) => { - // Rollback on error - if (context?.previousConversations) { - queryClient.setQueryData( - ['chatConversations', user?.id], - context.previousConversations, - ); - } - const errorMessage = - error.response?.data?.error || 'Failed to leave room'; - setMutationError(new Error(errorMessage)); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['chatConversations', user?.id], - }); - toast.success('Left room successfully'); - setCurrentConversation(null); - setShowLeaveDialog(false); - setMutationError(null); - }, - }); - - // Action 4.4.1.5: Add optimistic update - const deleteRoomMutation = useMutation({ - mutationFn: async (roomId: string) => { - await apiClient.delete(`/conversations/${roomId}`); - }, - onMutate: async (roomId: string) => { - // Cancel outgoing refetches - await queryClient.cancelQueries({ - queryKey: ['chatConversations', user?.id], - }); - - // Snapshot previous value - const previousConversations = queryClient.getQueryData( - ['chatConversations', user?.id], - ); - - // Optimistically remove conversation from list - if (previousConversations) { - queryClient.setQueryData( - ['chatConversations', user?.id], - previousConversations.filter((c) => c.id !== roomId), - ); - } - - return { previousConversations }; - }, - onError: (error: any, _roomId, context) => { - // Rollback on error - if (context?.previousConversations) { - queryClient.setQueryData( - ['chatConversations', user?.id], - context.previousConversations, - ); - } - const errorMessage = - error.response?.data?.error || 'Failed to delete room'; - setMutationError(new Error(errorMessage)); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['chatConversations', user?.id], - }); - toast.success('Room deleted successfully'); - setCurrentConversation(null); - setShowDeleteDialog(false); - setMutationError(null); - setRetryCount(0); - setLastMutationType(null); - setLastRoomId(null); - }, - }); - - const handleLeave = (e: React.MouseEvent) => { - e.stopPropagation(); - setShowLeaveDialog(true); - }; - - const handleDelete = (e: React.MouseEvent) => { - e.stopPropagation(); - setShowDeleteDialog(true); - }; - - const confirmLeave = () => { - setLastMutationType('leave'); - setLastRoomId(conversation.id); - leaveRoomMutation.mutate(conversation.id); - }; - - const confirmDelete = () => { - setLastMutationType('delete'); - setLastRoomId(conversation.id); - deleteRoomMutation.mutate(conversation.id); - }; - - // Action 3.4.1.3: Retry handler for failed mutations - const handleRetry = async () => { - if (!lastMutationType || !lastRoomId || retryCount >= 3) return; - - setRetryCount((prev) => prev + 1); - try { - if (lastMutationType === 'leave') { - await leaveRoomMutation.mutateAsync(lastRoomId); - } else { - await deleteRoomMutation.mutateAsync(lastRoomId); - } - } catch (error) { - // Error will be handled by mutation's onError - } - }; - - return ( - <> - {mutationError && ( - { - setMutationError(null); - setRetryCount(0); - setLastMutationType(null); - setLastRoomId(null); - }} - /> - )} -
onSelect(conversation.id)} - className={cn( - 'group relative flex items-center justify-between p-4 rounded-xl cursor-pointer transition-all duration-300 border border-transparent', - isSelected - ? 'bg-kodo-cyan/10 border-kodo-cyan/30 shadow-[0_0_15px_rgba(102,252,241,0.1)]' - : 'hover:bg-white/5 hover:border-white/5', - )} - > -
-
- {conversation.type === 'direct' ? ( - - ) : ( - - )} -
- -
- - // ... inside component ... - - {safeString(conversation.name || - `Channel ${conversation.id.substring(0, 4)}`)} - - {conversation.type !== 'direct' && ( - - {safeString(conversation.type)} - - )} -
-
- - {conversation.unread_count && Number(conversation.unread_count) > 0 ? ( - - {conversation.unread_count} - - ) : null} - - - e.stopPropagation()}> - - - - - - Leave Channel - - {conversation.type !== 'direct' && ( - - - Delete Channel - - )} - - - - {/* Active Indicator Line */} - {isSelected && ( -
- )} -
- - setShowLeaveDialog(false)} - onConfirm={confirmLeave} - title="Leave Channel" - description="Disconnect from this secure frequency? Incoming transmission will cease." - confirmLabel="Disconnect" - cancelLabel="Cancel" - variant="default" - isLoading={leaveRoomMutation.isPending} - /> - setShowDeleteDialog(false)} - onConfirm={confirmDelete} - title="Delete Channel" - description="Permanently purge this channel from the network? This action is irreversible." - confirmLabel="Purge" - cancelLabel="Cancel" - variant="destructive" - isLoading={deleteRoomMutation.isPending} - /> - - ); -}; - -export const ChatSidebar: React.FC = () => { - const { data: user } = useUser(); - const userId = user?.id; - const queryClient = useQueryClient(); - const { - conversations, - currentConversationId, - setCurrentConversation, - addConversation, - } = useChatStore(); - const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - - const { data, isLoading, error } = useQuery({ - queryKey: ['chatConversations', userId], - queryFn: async () => { - if (!userId) return []; - const response = await apiClient.get('/conversations'); - return response.data.conversations; - }, - enabled: !!userId, - }); - - useEffect(() => { - if (data) { - data.forEach((conv: any) => { - if (!conversations.some((c) => c.id === conv.id)) { - addConversation({ - id: conv.id, - name: conv.name, - type: conv.type, - participants: conv.participants, - unread_count: 0, - }); - } - }); - } - }, [data, conversations, addConversation]); - - if (isLoading) { - return ( -
- -
- ); - } - - if (error) { - return ( -
- - queryClient.invalidateQueries({ - queryKey: ['chatConversations', userId], - }) - } - /> -
- ); - } - - return ( -
-
-
-

- - Active Channels -

- - {conversations.length} - -
-
- -
- {conversations.length === 0 ? ( -
- No active frequencies detected. -
- Initialize a new channel. -
- ) : ( - conversations.map((conv) => ( - - )) - )} -
- -
- -
- setIsCreateDialogOpen(false)} - /> -
- ); -}; +/** + * Re-export from chat-sidebar module. + */ +export { ChatSidebar } from './chat-sidebar'; diff --git a/apps/web/src/features/chat/components/chat-sidebar/ChatSidebar.tsx b/apps/web/src/features/chat/components/chat-sidebar/ChatSidebar.tsx new file mode 100644 index 000000000..5c904819c --- /dev/null +++ b/apps/web/src/features/chat/components/chat-sidebar/ChatSidebar.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import { useUser } from '@/features/auth/hooks/useUser'; +import { useQueryClient } from '@tanstack/react-query'; +import { Plus } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ErrorDisplay } from '@/components/ui/ErrorDisplay'; +import { CreateRoomDialog } from '../CreateRoomDialog'; +import { useChatStore } from '../../store/chatStore'; +import { useChatConversations } from './useChatConversations'; +import { ChatSidebarHeader } from './ChatSidebarHeader'; +import { ChatSidebarEmpty } from './ChatSidebarEmpty'; +import { ChatSidebarSkeleton } from './ChatSidebarSkeleton'; +import { ConversationItem } from './ConversationItem'; +import { cn } from '@/lib/utils'; + +export const ChatSidebar: React.FC = () => { + const { data: user } = useUser(); + const userId = user?.id; + const queryClient = useQueryClient(); + const { + conversations, + currentConversationId, + setCurrentConversation, + addConversation, + } = useChatStore(); + + const { data: _queryData, isLoading, error } = useChatConversations( + userId, + addConversation, + conversations, + ); + + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + + if (isLoading) { + return ; + } + + if (error) { + return ( +
+ + queryClient.invalidateQueries({ + queryKey: ['chatConversations', userId], + }) + } + /> +
+ ); + } + + return ( +
+ + +
+ {conversations.length === 0 ? ( + + ) : ( + conversations.map((conv) => ( + setCurrentConversation(id)} + isSelected={conv.id === currentConversationId} + /> + )) + )} +
+ +
+ +
+ setIsCreateDialogOpen(false)} + /> +
+ ); +}; diff --git a/apps/web/src/features/chat/components/chat-sidebar/ChatSidebarEmpty.tsx b/apps/web/src/features/chat/components/chat-sidebar/ChatSidebarEmpty.tsx new file mode 100644 index 000000000..cf5a85cba --- /dev/null +++ b/apps/web/src/features/chat/components/chat-sidebar/ChatSidebarEmpty.tsx @@ -0,0 +1,20 @@ +import { cn } from '@/lib/utils'; + +export interface ChatSidebarEmptyProps { + className?: string; +} + +export function ChatSidebarEmpty({ className }: ChatSidebarEmptyProps) { + return ( +
+ No active frequencies detected. +
+ Initialize a new channel. +
+ ); +} diff --git a/apps/web/src/features/chat/components/chat-sidebar/ChatSidebarHeader.tsx b/apps/web/src/features/chat/components/chat-sidebar/ChatSidebarHeader.tsx new file mode 100644 index 000000000..e4df982cc --- /dev/null +++ b/apps/web/src/features/chat/components/chat-sidebar/ChatSidebarHeader.tsx @@ -0,0 +1,28 @@ +import { MessageSquare } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface ChatSidebarHeaderProps { + count: number; + className?: string; +} + +export function ChatSidebarHeader({ count, className }: ChatSidebarHeaderProps) { + return ( +
+
+

+ + Active Channels +

+ + {count} + +
+
+ ); +} diff --git a/apps/web/src/features/chat/components/chat-sidebar/ChatSidebarSkeleton.tsx b/apps/web/src/features/chat/components/chat-sidebar/ChatSidebarSkeleton.tsx new file mode 100644 index 000000000..a6f6c8938 --- /dev/null +++ b/apps/web/src/features/chat/components/chat-sidebar/ChatSidebarSkeleton.tsx @@ -0,0 +1,20 @@ +import { Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface ChatSidebarSkeletonProps { + className?: string; +} + +export function ChatSidebarSkeleton({ className }: ChatSidebarSkeletonProps) { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/features/chat/components/chat-sidebar/ConversationItem.tsx b/apps/web/src/features/chat/components/chat-sidebar/ConversationItem.tsx new file mode 100644 index 000000000..9b850fd1f --- /dev/null +++ b/apps/web/src/features/chat/components/chat-sidebar/ConversationItem.tsx @@ -0,0 +1,219 @@ +import React, { useState } from 'react'; +import { useUser } from '@/features/auth/hooks/useUser'; +import { Button } from '@/components/ui/button'; +import { ErrorDisplay } from '@/components/ui/ErrorDisplay'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'; +import { Hash, LogOut, MoreVertical, Trash2, User } from 'lucide-react'; +import { useConversationActions } from './useConversationActions'; +import { cn } from '@/lib/utils'; +import type { ConversationItemData } from './types'; + +function safeString(val: unknown): string { + if (val === null || val === undefined) return ''; + if (typeof val === 'string') return val; + try { + return String(val); + } catch { + return 'Invalid Value'; + } +} + +export interface ConversationItemProps { + conversation: ConversationItemData; + onSelect: (id: string) => void; + isSelected: boolean; +} + +export function ConversationItem({ + conversation, + onSelect, + isSelected, +}: ConversationItemProps) { + const { data: user } = useUser(); + const [showLeaveDialog, setShowLeaveDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [mutationError, setMutationError] = useState(null); + const [retryCount, setRetryCount] = useState(0); + const [lastMutationType, setLastMutationType] = useState<'leave' | 'delete' | null>(null); + const [lastRoomId, setLastRoomId] = useState(null); + + const { leaveRoomMutation, deleteRoomMutation } = useConversationActions(user?.id, { + onLeaveError: setMutationError, + onDeleteError: setMutationError, + onLeaveSuccess: () => { + setShowLeaveDialog(false); + setMutationError(null); + }, + onDeleteSuccess: () => { + setShowDeleteDialog(false); + setMutationError(null); + }, + }); + + const confirmLeave = () => { + setLastMutationType('leave'); + setLastRoomId(conversation.id); + leaveRoomMutation.mutate(conversation.id); + }; + + const confirmDelete = () => { + setLastMutationType('delete'); + setLastRoomId(conversation.id); + deleteRoomMutation.mutate(conversation.id); + }; + + const handleRetry = () => { + if (!lastMutationType || !lastRoomId || retryCount >= 3) return; + setRetryCount((prev) => prev + 1); + if (lastMutationType === 'leave') { + leaveRoomMutation.mutate(lastRoomId); + } else { + deleteRoomMutation.mutate(lastRoomId); + } + }; + + return ( + <> + {mutationError && ( + { + setMutationError(null); + setRetryCount(0); + setLastMutationType(null); + setLastRoomId(null); + }} + /> + )} +
onSelect(conversation.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(conversation.id); + } + }} + className={cn( + 'group relative flex items-center justify-between p-4 rounded-xl cursor-pointer transition-all duration-300 border border-transparent', + isSelected + ? 'bg-kodo-cyan/10 border-kodo-cyan/30 shadow-[0_0_15px_rgba(102,252,241,0.1)]' + : 'hover:bg-white/5 hover:border-white/5', + )} + > +
+
+ {conversation.type === 'direct' ? ( + + ) : ( + + )} +
+
+ + {safeString(conversation.name || `Channel ${conversation.id.substring(0, 4)}`)} + + {conversation.type !== 'direct' && ( + + {safeString(conversation.type)} + + )} +
+
+ + {conversation.unread_count != null && Number(conversation.unread_count) > 0 ? ( + + {conversation.unread_count} + + ) : null} + + + e.stopPropagation()}> + + + + { e.stopPropagation(); setShowLeaveDialog(true); }} + className="focus:bg-white/10 cursor-pointer" + > + + Leave Channel + + {conversation.type !== 'direct' && ( + { e.stopPropagation(); setShowDeleteDialog(true); }} + className="text-kodo-red focus:bg-kodo-red/10 cursor-pointer" + > + + Delete Channel + + )} + + + + {isSelected && ( +
+ )} +
+ + setShowLeaveDialog(false)} + onConfirm={confirmLeave} + title="Leave Channel" + description="Disconnect from this secure frequency? Incoming transmission will cease." + confirmLabel="Disconnect" + cancelLabel="Cancel" + variant="default" + isLoading={leaveRoomMutation.isPending} + /> + setShowDeleteDialog(false)} + onConfirm={confirmDelete} + title="Delete Channel" + description="Permanently purge this channel from the network? This action is irreversible." + confirmLabel="Purge" + cancelLabel="Cancel" + variant="destructive" + isLoading={deleteRoomMutation.isPending} + /> + + ); +} diff --git a/apps/web/src/features/chat/components/chat-sidebar/index.ts b/apps/web/src/features/chat/components/chat-sidebar/index.ts new file mode 100644 index 000000000..116678694 --- /dev/null +++ b/apps/web/src/features/chat/components/chat-sidebar/index.ts @@ -0,0 +1,8 @@ +export { ChatSidebar } from './ChatSidebar'; +export { ChatSidebarHeader } from './ChatSidebarHeader'; +export { ChatSidebarEmpty } from './ChatSidebarEmpty'; +export { ChatSidebarSkeleton } from './ChatSidebarSkeleton'; +export { ConversationItem } from './ConversationItem'; +export { useChatConversations } from './useChatConversations'; +export { useConversationActions } from './useConversationActions'; +export type { ConversationItemData } from './types'; diff --git a/apps/web/src/features/chat/components/chat-sidebar/types.ts b/apps/web/src/features/chat/components/chat-sidebar/types.ts new file mode 100644 index 000000000..e76ff5dcf --- /dev/null +++ b/apps/web/src/features/chat/components/chat-sidebar/types.ts @@ -0,0 +1,10 @@ +/** + * Chat sidebar types. + */ + +export interface ConversationItemData { + id: string; + name: string; + type: string; + unread_count?: number; +} diff --git a/apps/web/src/features/chat/components/chat-sidebar/useChatConversations.ts b/apps/web/src/features/chat/components/chat-sidebar/useChatConversations.ts new file mode 100644 index 000000000..4c7f48443 --- /dev/null +++ b/apps/web/src/features/chat/components/chat-sidebar/useChatConversations.ts @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/services/api/client'; +import type { Conversation } from '../../store/chatStore'; + +export function useChatConversations( + userId: string | undefined, + addConversation: (c: Conversation) => void, + conversations: Conversation[], +) { + const { data, isLoading, error } = useQuery({ + queryKey: ['chatConversations', userId], + queryFn: async () => { + if (!userId) return []; + const response = await apiClient.get<{ conversations?: unknown[] } | unknown[]>('/conversations'); + const raw = response.data; + const list = Array.isArray(raw) ? raw : (raw as { conversations?: unknown[] })?.conversations ?? []; + return Array.isArray(list) ? list : []; + }, + enabled: !!userId, + }); + + useEffect(() => { + if (!data) return; + data.forEach((conv: { id: string; name: string; type: string; participants?: string[] }) => { + if (!conversations.some((c) => c.id === conv.id)) { + addConversation({ + id: conv.id, + name: conv.name, + type: (conv.type as Conversation['type']) ?? 'direct', + participants: Array.isArray(conv.participants) ? conv.participants : [], + unread_count: 0, + }); + } + }); + }, [data, conversations, addConversation]); + + return { data: data ?? [], isLoading, error }; +} diff --git a/apps/web/src/features/chat/components/chat-sidebar/useConversationActions.ts b/apps/web/src/features/chat/components/chat-sidebar/useConversationActions.ts new file mode 100644 index 000000000..4f85c91d1 --- /dev/null +++ b/apps/web/src/features/chat/components/chat-sidebar/useConversationActions.ts @@ -0,0 +1,82 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '@/services/api/client'; +import { useToast } from '@/hooks/useToast'; +import { useChatStore } from '../../store/chatStore'; + +export interface UseConversationActionsCallbacks { + onLeaveError?: (error: Error) => void; + onDeleteError?: (error: Error) => void; + onLeaveSuccess?: () => void; + onDeleteSuccess?: () => void; +} + +export function useConversationActions( + userId: string | undefined, + callbacks?: UseConversationActionsCallbacks, +) { + const queryClient = useQueryClient(); + const toast = useToast(); + const setCurrentConversation = useChatStore((s) => s.setCurrentConversation); + + const leaveRoomMutation = useMutation({ + mutationFn: async (roomId: string) => { + await apiClient.delete(`/conversations/${roomId}/participants/${userId}`); + }, + onMutate: async (roomId: string) => { + await queryClient.cancelQueries({ queryKey: ['chatConversations', userId] }); + const previous = queryClient.getQueryData(['chatConversations', userId]); + if (previous && Array.isArray(previous)) { + queryClient.setQueryData( + ['chatConversations', userId], + previous.filter((c: { id: string }) => c.id !== roomId), + ); + } + return { previous }; + }, + onError: (err, _roomId, context) => { + if (context?.previous) { + queryClient.setQueryData(['chatConversations', userId], context.previous); + } + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed to leave room'; + callbacks?.onLeaveError?.(new Error(msg)); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['chatConversations', userId] }); + toast.success('Left room successfully'); + setCurrentConversation(null); + callbacks?.onLeaveSuccess?.(); + }, + }); + + const deleteRoomMutation = useMutation({ + mutationFn: async (roomId: string) => { + await apiClient.delete(`/conversations/${roomId}`); + }, + onMutate: async (roomId: string) => { + await queryClient.cancelQueries({ queryKey: ['chatConversations', userId] }); + const previous = queryClient.getQueryData(['chatConversations', userId]); + if (previous && Array.isArray(previous)) { + queryClient.setQueryData( + ['chatConversations', userId], + previous.filter((c: { id: string }) => c.id !== roomId), + ); + } + return { previous }; + }, + onError: (err, _roomId, context) => { + if (context?.previous) { + queryClient.setQueryData(['chatConversations', userId], context.previous); + } + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed to delete room'; + callbacks?.onDeleteError?.(new Error(msg)); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['chatConversations', userId] }); + toast.success('Room deleted successfully'); + setCurrentConversation(null); + callbacks?.onDeleteSuccess?.(); + }, + }); + + return { leaveRoomMutation, deleteRoomMutation }; +}