- 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
370 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|