veza/apps/web/src/features/chat/components/ChatSidebar.tsx

458 lines
14 KiB
TypeScript
Raw Normal View History

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;
}
2025-12-13 02:34:34 +00:00
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();
2025-12-13 02:34:34 +00:00
const userId = user?.id;
const queryClient = useQueryClient();
2025-12-13 02:34:34 +00:00
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,
});
2025-12-13 02:34:34 +00:00
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,
});
}
});
2025-12-13 02:34:34 +00:00
}
}, [data, conversations, addConversation]);
2025-12-13 02:34:34 +00:00
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center">
aesthetic-improvements: reduce decorative cyan in chat, auth, player, streaming, and dashboard (80/20 rule, batch 13) - Chat: ChatSidebar loading spinner and decorative icon, VirtualizedChatMessages decorative attachment badge, ChatPage decorative icon and loading spinner border/text, ChatMessage decorative username indicator and icon (7 instances) - Auth: TwoFactorVerify decorative icon (1 instance) - Player: PlayerLoading decorative spinner (1 instance) - Streaming: PlaybackSummary decorative icon (1 instance) - Dashboard: DashboardPage decorative chart color and gradient and icon (3 instances) - Total: ~13 files, ~13 instances replaced - Preserved: Active/selected states (ChatSidebar selected conversation, ChatMessage isMe message bubble and highlighted message, DashboardPage selected button 30J, ChatInput drag active overlay and emoji picker active, TrackFilters active filter badge, TrackHistory current track, TrackGridDensitySelector selected density, PlaybackSpeedControl selected speed, ViewToggle selected view mode, TrackList selected tracks, TrackListRow selected state, PlaylistList selected view mode, QualitySelector selected quality, SettingsPage selected tab and theme, LoginForm checkbox accent - focus/interaction, RegisterPage checkbox accent - focus/interaction), functional links (ForgotPasswordPage link, TwoFactorVerify links, RegisterPage links, AuthLayout link, ProfileForm links, LoginPage link, RegisterPage link), design system variants, semantic status indicators, interactive states, functional loading indicators, informational alerts/toasts - Action 11.3.1.3 in progress (thirteenth batch: chat, auth, player, streaming, and dashboard components)
2026-01-16 10:32:55 +00:00
<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">
aesthetic-improvements: reduce decorative cyan in chat, auth, player, streaming, and dashboard (80/20 rule, batch 13) - Chat: ChatSidebar loading spinner and decorative icon, VirtualizedChatMessages decorative attachment badge, ChatPage decorative icon and loading spinner border/text, ChatMessage decorative username indicator and icon (7 instances) - Auth: TwoFactorVerify decorative icon (1 instance) - Player: PlayerLoading decorative spinner (1 instance) - Streaming: PlaybackSummary decorative icon (1 instance) - Dashboard: DashboardPage decorative chart color and gradient and icon (3 instances) - Total: ~13 files, ~13 instances replaced - Preserved: Active/selected states (ChatSidebar selected conversation, ChatMessage isMe message bubble and highlighted message, DashboardPage selected button 30J, ChatInput drag active overlay and emoji picker active, TrackFilters active filter badge, TrackHistory current track, TrackGridDensitySelector selected density, PlaybackSpeedControl selected speed, ViewToggle selected view mode, TrackList selected tracks, TrackListRow selected state, PlaylistList selected view mode, QualitySelector selected quality, SettingsPage selected tab and theme, LoginForm checkbox accent - focus/interaction, RegisterPage checkbox accent - focus/interaction), functional links (ForgotPasswordPage link, TwoFactorVerify links, RegisterPage links, AuthLayout link, ProfileForm links, LoginPage link, RegisterPage link), design system variants, semantic status indicators, interactive states, functional loading indicators, informational alerts/toasts - Action 11.3.1.3 in progress (thirteenth batch: chat, auth, player, streaming, and dashboard components)
2026-01-16 10:32:55 +00:00
<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>
);
};