Compare commits
4 Commits
882e86dc33
...
b73cc8d421
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b73cc8d421 | ||
|
|
66006d842e | ||
|
|
b740e2884d | ||
|
|
39d30678b5 |
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -2277,6 +2277,7 @@ dependencies = [
|
||||
"lettre",
|
||||
"regex",
|
||||
"serde",
|
||||
"slog",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
||||
@ -559,6 +559,38 @@ impl ChatService {
|
||||
messages.push(mem.to_system_message());
|
||||
}
|
||||
|
||||
// Inject project context so the AI knows which project it is operating in.
|
||||
let project_info = format!(
|
||||
"Current Project:\n{}\nDescription: {}\nPublic: {}",
|
||||
request.project.display_name,
|
||||
request.project.description.as_deref().unwrap_or("(none)"),
|
||||
if request.project.is_public { "yes" } else { "no" }
|
||||
);
|
||||
messages.push(ChatCompletionRequestMessage::System(
|
||||
ChatCompletionRequestSystemMessage {
|
||||
content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text(project_info),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
|
||||
// Inject sender info so the AI knows who is asking.
|
||||
let mut sender_parts = vec![format!("**Sender:** {}", request.sender.username)];
|
||||
if let Some(ref display_name) = request.sender.display_name {
|
||||
sender_parts.push(display_name.clone());
|
||||
}
|
||||
if let Some(ref org) = request.sender.organization {
|
||||
sender_parts.push(format!("({})", org));
|
||||
}
|
||||
let sender_display = sender_parts.join(" ");
|
||||
messages.push(ChatCompletionRequestMessage::System(
|
||||
ChatCompletionRequestSystemMessage {
|
||||
content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text(
|
||||
format!("The person sending the next message:\n{}", sender_display),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
|
||||
messages.push(ChatCompletionRequestMessage::User(
|
||||
ChatCompletionRequestUserMessage {
|
||||
content: async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text(
|
||||
|
||||
@ -17,9 +17,10 @@ path = "lib.rs"
|
||||
[dependencies]
|
||||
config = { workspace = true }
|
||||
lettre = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "rt"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "rt", "sync", "macros"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
anyhow = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
slog = { workspace = true }
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
use config::AppConfig;
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::transport::smtp::client::Tls;
|
||||
use lettre::transport::smtp::{PoolConfig, SmtpTransport};
|
||||
use lettre::Transport;
|
||||
use regex::Regex;
|
||||
@ -26,7 +24,7 @@ pub struct AppEmail {
|
||||
}
|
||||
|
||||
impl AppEmail {
|
||||
pub async fn init(cfg: &AppConfig) -> anyhow::Result<Self> {
|
||||
pub async fn init(cfg: &AppConfig, logs: slog::Logger) -> anyhow::Result<Self> {
|
||||
let smtp_host = cfg.smtp_host()?;
|
||||
let smtp_port = cfg.smtp_port()?;
|
||||
let smtp_username = cfg.smtp_username()?;
|
||||
@ -35,24 +33,28 @@ impl AppEmail {
|
||||
let smtp_tls = cfg.smtp_tls()?;
|
||||
let smtp_timeout = cfg.smtp_timeout()?;
|
||||
|
||||
let cred = Credentials::new(smtp_username, smtp_password);
|
||||
|
||||
let tls_param = if smtp_tls {
|
||||
Tls::Required(
|
||||
lettre::transport::smtp::client::TlsParameters::builder(smtp_host.clone())
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!("TLS build error: {}", e))?,
|
||||
// Port 465 = SMTPS (implicit TLS via smtps://), others = STARTTLS via smtp://
|
||||
let url = if smtp_port == 465 && smtp_tls {
|
||||
format!(
|
||||
"smtps://{}:{}@{}:{}",
|
||||
smtp_username, smtp_password, smtp_host, smtp_port
|
||||
)
|
||||
} else {
|
||||
Tls::None
|
||||
let tls_mode = if smtp_tls {
|
||||
"required"
|
||||
} else {
|
||||
"opportunistic"
|
||||
};
|
||||
format!(
|
||||
"smtp://{}:{}@{}:{}?tls={}",
|
||||
smtp_username, smtp_password, smtp_host, smtp_port, tls_mode
|
||||
)
|
||||
};
|
||||
|
||||
let mailer = SmtpTransport::builder_dangerous(smtp_host)
|
||||
.port(smtp_port)
|
||||
.tls(tls_param)
|
||||
let mailer = SmtpTransport::from_url(&url)
|
||||
.map_err(|e| anyhow::anyhow!("SMTP transport build error: {}", e))?
|
||||
.timeout(Some(Duration::from_secs(smtp_timeout)))
|
||||
.credentials(cred)
|
||||
.pool_config(PoolConfig::new().min_idle(5).max_size(100))
|
||||
.pool_config(PoolConfig::new().min_idle(0).max_size(10))
|
||||
.build();
|
||||
|
||||
let from: Mailbox = smtp_from.parse()?;
|
||||
@ -72,13 +74,15 @@ impl AppEmail {
|
||||
.body(msg.clone().body)
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
Err(_) => {
|
||||
slog::warn!(logs, "Email build error: to={}", msg.to);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut success = false;
|
||||
for i in 0..3 {
|
||||
let mailer = mailer.clone();
|
||||
let email = email.clone();
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || mailer.send(&email)).await;
|
||||
|
||||
match result {
|
||||
@ -86,15 +90,26 @@ impl AppEmail {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
let backoff = 100 * (i + 1);
|
||||
tokio::time::sleep(Duration::from_millis(backoff)).await;
|
||||
Ok(Err(e)) => {
|
||||
if i == 2 {
|
||||
slog::error!(
|
||||
logs,
|
||||
"Email send failed after retries: to={}, error={}",
|
||||
msg.to,
|
||||
e
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs((1 << i) as u64)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
slog::error!(logs, "Email spawn error: to={}, err={}", msg.to, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !success {
|
||||
println!("[System] email send fail: {:?}", msg.clone());
|
||||
slog::warn!(logs, "Email send permanently failed: to={}", msg.to);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@ use chrono::Utc;
|
||||
use models::rooms::{
|
||||
RoomMemberRole, room, room_ai, room_category, room_member, room_message, room_pin, room_thread,
|
||||
};
|
||||
use models::projects::{project_members, MemberRole as Role};
|
||||
use queue::ProjectRoomEvent;
|
||||
use sea_orm::*;
|
||||
use uuid::Uuid;
|
||||
@ -122,6 +123,37 @@ impl RoomService {
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
|
||||
// Inherit project members into room members
|
||||
let project_members_list = project_members::Entity::find()
|
||||
.filter(project_members::Column::Project.eq(project.id))
|
||||
.all(&txn)
|
||||
.await?;
|
||||
|
||||
for pm in project_members_list {
|
||||
if pm.user != user_id {
|
||||
let role = match pm.scope_role() {
|
||||
Ok(Role::Owner) => RoomMemberRole::Owner,
|
||||
Ok(Role::Admin) => RoomMemberRole::Admin,
|
||||
Ok(_) | Err(_) => RoomMemberRole::Member,
|
||||
};
|
||||
|
||||
room_member::ActiveModel {
|
||||
room: Set(room_model.id),
|
||||
user: Set(pm.user),
|
||||
role: Set(role),
|
||||
first_msg_in: Set(None),
|
||||
joined_at: Set(Some(Utc::now())),
|
||||
last_read_seq: Set(None),
|
||||
do_not_disturb: Set(false),
|
||||
dnd_start_hour: Set(None),
|
||||
dnd_end_hour: Set(None),
|
||||
}
|
||||
.insert(&txn)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
self.spawn_room_workers(room_model.id);
|
||||
|
||||
@ -104,7 +104,7 @@ impl AppService {
|
||||
.main_domain()
|
||||
.map_err(|_| AppError::DoMainNotSet)?;
|
||||
|
||||
let verify_link = format!("https://{}/auth/verify-email?token={}", domain, token);
|
||||
let verify_link = format!("{}/auth/verify-email?token={}", domain, token);
|
||||
|
||||
let envelope = queue::EmailEnvelope {
|
||||
id: Uuid::new_v4(),
|
||||
|
||||
@ -122,7 +122,7 @@ impl AppService {
|
||||
.map_err(|_| AppError::DoMainNotSet)?;
|
||||
|
||||
let email_address = params.email.clone();
|
||||
let reset_link = format!("https://{}/auth/reset-password?token={}", domain, token);
|
||||
let reset_link = format!("{}/auth/reset-password?token={}", domain, token);
|
||||
|
||||
let envelope = queue::EmailEnvelope {
|
||||
id: Uuid::new_v4(),
|
||||
|
||||
@ -95,12 +95,13 @@ impl AppService {
|
||||
pub async fn new(config: AppConfig) -> anyhow::Result<Self> {
|
||||
let db = AppDatabase::init(&config).await?;
|
||||
let cache = AppCache::init(&config).await?;
|
||||
let email = AppEmail::init(&config).await?;
|
||||
let avatar = AppAvatar::init(&config).await?;
|
||||
|
||||
let log_level = config.log_level().unwrap_or_else(|_| "info".to_string());
|
||||
let logs = Self::build_slog_logger(&log_level);
|
||||
|
||||
let email = AppEmail::init(&config, logs.clone()).await?;
|
||||
let avatar = AppAvatar::init(&config).await?;
|
||||
|
||||
// Build get_redis closure for MessageProducer
|
||||
let get_redis: Arc<
|
||||
dyn Fn() -> tokio::task::JoinHandle<anyhow::Result<deadpool_redis::cluster::Connection>>
|
||||
|
||||
@ -350,7 +350,7 @@ impl AppService {
|
||||
.map_err(|_| AppError::DoMainNotSet)?;
|
||||
|
||||
let invite_link = format!(
|
||||
"https://{}/auth/accept-workspace-invite?token={}",
|
||||
"{}/auth/accept-workspace-invite?token={}",
|
||||
domain,
|
||||
token.clone()
|
||||
);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useRoom, useUser } from '@/contexts';
|
||||
import { RoomProvider } from '@/contexts';
|
||||
@ -18,6 +18,8 @@ function ProjectRoomInner() {
|
||||
const {
|
||||
rooms,
|
||||
roomsLoading,
|
||||
categories,
|
||||
createCategory,
|
||||
activeRoom,
|
||||
activeRoomId,
|
||||
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(
|
||||
(m) =>
|
||||
m.user === user.uid &&
|
||||
@ -112,6 +125,9 @@ function ProjectRoomInner() {
|
||||
selectedRoomId={activeRoomId}
|
||||
onSelectRoom={handleSelectRoom}
|
||||
onCreateRoom={handleOpenCreate}
|
||||
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
|
||||
onCreateCategory={handleCreateCategory}
|
||||
onMoveRoomToCategory={handleMoveRoomToCategory}
|
||||
/>
|
||||
|
||||
{/* Main chat area */}
|
||||
|
||||
@ -1,198 +1,437 @@
|
||||
'use client';
|
||||
|
||||
import {memo, useCallback, useState} from 'react';
|
||||
import type {RoomWithCategory} from '@/contexts/room-context';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {ChevronDown, ChevronRight, Hash, Lock, Plus, Settings} from 'lucide-react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import type { RoomWithCategory } from '@/contexts/room-context';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 {
|
||||
unread_count?: number;
|
||||
unread_count?: number;
|
||||
}
|
||||
|
||||
interface DiscordChannelSidebarProps {
|
||||
projectName: string;
|
||||
rooms: RoomWithCategory[];
|
||||
selectedRoomId: string | null;
|
||||
onSelectRoom: (room: RoomWithCategory) => void;
|
||||
onCreateRoom: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
projectName: string;
|
||||
rooms: RoomWithCategory[];
|
||||
selectedRoomId: string | null;
|
||||
onSelectRoom: (room: RoomWithCategory) => void;
|
||||
onCreateRoom: () => void;
|
||||
categories: Array<{ id: string; name: string }>;
|
||||
onCreateCategory: (name: string) => Promise<void>;
|
||||
onMoveRoomToCategory: (roomId: string, categoryId: string | null) => void;
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
interface ChannelGroupProps {
|
||||
categoryName: string;
|
||||
rooms: RoomWithCategory[];
|
||||
selectedRoomId: string | null;
|
||||
onSelectRoom: (room: RoomWithCategory) => void;
|
||||
isCollapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
type CatName = string;
|
||||
|
||||
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;
|
||||
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({
|
||||
categoryName,
|
||||
rooms,
|
||||
selectedRoomId,
|
||||
onSelectRoom,
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
}: ChannelGroupProps) {
|
||||
if (rooms.length === 0) return null;
|
||||
categoryName,
|
||||
rooms,
|
||||
selectedRoomId,
|
||||
onSelectRoom,
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
canReceiveDrops,
|
||||
}: {
|
||||
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 (
|
||||
<div className="discord-channel-category">
|
||||
<button
|
||||
className={cn('discord-channel-category-header w-full', isCollapsed && 'collapsed')}
|
||||
onClick={onToggle}
|
||||
title={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-3 w-3"/>
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3"/>
|
||||
)}
|
||||
<span className="flex-1 text-left">{categoryName}</span>
|
||||
</button>
|
||||
return (
|
||||
<div
|
||||
className="discord-channel-category"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={canReceiveDrops ? () => undefined /* handled by DnD */ : undefined}
|
||||
>
|
||||
<button
|
||||
className={cn('discord-channel-category-header w-full', isCollapsed && 'collapsed')}
|
||||
onClick={onToggle}
|
||||
title={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
<span className="flex-1 text-left">{categoryName}</span>
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<ul className="space-y-0.5 pl-2">
|
||||
{rooms.map((room) => {
|
||||
const isSelected = selectedRoomId === room.id;
|
||||
const unreadCount = (room as RoomWithUnread).unread_count ?? 0;
|
||||
return (
|
||||
<li key={room.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectRoom(room)}
|
||||
className={cn('discord-channel-item w-full', isSelected && 'active')}
|
||||
>
|
||||
<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"/>
|
||||
)}
|
||||
{unreadCount > 0 && (
|
||||
<span className="discord-mention-badge">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{!isCollapsed && (
|
||||
<ul className="space-y-0.5 pl-2">
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{rooms.map((room) => (
|
||||
<DraggableRow key={room.id} roomId={room.id}>
|
||||
<RoomButton
|
||||
room={room}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onSelectRoom={onSelectRoom}
|
||||
/>
|
||||
</DraggableRow>
|
||||
))}
|
||||
</SortableContext>
|
||||
</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({
|
||||
projectName,
|
||||
rooms,
|
||||
selectedRoomId,
|
||||
onSelectRoom,
|
||||
onCreateRoom,
|
||||
onOpenSettings,
|
||||
}: DiscordChannelSidebarProps) {
|
||||
// Group rooms by category
|
||||
const uncategorized = rooms.filter((r) => !r.category_info?.name);
|
||||
const categorized = rooms.filter((r) => r.category_info?.name);
|
||||
projectName,
|
||||
rooms,
|
||||
selectedRoomId,
|
||||
onSelectRoom,
|
||||
onCreateRoom,
|
||||
categories,
|
||||
onCreateCategory,
|
||||
onMoveRoomToCategory,
|
||||
onOpenSettings,
|
||||
}: 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 categoryMap = new Map<string, RoomWithCategory[]>();
|
||||
const [creatingCat, setCreatingCat] = useState(false);
|
||||
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 (!categoryMap.has(name)) categoryMap.set(name, []);
|
||||
categoryMap.get(name)!.push(room);
|
||||
const name = room.category_info!.name;
|
||||
if (!map.has(name)) map.set(name, []);
|
||||
map.get(name)!.push(room);
|
||||
}
|
||||
return map;
|
||||
}, [categorized]);
|
||||
|
||||
// Collapse state per category
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleCategory = useCallback((name: string) => {
|
||||
setCollapsed((prev) => ({...prev, [name]: !prev[name]}));
|
||||
}, []);
|
||||
|
||||
// Uncategorized channels at top, then alphabetical categories
|
||||
const sortedCategoryNames = Array.from(categoryMap.keys()).sort();
|
||||
|
||||
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>
|
||||
return (
|
||||
<div className="discord-channel-sidebar flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="discord-channel-header flex items-center justify-between px-3 py-2 border-b">
|
||||
<div
|
||||
className="flex items-center gap-2 font-bold truncate"
|
||||
style={{ color: 'var(--room-text)' }}
|
||||
>
|
||||
<span className="truncate">{projectName}</span>
|
||||
</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>
|
||||
);
|
||||
});
|
||||
|
||||
@ -439,45 +439,59 @@ export function RoomProvider({
|
||||
});
|
||||
}
|
||||
},
|
||||
onAiStreamChunk: (chunk) => {
|
||||
if (chunk.done) {
|
||||
setStreamingContent((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(chunk.message_id);
|
||||
return next;
|
||||
});
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === chunk.message_id
|
||||
? { ...m, content: chunk.content, is_streaming: false }
|
||||
: m,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setStreamingContent((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(chunk.message_id) ?? '';
|
||||
next.set(chunk.message_id, existing + chunk.content);
|
||||
return next;
|
||||
});
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === chunk.message_id)) {
|
||||
return prev;
|
||||
}
|
||||
const newMsg: MessageWithMeta = {
|
||||
id: chunk.message_id,
|
||||
room: chunk.room_id,
|
||||
seq: 0,
|
||||
sender_type: 'ai',
|
||||
content: '',
|
||||
content_type: 'text',
|
||||
send_at: new Date().toISOString(),
|
||||
is_streaming: true,
|
||||
};
|
||||
return [...prev, newMsg];
|
||||
});
|
||||
}
|
||||
},
|
||||
const onAiStreamChunk = useCallback((chunk: AiStreamChunkPayload) => {
|
||||
if (chunk.done) {
|
||||
setStreamingContent((prev) => {
|
||||
prev.delete(chunk.message_id);
|
||||
return new Map(prev);
|
||||
});
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === chunk.message_id
|
||||
? { ...m, content: chunk.content, display_content: chunk.content, is_streaming: false }
|
||||
: m,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Accumulate streaming content in dedicated map
|
||||
setStreamingContent((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(chunk.message_id, (next.get(chunk.message_id) ?? '') + chunk.content);
|
||||
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) => {
|
||||
const idx = prev.findIndex((m) => m.id === chunk.message_id);
|
||||
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 = {
|
||||
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) => {
|
||||
if (!activeRoomIdRef.current) return;
|
||||
setMessages((prev) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user