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:
parent
60d8c3a617
commit
cf5c728286
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
));
|
));
|
||||||
|
|||||||
@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user