refactor(chat): decompose ChatSidebar into sub-components
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4d31877da9
commit
e0f28a0e16
10 changed files with 530 additions and 469 deletions
|
|
@ -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<ConversationItemProps> = ({
|
||||
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<Error | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [lastMutationType, setLastMutationType] = useState<
|
||||
'leave' | 'delete' | null
|
||||
>(null);
|
||||
const [lastRoomId, setLastRoomId] = useState<string | null>(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<any[]>(
|
||||
['chatConversations', user?.id],
|
||||
);
|
||||
|
||||
// Optimistically remove conversation from list
|
||||
if (previousConversations) {
|
||||
queryClient.setQueryData<any[]>(
|
||||
['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<any[]>(
|
||||
['chatConversations', user?.id],
|
||||
);
|
||||
|
||||
// Optimistically remove conversation from list
|
||||
if (previousConversations) {
|
||||
queryClient.setQueryData<any[]>(
|
||||
['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 && (
|
||||
<ErrorDisplay
|
||||
error={mutationError}
|
||||
variant="banner"
|
||||
severity="error"
|
||||
context={{
|
||||
action: 'managing room',
|
||||
resource: 'conversation',
|
||||
resourceId: conversation.id,
|
||||
}}
|
||||
onRetry={retryCount < 3 ? handleRetry : undefined}
|
||||
onDismiss={() => {
|
||||
setMutationError(null);
|
||||
setRetryCount(0);
|
||||
setLastMutationType(null);
|
||||
setLastRoomId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
onClick={() => 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',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center transition-colors',
|
||||
isSelected
|
||||
? 'bg-kodo-cyan text-kodo-void'
|
||||
: 'bg-white/5 text-kodo-secondary group-hover:text-white',
|
||||
)}
|
||||
>
|
||||
{conversation.type === 'direct' ? (
|
||||
<User size={14} />
|
||||
) : (
|
||||
<Hash size={14} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col min-w-0">
|
||||
|
||||
// ... inside component ...
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium truncate transition-colors',
|
||||
isSelected
|
||||
? 'text-white'
|
||||
: 'text-kodo-secondary group-hover:text-white',
|
||||
)}
|
||||
>
|
||||
{safeString(conversation.name ||
|
||||
`Channel ${conversation.id.substring(0, 4)}`)}
|
||||
</span>
|
||||
{conversation.type !== 'direct' && (
|
||||
<span className="text-[10px] text-kodo-secondary/50 uppercase tracking-wider">
|
||||
{safeString(conversation.type)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conversation.unread_count && Number(conversation.unread_count) > 0 ? (
|
||||
<span className="bg-kodo-magenta text-white text-[10px] px-1.5 py-0.5 rounded-full font-bold shadow-lg shadow-kodo-magenta/20">
|
||||
{conversation.unread_count}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
isSelected
|
||||
? 'text-kodo-cyan hover:bg-kodo-cyan/20'
|
||||
: 'text-kodo-secondary hover:text-white',
|
||||
)}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-kodo-void border-white/10 text-white"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={handleLeave}
|
||||
className="focus:bg-white/10 cursor-pointer"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Leave Channel
|
||||
</DropdownMenuItem>
|
||||
{conversation.type !== 'direct' && (
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
className="text-kodo-red focus:bg-kodo-red/10 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Channel
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Active Indicator Line */}
|
||||
{isSelected && (
|
||||
<div className="absolute left-0 top-3 bottom-3 w-0.5 bg-kodo-cyan rounded-r-full shadow-[0_0_8px_rgba(102,252,241,0.8)]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmationDialog
|
||||
open={showLeaveDialog}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
<ConfirmationDialog
|
||||
open={showDeleteDialog}
|
||||
onClose={() => 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 (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="animate-spin text-kodo-steel" size={24} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<ErrorDisplay
|
||||
error={error instanceof Error ? error : new Error('Signal Lost')}
|
||||
variant="card"
|
||||
severity="error"
|
||||
context={{
|
||||
action: 'fetching conversations',
|
||||
resource: 'conversations',
|
||||
}}
|
||||
onRetry={() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['chatConversations', userId],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 border-b border-white/5 bg-white/2 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="text-sm font-bold text-white tracking-wide uppercase flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-kodo-steel" />
|
||||
Active Channels
|
||||
</h2>
|
||||
<span className="text-[10px] font-mono text-kodo-secondary bg-white/5 px-1.5 py-0.5 rounded">
|
||||
{conversations.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-1">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="text-kodo-secondary/50 text-sm p-4 text-center italic border border-dashed border-white/5 rounded-xl m-2">
|
||||
No active frequencies detected.
|
||||
<br />
|
||||
Initialize a new channel.
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conv) => (
|
||||
<ConversationItem
|
||||
key={conv.id}
|
||||
conversation={conv}
|
||||
onSelect={setCurrentConversation}
|
||||
isSelected={conv.id === currentConversationId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-white/5 bg-white/2 backdrop-blur-sm">
|
||||
<Button
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
className="w-full shadow-lg shadow-kodo-steel/10"
|
||||
variant="default"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Channel
|
||||
</Button>
|
||||
</div>
|
||||
<CreateRoomDialog
|
||||
open={isCreateDialogOpen}
|
||||
onClose={() => setIsCreateDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
/**
|
||||
* Re-export from chat-sidebar module.
|
||||
*/
|
||||
export { ChatSidebar } from './chat-sidebar';
|
||||
|
|
|
|||
|
|
@ -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 <ChatSidebarSkeleton />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-4 min-h-layout-page-sm">
|
||||
<ErrorDisplay
|
||||
error={error instanceof Error ? error : new Error('Signal Lost')}
|
||||
variant="card"
|
||||
severity="error"
|
||||
context={{
|
||||
action: 'fetching conversations',
|
||||
resource: 'conversations',
|
||||
}}
|
||||
onRetry={() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['chatConversations', userId],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full')}>
|
||||
<ChatSidebarHeader count={conversations.length} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-1">
|
||||
{conversations.length === 0 ? (
|
||||
<ChatSidebarEmpty />
|
||||
) : (
|
||||
conversations.map((conv) => (
|
||||
<ConversationItem
|
||||
key={conv.id}
|
||||
conversation={{
|
||||
id: conv.id,
|
||||
name: conv.name,
|
||||
type: conv.type,
|
||||
unread_count: conv.unread_count,
|
||||
}}
|
||||
onSelect={(id) => setCurrentConversation(id)}
|
||||
isSelected={conv.id === currentConversationId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-white/5 bg-white/2 backdrop-blur-sm">
|
||||
<Button
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
className="w-full shadow-lg shadow-kodo-steel/10"
|
||||
variant="default"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Channel
|
||||
</Button>
|
||||
</div>
|
||||
<CreateRoomDialog
|
||||
open={isCreateDialogOpen}
|
||||
onClose={() => setIsCreateDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ChatSidebarEmptyProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatSidebarEmpty({ className }: ChatSidebarEmptyProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-kodo-secondary/50 text-sm p-4 text-center italic border border-dashed border-white/5 rounded-xl m-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
No active frequencies detected.
|
||||
<br />
|
||||
Initialize a new channel.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 border-b border-white/5 bg-white/2 backdrop-blur-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="text-sm font-bold text-white tracking-wide uppercase flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-kodo-steel" />
|
||||
Active Channels
|
||||
</h2>
|
||||
<span className="text-[10px] font-mono text-kodo-secondary bg-white/5 px-1.5 py-0.5 rounded">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center min-h-layout-story',
|
||||
className,
|
||||
)}
|
||||
data-testid="chat-sidebar-skeleton"
|
||||
>
|
||||
<Loader2 className="animate-spin text-kodo-steel" size={24} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Error | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [lastMutationType, setLastMutationType] = useState<'leave' | 'delete' | null>(null);
|
||||
const [lastRoomId, setLastRoomId] = useState<string | null>(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 && (
|
||||
<ErrorDisplay
|
||||
error={mutationError}
|
||||
variant="banner"
|
||||
severity="error"
|
||||
context={{
|
||||
action: 'managing room',
|
||||
resource: 'conversation',
|
||||
resourceId: conversation.id,
|
||||
}}
|
||||
onRetry={retryCount < 3 ? handleRetry : undefined}
|
||||
onDismiss={() => {
|
||||
setMutationError(null);
|
||||
setRetryCount(0);
|
||||
setLastMutationType(null);
|
||||
setLastRoomId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => 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',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center transition-colors shrink-0',
|
||||
isSelected
|
||||
? 'bg-kodo-cyan text-kodo-void'
|
||||
: 'bg-white/5 text-kodo-secondary group-hover:text-white',
|
||||
)}
|
||||
>
|
||||
{conversation.type === 'direct' ? (
|
||||
<User size={14} />
|
||||
) : (
|
||||
<Hash size={14} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium truncate transition-colors',
|
||||
isSelected ? 'text-white' : 'text-kodo-secondary group-hover:text-white',
|
||||
)}
|
||||
>
|
||||
{safeString(conversation.name || `Channel ${conversation.id.substring(0, 4)}`)}
|
||||
</span>
|
||||
{conversation.type !== 'direct' && (
|
||||
<span className="text-[10px] text-kodo-secondary/50 uppercase tracking-wider">
|
||||
{safeString(conversation.type)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conversation.unread_count != null && Number(conversation.unread_count) > 0 ? (
|
||||
<span className="bg-kodo-magenta text-white text-[10px] px-1.5 py-0.5 rounded-full font-bold shadow-lg shadow-kodo-magenta/20 shrink-0">
|
||||
{conversation.unread_count}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity shrink-0',
|
||||
isSelected ? 'text-kodo-cyan hover:bg-kodo-cyan/20' : 'text-kodo-secondary hover:text-white',
|
||||
)}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-kodo-void border-white/10 text-white">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => { e.stopPropagation(); setShowLeaveDialog(true); }}
|
||||
className="focus:bg-white/10 cursor-pointer"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Leave Channel
|
||||
</DropdownMenuItem>
|
||||
{conversation.type !== 'direct' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => { e.stopPropagation(); setShowDeleteDialog(true); }}
|
||||
className="text-kodo-red focus:bg-kodo-red/10 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Channel
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{isSelected && (
|
||||
<div className="absolute left-0 top-3 bottom-3 w-0.5 bg-kodo-cyan rounded-r-full shadow-[0_0_8px_rgba(102,252,241,0.8)]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmationDialog
|
||||
open={showLeaveDialog}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
<ConfirmationDialog
|
||||
open={showDeleteDialog}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
10
apps/web/src/features/chat/components/chat-sidebar/types.ts
Normal file
10
apps/web/src/features/chat/components/chat-sidebar/types.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Chat sidebar types.
|
||||
*/
|
||||
|
||||
export interface ConversationItemData {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
unread_count?: number;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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<unknown[]>(['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<unknown[]>(['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 };
|
||||
}
|
||||
Loading…
Reference in a new issue