veza/apps/web/src/features/chat/components/ChatSidebar.tsx
senke 3fb12b2ce2 aesthetic-improvements: automated replacement of decorative cyan with steel (80/20 rule, Action 11.3.1.3)
- Created automated script (scripts/replace-decorative-cyan.py) to systematically replace decorative/informational kodo-cyan instances with kodo-steel variants
- Script intelligently preserves active/functional states, design system variants, semantic indicators, and interactive states
- Modified 85 files, replaced 145 decorative instances, preserved 47 functional instances
- No linter errors, type safety maintained
- Action 11.3.1.3 significantly advanced (total: ~302 instances replaced across ~229 files including previous batches)
2026-01-16 11:40:13 +01:00

457 lines
14 KiB
TypeScript

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';
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-3 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-3 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">
<span
className={cn(
'text-sm font-medium truncate transition-colors',
isSelected
? 'text-white'
: 'text-kodo-secondary group-hover:text-white',
)}
>
{conversation.name ||
`Channel ${conversation.id.substring(0, 4)}`}
</span>
{conversation.type !== 'direct' && (
<span className="text-[10px] text-kodo-secondary/50 uppercase tracking-wider">
{conversation.type}
</span>
)}
</div>
</div>
{conversation.unread_count && 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-3 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>
);
};