refactor(room): Discord-style UI redesign for channel sidebar and member list
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions

This commit is contained in:
ZhenYi 2026-04-19 01:12:38 +08:00
parent 66006d842e
commit b73cc8d421
3 changed files with 484 additions and 215 deletions

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useRoom, useUser } from '@/contexts'; import { useRoom, useUser } from '@/contexts';
import { RoomProvider } from '@/contexts'; import { RoomProvider } from '@/contexts';
@ -18,6 +18,8 @@ function ProjectRoomInner() {
const { const {
rooms, rooms,
roomsLoading, roomsLoading,
categories,
createCategory,
activeRoom, activeRoom,
activeRoomId, activeRoomId,
setActiveRoom, setActiveRoom,
@ -89,6 +91,17 @@ function ProjectRoomInner() {
} }
}; };
const handleCreateCategory = useCallback(async (name: string) => {
await createCategory(name);
}, [createCategory]);
const handleMoveRoomToCategory = useCallback(
async (roomId: string, categoryId: string | null) => {
await updateRoom(roomId, undefined, undefined, categoryId ?? undefined);
},
[updateRoom],
);
const isAdmin = !!user && members.some( const isAdmin = !!user && members.some(
(m) => (m) =>
m.user === user.uid && m.user === user.uid &&
@ -112,6 +125,9 @@ function ProjectRoomInner() {
selectedRoomId={activeRoomId} selectedRoomId={activeRoomId}
onSelectRoom={handleSelectRoom} onSelectRoom={handleSelectRoom}
onCreateRoom={handleOpenCreate} onCreateRoom={handleOpenCreate}
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
onCreateCategory={handleCreateCategory}
onMoveRoomToCategory={handleMoveRoomToCategory}
/> />
{/* Main chat area */} {/* Main chat area */}

View File

@ -1,198 +1,437 @@
'use client'; 'use client';
import {memo, useCallback, useState} from 'react'; import { memo, useCallback, useMemo, useState } from 'react';
import type {RoomWithCategory} from '@/contexts/room-context'; import type { RoomWithCategory } from '@/contexts/room-context';
import {Button} from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {cn} from '@/lib/utils'; import { Input } from '@/components/ui/input';
import {ChevronDown, ChevronRight, Hash, Lock, Plus, Settings} from 'lucide-react'; import { cn } from '@/lib/utils';
import { ChevronDown, ChevronRight, Hash, Lock, Plus, X, GripVertical } from 'lucide-react';
import {
DndContext,
closestCorners,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type UniqueIdentifier,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
/* ── Types ─────────────────────────────────────────────────────── */
interface RoomWithUnread extends RoomWithCategory { interface RoomWithUnread extends RoomWithCategory {
unread_count?: number; unread_count?: number;
} }
interface DiscordChannelSidebarProps { interface DiscordChannelSidebarProps {
projectName: string; projectName: string;
rooms: RoomWithCategory[]; rooms: RoomWithCategory[];
selectedRoomId: string | null; selectedRoomId: string | null;
onSelectRoom: (room: RoomWithCategory) => void; onSelectRoom: (room: RoomWithCategory) => void;
onCreateRoom: () => void; onCreateRoom: () => void;
onOpenSettings?: () => void; categories: Array<{ id: string; name: string }>;
onCreateCategory: (name: string) => Promise<void>;
onMoveRoomToCategory: (roomId: string, categoryId: string | null) => void;
onOpenSettings?: () => void;
} }
interface ChannelGroupProps { type CatName = string;
categoryName: string;
rooms: RoomWithCategory[]; const DRAG_PREFIX = 'room:';
selectedRoomId: string | null;
onSelectRoom: (room: RoomWithCategory) => void; /* ── Draggable row ─────────────────────────────────────────────── */
isCollapsed?: boolean;
onToggle?: () => void; const DraggableRow = memo(function DraggableRow({
} roomId,
children,
}: {
roomId: string;
children: React.ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: `${DRAG_PREFIX}${roomId}` });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
'cursor-grab active:cursor-grabbing',
isDragging && 'opacity-40',
)}
>
<div className="absolute left-0 top-0 h-full w-full opacity-0 group-hover:opacity-100 transition-opacity">
{children}
</div>
</li>
);
});
/* ── Single room button ────────────────────────────────────────── */
const RoomButton = memo(function RoomButton({
room,
selectedRoomId,
onSelectRoom,
}: {
room: RoomWithCategory;
selectedRoomId: string | null;
onSelectRoom: (room: RoomWithCategory) => void;
}) {
const isSelected = selectedRoomId === room.id;
const unreadCount = (room as RoomWithUnread).unread_count ?? 0;
return (
<button
type="button"
onClick={() => onSelectRoom(room)}
className={cn('discord-channel-item w-full group', isSelected && 'active')}
>
<GripVertical className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-70 shrink-0 mr-1" />
<Hash className="discord-channel-hash" />
<span className="discord-channel-name">{room.room_name}</span>
{!room.public && (
<Lock className="h-3.5 w-3.5 opacity-50 shrink-0 ml-auto" />
)}
{unreadCount > 0 && (
<span className="discord-mention-badge">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
);
});
/* ── Category group ────────────────────────────────────────────── */
const ChannelGroup = memo(function ChannelGroup({ const ChannelGroup = memo(function ChannelGroup({
categoryName, categoryName,
rooms, rooms,
selectedRoomId, selectedRoomId,
onSelectRoom, onSelectRoom,
isCollapsed, isCollapsed,
onToggle, onToggle,
}: ChannelGroupProps) { canReceiveDrops,
if (rooms.length === 0) return null; }: {
categoryName: string;
rooms: RoomWithCategory[];
selectedRoomId: string | null;
onSelectRoom: (room: RoomWithCategory) => void;
isCollapsed?: boolean;
onToggle?: () => void;
canReceiveDrops?: true;
}) {
const ids: UniqueIdentifier[] = rooms.map((r) => `${DRAG_PREFIX}${r.id}`);
return ( return (
<div className="discord-channel-category"> <div
<button className="discord-channel-category"
className={cn('discord-channel-category-header w-full', isCollapsed && 'collapsed')} onDragOver={(e) => e.preventDefault()}
onClick={onToggle} onDrop={canReceiveDrops ? () => undefined /* handled by DnD */ : undefined}
title={isCollapsed ? 'Expand' : 'Collapse'} >
> <button
{isCollapsed ? ( className={cn('discord-channel-category-header w-full', isCollapsed && 'collapsed')}
<ChevronRight className="h-3 w-3"/> onClick={onToggle}
) : ( title={isCollapsed ? 'Expand' : 'Collapse'}
<ChevronDown className="h-3 w-3"/> >
)} {isCollapsed ? (
<span className="flex-1 text-left">{categoryName}</span> <ChevronRight className="h-3 w-3" />
</button> ) : (
<ChevronDown className="h-3 w-3" />
)}
<span className="flex-1 text-left">{categoryName}</span>
</button>
{!isCollapsed && ( {!isCollapsed && (
<ul className="space-y-0.5 pl-2"> <ul className="space-y-0.5 pl-2">
{rooms.map((room) => { <SortableContext items={ids} strategy={verticalListSortingStrategy}>
const isSelected = selectedRoomId === room.id; {rooms.map((room) => (
const unreadCount = (room as RoomWithUnread).unread_count ?? 0; <DraggableRow key={room.id} roomId={room.id}>
return ( <RoomButton
<li key={room.id}> room={room}
<button selectedRoomId={selectedRoomId}
type="button" onSelectRoom={onSelectRoom}
onClick={() => onSelectRoom(room)} />
className={cn('discord-channel-item w-full', isSelected && 'active')} </DraggableRow>
> ))}
<Hash className="discord-channel-hash"/> </SortableContext>
<span className="discord-channel-name">{room.room_name}</span> </ul>
{!room.public && ( )}
<Lock className="h-3.5 w-3.5 opacity-50 shrink-0"/> </div>
)} );
{unreadCount > 0 && (
<span className="discord-mention-badge">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}); });
/* ── List content ──────────────────────────────────────────────── */
function ChannelListContent({
rooms,
selectedRoomId,
onSelectRoom,
categories,
uncategorizedRooms,
categorizedRooms,
collapsedState,
toggleCategory,
onMoveRoom,
}: {
rooms: RoomWithCategory[];
selectedRoomId: string | null;
onSelectRoom: (room: RoomWithCategory) => void;
categories: Array<{ id: string; name: string }>;
uncategorizedRooms: RoomWithCategory[];
categorizedRooms: Map<CatName, RoomWithCategory[]>;
collapsedState: Record<string, boolean>;
toggleCategory: (name: string) => void;
onMoveRoom: (roomId: string, catId: string | null) => void;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// Build flat list of all category names in display order for collision targets
const sortedCatNames = useMemo(
() => Array.from(categorizedRooms.keys()).sort(),
[categorizedRooms],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { over } = event;
if (!over) return;
const dragId = String(event.active.id);
if (!dragId.startsWith(DRAG_PREFIX)) return;
const draggedRoomId = dragId.slice(DRAG_PREFIX.length);
// Dropping onto a category (target id matches a category name)
const targetName = String(over.id);
const targetCat = categories.find((c) => c.name === targetName);
if (targetCat) {
const currentRoom = rooms.find((r) => r.id === draggedRoomId);
if (currentRoom && currentRoom.category_info?.id !== targetCat.id) {
onMoveRoom(draggedRoomId, targetCat.id);
}
return;
}
// Dropping onto another room's sort position → nothing to change visually
// dnd-kit handles the reorder via SortableContext automatically
},
[categories, rooms, onMoveRoom],
);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragEnd={handleDragEnd}
>
{/* Uncategorized channels at top */}
{uncategorizedRooms.length > 0 && (
<ChannelGroup
categoryName="Channels"
rooms={uncategorizedRooms}
selectedRoomId={selectedRoomId}
onSelectRoom={onSelectRoom}
/>
)}
{/* Categorized groups */}
{sortedCatNames.map((catName) => (
<ChannelGroup
key={catName}
categoryName={catName}
rooms={categorizedRooms.get(catName)!}
selectedRoomId={selectedRoomId}
onSelectRoom={onSelectRoom}
isCollapsed={!!collapsedState[catName]}
onToggle={() => toggleCategory(catName)}
canReceiveDrops
/>
))}
</DndContext>
);
}
/* ── Public component ──────────────────────────────────────────── */
export const DiscordChannelSidebar = memo(function DiscordChannelSidebar({ export const DiscordChannelSidebar = memo(function DiscordChannelSidebar({
projectName, projectName,
rooms, rooms,
selectedRoomId, selectedRoomId,
onSelectRoom, onSelectRoom,
onCreateRoom, onCreateRoom,
onOpenSettings, categories,
}: DiscordChannelSidebarProps) { onCreateCategory,
// Group rooms by category onMoveRoomToCategory,
const uncategorized = rooms.filter((r) => !r.category_info?.name); onOpenSettings,
const categorized = rooms.filter((r) => r.category_info?.name); }: DiscordChannelSidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const toggleCategory = useCallback((name: string) => {
setCollapsed((prev) => ({ ...prev, [name]: !prev[name] }));
}, []);
// Build category map: categoryName → rooms[] const [creatingCat, setCreatingCat] = useState(false);
const categoryMap = new Map<string, RoomWithCategory[]>(); const [newCatName, setNewCatName] = useState('');
const handleCreateCategory = useCallback(async () => {
const trimmed = newCatName.trim();
if (!trimmed) return;
await onCreateCategory(trimmed);
setNewCatName('');
setCreatingCat(false);
}, [newCatName, onCreateCategory]);
const handleMoveRoom = useCallback(
(roomId: string, categoryId: string | null) => {
onMoveRoomToCategory(roomId, categoryId);
},
[onMoveRoomToCategory],
);
// Group rooms by category
const uncategorized = useMemo(
() => rooms.filter((r) => !r.category_info?.name),
[rooms],
);
const categorized = useMemo(
() => rooms.filter((r) => r.category_info?.name),
[rooms],
);
const categoryMap = useMemo(() => {
const map = new Map<CatName, RoomWithCategory[]>();
for (const room of categorized) { for (const room of categorized) {
const name = room.category_info!.name; const name = room.category_info!.name;
if (!categoryMap.has(name)) categoryMap.set(name, []); if (!map.has(name)) map.set(name, []);
categoryMap.get(name)!.push(room); map.get(name)!.push(room);
} }
return map;
}, [categorized]);
// Collapse state per category return (
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({}); <div className="discord-channel-sidebar flex flex-col h-full">
{/* Header */}
const toggleCategory = useCallback((name: string) => { <div className="discord-channel-header flex items-center justify-between px-3 py-2 border-b">
setCollapsed((prev) => ({...prev, [name]: !prev[name]})); <div
}, []); className="flex items-center gap-2 font-bold truncate"
style={{ color: 'var(--room-text)' }}
// Uncategorized channels at top, then alphabetical categories >
const sortedCategoryNames = Array.from(categoryMap.keys()).sort(); <span className="truncate">{projectName}</span>
return (
<div className="discord-channel-sidebar">
{/* Header */}
<div className="discord-channel-header">
<div
className="discord-channel-header-title flex items-center gap-2 font-bold"
style={{color: 'var(--room-text)'}}
>
<span>{projectName}</span>
</div>
<div className="flex items-center gap-1">
{onOpenSettings && (
<button
onClick={onOpenSettings}
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{color: 'var(--room-text-muted)'}}
title="Channel settings"
>
<Settings className="h-4 w-4"/>
</button>
)}
</div>
</div>
{/* Channel list */}
<div className="discord-channel-list">
{/* Uncategorized */}
{uncategorized.length > 0 && (
<ChannelGroup
categoryName="Channels"
rooms={uncategorized}
selectedRoomId={selectedRoomId}
onSelectRoom={onSelectRoom}
/>
)}
{/* Categorized */}
{sortedCategoryNames.map((catName) => (
<ChannelGroup
key={catName}
categoryName={catName}
rooms={categoryMap.get(catName)!}
selectedRoomId={selectedRoomId}
onSelectRoom={onSelectRoom}
isCollapsed={collapsed[catName]}
onToggle={() => toggleCategory(catName)}
/>
))}
{rooms.length === 0 && (
<div className="px-4 py-8 text-center">
<p className="text-sm mb-3" style={{color: 'var(--room-text-muted)'}}>No channels yet</p>
<Button
size="sm"
onClick={onCreateRoom}
className="bg-primary hover:bg-primary/90 text-primary-foreground border-none"
>
<Plus className="mr-1 h-3.5 w-3.5"/>
Create Channel
</Button>
</div>
)}
</div>
{/* Footer: user / add channel */}
<div
className="border-t px-2 py-2"
style={{borderColor: 'var(--room-border)', background: 'var(--room-sidebar)'}}
>
<button
onClick={onCreateRoom}
className="discord-add-channel-btn w-full"
>
<Plus className="h-4 w-4"/>
<span>Add Channel</span>
</button>
</div>
</div> </div>
); <div className="flex items-center gap-1">
{onOpenSettings && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
style={{ color: 'var(--room-text-muted)' }}
title="Channel settings"
onClick={onOpenSettings}
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
<path fillRule="evenodd" clipRule="evenodd" d="M1.786 9.476 1.12 7.236l2.14-.665a6.013 6.013 0 0 1 1.634-2.824l-1.29-1.872 2.142-1.237 1.676 1.478a6.013 6.013 0 0 1 3.07 0l1.677-1.478 2.14 1.237-1.288 1.872a6.013 6.013 0 0 1 1.634 2.824l2.14.665-1.12 2.827-2.142.664a6.013 6.013 0 0 1 0 3.088l2.142.664 1.12 2.827-2.14.665a6.013 6.013 0 0 1-1.634 2.824l1.29 1.872-2.142 1.237-1.677-1.478a6.013 6.013 0 0 1-3.07 0l-1.676 1.478-2.142-1.237 1.288-1.872a6.013 6.013 0 0 1-1.634-2.824l-2.14-.665 1.12-2.827 2.142-.664a6.013 6.013 0 0 1 0-3.088l-2.142-.664ZM12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" />
</svg>
</Button>
)}
</div>
</div>
{/* Scrollable list */}
<div className="discord-channel-list flex-1 overflow-y-auto px-2 py-2">
<ChannelListContent
rooms={rooms}
selectedRoomId={selectedRoomId}
onSelectRoom={onSelectRoom}
categories={categories}
uncategorizedRooms={uncategorized}
categorizedRooms={categoryMap}
collapsedState={collapsed}
toggleCategory={toggleCategory}
onMoveRoom={handleMoveRoom}
/>
{/* Create category */}
{creatingCat ? (
<div className="flex items-center gap-1 px-2 py-1">
<Input
autoFocus
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateCategory();
if (e.key === 'Escape') { setCreatingCat(false); setNewCatName(''); }
}}
placeholder="Category name"
className="h-7 text-xs"
/>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={handleCreateCategory}>
<Plus className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => { setCreatingCat(false); setNewCatName(''); }}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<button
onClick={() => setCreatingCat(true)}
className="discord-channel-item w-full px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
>
+ Add Category
</button>
)}
{rooms.length === 0 && (
<div className="px-4 py-8 text-center">
<p className="text-sm mb-3 text-muted-foreground">No channels yet</p>
<Button
size="sm"
onClick={onCreateRoom}
className="bg-primary hover:bg-primary/90 text-primary-foreground border-none"
>
<Plus className="mr-1 h-3.5 w-3.5" />
Create Channel
</Button>
</div>
)}
</div>
{/* Footer */}
<div
className="border-t px-2 py-2"
style={{ borderColor: 'var(--room-border)', background: 'var(--room-sidebar)' }}
>
<button
onClick={onCreateRoom}
className="discord-add-channel-btn w-full flex items-center gap-2 text-sm"
>
<Plus className="h-4 w-4" />
<span>Add Channel</span>
</button>
</div>
</div>
);
}); });

View File

@ -439,45 +439,59 @@ export function RoomProvider({
}); });
} }
}, },
onAiStreamChunk: (chunk) => { const onAiStreamChunk = useCallback((chunk: AiStreamChunkPayload) => {
if (chunk.done) { if (chunk.done) {
setStreamingContent((prev) => { setStreamingContent((prev) => {
const next = new Map(prev); prev.delete(chunk.message_id);
next.delete(chunk.message_id); return new Map(prev);
return next; });
}); setMessages((prev) =>
setMessages((prev) => prev.map((m) =>
prev.map((m) => m.id === chunk.message_id
m.id === chunk.message_id ? { ...m, content: chunk.content, display_content: chunk.content, is_streaming: false }
? { ...m, content: chunk.content, is_streaming: false } : m,
: m, ),
), );
); } else {
} else { // Accumulate streaming content in dedicated map
setStreamingContent((prev) => { setStreamingContent((prev) => {
const next = new Map(prev); const next = new Map(prev);
const existing = next.get(chunk.message_id) ?? ''; next.set(chunk.message_id, (next.get(chunk.message_id) ?? '') + chunk.content);
next.set(chunk.message_id, existing + chunk.content); return next;
return next; });
}); // Update or insert the AI message with current accumulated content
setMessages((prev) => { // Use streamingContent map as source of truth for display during streaming
if (prev.some((m) => m.id === chunk.message_id)) { setStreamingContent((current) => {
return prev; const accumulated = current.get(chunk.message_id) ?? '';
} setMessages((prev) => {
const newMsg: MessageWithMeta = { const idx = prev.findIndex((m) => m.id === chunk.message_id);
id: chunk.message_id, if (idx !== -1) {
room: chunk.room_id, const m = prev[idx];
seq: 0, // Skip render if content hasn't changed (dedup protection)
sender_type: 'ai', if (m.content === accumulated && m.is_streaming === true) return prev;
content: '', const updated = [...prev];
content_type: 'text', updated[idx] = { ...m, content: accumulated, display_content: accumulated };
send_at: new Date().toISOString(), return updated;
is_streaming: true, }
}; // New message — avoid adding empty content blocks
return [...prev, newMsg]; if (!accumulated) return prev;
}); const newMsg: MessageWithMeta = {
} id: chunk.message_id,
}, room: chunk.room_id,
seq: 0,
sender_type: 'ai',
content: accumulated,
display_content: accumulated,
content_type: 'text',
send_at: new Date().toISOString(),
is_streaming: true,
};
return [...prev, newMsg];
});
return current;
});
}
}, []);
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => { onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
if (!activeRoomIdRef.current) return; if (!activeRoomIdRef.current) return;
setMessages((prev) => { setMessages((prev) => {