From cf5c728286dbdf7088ecfb7a63bb7a33b4904ce1 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Fri, 17 Apr 2026 21:08:40 +0800 Subject: [PATCH] fix(room): fix scrolling lag, N+1 queries, and multiple WS token requests Frontend: - P0: Replace constant estimateSize(40px) with content-based estimation using line count and reply presence for accurate virtual list scroll - P1: Replace Shadow DOM custom elements with styled spans for @mentions, eliminating expensive attachShadow calls per mention instance - P1: Remove per-message ResizeObserver (one per bubble), replace with static inline toolbar layout to avoid observer overhead - P2: Fix WS token re-fetch on every room switch by preserving token across navigation and not clearing activeRoomIdRef on cleanup Backend: - P1: Fix reaction check+insert race condition by moving into transaction instead of separate query + on-conflict insert - P2: Fix N+1 queries in get_mention_notifications with batch fetch for users and rooms using IN clauses - P2: Update room_last_activity in broadcast_stream_chunk to prevent idle room cleanup during active AI streaming - P3: Use enum comparison instead of to_string() in room_member_leave --- libs/room/src/connection.rs | 6 + libs/room/src/draft_and_history.rs | 97 +++++--- libs/room/src/reaction.rs | 73 +++--- libs/room/src/search.rs | 2 +- src/components/room/MessageMentions.tsx | 160 ++----------- src/components/room/RoomMessageBubble.tsx | 261 +++++++--------------- src/components/room/RoomMessageList.tsx | 16 +- src/hooks/useRoomWs.ts | 22 +- 8 files changed, 233 insertions(+), 404 deletions(-) diff --git a/libs/room/src/connection.rs b/libs/room/src/connection.rs index 2a46684..9d5622d 100644 --- a/libs/room/src/connection.rs +++ b/libs/room/src/connection.rs @@ -590,6 +590,12 @@ impl RoomConnectionManager { } pub async fn broadcast_stream_chunk(&self, event: RoomMessageStreamChunkEvent) { + // Update activity tracker to prevent idle cleanup during active streaming + { + let mut activity = self.room_last_activity.write().await; + activity.insert(event.room_id, Instant::now()); + } + let event = Arc::new(event); let is_final_chunk = event.done; diff --git a/libs/room/src/draft_and_history.rs b/libs/room/src/draft_and_history.rs index d8038ae..9b243ae 100644 --- a/libs/room/src/draft_and_history.rs +++ b/libs/room/src/draft_and_history.rs @@ -124,44 +124,73 @@ impl RoomService { .all(&self.db) .await?; - let mut result = Vec::new(); - for notification in notifications { - let mentioned_by = - user_model::Entity::find_by_id(notification.related_user_id.unwrap_or_default()) - .one(&self.db) - .await?; + // Batch fetch related users to avoid N+1 queries + let related_user_ids: Vec = notifications + .iter() + .filter_map(|n| n.related_user_id) + .collect(); + let users: std::collections::HashMap = if !related_user_ids.is_empty() { + user_model::Entity::find() + .filter(user_model::Column::Uid.is_in(related_user_ids)) + .all(&self.db) + .await? + .into_iter() + .map(|u| (u.uid, u.display_name.unwrap_or(u.username))) + .collect() + } else { + std::collections::HashMap::new() + }; - let room_name = if let Some(room_id) = notification.room { - models::rooms::room::Entity::find_by_id(room_id) - .one(&self.db) - .await? - .map(|r| r.room_name) - .unwrap_or_else(|| "Unknown Room".to_string()) - } else { - "Unknown Room".to_string() - }; + // Batch fetch room names to avoid N+1 queries + let room_ids: Vec = notifications + .iter() + .filter_map(|n| n.room) + .collect(); + let rooms: std::collections::HashMap = if !room_ids.is_empty() { + models::rooms::room::Entity::find() + .filter(models::rooms::room::Column::Id.is_in(room_ids)) + .all(&self.db) + .await? + .into_iter() + .map(|r| (r.id, r.room_name)) + .collect() + } else { + std::collections::HashMap::new() + }; - let mentioned_by_name = mentioned_by - .map(|u| u.display_name.unwrap_or(u.username)) - .unwrap_or_else(|| "Unknown User".to_string()); + let result = notifications + .into_iter() + .map(|notification| { + let mentioned_by_name = notification + .related_user_id + .and_then(|uid| users.get(&uid)) + .cloned() + .unwrap_or_else(|| "Unknown User".to_string()); - let content_preview = notification - .content - .unwrap_or_default() - .chars() - .take(100) - .collect(); + let room_name = notification + .room + .and_then(|rid| rooms.get(&rid)) + .cloned() + .unwrap_or_else(|| "Unknown Room".to_string()); - result.push(MentionNotificationResponse { - message_id: notification.related_message_id.unwrap_or_default(), - mentioned_by: notification.related_user_id.unwrap_or_default(), - mentioned_by_name, - content_preview, - room_id: notification.room.unwrap_or_default(), - room_name, - created_at: notification.created_at, - }); - } + let content_preview = notification + .content + .unwrap_or_default() + .chars() + .take(100) + .collect(); + + MentionNotificationResponse { + message_id: notification.related_message_id.unwrap_or_default(), + mentioned_by: notification.related_user_id.unwrap_or_default(), + mentioned_by_name, + content_preview, + room_id: notification.room.unwrap_or_default(), + room_name, + created_at: notification.created_at, + } + }) + .collect(); Ok(result) } diff --git a/libs/room/src/reaction.rs b/libs/room/src/reaction.rs index a56d1d7..c08253c 100644 --- a/libs/room/src/reaction.rs +++ b/libs/room/src/reaction.rs @@ -6,7 +6,6 @@ use models::rooms::room_message_reaction; use models::users::user as user_model; use queue::ReactionGroup; use sea_orm::*; -use sea_query::OnConflict; use uuid::Uuid; #[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] @@ -45,6 +44,21 @@ impl RoomService { let now = Utc::now(); + // Use a transaction to atomically check-and-insert, preventing race conditions + let txn = self.db.begin().await?; + + let existing = room_message_reaction::Entity::find() + .filter(room_message_reaction::Column::Message.eq(message_id)) + .filter(room_message_reaction::Column::User.eq(user_id)) + .filter(room_message_reaction::Column::Emoji.eq(&emoji)) + .one(&txn) + .await?; + + if existing.is_some() { + txn.commit().await?; + return self.get_message_reactions(message_id, Some(user_id)).await; + } + let reaction = room_message_reaction::ActiveModel { id: Set(Uuid::now_v7()), room: Set(message.room), @@ -54,46 +68,29 @@ impl RoomService { created_at: Set(now), }; - // Check if reaction already exists before inserting - let existing = room_message_reaction::Entity::find() - .filter(room_message_reaction::Column::Message.eq(message_id)) - .filter(room_message_reaction::Column::User.eq(user_id)) - .filter(room_message_reaction::Column::Emoji.eq(&emoji)) - .one(&self.db) + room_message_reaction::Entity::insert(reaction) + .exec(&txn) .await?; - if existing.is_none() { - room_message_reaction::Entity::insert(reaction) - .on_conflict( - OnConflict::columns([ - room_message_reaction::Column::Message, - room_message_reaction::Column::User, - room_message_reaction::Column::Emoji, - ]) - .do_nothing() - .to_owned(), - ) - .exec(&self.db) - .await?; + txn.commit().await?; - // Only publish if we actually inserted a new reaction - let reactions = self - .get_message_reactions(message_id, Some(user_id)) - .await?; - let reaction_groups = reactions - .reactions - .into_iter() - .map(|g| ReactionGroup { - emoji: g.emoji, - count: g.count, - reacted_by_me: g.reacted_by_me, - users: g.users, - }) - .collect(); - self.queue - .publish_reaction_event(message.room, message_id, reaction_groups) - .await; - } + // Only publish if we actually inserted a new reaction + let reactions = self + .get_message_reactions(message_id, Some(user_id)) + .await?; + let reaction_groups = reactions + .reactions + .into_iter() + .map(|g| ReactionGroup { + emoji: g.emoji, + count: g.count, + reacted_by_me: g.reacted_by_me, + users: g.users, + }) + .collect(); + self.queue + .publish_reaction_event(message.room, message_id, reaction_groups) + .await; self.get_message_reactions(message_id, Some(user_id)).await } diff --git a/libs/room/src/search.rs b/libs/room/src/search.rs index 0503909..73dcf9e 100644 --- a/libs/room/src/search.rs +++ b/libs/room/src/search.rs @@ -256,7 +256,7 @@ impl RoomService { .await? .ok_or_else(|| RoomError::NotFound("You are not a member of this room".to_string()))?; - if member.role.to_string() == "owner" { + if member.role == models::rooms::RoomMemberRole::Owner { return Err(RoomError::BadRequest( "Owner cannot leave the room. Transfer ownership first.".to_string(), )); diff --git a/src/components/room/MessageMentions.tsx b/src/components/room/MessageMentions.tsx index b87d890..84d32ca 100644 --- a/src/components/room/MessageMentions.tsx +++ b/src/components/room/MessageMentions.tsx @@ -1,128 +1,7 @@ -import { memo, useMemo, useEffect } from 'react'; +import { memo, useMemo } from 'react'; import { cn } from '@/lib/utils'; -// Register web components for mentions -function registerMentionComponents() { - if (typeof window === 'undefined') return; - if (customElements.get('mention-user')) return; - - class MentionUser extends HTMLElement { - connectedCallback() { - const name = this.getAttribute('name') || ''; - this.attachShadow({ mode: 'open' }).innerHTML = ` - - - - - - @${name} - `; - } - } - - class MentionRepo extends HTMLElement { - connectedCallback() { - const name = this.getAttribute('name') || ''; - this.attachShadow({ mode: 'open' }).innerHTML = ` - - - - - @${name} - `; - } - } - - class MentionAi extends HTMLElement { - connectedCallback() { - const name = this.getAttribute('name') || ''; - this.attachShadow({ mode: 'open' }).innerHTML = ` - - - - - - - @${name} - `; - } - } - - customElements.define('mention-user', MentionUser); - customElements.define('mention-repo', MentionRepo); - customElements.define('mention-ai', MentionAi); -} - type MentionType = 'repository' | 'user' | 'ai' | 'notify'; interface MentionToken { @@ -228,15 +107,17 @@ interface MessageContentWithMentionsProps { content: string; } -/** Renders message content with @mention highlighting using web components */ +const mentionStyles: Record = { + user: 'inline-flex items-center rounded bg-blue-100/80 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/60 transition-colors text-sm leading-5', + repository: 'inline-flex items-center rounded bg-purple-100/80 px-1.5 py-0.5 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300 font-medium cursor-pointer hover:bg-purple-200 dark:hover:bg-purple-900/60 transition-colors text-sm leading-5', + ai: 'inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium cursor-pointer hover:bg-green-200 dark:hover:bg-green-900/60 transition-colors text-sm leading-5', + notify: 'inline-flex items-center rounded bg-yellow-100/80 px-1.5 py-0.5 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 font-medium cursor-pointer hover:bg-yellow-200 dark:hover:bg-yellow-900/60 transition-colors text-sm leading-5', +}; + +/** Renders message content with @mention highlighting using styled spans */ export const MessageContentWithMentions = memo(function MessageContentWithMentions({ content, }: MessageContentWithMentionsProps) { - // Register web components on first render - useEffect(() => { - registerMentionComponents(); - }, []); - const processed = useMemo(() => { const tokens = extractMentionTokens(content); if (tokens.length === 0) return [{ type: 'text' as const, content }]; @@ -272,23 +153,12 @@ export const MessageContentWithMentions = memo(function MessageContentWithMentio > {processed.map((part, i) => part.type === 'mention' ? ( - part.mention.type === 'user' ? ( - // @ts-ignore custom element - - ) : part.mention.type === 'repository' ? ( - // @ts-ignore custom element - - ) : part.mention.type === 'ai' ? ( - // @ts-ignore custom element - - ) : ( - - @{part.mention.name} - - ) + + @{part.mention.name} + ) : ( {part.content} ), diff --git a/src/components/room/RoomMessageBubble.tsx b/src/components/room/RoomMessageBubble.tsx index 42b22ad..9f64c7a 100644 --- a/src/components/room/RoomMessageBubble.tsx +++ b/src/components/room/RoomMessageBubble.tsx @@ -8,7 +8,7 @@ import { AlertCircle, AlertTriangle, ChevronDown, ChevronUp, Copy, Edit2, Reply import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { SmilePlus } from 'lucide-react'; import { useUser, useRoom } from '@/contexts'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useMemo, useState, useCallback, useRef } from 'react'; import { toast } from 'sonner'; import { ModelIcon } from './icon-match'; import { FunctionCallBadge } from './FunctionCallBadge'; @@ -80,7 +80,6 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({ const [editContent, setEditContent] = useState(message.content); const [isSavingEdit, setIsSavingEdit] = useState(false); const [showReactionPicker, setShowReactionPicker] = useState(false); - const [isNarrow, setIsNarrow] = useState(false); const containerRef = useRef(null); const reactionButtonRef = useRef(null); const [reactionPickerPosition, setReactionPickerPosition] = useState<{ top: number; left: number } | null>(null); @@ -107,19 +106,8 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({ ? streamingMessages.get(message.id)! : message.content; - // Detect narrow container width - useEffect(() => { - const el = containerRef.current; - if (!el) return; - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - // Collapse toolbar when container < 300px - setIsNarrow(entry.contentRect.width < 300); - } - }); - observer.observe(el); - return () => observer.disconnect(); - }, []); + // Detect narrow container width using CSS container query instead of ResizeObserver + // The .group/narrow class on the container enables CSS container query support const handleReaction = useCallback(async (emoji: string) => { if (!wsClient) return; @@ -416,169 +404,92 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({ )} - {/* Action toolbar - inline icons when wide, collapsed to dropdown when narrow */} + {/* Action toolbar - inline icon buttons */} {!isEditing && !isRevoked && !isPending && (
- {isNarrow ? ( - /* Narrow: all actions in dropdown */ - - - - - } - /> - - { - e.preventDefault(); - setShowReactionPicker(true); - }} - > - - Add reaction - - {onReply && ( - onReply(message)}> - - Reply - - )} - {onCreateThread && !message.thread_id && ( - onCreateThread(message)}> - - Create thread - - )} - {message.content_type === 'text' && ( - { - try { - await navigator.clipboard.writeText(message.content); - toast.success('Message copied'); - } catch { - toast.error('Failed to copy'); - } - }} - > - - Copy - - )} - {message.edited_at && onViewHistory && ( - onViewHistory(message)}> - - View edit history - - )} - {isOwner && message.content_type === 'text' && ( - - - Edit - - )} - {isOwner && onRevoke && ( - onRevoke(message)} className="text-destructive focus:text-destructive"> - - Delete - - )} - - - ) : ( - /* Wide: inline icon buttons */ - <> - {/* Add reaction */} - - {/* Reply */} - {onReply && ( - - )} - {/* Copy */} - {message.content_type === 'text' && ( - - )} - {/* More menu */} - - - - - } - /> - - {onCreateThread && !message.thread_id && ( - onCreateThread(message)}> - - Create thread - - )} - {message.edited_at && onViewHistory && ( - onViewHistory(message)}> - - View edit history - - )} - {isOwner && message.content_type === 'text' && ( - - - Edit - - )} - {isOwner && onRevoke && ( - onRevoke(message)} className="text-destructive focus:text-destructive"> - - Delete - - )} - - - + {/* Add reaction */} + + {/* Reply */} + {onReply && ( + )} + {/* Copy */} + {message.content_type === 'text' && ( + + )} + {/* More menu */} + + + + + } + /> + + {onCreateThread && !message.thread_id && ( + onCreateThread(message)}> + + Create thread + + )} + {message.edited_at && onViewHistory && ( + onViewHistory(message)}> + + View edit history + + )} + {isOwner && message.content_type === 'text' && ( + + + Edit + + )} + {isOwner && onRevoke && ( + onRevoke(message)} className="text-destructive focus:text-destructive"> + + Delete + + )} + +
)} diff --git a/src/components/room/RoomMessageList.tsx b/src/components/room/RoomMessageList.tsx index 6b9ee33..96c0398 100644 --- a/src/components/room/RoomMessageList.tsx +++ b/src/components/room/RoomMessageList.tsx @@ -74,8 +74,17 @@ function getSenderKey(message: MessageWithMeta): string { return `sender:${message.sender_type}`; } -/** Estimated height for a message row in pixels (used as initial guess before measurement) */ -const ESTIMATED_ROW_HEIGHT = 40; +/** Estimate message row height based on content characteristics */ +function estimateMessageRowHeight(message: MessageWithMeta): number { + const lineCount = message.content.split(/\r?\n/).reduce((total, line) => { + return total + Math.max(1, Math.ceil(line.trim().length / 90)); + }, 0); + const baseHeight = 24; // avatar + padding + const lineHeight = 20; + const replyHeight = message.in_reply_to ? 36 : 0; + return baseHeight + Math.min(lineCount, 5) * lineHeight + replyHeight; +} + /** Estimated height for a date divider row in pixels */ const ESTIMATED_DIVIDER_HEIGHT = 30; @@ -203,7 +212,8 @@ export const RoomMessageList = memo(function RoomMessageList({ estimateSize: (index) => { const row = rows[index]; if (row?.type === 'divider') return ESTIMATED_DIVIDER_HEIGHT; - return ESTIMATED_ROW_HEIGHT; + if (row?.type === 'message' && row.message) return estimateMessageRowHeight(row.message); + return 60; }, overscan: 5, gap: 0, diff --git a/src/hooks/useRoomWs.ts b/src/hooks/useRoomWs.ts index 1ad1ce0..32161cf 100644 --- a/src/hooks/useRoomWs.ts +++ b/src/hooks/useRoomWs.ts @@ -399,14 +399,19 @@ export function useRoomWs({ shouldReconnectRef.current = true; reconnectAttemptRef.current = 0; - // Fetch WS token before connecting + // Fetch WS token before connecting (skip if we have a recent token) const connectWithToken = async () => { - try { - const token = await requestWsToken(); - wsTokenRef.current = token; - } catch (error) { - console.warn('[useRoomWs] Failed to fetch WS token, falling back to cookie auth:', error); - wsTokenRef.current = null; + // Only fetch a new token if we don't have one or it's older than 4 minutes + // (tokens have 5-min TTL) + const shouldFetchToken = !wsTokenRef.current; + if (shouldFetchToken) { + try { + const token = await requestWsToken(); + wsTokenRef.current = token; + } catch (error) { + console.warn('[useRoomWs] Failed to fetch WS token, falling back to cookie auth:', error); + wsTokenRef.current = null; + } } // Restore from cache or start fresh @@ -484,7 +489,8 @@ export function useRoomWs({ cancelAnimationFrame(streamingRafRef.current); streamingRafRef.current = null; } - activeRoomIdRef.current = null; + // Don't clear activeRoomIdRef or wsTokenRef — preserving them + // prevents unnecessary re-token-fetch and reconnect on re-mount }; }, [roomId, connectWs]);