refactor(room): Discord-style UI redesign for channel sidebar and member list
This commit is contained in:
parent
66006d842e
commit
b73cc8d421
@ -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 */}
|
||||||
|
|||||||
@ -1,11 +1,30 @@
|
|||||||
'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 { Input } from '@/components/ui/input';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {ChevronDown, ChevronRight, Hash, Lock, Plus, Settings} from 'lucide-react';
|
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;
|
||||||
@ -17,17 +36,93 @@ interface DiscordChannelSidebarProps {
|
|||||||
selectedRoomId: string | null;
|
selectedRoomId: string | null;
|
||||||
onSelectRoom: (room: RoomWithCategory) => void;
|
onSelectRoom: (room: RoomWithCategory) => void;
|
||||||
onCreateRoom: () => void;
|
onCreateRoom: () => void;
|
||||||
|
categories: Array<{ id: string; name: string }>;
|
||||||
|
onCreateCategory: (name: string) => Promise<void>;
|
||||||
|
onMoveRoomToCategory: (roomId: string, categoryId: string | null) => void;
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChannelGroupProps {
|
type CatName = string;
|
||||||
categoryName: string;
|
|
||||||
rooms: RoomWithCategory[];
|
const DRAG_PREFIX = 'room:';
|
||||||
|
|
||||||
|
/* ── Draggable row ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
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;
|
selectedRoomId: string | null;
|
||||||
onSelectRoom: (room: RoomWithCategory) => void;
|
onSelectRoom: (room: RoomWithCategory) => void;
|
||||||
isCollapsed?: boolean;
|
}) {
|
||||||
onToggle?: () => 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,
|
||||||
@ -36,11 +131,24 @@ const ChannelGroup = memo(function ChannelGroup({
|
|||||||
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
|
||||||
|
className="discord-channel-category"
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={canReceiveDrops ? () => undefined /* handled by DnD */ : undefined}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
className={cn('discord-channel-category-header w-full', isCollapsed && 'collapsed')}
|
className={cn('discord-channel-category-header w-full', isCollapsed && 'collapsed')}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
@ -56,118 +164,249 @@ const ChannelGroup = memo(function ChannelGroup({
|
|||||||
|
|
||||||
{!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>
|
|
||||||
{!room.public && (
|
|
||||||
<Lock className="h-3.5 w-3.5 opacity-50 shrink-0"/>
|
|
||||||
)}
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<span className="discord-mention-badge">
|
|
||||||
{unreadCount > 99 ? '99+' : unreadCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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,
|
||||||
|
categories,
|
||||||
|
onCreateCategory,
|
||||||
|
onMoveRoomToCategory,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
}: DiscordChannelSidebarProps) {
|
}: DiscordChannelSidebarProps) {
|
||||||
// Group rooms by category
|
|
||||||
const uncategorized = rooms.filter((r) => !r.category_info?.name);
|
|
||||||
const categorized = rooms.filter((r) => r.category_info?.name);
|
|
||||||
|
|
||||||
// Build category map: categoryName → rooms[]
|
|
||||||
const categoryMap = new Map<string, RoomWithCategory[]>();
|
|
||||||
for (const room of categorized) {
|
|
||||||
const name = room.category_info!.name;
|
|
||||||
if (!categoryMap.has(name)) categoryMap.set(name, []);
|
|
||||||
categoryMap.get(name)!.push(room);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collapse state per category
|
|
||||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const toggleCategory = useCallback((name: string) => {
|
const toggleCategory = useCallback((name: string) => {
|
||||||
setCollapsed((prev) => ({ ...prev, [name]: !prev[name] }));
|
setCollapsed((prev) => ({ ...prev, [name]: !prev[name] }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Uncategorized channels at top, then alphabetical categories
|
const [creatingCat, setCreatingCat] = useState(false);
|
||||||
const sortedCategoryNames = Array.from(categoryMap.keys()).sort();
|
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) {
|
||||||
|
const name = room.category_info!.name;
|
||||||
|
if (!map.has(name)) map.set(name, []);
|
||||||
|
map.get(name)!.push(room);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [categorized]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="discord-channel-sidebar">
|
<div className="discord-channel-sidebar flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="discord-channel-header">
|
<div className="discord-channel-header flex items-center justify-between px-3 py-2 border-b">
|
||||||
<div
|
<div
|
||||||
className="discord-channel-header-title flex items-center gap-2 font-bold"
|
className="flex items-center gap-2 font-bold truncate"
|
||||||
style={{ color: 'var(--room-text)' }}
|
style={{ color: 'var(--room-text)' }}
|
||||||
>
|
>
|
||||||
<span>{projectName}</span>
|
<span className="truncate">{projectName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{onOpenSettings && (
|
{onOpenSettings && (
|
||||||
<button
|
<Button
|
||||||
onClick={onOpenSettings}
|
variant="ghost"
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
style={{ color: 'var(--room-text-muted)' }}
|
style={{ color: 'var(--room-text-muted)' }}
|
||||||
title="Channel settings"
|
title="Channel settings"
|
||||||
|
onClick={onOpenSettings}
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4"/>
|
<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>
|
</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 && (
|
{rooms.length === 0 && (
|
||||||
<div className="px-4 py-8 text-center">
|
<div className="px-4 py-8 text-center">
|
||||||
<p className="text-sm mb-3" style={{color: 'var(--room-text-muted)'}}>No channels yet</p>
|
<p className="text-sm mb-3 text-muted-foreground">No channels yet</p>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onCreateRoom}
|
onClick={onCreateRoom}
|
||||||
@ -180,14 +419,14 @@ export const DiscordChannelSidebar = memo(function DiscordChannelSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer: user / add channel */}
|
{/* Footer */}
|
||||||
<div
|
<div
|
||||||
className="border-t px-2 py-2"
|
className="border-t px-2 py-2"
|
||||||
style={{ borderColor: 'var(--room-border)', background: 'var(--room-sidebar)' }}
|
style={{ borderColor: 'var(--room-border)', background: 'var(--room-sidebar)' }}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onCreateRoom}
|
onClick={onCreateRoom}
|
||||||
className="discord-add-channel-btn w-full"
|
className="discord-add-channel-btn w-full flex items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span>Add Channel</span>
|
<span>Add Channel</span>
|
||||||
|
|||||||
@ -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, is_streaming: false }
|
? { ...m, content: chunk.content, display_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
|
||||||
|
// Use streamingContent map as source of truth for display during streaming
|
||||||
|
setStreamingContent((current) => {
|
||||||
|
const accumulated = current.get(chunk.message_id) ?? '';
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
if (prev.some((m) => m.id === chunk.message_id)) {
|
const idx = prev.findIndex((m) => m.id === chunk.message_id);
|
||||||
return prev;
|
if (idx !== -1) {
|
||||||
|
const m = prev[idx];
|
||||||
|
// Skip render if content hasn't changed (dedup protection)
|
||||||
|
if (m.content === accumulated && m.is_streaming === true) return prev;
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[idx] = { ...m, content: accumulated, display_content: accumulated };
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
// New message — avoid adding empty content blocks
|
||||||
|
if (!accumulated) return prev;
|
||||||
const newMsg: MessageWithMeta = {
|
const newMsg: MessageWithMeta = {
|
||||||
id: chunk.message_id,
|
id: chunk.message_id,
|
||||||
room: chunk.room_id,
|
room: chunk.room_id,
|
||||||
seq: 0,
|
seq: 0,
|
||||||
sender_type: 'ai',
|
sender_type: 'ai',
|
||||||
content: '',
|
content: accumulated,
|
||||||
|
display_content: accumulated,
|
||||||
content_type: 'text',
|
content_type: 'text',
|
||||||
send_at: new Date().toISOString(),
|
send_at: new Date().toISOString(),
|
||||||
is_streaming: true,
|
is_streaming: true,
|
||||||
};
|
};
|
||||||
return [...prev, newMsg];
|
return [...prev, newMsg];
|
||||||
});
|
});
|
||||||
|
return current;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
}, []);
|
||||||
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
|
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
|
||||||
if (!activeRoomIdRef.current) return;
|
if (!activeRoomIdRef.current) return;
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user