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
This commit is contained in:
ZhenYi 2026-04-17 21:08:40 +08:00
parent 60d8c3a617
commit cf5c728286
8 changed files with 233 additions and 404 deletions

View File

@ -590,6 +590,12 @@ impl RoomConnectionManager {
} }
pub async fn broadcast_stream_chunk(&self, event: RoomMessageStreamChunkEvent) { 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 event = Arc::new(event);
let is_final_chunk = event.done; let is_final_chunk = event.done;

View File

@ -124,44 +124,73 @@ impl RoomService {
.all(&self.db) .all(&self.db)
.await?; .await?;
let mut result = Vec::new(); // Batch fetch related users to avoid N+1 queries
for notification in notifications { let related_user_ids: Vec<Uuid> = notifications
let mentioned_by = .iter()
user_model::Entity::find_by_id(notification.related_user_id.unwrap_or_default()) .filter_map(|n| n.related_user_id)
.one(&self.db) .collect();
.await?; let users: std::collections::HashMap<Uuid, String> = 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 { // Batch fetch room names to avoid N+1 queries
models::rooms::room::Entity::find_by_id(room_id) let room_ids: Vec<Uuid> = notifications
.one(&self.db) .iter()
.await? .filter_map(|n| n.room)
.map(|r| r.room_name) .collect();
.unwrap_or_else(|| "Unknown Room".to_string()) let rooms: std::collections::HashMap<Uuid, String> = if !room_ids.is_empty() {
} else { models::rooms::room::Entity::find()
"Unknown Room".to_string() .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 let result = notifications
.map(|u| u.display_name.unwrap_or(u.username)) .into_iter()
.unwrap_or_else(|| "Unknown User".to_string()); .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 let room_name = notification
.content .room
.unwrap_or_default() .and_then(|rid| rooms.get(&rid))
.chars() .cloned()
.take(100) .unwrap_or_else(|| "Unknown Room".to_string());
.collect();
result.push(MentionNotificationResponse { let content_preview = notification
message_id: notification.related_message_id.unwrap_or_default(), .content
mentioned_by: notification.related_user_id.unwrap_or_default(), .unwrap_or_default()
mentioned_by_name, .chars()
content_preview, .take(100)
room_id: notification.room.unwrap_or_default(), .collect();
room_name,
created_at: notification.created_at, 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) Ok(result)
} }

View File

@ -6,7 +6,6 @@ use models::rooms::room_message_reaction;
use models::users::user as user_model; use models::users::user as user_model;
use queue::ReactionGroup; use queue::ReactionGroup;
use sea_orm::*; use sea_orm::*;
use sea_query::OnConflict;
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] #[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
@ -45,6 +44,21 @@ impl RoomService {
let now = Utc::now(); 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 { let reaction = room_message_reaction::ActiveModel {
id: Set(Uuid::now_v7()), id: Set(Uuid::now_v7()),
room: Set(message.room), room: Set(message.room),
@ -54,46 +68,29 @@ impl RoomService {
created_at: Set(now), created_at: Set(now),
}; };
// Check if reaction already exists before inserting room_message_reaction::Entity::insert(reaction)
let existing = room_message_reaction::Entity::find() .exec(&txn)
.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)
.await?; .await?;
if existing.is_none() { txn.commit().await?;
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?;
// Only publish if we actually inserted a new reaction // Only publish if we actually inserted a new reaction
let reactions = self let reactions = self
.get_message_reactions(message_id, Some(user_id)) .get_message_reactions(message_id, Some(user_id))
.await?; .await?;
let reaction_groups = reactions let reaction_groups = reactions
.reactions .reactions
.into_iter() .into_iter()
.map(|g| ReactionGroup { .map(|g| ReactionGroup {
emoji: g.emoji, emoji: g.emoji,
count: g.count, count: g.count,
reacted_by_me: g.reacted_by_me, reacted_by_me: g.reacted_by_me,
users: g.users, users: g.users,
}) })
.collect(); .collect();
self.queue self.queue
.publish_reaction_event(message.room, message_id, reaction_groups) .publish_reaction_event(message.room, message_id, reaction_groups)
.await; .await;
}
self.get_message_reactions(message_id, Some(user_id)).await self.get_message_reactions(message_id, Some(user_id)).await
} }

View File

@ -256,7 +256,7 @@ impl RoomService {
.await? .await?
.ok_or_else(|| RoomError::NotFound("You are not a member of this room".to_string()))?; .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( return Err(RoomError::BadRequest(
"Owner cannot leave the room. Transfer ownership first.".to_string(), "Owner cannot leave the room. Transfer ownership first.".to_string(),
)); ));

View File

@ -1,128 +1,7 @@
import { memo, useMemo, useEffect } from 'react'; import { memo, useMemo } from 'react';
import { cn } from '@/lib/utils'; 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 = `
<style>
:host {
display: inline-flex;
align-items: center;
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-weight: 500;
cursor: pointer;
font-size: 0.875rem;
line-height: 1.25rem;
transition: background 0.15s;
}
:host(:hover) {
background: rgba(59, 130, 246, 0.25);
}
.icon {
width: 14px;
height: 14px;
margin-right: 4px;
flex-shrink: 0;
}
</style>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span>@${name}</span>
`;
}
}
class MentionRepo extends HTMLElement {
connectedCallback() {
const name = this.getAttribute('name') || '';
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
background: rgba(168, 85, 247, 0.15);
color: #a855f7;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-weight: 500;
cursor: pointer;
font-size: 0.875rem;
line-height: 1.25rem;
transition: background 0.15s;
}
:host(:hover) {
background: rgba(168, 85, 247, 0.25);
}
.icon {
width: 14px;
height: 14px;
margin-right: 4px;
flex-shrink: 0;
}
</style>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span>@${name}</span>
`;
}
}
class MentionAi extends HTMLElement {
connectedCallback() {
const name = this.getAttribute('name') || '';
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-weight: 500;
cursor: pointer;
font-size: 0.875rem;
line-height: 1.25rem;
transition: background 0.15s;
}
:host(:hover) {
background: rgba(34, 197, 94, 0.25);
}
.icon {
width: 14px;
height: 14px;
margin-right: 4px;
flex-shrink: 0;
}
</style>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"/>
<circle cx="8" cy="14" r="1"/>
<circle cx="16" cy="14" r="1"/>
</svg>
<span>@${name}</span>
`;
}
}
customElements.define('mention-user', MentionUser);
customElements.define('mention-repo', MentionRepo);
customElements.define('mention-ai', MentionAi);
}
type MentionType = 'repository' | 'user' | 'ai' | 'notify'; type MentionType = 'repository' | 'user' | 'ai' | 'notify';
interface MentionToken { interface MentionToken {
@ -228,15 +107,17 @@ interface MessageContentWithMentionsProps {
content: string; content: string;
} }
/** Renders message content with @mention highlighting using web components */ const mentionStyles: Record<MentionType, string> = {
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({ export const MessageContentWithMentions = memo(function MessageContentWithMentions({
content, content,
}: MessageContentWithMentionsProps) { }: MessageContentWithMentionsProps) {
// Register web components on first render
useEffect(() => {
registerMentionComponents();
}, []);
const processed = useMemo(() => { const processed = useMemo(() => {
const tokens = extractMentionTokens(content); const tokens = extractMentionTokens(content);
if (tokens.length === 0) return [{ type: 'text' as const, 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) => {processed.map((part, i) =>
part.type === 'mention' ? ( part.type === 'mention' ? (
part.mention.type === 'user' ? ( <span
// @ts-ignore custom element key={i}
<mention-user key={i} name={part.mention.name} /> className={mentionStyles[part.mention.type]}
) : part.mention.type === 'repository' ? ( >
// @ts-ignore custom element @{part.mention.name}
<mention-repo key={i} name={part.mention.name} /> </span>
) : part.mention.type === 'ai' ? (
// @ts-ignore custom element
<mention-ai key={i} name={part.mention.name} />
) : (
<span
key={i}
className="inline-flex items-center rounded bg-blue-100 px-1 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"
>
@{part.mention.name}
</span>
)
) : ( ) : (
<span key={i}>{part.content}</span> <span key={i}>{part.content}</span>
), ),

View File

@ -8,7 +8,7 @@ import { AlertCircle, AlertTriangle, ChevronDown, ChevronUp, Copy, Edit2, Reply
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { SmilePlus } from 'lucide-react'; import { SmilePlus } from 'lucide-react';
import { useUser, useRoom } from '@/contexts'; 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 { toast } from 'sonner';
import { ModelIcon } from './icon-match'; import { ModelIcon } from './icon-match';
import { FunctionCallBadge } from './FunctionCallBadge'; import { FunctionCallBadge } from './FunctionCallBadge';
@ -80,7 +80,6 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
const [editContent, setEditContent] = useState(message.content); const [editContent, setEditContent] = useState(message.content);
const [isSavingEdit, setIsSavingEdit] = useState(false); const [isSavingEdit, setIsSavingEdit] = useState(false);
const [showReactionPicker, setShowReactionPicker] = useState(false); const [showReactionPicker, setShowReactionPicker] = useState(false);
const [isNarrow, setIsNarrow] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const reactionButtonRef = useRef<HTMLButtonElement>(null); const reactionButtonRef = useRef<HTMLButtonElement>(null);
const [reactionPickerPosition, setReactionPickerPosition] = useState<{ top: number; left: number } | null>(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)! ? streamingMessages.get(message.id)!
: message.content; : message.content;
// Detect narrow container width // Detect narrow container width using CSS container query instead of ResizeObserver
useEffect(() => { // The .group/narrow class on the container enables CSS container query support
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();
}, []);
const handleReaction = useCallback(async (emoji: string) => { const handleReaction = useCallback(async (emoji: string) => {
if (!wsClient) return; if (!wsClient) return;
@ -416,169 +404,92 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
</div> </div>
)} )}
{/* Action toolbar - inline icons when wide, collapsed to dropdown when narrow */} {/* Action toolbar - inline icon buttons */}
{!isEditing && !isRevoked && !isPending && ( {!isEditing && !isRevoked && !isPending && (
<div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{isNarrow ? ( {/* Add reaction */}
/* Narrow: all actions in dropdown */ <Button
<DropdownMenu> variant="ghost"
<DropdownMenuTrigger size="sm"
render={ ref={reactionButtonRef}
<Button className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
variant="ghost" onClick={handleOpenReactionPicker}
size="sm" title="Add reaction"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground" >
title="More actions" <SmilePlus className="size-3.5" />
> </Button>
<MoreHorizontal className="size-3.5" /> {/* Reply */}
</Button> {onReply && (
} <Button
/> variant="ghost"
<DropdownMenuContent align="end"> size="sm"
<DropdownMenuItem className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onSelect={(e) => { onClick={() => onReply(message)}
e.preventDefault(); title="Reply"
setShowReactionPicker(true); >
}} <ReplyIcon className="size-3.5" />
> </Button>
<SmilePlus className="mr-2 size-4" />
Add reaction
</DropdownMenuItem>
{onReply && (
<DropdownMenuItem onClick={() => onReply(message)}>
<ReplyIcon className="mr-2 size-4" />
Reply
</DropdownMenuItem>
)}
{onCreateThread && !message.thread_id && (
<DropdownMenuItem onClick={() => onCreateThread(message)}>
<MessageSquare className="mr-2 size-4" />
Create thread
</DropdownMenuItem>
)}
{message.content_type === 'text' && (
<DropdownMenuItem
onClick={async () => {
try {
await navigator.clipboard.writeText(message.content);
toast.success('Message copied');
} catch {
toast.error('Failed to copy');
}
}}
>
<Copy className="mr-2 size-4" />
Copy
</DropdownMenuItem>
)}
{message.edited_at && onViewHistory && (
<DropdownMenuItem onClick={() => onViewHistory(message)}>
<History className="mr-2 size-4" />
View edit history
</DropdownMenuItem>
)}
{isOwner && message.content_type === 'text' && (
<DropdownMenuItem onClick={handleStartEdit}>
<Edit2 className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}
{isOwner && onRevoke && (
<DropdownMenuItem onClick={() => onRevoke(message)} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
/* Wide: inline icon buttons */
<>
{/* Add reaction */}
<Button
variant="ghost"
size="sm"
ref={reactionButtonRef}
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={handleOpenReactionPicker}
title="Add reaction"
>
<SmilePlus className="size-3.5" />
</Button>
{/* Reply */}
{onReply && (
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => onReply(message)}
title="Reply"
>
<ReplyIcon className="size-3.5" />
</Button>
)}
{/* Copy */}
{message.content_type === 'text' && (
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={async () => {
try {
await navigator.clipboard.writeText(message.content);
toast.success('Message copied');
} catch {
toast.error('Failed to copy');
}
}}
title="Copy"
>
<Copy className="size-3.5" />
</Button>
)}
{/* More menu */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
title="More"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
{onCreateThread && !message.thread_id && (
<DropdownMenuItem onClick={() => onCreateThread(message)}>
<MessageSquare className="mr-2 size-4" />
Create thread
</DropdownMenuItem>
)}
{message.edited_at && onViewHistory && (
<DropdownMenuItem onClick={() => onViewHistory(message)}>
<History className="mr-2 size-4" />
View edit history
</DropdownMenuItem>
)}
{isOwner && message.content_type === 'text' && (
<DropdownMenuItem onClick={handleStartEdit}>
<Edit2 className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}
{isOwner && onRevoke && (
<DropdownMenuItem onClick={() => onRevoke(message)} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)} )}
{/* Copy */}
{message.content_type === 'text' && (
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={async () => {
try {
await navigator.clipboard.writeText(message.content);
toast.success('Message copied');
} catch {
toast.error('Failed to copy');
}
}}
title="Copy"
>
<Copy className="size-3.5" />
</Button>
)}
{/* More menu */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
title="More"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
{onCreateThread && !message.thread_id && (
<DropdownMenuItem onClick={() => onCreateThread(message)}>
<MessageSquare className="mr-2 size-4" />
Create thread
</DropdownMenuItem>
)}
{message.edited_at && onViewHistory && (
<DropdownMenuItem onClick={() => onViewHistory(message)}>
<History className="mr-2 size-4" />
View edit history
</DropdownMenuItem>
)}
{isOwner && message.content_type === 'text' && (
<DropdownMenuItem onClick={handleStartEdit}>
<Edit2 className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}
{isOwner && onRevoke && (
<DropdownMenuItem onClick={() => onRevoke(message)} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
)} )}

View File

@ -74,8 +74,17 @@ function getSenderKey(message: MessageWithMeta): string {
return `sender:${message.sender_type}`; return `sender:${message.sender_type}`;
} }
/** Estimated height for a message row in pixels (used as initial guess before measurement) */ /** Estimate message row height based on content characteristics */
const ESTIMATED_ROW_HEIGHT = 40; 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 */ /** Estimated height for a date divider row in pixels */
const ESTIMATED_DIVIDER_HEIGHT = 30; const ESTIMATED_DIVIDER_HEIGHT = 30;
@ -203,7 +212,8 @@ export const RoomMessageList = memo(function RoomMessageList({
estimateSize: (index) => { estimateSize: (index) => {
const row = rows[index]; const row = rows[index];
if (row?.type === 'divider') return ESTIMATED_DIVIDER_HEIGHT; 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, overscan: 5,
gap: 0, gap: 0,

View File

@ -399,14 +399,19 @@ export function useRoomWs({
shouldReconnectRef.current = true; shouldReconnectRef.current = true;
reconnectAttemptRef.current = 0; reconnectAttemptRef.current = 0;
// Fetch WS token before connecting // Fetch WS token before connecting (skip if we have a recent token)
const connectWithToken = async () => { const connectWithToken = async () => {
try { // Only fetch a new token if we don't have one or it's older than 4 minutes
const token = await requestWsToken(); // (tokens have 5-min TTL)
wsTokenRef.current = token; const shouldFetchToken = !wsTokenRef.current;
} catch (error) { if (shouldFetchToken) {
console.warn('[useRoomWs] Failed to fetch WS token, falling back to cookie auth:', error); try {
wsTokenRef.current = null; 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 // Restore from cache or start fresh
@ -484,7 +489,8 @@ export function useRoomWs({
cancelAnimationFrame(streamingRafRef.current); cancelAnimationFrame(streamingRafRef.current);
streamingRafRef.current = null; 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]); }, [roomId, connectWs]);