gitdataai/src/components/room/DiscordChatPanel.tsx
ZhenYi 821b0e998d refactor(room): Discord layout and room WebSocket client refactor
- Refactor room-context.tsx with improved WebSocket state management
- Enhance room-ws-client.ts with reconnect logic and message handling
- Update Discord layout components with message editor improvements
- Add WebSocket universal endpoint support in ws_universal.rs
2026-04-18 19:05:21 +08:00

370 lines
13 KiB
TypeScript

'use client';
/**
* Discord-style main chat panel.
* Layout: header + message list + input + member sidebar
*/
import type { RoomResponse, RoomThreadResponse } from '@/client';
import type { MessageWithMeta } from '@/contexts';
import { cn } from '@/lib/utils';
import {
Hash, Lock, Users, Search, ChevronLeft,
AtSign, Pin, Settings,
} from 'lucide-react';
import {
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { MessageList } from './message/MessageList';
import { MessageInput, type MessageInputHandle } from './message/MessageInput';
import { RoomMessageEditDialog } from './RoomMessageEditDialog';
import { RoomMessageEditHistoryDialog } from './RoomMessageEditHistoryDialog';
import { RoomMentionPanel } from './RoomMentionPanel';
import { RoomThreadPanel } from './RoomThreadPanel';
import { RoomSettingsPanel } from './RoomSettingsPanel';
import { DiscordMemberList } from './DiscordMemberList';
import { useRoom } from '@/contexts';
// ─── Main Panel ──────────────────────────────────────────────────────────
interface DiscordChatPanelProps {
room: RoomResponse;
isAdmin: boolean;
onClose: () => void;
onDelete: () => void;
}
export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordChatPanelProps) {
const {
messages,
members,
membersLoading,
sendMessage,
editMessage,
revokeMessage,
updateRoom,
wsStatus,
wsError,
wsClient,
threads,
refreshThreads,
roomAiConfigs,
} = useRoom();
const messagesEndRef = useRef<HTMLDivElement>(null);
const messageInputRef = useRef<MessageInputHandle | null>(null);
const [replyingTo, setReplyingTo] = useState<MessageWithMeta | null>(null);
const [editingMessage, setEditingMessage] = useState<MessageWithMeta | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editHistoryDialogOpen, setEditHistoryDialogOpen] = useState(false);
const [selectedMessageForHistory, setSelectedMessageForHistory] = useState<string>('');
const [showSettings, setShowSettings] = useState(false);
const [showMentions, setShowMentions] = useState(false);
const [showMemberList, setShowMemberList] = useState(true);
const [activeThread, setActiveThread] = useState<{ thread: RoomThreadResponse; parentMessage: MessageWithMeta } | null>(null);
const [isUpdatingRoom, setIsUpdatingRoom] = useState(false);
const wsDotClass =
wsStatus === 'open' ? 'connected'
: wsStatus === 'connecting' ? 'connecting'
: 'disconnected';
const handleSend = useCallback(
(content: string) => {
sendMessage(content, 'text', replyingTo?.id ?? undefined);
setReplyingTo(null);
},
[sendMessage, replyingTo],
);
const handleEdit = useCallback((message: MessageWithMeta) => {
setEditingMessage(message);
setEditDialogOpen(true);
}, []);
const handleViewEditHistory = useCallback((message: MessageWithMeta) => {
setSelectedMessageForHistory(message.id);
setEditHistoryDialogOpen(true);
}, []);
const handleEditConfirm = useCallback(
(newContent: string) => {
if (!editingMessage) return;
editMessage(editingMessage.id, newContent);
setEditDialogOpen(false);
setEditingMessage(null);
toast.success('Message updated');
},
[editingMessage?.id, editMessage],
);
const handleRevoke = useCallback(
(message: MessageWithMeta) => {
revokeMessage(message.id);
toast.success('Message deleted');
},
[revokeMessage],
);
const handleOpenThread = useCallback((message: MessageWithMeta) => {
if (!message.thread_id) return;
const thread = threads.find(t => t.id === message.thread_id);
if (thread) setActiveThread({ thread, parentMessage: message });
}, [threads]);
const handleCreateThread = useCallback(async (message: MessageWithMeta) => {
if (!wsClient || message.thread_id) return;
try {
const thread = await wsClient.threadCreate(room.id, message.seq);
setActiveThread({ thread, parentMessage: message });
refreshThreads();
} catch (err) {
console.error('Failed to create thread:', err);
toast.error('Failed to create thread');
}
}, [wsClient, room.id, refreshThreads]);
const handleUpdateRoom = useCallback(
async (name: string, isPublic: boolean) => {
setIsUpdatingRoom(true);
try {
await updateRoom(room.id, name, isPublic);
toast.success('Room updated');
setShowSettings(false);
} catch {
toast.error('Failed to update room');
} finally {
setIsUpdatingRoom(false);
}
},
[room.id, updateRoom],
);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages.length]);
useEffect(() => {
setReplyingTo(null);
setEditingMessage(null);
setEditDialogOpen(false);
setShowSettings(false);
setShowMentions(false);
setActiveThread(null);
}, [room.id]);
return (
<div
className="flex flex-1 min-w-0 flex-col"
style={{ background: 'var(--room-bg)', color: 'var(--room-text)' }}
>
{/* ── Header ─────────────────────────────────────────────── */}
<header
className="flex h-12 items-center border-b px-4 gap-2 shrink-0"
style={{ borderColor: 'var(--room-border)', background: 'var(--room-sidebar)' }}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{room.public
? <Hash className="h-5 w-5 shrink-0" style={{ color: 'var(--room-text-muted)' }} />
: <Lock className="h-4 w-4 shrink-0" style={{ color: 'var(--room-text-muted)' }} />
}
<h1 className="text-base font-semibold truncate" style={{ color: 'var(--room-text)' }}>{room.room_name}</h1>
<div className="discord-ws-status ml-1">
<span className={cn('discord-ws-dot', wsDotClass)} />
{wsStatus !== 'open' && wsStatus !== 'idle' && (
<span className="text-xs" style={{ color: 'var(--room-text-muted)' }}>
{wsStatus === 'connecting' ? 'Connecting...' : wsError ?? 'Disconnected'}
</span>
)}
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{
color: showMentions ? 'var(--room-accent)' : 'var(--room-text-muted)',
background: showMentions ? 'var(--room-channel-active)' : 'transparent',
}}
onClick={() => { setShowMentions(v => !v); setShowSettings(false); }}
title="@ Mentions"
>
<AtSign className="h-4 w-4" />
</button>
<button
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--room-text-muted)' }}
title="Search messages"
>
<Search className="h-4 w-4" />
</button>
<button
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{
color: showMemberList ? 'var(--room-accent)' : 'var(--room-text-muted)',
background: showMemberList ? 'var(--room-channel-active)' : 'transparent',
}}
onClick={() => { setShowMemberList(v => !v); setShowSettings(false); }}
title="Member list"
>
<Users className="h-4 w-4" />
</button>
<button
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--room-text-muted)' }}
title="Pinned messages"
>
<Pin className="h-4 w-4" />
</button>
{/* Settings — opens Room Settings */}
{isAdmin && (
<button
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{
color: showSettings ? 'var(--room-accent)' : 'var(--room-text-muted)',
background: showSettings ? 'var(--room-channel-active)' : 'transparent',
}}
onClick={() => { setShowSettings(v => !v); setShowMentions(false); setShowMemberList(false); }}
title="Room Settings"
>
<Settings className="h-4 w-4" />
</button>
)}
{isAdmin && (
<button
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--room-text-muted)' }}
onClick={onDelete}
title="Delete channel"
>
<span className="text-xs">🗑</span>
</button>
)}
<Button
variant="ghost"
size="sm"
className="h-8 md:hidden"
style={{ color: 'var(--room-text-muted)' }}
onClick={onClose}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Back
</Button>
</div>
</header>
{/* ── Body ──────────────────────────────────────────────── */}
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col">
<MessageList
roomId={room.id}
messages={messages}
messagesEndRef={messagesEndRef}
onInlineEdit={handleEdit}
onViewHistory={handleViewEditHistory}
onRevoke={handleRevoke}
onReply={setReplyingTo}
onMention={undefined}
onOpenUserCard={({ userId, username }) => {
messageInputRef.current?.insertMention('user', userId, username);
messageInputRef.current?.focus();
}}
onOpenThread={handleOpenThread}
onCreateThread={handleCreateThread}
/>
<MessageInput
ref={messageInputRef}
roomName={room.room_name ?? 'room'}
onSend={handleSend}
replyingTo={replyingTo ? { id: replyingTo.id, display_name: replyingTo.display_name ?? undefined, content: replyingTo.content } : null}
onCancelReply={() => setReplyingTo(null)}
/>
</div>
{showMemberList && (
<DiscordMemberList
members={members}
membersLoading={membersLoading}
onMemberClick={({ user, user_info, role }) => {
const label = user_info?.username ?? user;
const type = role === 'ai' ? 'ai' : 'user';
messageInputRef.current?.insertMention(type, user, label);
messageInputRef.current?.focus();
}}
aiConfigs={roomAiConfigs}
/>
)}
</div>
{/* ── Slide Panels ──────────────────────────────────────── */}
{showSettings && (
<aside
className="absolute right-0 top-12 bottom-0 w-[360px] border-l z-20 flex flex-col animate-in slide-in-from-right duration-200"
style={{
borderColor: 'var(--room-border)',
background: 'var(--room-bg)',
}}
>
<RoomSettingsPanel
room={room}
onUpdate={handleUpdateRoom}
onClose={() => setShowSettings(false)}
isPending={isUpdatingRoom}
/>
</aside>
)}
{showMentions && (
<aside
className="absolute right-0 top-12 bottom-0 w-[380px] border-l z-20 flex flex-col animate-in slide-in-from-right duration-200"
style={{ borderColor: 'var(--room-border)', background: 'var(--room-bg)' }}
>
<RoomMentionPanel
onClose={() => setShowMentions(false)}
onSelectNotification={(mention) => {
toast.info(`Navigate to message in ${mention.room_name}`);
setShowMentions(false);
}}
/>
</aside>
)}
{activeThread && (
<RoomThreadPanel
roomId={room.id}
thread={activeThread.thread}
parentMessage={activeThread.parentMessage}
onClose={() => setActiveThread(null)}
/>
)}
<RoomMessageEditDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
originalContent={editingMessage?.content ?? ''}
onConfirm={handleEditConfirm}
/>
<RoomMessageEditHistoryDialog
open={editHistoryDialogOpen}
onOpenChange={setEditHistoryDialogOpen}
messageId={selectedMessageForHistory}
roomId={room.id}
/>
</div>
);
}