Compare commits
19 Commits
5256e72be7
...
a171d691c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a171d691c6 | ||
|
|
047782e585 | ||
|
|
0cbf6d6aa1 | ||
|
|
7152346be8 | ||
|
|
a1ddb5d5bc | ||
|
|
2f1ed69b31 | ||
|
|
ef1adb663d | ||
|
|
4767e1d692 | ||
|
|
44c9f2c772 | ||
|
|
50f9cc40fe | ||
|
|
b70d91866c | ||
|
|
309bc50e86 | ||
|
|
bab675cf60 | ||
|
|
5cd4c66445 | ||
|
|
991d86237b | ||
|
|
70381006cf | ||
|
|
f2a2ae5d7f | ||
|
|
cf5c728286 | ||
|
|
60d8c3a617 |
@ -118,6 +118,11 @@ pub fn init_room_routes(cfg: &mut web::ServiceConfig) {
|
||||
"/rooms/{room_id}/messages/{message_id}/reactions",
|
||||
web::get().to(reaction::reaction_get),
|
||||
)
|
||||
// batch reactions
|
||||
.route(
|
||||
"/rooms/{room_id}/messages/reactions/batch",
|
||||
web::get().to(reaction::reaction_batch),
|
||||
)
|
||||
// message search
|
||||
.route(
|
||||
"/rooms/{room_id}/messages/search",
|
||||
|
||||
@ -18,6 +18,12 @@ pub struct MessageSearchQuery {
|
||||
pub offset: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, IntoParams)]
|
||||
pub struct ReactionBatchQuery {
|
||||
/// Comma-separated list of message IDs
|
||||
pub message_ids: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/rooms/{room_id}/messages/{message_id}/reactions",
|
||||
@ -119,6 +125,43 @@ pub async fn reaction_get(
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/rooms/{room_id}/messages/reactions/batch",
|
||||
params(
|
||||
("room_id" = Uuid, Path),
|
||||
("message_ids" = Vec<Uuid>, Query, description = "List of message IDs to fetch reactions for"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Batch get reactions", body = ApiResponse<Vec<room::MessageReactionsResponse>>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
tag = "Room"
|
||||
)]
|
||||
pub async fn reaction_batch(
|
||||
service: web::Data<AppService>,
|
||||
session: Session,
|
||||
path: web::Path<Uuid>,
|
||||
query: web::Query<ReactionBatchQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let room_id = path.into_inner();
|
||||
let user_id = session
|
||||
.user()
|
||||
.ok_or_else(|| ApiError::from(service::error::AppError::Unauthorized))?;
|
||||
let ctx = WsUserContext::new(user_id);
|
||||
let message_ids: Vec<Uuid> = query
|
||||
.message_ids
|
||||
.split(',')
|
||||
.filter_map(|s| Uuid::parse_str(s.trim()).ok())
|
||||
.collect();
|
||||
let resp = service
|
||||
.room
|
||||
.message_reactions_batch(room_id, message_ids, &ctx)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/rooms/{room_id}/messages/search",
|
||||
|
||||
@ -449,9 +449,9 @@ impl From<room::MessageReactionsResponse> for ReactionListData {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ReactionItem {
|
||||
pub emoji: String,
|
||||
pub count: i64,
|
||||
pub count: i32,
|
||||
pub reacted_by_me: bool,
|
||||
pub users: Vec<Uuid>,
|
||||
pub users: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
@ -196,7 +196,7 @@ pub async fn ws_universal(
|
||||
let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await;
|
||||
break;
|
||||
}
|
||||
push_event = poll_push_streams(&mut push_streams, &manager, user_id) => {
|
||||
push_event = poll_push_streams(&mut push_streams, &manager, &handler.service(), user_id) => {
|
||||
match push_event {
|
||||
Some(WsPushEvent::RoomMessage { room_id, event }) => {
|
||||
let payload = serde_json::json!({
|
||||
@ -372,6 +372,7 @@ pub async fn ws_universal(
|
||||
async fn poll_push_streams(
|
||||
streams: &mut PushStreams,
|
||||
manager: &Arc<RoomConnectionManager>,
|
||||
service: &Arc<AppService>,
|
||||
user_id: Uuid,
|
||||
) -> Option<WsPushEvent> {
|
||||
loop {
|
||||
@ -412,16 +413,21 @@ async fn poll_push_streams(
|
||||
}
|
||||
}
|
||||
|
||||
// Re-subscribe dead rooms so we don't permanently lose events
|
||||
// Re-subscribe dead rooms so we don't permanently lose events.
|
||||
// Re-check access in case the user's permissions were revoked while the
|
||||
// stream was dead.
|
||||
for room_id in dead_rooms {
|
||||
if streams.remove(&room_id).is_some() {
|
||||
if let Ok(rx) = manager.subscribe(room_id, user_id).await {
|
||||
let stream_rx = manager.subscribe_room_stream(room_id).await;
|
||||
streams.insert(room_id, (
|
||||
BroadcastStream::new(rx),
|
||||
BroadcastStream::new(stream_rx),
|
||||
));
|
||||
if service.room.check_room_access(room_id, user_id).await.is_ok() {
|
||||
if let Ok(rx) = manager.subscribe(room_id, user_id).await {
|
||||
let stream_rx = manager.subscribe_room_stream(room_id).await;
|
||||
streams.insert(room_id, (
|
||||
BroadcastStream::new(rx),
|
||||
BroadcastStream::new(stream_rx),
|
||||
));
|
||||
}
|
||||
}
|
||||
// If access check fails, silently skip re-subscribe (user was removed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -40,9 +40,10 @@ pub struct RoomMessageEvent {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReactionGroup {
|
||||
pub emoji: String,
|
||||
pub count: i64,
|
||||
pub count: i32,
|
||||
pub reacted_by_me: bool,
|
||||
pub users: Vec<Uuid>,
|
||||
/// Stored as strings (UUIDs) to match the frontend's `users: string[]` type.
|
||||
pub users: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<RoomMessageEnvelope> for RoomMessageEvent {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<Uuid> = notifications
|
||||
.iter()
|
||||
.filter_map(|n| n.related_user_id)
|
||||
.collect();
|
||||
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 {
|
||||
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<Uuid> = notifications
|
||||
.iter()
|
||||
.filter_map(|n| n.room)
|
||||
.collect();
|
||||
let rooms: std::collections::HashMap<Uuid, String> = 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)
|
||||
}
|
||||
|
||||
@ -257,24 +257,6 @@ impl RoomService {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn next_room_message_seq<C>(
|
||||
&self,
|
||||
room_id: Uuid,
|
||||
db: &C,
|
||||
) -> Result<i64, RoomError>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
let max_seq: Option<Option<i64>> = room_message::Entity::find()
|
||||
.filter(room_message::Column::Room.eq(room_id))
|
||||
.select_only()
|
||||
.column_as(room_message::Column::Seq.max(), "max_seq")
|
||||
.into_tuple::<Option<i64>>()
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(max_seq.flatten().unwrap_or(0) + 1)
|
||||
}
|
||||
|
||||
pub async fn utils_find_project_by_name(
|
||||
&self,
|
||||
name: String,
|
||||
|
||||
@ -134,7 +134,7 @@ impl RoomService {
|
||||
}
|
||||
}
|
||||
|
||||
let seq = self.next_room_message_seq(room_id, &self.db).await?;
|
||||
let seq = Self::next_room_message_seq_internal(room_id, &self.db, &self.cache).await?;
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
let project_id = room_model.project;
|
||||
@ -207,6 +207,17 @@ impl RoomService {
|
||||
.await;
|
||||
|
||||
let mentioned_users = self.resolve_mentions(&request.content).await;
|
||||
// Look up sender display name once for all mention notifications
|
||||
let sender_display_name = {
|
||||
let user = user_model::Entity::find()
|
||||
.filter(user_model::Column::Uid.eq(user_id))
|
||||
.one(&self.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
user.map(|u| u.display_name.unwrap_or_else(|| u.username))
|
||||
.unwrap_or_else(|| user_id.to_string())
|
||||
};
|
||||
for mentioned_user_id in mentioned_users {
|
||||
if mentioned_user_id == user_id {
|
||||
continue;
|
||||
@ -215,7 +226,7 @@ impl RoomService {
|
||||
.notification_create(super::NotificationCreateRequest {
|
||||
notification_type: super::NotificationType::Mention,
|
||||
user_id: mentioned_user_id,
|
||||
title: format!("{} 在 {} 中提到了你", user_id, room_model.room_name),
|
||||
title: format!("{} 在 {} 中提到了你", sender_display_name, room_model.room_name),
|
||||
content: Some(content.clone()),
|
||||
room_id: Some(room_id),
|
||||
project_id,
|
||||
@ -228,7 +239,13 @@ impl RoomService {
|
||||
.await;
|
||||
}
|
||||
|
||||
let should_respond = self.should_ai_respond(room_id).await.unwrap_or(false);
|
||||
let should_respond = match self.should_ai_respond(room_id).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
slog::warn!(self.log, "should_ai_respond failed for room {}: {}", room_id, e);
|
||||
false
|
||||
}
|
||||
};
|
||||
let is_text_message = request
|
||||
.content_type
|
||||
.as_ref()
|
||||
@ -243,23 +260,13 @@ impl RoomService {
|
||||
}
|
||||
}
|
||||
|
||||
let display_name = {
|
||||
let user = user_model::Entity::find()
|
||||
.filter(user_model::Column::Uid.eq(user_id))
|
||||
.one(&self.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
user.map(|u| u.display_name.unwrap_or_else(|| u.username))
|
||||
};
|
||||
|
||||
Ok(super::RoomMessageResponse {
|
||||
id,
|
||||
seq,
|
||||
room: room_id,
|
||||
sender_type: "member".to_string(),
|
||||
sender_id: Some(user_id),
|
||||
display_name,
|
||||
display_name: Some(sender_display_name),
|
||||
thread: thread_id,
|
||||
in_reply_to,
|
||||
content: request.content,
|
||||
|
||||
@ -6,15 +6,14 @@ 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)]
|
||||
pub struct ReactionGroupResponse {
|
||||
pub emoji: String,
|
||||
pub count: i64,
|
||||
pub count: i32,
|
||||
pub reacted_by_me: bool,
|
||||
pub users: Vec<Uuid>,
|
||||
pub users: Vec<String>,
|
||||
}
|
||||
|
||||
#[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,37 +68,29 @@ impl RoomService {
|
||||
created_at: Set(now),
|
||||
};
|
||||
|
||||
let result = 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;
|
||||
room_message_reaction::Entity::insert(reaction)
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
|
||||
if result.is_ok() {
|
||||
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;
|
||||
}
|
||||
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 as i32,
|
||||
reacted_by_me: g.reacted_by_me,
|
||||
users: g.users.into_iter().map(|u| u.to_string()).collect(),
|
||||
})
|
||||
.collect();
|
||||
self.queue
|
||||
.publish_reaction_event(message.room, message_id, reaction_groups)
|
||||
.await;
|
||||
|
||||
self.get_message_reactions(message_id, Some(user_id)).await
|
||||
}
|
||||
@ -115,9 +121,9 @@ impl RoomService {
|
||||
.into_iter()
|
||||
.map(|g| ReactionGroup {
|
||||
emoji: g.emoji,
|
||||
count: g.count,
|
||||
count: g.count as i32,
|
||||
reacted_by_me: g.reacted_by_me,
|
||||
users: g.users,
|
||||
users: g.users.into_iter().map(|u| u.to_string()).collect(),
|
||||
})
|
||||
.collect();
|
||||
self.queue
|
||||
@ -252,11 +258,15 @@ impl RoomService {
|
||||
grouped
|
||||
.into_iter()
|
||||
.map(|(emoji, user_reactions)| {
|
||||
let count = user_reactions.len() as i64;
|
||||
let count = user_reactions.len() as i32;
|
||||
let reacted_by_me = current_user_id
|
||||
.map(|uid| user_reactions.iter().any(|r| r.user == uid))
|
||||
.unwrap_or(false);
|
||||
let users = user_reactions.iter().take(3).map(|r| r.user).collect();
|
||||
let users = user_reactions
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|r| r.user.to_string())
|
||||
.collect();
|
||||
|
||||
ReactionGroupResponse {
|
||||
emoji,
|
||||
|
||||
@ -273,6 +273,19 @@ impl RoomService {
|
||||
|
||||
self.room_manager.shutdown_room(room_id).await;
|
||||
|
||||
// Clean up Redis seq key so re-creating the room starts fresh
|
||||
let seq_key = format!("room:seq:{}", room_id);
|
||||
if let Ok(mut conn) = self.cache.conn().await {
|
||||
let _: Option<String> = redis::cmd("DEL")
|
||||
.arg(&seq_key)
|
||||
.query_async(&mut conn)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
slog::warn!(self.log, "room_delete: failed to DEL seq key {}: {}", seq_key, e);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
let event = ProjectRoomEvent {
|
||||
event_type: super::RoomEventType::RoomDeleted.as_str().into(),
|
||||
project_id,
|
||||
|
||||
@ -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(),
|
||||
));
|
||||
|
||||
@ -1112,7 +1112,7 @@ impl RoomService {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn next_room_message_seq_internal(
|
||||
pub(crate) async fn next_room_message_seq_internal(
|
||||
room_id: Uuid,
|
||||
db: &AppDatabase,
|
||||
cache: &AppCache,
|
||||
|
||||
@ -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 = `
|
||||
<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';
|
||||
|
||||
interface MentionToken {
|
||||
@ -228,15 +107,17 @@ interface MessageContentWithMentionsProps {
|
||||
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({
|
||||
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
|
||||
<mention-user key={i} name={part.mention.name} />
|
||||
) : part.mention.type === 'repository' ? (
|
||||
// @ts-ignore custom element
|
||||
<mention-repo key={i} name={part.mention.name} />
|
||||
) : 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}
|
||||
className={mentionStyles[part.mention.type]}
|
||||
>
|
||||
@{part.mention.name}
|
||||
</span>
|
||||
) : (
|
||||
<span key={i}>{part.content}</span>
|
||||
),
|
||||
|
||||
@ -22,7 +22,6 @@ import { RoomMessageEditHistoryDialog } from './RoomMessageEditHistoryDialog';
|
||||
import { RoomMessageList } from './RoomMessageList';
|
||||
import { RoomParticipantsPanel } from './RoomParticipantsPanel';
|
||||
import { RoomSettingsPanel } from './RoomSettingsPanel';
|
||||
import { RoomPerformanceMonitor } from './RoomPerformanceMonitor';
|
||||
import { RoomMessageSearch } from './RoomMessageSearch';
|
||||
import { RoomMentionPanel } from './RoomMentionPanel';
|
||||
import { RoomThreadPanel } from './RoomThreadPanel';
|
||||
@ -131,7 +130,7 @@ const ChatInputArea = memo(function ChatInputArea({
|
||||
{replyingTo && (
|
||||
<div className="mb-2 flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2 text-xs">
|
||||
<span className="font-medium text-foreground">Replying to {replyingTo.display_name}</span>
|
||||
<span className="truncate text-muted-foreground">{replyingTo.content}</span>
|
||||
<span className="truncate text-muted-foreground" title={replyingTo.content}>{replyingTo.content.length > 80 ? replyingTo.content.slice(0, 80) + '…' : replyingTo.content}</span>
|
||||
<button onClick={onCancelReply} className="ml-auto text-muted-foreground hover:text-foreground">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
@ -262,7 +261,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
|
||||
const [showMentions, setShowMentions] = useState(false);
|
||||
const [isUpdatingRoom, setIsUpdatingRoom] = useState(false);
|
||||
const [activeThread, setActiveThread] = useState<{ thread: RoomThreadResponse; parentMessage: MessageWithMeta } | null>(null);
|
||||
const [renderedMessageCount, setRenderedMessageCount] = useState<number | undefined>(undefined);
|
||||
|
||||
// Draft management
|
||||
const { draft, setDraft, clearDraft } = useRoomDraft(room.id);
|
||||
@ -289,6 +287,7 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
|
||||
sendMessage(content, 'text', replyingTo?.id ?? undefined);
|
||||
setReplyingTo(null);
|
||||
},
|
||||
// sendMessage from useRoom is already stable; replyingTo changes trigger handleSend rebuild (acceptable)
|
||||
[sendMessage, replyingTo],
|
||||
);
|
||||
|
||||
@ -310,7 +309,8 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
|
||||
setEditingMessage(null);
|
||||
toast.success('Message updated');
|
||||
},
|
||||
[editingMessage, editMessage],
|
||||
// Only rebuild when editingMessage.id actually changes, not on every new message
|
||||
[editingMessage?.id, editMessage],
|
||||
);
|
||||
|
||||
const handleRevoke = useCallback(
|
||||
@ -321,6 +321,7 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
|
||||
[revokeMessage],
|
||||
);
|
||||
|
||||
// Stable: chatInputRef is stable, no deps that change on message updates
|
||||
const handleMention = useCallback((name: string, type: 'user' | 'ai') => {
|
||||
chatInputRef.current?.insertMention(name, type);
|
||||
}, []);
|
||||
@ -520,7 +521,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
|
||||
onMention={handleMention}
|
||||
onOpenThread={handleOpenThread}
|
||||
onCreateThread={handleCreateThread}
|
||||
onRenderedCountChange={setRenderedMessageCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -604,7 +604,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
|
||||
roomId={room.id}
|
||||
/>
|
||||
|
||||
<RoomPerformanceMonitor messageCount={messages.length} renderedCount={renderedMessageCount} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,9 +6,10 @@ import { parseFunctionCalls, type FunctionCall } from '@/lib/functionCallParser'
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AlertCircle, AlertTriangle, ChevronDown, ChevronUp, Copy, Edit2, Reply as ReplyIcon, Trash2, History, MoreHorizontal, MessageSquare } from 'lucide-react';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
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,10 +81,7 @@ 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<HTMLDivElement>(null);
|
||||
const reactionButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [reactionPickerPosition, setReactionPickerPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
const isAi = ['ai', 'system', 'tool'].includes(message.sender_type);
|
||||
const isSystem = message.sender_type === 'system';
|
||||
@ -107,19 +105,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;
|
||||
@ -136,17 +123,6 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
|
||||
setShowReactionPicker(false);
|
||||
}, [roomId, message.id, message.reactions, wsClient]);
|
||||
|
||||
const handleOpenReactionPicker = useCallback(() => {
|
||||
if (reactionButtonRef.current) {
|
||||
const rect = reactionButtonRef.current.getBoundingClientRect();
|
||||
setReactionPickerPosition({
|
||||
top: rect.bottom + 8, // 8px below the button
|
||||
left: rect.left + rect.width / 2,
|
||||
});
|
||||
}
|
||||
setShowReactionPicker(true);
|
||||
}, []);
|
||||
|
||||
const functionCalls = useMemo<FunctionCall[]>(
|
||||
() =>
|
||||
message.content_type === 'text' || message.content_type === 'Text'
|
||||
@ -416,212 +392,116 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action toolbar - inline icons when wide, collapsed to dropdown when narrow */}
|
||||
{/* Action toolbar - inline icon buttons */}
|
||||
{!isEditing && !isRevoked && !isPending && (
|
||||
<div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{isNarrow ? (
|
||||
/* Narrow: all actions in dropdown */
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
{/* Add reaction */}
|
||||
<Popover open={showReactionPicker} onOpenChange={setShowReactionPicker}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
title="Add reaction"
|
||||
>
|
||||
<SmilePlus className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-auto p-2" align="start" sideOffset={4}>
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">Select emoji</p>
|
||||
<div className="grid grid-cols-8 gap-1">
|
||||
{COMMON_EMOJIS.map((emoji) => (
|
||||
<Button
|
||||
key={emoji}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
title="More actions"
|
||||
onClick={() => handleReaction(emoji)}
|
||||
className="size-7 p-0 text-base hover:bg-accent"
|
||||
title={emoji}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
{emoji}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setShowReactionPicker(true);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{/* 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Emoji picker overlay - positioned relative to the reaction button */}
|
||||
{showReactionPicker && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowReactionPicker(false)} />
|
||||
<div
|
||||
className="fixed z-50"
|
||||
style={{
|
||||
top: reactionPickerPosition?.top ?? '50%',
|
||||
left: reactionPickerPosition?.left ?? '50%',
|
||||
transform: reactionPickerPosition ? 'translateX(-50%)' : 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
<div className="rounded-lg border border-border bg-popover p-3 shadow-xl">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">Select emoji</p>
|
||||
<EmojiPicker onEmojiSelect={(emoji) => {
|
||||
handleReaction(emoji);
|
||||
setShowReactionPicker(false);
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function EmojiPicker({ onEmojiSelect }: { onEmojiSelect: (emoji: string) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-8 gap-1">
|
||||
{COMMON_EMOJIS.map((emoji) => (
|
||||
<Button
|
||||
key={emoji}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEmojiSelect(emoji)}
|
||||
className="size-7 p-0 text-base hover:bg-accent"
|
||||
>
|
||||
{emoji}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { MessageWithMeta } from '@/contexts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowDown, Loader2 } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, useTransition, useDeferredValue } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { RoomMessageBubble } from './RoomMessageBubble';
|
||||
import { getSenderModelId } from './sender';
|
||||
@ -27,8 +27,6 @@ interface RoomMessageListProps {
|
||||
isLoadingMore?: boolean;
|
||||
onOpenThread?: (message: MessageWithMeta) => void;
|
||||
onCreateThread?: (message: MessageWithMeta) => void;
|
||||
/** Called with the count of currently rendered (visible) rows whenever it changes. */
|
||||
onRenderedCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
interface MessageRow {
|
||||
@ -37,7 +35,6 @@ interface MessageRow {
|
||||
message?: MessageWithMeta;
|
||||
grouped?: boolean;
|
||||
replyMessage?: MessageWithMeta | null;
|
||||
/** Unique key for the virtualizer */
|
||||
key: string;
|
||||
}
|
||||
|
||||
@ -74,12 +71,20 @@ 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;
|
||||
/** Estimated height for a date divider row in pixels */
|
||||
/** 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;
|
||||
}
|
||||
|
||||
const ESTIMATED_DIVIDER_HEIGHT = 30;
|
||||
|
||||
export const RoomMessageList = memo(function RoomMessageList({
|
||||
const RoomMessageListInner = memo(function RoomMessageListInner({
|
||||
roomId,
|
||||
messages,
|
||||
messagesEndRef,
|
||||
@ -94,39 +99,32 @@ export const RoomMessageList = memo(function RoomMessageList({
|
||||
isLoadingMore = false,
|
||||
onOpenThread,
|
||||
onCreateThread,
|
||||
onRenderedCountChange,
|
||||
}: RoomMessageListProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const prevScrollHeightRef = useRef<number | null>(null);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isRestoringScrollRef = useRef(false);
|
||||
const [, startScrollTransition] = useTransition();
|
||||
|
||||
// Record the ID of the first visible message before loading, for more precise scroll position restoration
|
||||
const firstVisibleMessageIdRef = useRef<string | null>(null);
|
||||
|
||||
// Defer messages so React can prioritize scroll/interaction state updates.
|
||||
// When messages arrive rapidly (e.g. WS stream), React renders the deferred
|
||||
// version in a lower-priority work window, preventing scroll jank.
|
||||
const deferredMessages = useDeferredValue(messages);
|
||||
|
||||
const messageMap = useMemo(() => {
|
||||
// Build reply lookup map (stable reference, recomputes only when messages change)
|
||||
const replyMap = useMemo(() => {
|
||||
const map = new Map<string, MessageWithMeta>();
|
||||
deferredMessages.forEach((message) => map.set(message.id, message));
|
||||
messages.forEach((m) => {
|
||||
if (m.id) map.set(m.id, m);
|
||||
});
|
||||
return map;
|
||||
}, [deferredMessages]);
|
||||
}, [messages]);
|
||||
|
||||
// Build rows: date dividers + messages
|
||||
// Uses deferredMessages so row computation is deprioritized during rapid message updates
|
||||
// Use a separate Map to avoid rows depending on replyMap (which changes reference)
|
||||
const rows = useMemo<MessageRow[]>(() => {
|
||||
const result: MessageRow[] = [];
|
||||
let lastDateKey: string | null = null;
|
||||
let lastSenderKey: string | null = null;
|
||||
|
||||
for (const message of deferredMessages) {
|
||||
for (const message of messages) {
|
||||
const dateKey = getDateKey(message.send_at);
|
||||
const senderKey = getSenderKey(message);
|
||||
|
||||
@ -145,36 +143,42 @@ export const RoomMessageList = memo(function RoomMessageList({
|
||||
type: 'message',
|
||||
message,
|
||||
grouped,
|
||||
replyMessage: message.in_reply_to ? messageMap.get(message.in_reply_to) ?? null : null,
|
||||
replyMessage: message.in_reply_to ? replyMap.get(message.in_reply_to) ?? null : null,
|
||||
key: message.id,
|
||||
});
|
||||
lastSenderKey = senderKey;
|
||||
}
|
||||
return result;
|
||||
}, [deferredMessages, messageMap]);
|
||||
}, [messages, replyMap]);
|
||||
|
||||
const scrollToBottom = useCallback((smooth = true) => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
|
||||
}, [messagesEndRef]);
|
||||
|
||||
// Track user scroll to detect if user is at bottom.
|
||||
// Wrapped in startTransition so React knows these state updates are non-urgent
|
||||
// and can be interrupted if a higher-priority update (e.g., new message) comes in.
|
||||
// Lightweight scroll handler: only update the "show" flag, decoupled from layout reads
|
||||
// Reads scroll position synchronously but defers state updates so browser can paint first.
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Synchronous read of scroll position (triggers layout, unavoidable)
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
const nearBottom = distanceFromBottom < 100;
|
||||
|
||||
startScrollTransition(() => {
|
||||
// Update state asynchronously — browser can process other frames before committing
|
||||
requestAnimationFrame(() => {
|
||||
setShowScrollToBottom(!nearBottom);
|
||||
setIsUserScrolling(true);
|
||||
});
|
||||
|
||||
// Reset user scrolling flag after a delay
|
||||
// Reset user-scrolling flag after delay
|
||||
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
|
||||
scrollTimeoutRef.current = setTimeout(() => {
|
||||
setIsUserScrolling(false);
|
||||
// Only clear if still at bottom
|
||||
const c = scrollContainerRef.current;
|
||||
if (c) {
|
||||
const dist = c.scrollHeight - c.scrollTop - c.clientHeight;
|
||||
if (dist < 100) setShowScrollToBottom(false);
|
||||
}
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
@ -188,30 +192,34 @@ export const RoomMessageList = memo(function RoomMessageList({
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive (only if user was already at bottom).
|
||||
// Uses deferredMessages.length so auto-scroll waits for the deferred render to settle.
|
||||
// Auto-scroll when new messages arrive (only if user was already at bottom)
|
||||
useEffect(() => {
|
||||
if (!isUserScrolling && deferredMessages.length > 0) {
|
||||
scrollToBottom(false);
|
||||
if (messages.length === 0) return;
|
||||
// Check if near bottom before scheduling scroll
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
if (distanceFromBottom < 100) {
|
||||
requestAnimationFrame(() => scrollToBottom(false));
|
||||
}
|
||||
}, [deferredMessages.length, isUserScrolling, scrollToBottom]);
|
||||
}, [messages.length, scrollToBottom]);
|
||||
|
||||
// Virtualizer
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
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,
|
||||
overscan: 30,
|
||||
gap: 0,
|
||||
});
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
// IntersectionObserver for load more (only when scrolled to top)
|
||||
// IntersectionObserver for load more
|
||||
useEffect(() => {
|
||||
const sentinel = topSentinelRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
@ -220,16 +228,12 @@ export const RoomMessageList = memo(function RoomMessageList({
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !isLoadingMore && hasMore) {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (scrollContainer) {
|
||||
prevScrollHeightRef.current = scrollContainer.scrollHeight;
|
||||
// Record the ID of the first visible message
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
if (virtualItems.length > 0) {
|
||||
const firstVisibleRow = rows[virtualItems[0].index];
|
||||
if (firstVisibleRow?.type === 'message' && firstVisibleRow.message) {
|
||||
firstVisibleMessageIdRef.current = firstVisibleRow.message.id;
|
||||
}
|
||||
prevScrollHeightRef.current = container.scrollHeight;
|
||||
const items = virtualizer.getVirtualItems();
|
||||
if (items.length > 0) {
|
||||
const firstVisibleRow = rows[items[0].index];
|
||||
if (firstVisibleRow?.type === 'message' && firstVisibleRow.message) {
|
||||
firstVisibleMessageIdRef.current = firstVisibleRow.message.id;
|
||||
}
|
||||
}
|
||||
isRestoringScrollRef.current = true;
|
||||
@ -241,28 +245,23 @@ export const RoomMessageList = memo(function RoomMessageList({
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [onLoadMore, hasMore, isLoadingMore, rows]);
|
||||
}, [onLoadMore, hasMore, isLoadingMore, rows, virtualizer]);
|
||||
|
||||
// Maintain scroll position after loading more messages
|
||||
useEffect(() => {
|
||||
// Only run this effect when we're restoring scroll from a load-more operation
|
||||
if (!isRestoringScrollRef.current) return;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container || prevScrollHeightRef.current === null) {
|
||||
isRestoringScrollRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const newScrollHeight = container.scrollHeight;
|
||||
const delta = newScrollHeight - prevScrollHeightRef.current;
|
||||
const delta = container.scrollHeight - prevScrollHeightRef.current;
|
||||
|
||||
// Method 1: Try to find the previously recorded first visible message
|
||||
if (firstVisibleMessageIdRef.current) {
|
||||
const messageElement = container.querySelector(`[data-message-id="${firstVisibleMessageIdRef.current}"]`);
|
||||
if (messageElement) {
|
||||
// Use scrollIntoView to precisely scroll to the previously visible message
|
||||
messageElement.scrollIntoView({ block: 'start' });
|
||||
const el = container.querySelector(`[data-message-id="${firstVisibleMessageIdRef.current}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: 'start' });
|
||||
prevScrollHeightRef.current = null;
|
||||
firstVisibleMessageIdRef.current = null;
|
||||
isRestoringScrollRef.current = false;
|
||||
@ -270,22 +269,13 @@ export const RoomMessageList = memo(function RoomMessageList({
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Fallback to the previous scrollHeight delta method
|
||||
if (delta > 0) {
|
||||
container.scrollTop += delta;
|
||||
}
|
||||
if (delta > 0) container.scrollTop += delta;
|
||||
|
||||
prevScrollHeightRef.current = null;
|
||||
firstVisibleMessageIdRef.current = null;
|
||||
isRestoringScrollRef.current = false;
|
||||
}, [deferredMessages.length]);
|
||||
}, [messages.length]);
|
||||
|
||||
// Report rendered count to parent (for performance monitor)
|
||||
useEffect(() => {
|
||||
onRenderedCountChange?.(virtualItems.length);
|
||||
}, [virtualItems.length, onRenderedCountChange]);
|
||||
|
||||
// Empty state
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center px-6 py-12">
|
||||
@ -308,7 +298,6 @@ export const RoomMessageList = memo(function RoomMessageList({
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Top sentinel for load more */}
|
||||
<div ref={topSentinelRef} className="absolute top-0 h-1 w-full" />
|
||||
|
||||
{isLoadingMore && (
|
||||
@ -345,12 +334,8 @@ export const RoomMessageList = memo(function RoomMessageList({
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
className="absolute left-0 w-full"
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
@ -381,7 +366,6 @@ export const RoomMessageList = memo(function RoomMessageList({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bottom sentinel for auto-scroll */}
|
||||
<div
|
||||
ref={messagesEndRef}
|
||||
className="absolute left-0 w-full"
|
||||
@ -407,3 +391,6 @@ export const RoomMessageList = memo(function RoomMessageList({
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { RoomMessageListInner };
|
||||
export const RoomMessageList = RoomMessageListInner;
|
||||
|
||||
@ -1,153 +0,0 @@
|
||||
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface PerformanceStats {
|
||||
totalMessages: number;
|
||||
renderedMessages: number;
|
||||
virtualizationEnabled: boolean;
|
||||
}
|
||||
|
||||
interface RoomPerformanceMonitorProps {
|
||||
messageCount: number;
|
||||
renderedCount?: number;
|
||||
}
|
||||
|
||||
const AUTO_CLOSE_DELAY = 5000; // auto-close after 5 seconds when stats are shown
|
||||
|
||||
export function RoomPerformanceMonitor({ messageCount, renderedCount }: RoomPerformanceMonitorProps) {
|
||||
const [showStats, setShowStats] = useState(false);
|
||||
const autoCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Auto-close after AUTO_CLOSE_DELAY when stats are visible
|
||||
useEffect(() => {
|
||||
if (!showStats) return;
|
||||
autoCloseTimerRef.current = setTimeout(() => {
|
||||
setShowStats(false);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
return () => {
|
||||
if (autoCloseTimerRef.current) clearTimeout(autoCloseTimerRef.current);
|
||||
};
|
||||
}, [showStats]);
|
||||
|
||||
const stats = useMemo<PerformanceStats>(() => ({
|
||||
totalMessages: messageCount,
|
||||
renderedMessages: renderedCount ?? messageCount,
|
||||
virtualizationEnabled: renderedCount !== undefined && renderedCount < messageCount,
|
||||
}), [messageCount, renderedCount]);
|
||||
|
||||
// --- Drag state ---
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const draggingRef = useRef(false);
|
||||
const dragStartRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
||||
// Don't start drag if clicking the close button
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
draggingRef.current = true;
|
||||
dragStartRef.current = { x: e.clientX, y: e.clientY };
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const dx = e.clientX - dragStartRef.current.x;
|
||||
const dy = e.clientY - dragStartRef.current.y;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
panel.style.left = `${rect.left + dx}px`;
|
||||
panel.style.top = `${rect.top + dy}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
dragStartRef.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
draggingRef.current = false;
|
||||
};
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!showStats && import.meta.env.PROD) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="fixed bottom-4 left-4 z-50 h-7 w-7 rounded-full p-0 opacity-50 hover:opacity-100"
|
||||
onClick={() => setShowStats(true)}
|
||||
title="Show performance stats"
|
||||
>
|
||||
📊
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="fixed z-50 rounded-lg border bg-background/95 p-3 shadow-lg backdrop-blur-sm select-none"
|
||||
style={{ bottom: '1rem', left: '1rem' }}
|
||||
>
|
||||
{/* Draggable header */}
|
||||
<div
|
||||
className="mb-2 flex items-center justify-between cursor-grab active:cursor-grabbing select-none"
|
||||
onMouseDown={handleDragStart}
|
||||
>
|
||||
<h4 className="text-xs font-semibold text-foreground">Performance Stats</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={() => setShowStats(false)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Total messages:</span>
|
||||
<Badge variant="secondary" className="font-mono">{stats.totalMessages}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Rendered:</span>
|
||||
<Badge variant="secondary" className="font-mono">{stats.renderedMessages}</Badge>
|
||||
</div>
|
||||
|
||||
{stats.virtualizationEnabled && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Skipped:</span>
|
||||
<Badge variant="outline" className="font-mono text-green-600">
|
||||
{stats.totalMessages - stats.renderedMessages}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Virtualization:</span>
|
||||
<Badge
|
||||
variant={stats.virtualizationEnabled ? 'default' : 'destructive'}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{stats.virtualizationEnabled ? '✓ Enabled' : '✗ Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{stats.virtualizationEnabled && (
|
||||
<div className="pt-1 border-t border-border">
|
||||
<span className="text-[10px] text-green-600">
|
||||
⚡ Rendering {((stats.renderedMessages / stats.totalMessages) * 100).toFixed(0)}% of messages
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -25,6 +25,7 @@ import {
|
||||
type RoomMessagePayload,
|
||||
type RoomCategoryResponse as WsRoomCategoryResponse,
|
||||
type RoomReactionUpdatedPayload,
|
||||
type ReactionListData,
|
||||
} from '@/lib/room-ws-client';
|
||||
import { requestWsToken } from '@/lib/ws-token';
|
||||
import { useUser } from '@/contexts';
|
||||
@ -32,8 +33,8 @@ import {
|
||||
saveMessage,
|
||||
saveMessages,
|
||||
loadMessages as loadMessagesFromIdb,
|
||||
loadOlderMessagesFromIdb,
|
||||
deleteMessage as deleteMessageFromIdb,
|
||||
clearRoomMessages,
|
||||
} from '@/lib/storage/indexed-db';
|
||||
|
||||
export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client';
|
||||
@ -221,7 +222,6 @@ export function RoomProvider({
|
||||
|
||||
useEffect(() => {
|
||||
if (prevRoomIdRef.current !== activeRoomId) {
|
||||
const oldRoomId = prevRoomIdRef.current;
|
||||
prevRoomIdRef.current = activeRoomId;
|
||||
loadMessagesAbortRef.current?.abort();
|
||||
loadMessagesAbortRef.current = null;
|
||||
@ -231,37 +231,68 @@ export function RoomProvider({
|
||||
setMessages([]);
|
||||
setIsHistoryLoaded(false);
|
||||
setNextCursor(null);
|
||||
// Clear old room's IDB cache asynchronously (fire and forget)
|
||||
if (oldRoomId) {
|
||||
clearRoomMessages(oldRoomId).catch(() => {});
|
||||
}
|
||||
// NOTE: intentionally NOT clearing IndexedDB — keeping it enables instant
|
||||
// load when the user returns to this room without waiting for API.
|
||||
}
|
||||
}, [activeRoomId]);
|
||||
|
||||
const loadMoreRef = useRef<((cursor?: number | null) => Promise<void>) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const client = wsClientRef.current;
|
||||
if (!activeRoomId || !client) return;
|
||||
|
||||
const setup = async () => {
|
||||
if (client.getStatus() !== 'open') {
|
||||
await client.connect();
|
||||
}
|
||||
// Re-check: activeRoomId may have changed while we were waiting for connect.
|
||||
// Use activeRoomIdRef to get the *current* room, not the stale closure value.
|
||||
const roomId = activeRoomIdRef.current;
|
||||
if (!roomId) return;
|
||||
await client.subscribeRoom(roomId);
|
||||
loadMoreRef.current?.(null);
|
||||
// IDB load does NOT need WS — show cached messages immediately.
|
||||
// loadMore checks IDB first, then falls back to API (WS-first + HTTP).
|
||||
loadMore(null);
|
||||
|
||||
// Connect WS in parallel for real-time push + reactions batch-fetch.
|
||||
// connect() is idempotent — no-op if already connecting/open.
|
||||
// subscribeRoom uses WS-first request() with HTTP fallback.
|
||||
await client.connect();
|
||||
if (activeRoomIdRef.current !== activeRoomId) return;
|
||||
client.subscribeRoom(activeRoomId).catch(() => {});
|
||||
};
|
||||
setup();
|
||||
setup().catch(() => {});
|
||||
|
||||
return () => {
|
||||
client.unsubscribeRoom(activeRoomId).catch(() => {});
|
||||
};
|
||||
}, [activeRoomId, wsClient]);
|
||||
|
||||
/**
|
||||
* Fetch reactions for a batch of messages via WS (with HTTP fallback),
|
||||
* then merge them into the messages state. Fires-and-forgets so it
|
||||
* does not block the caller.
|
||||
*/
|
||||
const thisLoadReactions = (
|
||||
roomId: string,
|
||||
client: RoomWsClient,
|
||||
msgs: MessageWithMeta[],
|
||||
) => {
|
||||
const msgIds = msgs.map((m) => m.id);
|
||||
if (msgIds.length === 0) return;
|
||||
client
|
||||
.reactionListBatch(roomId, msgIds)
|
||||
.then((reactionResults: ReactionListData[]) => {
|
||||
const reactionMap = new Map<string, ReactionListData['reactions']>();
|
||||
for (const result of reactionResults) {
|
||||
if (result.reactions.length > 0) {
|
||||
reactionMap.set(result.message_id, result.reactions);
|
||||
}
|
||||
}
|
||||
if (reactionMap.size > 0) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
reactionMap.has(m.id) ? { ...m, reactions: reactionMap.get(m.id) } : m,
|
||||
),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Non-fatal: WS push will keep reactions up to date
|
||||
});
|
||||
};
|
||||
|
||||
const loadMore = useCallback(
|
||||
async (cursor?: number | null) => {
|
||||
const client = wsClientRef.current;
|
||||
@ -274,31 +305,60 @@ export function RoomProvider({
|
||||
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
// Initial load: check IndexedDB first for fast render
|
||||
if (cursor === null || cursor === undefined) {
|
||||
const isInitial = cursor === null || cursor === undefined;
|
||||
const limit = isInitial ? 200 : 50;
|
||||
|
||||
// --- Initial load: try IndexedDB first for instant render ---
|
||||
if (isInitial) {
|
||||
const cached = await loadMessagesFromIdb(activeRoomId);
|
||||
if (cached.length > 0) {
|
||||
setMessages(cached);
|
||||
setIsTransitioningRoom(false);
|
||||
// Derive cursor from IDB data (oldest message's seq = cursor)
|
||||
const minSeq = cached[0].seq;
|
||||
setNextCursor(minSeq > 0 ? minSeq - 1 : null);
|
||||
// If IDB has data, skip API call — WS will push live updates
|
||||
// Still set isLoadingMore to false and return
|
||||
setIsLoadingMore(false);
|
||||
// No API call needed — WS will push any new messages that arrived while away.
|
||||
// Fetch reactions via WS (with HTTP fallback) so reactions appear without extra latency.
|
||||
thisLoadReactions(activeRoomId, client, cached);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Call API (IDB was empty on initial load, or user is loading older history)
|
||||
// --- Load older history: try IDB first, then fall back to API ---
|
||||
if (!isInitial && cursor != null) {
|
||||
const idbMessages = await loadOlderMessagesFromIdb(activeRoomId, cursor, limit);
|
||||
if (idbMessages.length > 0) {
|
||||
setMessages((prev) => {
|
||||
if (abortController.signal.aborted) return prev;
|
||||
const existingIds = new Set(prev.map((m) => m.id));
|
||||
const filtered = idbMessages.filter((m) => !existingIds.has(m.id));
|
||||
let merged = [...filtered, ...prev];
|
||||
merged.sort((a, b) => a.seq - b.seq);
|
||||
if (merged.length > MAX_MESSAGES_IN_MEMORY) {
|
||||
merged = merged.slice(-MAX_MESSAGES_IN_MEMORY);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
const oldest = idbMessages[0];
|
||||
setNextCursor(oldest.seq > 0 ? oldest.seq - 1 : null);
|
||||
if (idbMessages.length < limit) {
|
||||
setIsHistoryLoaded(true);
|
||||
}
|
||||
setIsLoadingMore(false);
|
||||
// Also fetch reactions for the IDB-loaded history messages.
|
||||
thisLoadReactions(activeRoomId, client, idbMessages);
|
||||
return;
|
||||
}
|
||||
// IDB empty for this range — fall through to API
|
||||
}
|
||||
|
||||
// --- API fetch ---
|
||||
const resp = await client.messageList(activeRoomId, {
|
||||
beforeSeq: cursor ?? undefined,
|
||||
limit: 50,
|
||||
limit,
|
||||
});
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
const newMessages = resp.messages.map((m) => ({
|
||||
...m,
|
||||
@ -308,17 +368,11 @@ export function RoomProvider({
|
||||
}));
|
||||
|
||||
setMessages((prev) => {
|
||||
// Double-check room hasn't changed
|
||||
if (abortController.signal.aborted) {
|
||||
return prev;
|
||||
}
|
||||
// If initial load (cursor=null), replace instead of merge (room switching)
|
||||
if (cursor === null || cursor === undefined) {
|
||||
// Clear transitioning state
|
||||
if (abortController.signal.aborted) return prev;
|
||||
if (isInitial) {
|
||||
setIsTransitioningRoom(false);
|
||||
return newMessages;
|
||||
}
|
||||
// loadMore: prepend older messages before existing
|
||||
const existingIds = new Set(prev.map((m) => m.id));
|
||||
const filtered = newMessages.filter((m) => !existingIds.has(m.id));
|
||||
let merged = [...filtered, ...prev];
|
||||
@ -329,44 +383,19 @@ export function RoomProvider({
|
||||
return merged;
|
||||
});
|
||||
|
||||
// Persist new messages to IndexedDB
|
||||
if (newMessages.length > 0) {
|
||||
saveMessages(activeRoomId, newMessages).catch(() => {});
|
||||
}
|
||||
|
||||
if (resp.messages.length < 50) {
|
||||
if (resp.messages.length < limit) {
|
||||
setIsHistoryLoaded(true);
|
||||
}
|
||||
// messages are in ascending order (oldest first), so [length-1] is newest
|
||||
setNextCursor(resp.messages.length > 0 ? resp.messages[resp.messages.length - 1].seq : null);
|
||||
|
||||
// Fetch reactions for all loaded messages (backend may not support this yet)
|
||||
const msgIds = newMessages.map((m) => m.id);
|
||||
if (msgIds.length > 0) {
|
||||
try {
|
||||
const reactionResults = await client.reactionListBatch(activeRoomId, msgIds);
|
||||
const reactionMap = new Map<string, import('@/lib/room-ws-client').ReactionItem[]>();
|
||||
for (const result of reactionResults) {
|
||||
if (result.reactions.length > 0) {
|
||||
reactionMap.set(result.message_id, result.reactions);
|
||||
}
|
||||
}
|
||||
if (reactionMap.size > 0) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
reactionMap.has(m.id) ? { ...m, reactions: reactionMap.get(m.id) } : m,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Reactions will be loaded via WebSocket updates if backend supports it
|
||||
}
|
||||
}
|
||||
// Fetch reactions for all loaded messages (WS-first with HTTP fallback)
|
||||
thisLoadReactions(activeRoomId, client, newMessages);
|
||||
} catch (error) {
|
||||
// Ignore abort errors
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (abortController.signal.aborted) return;
|
||||
handleRoomError('Load messages', error);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
@ -376,10 +405,6 @@ export function RoomProvider({
|
||||
[activeRoomId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadMoreRef.current = loadMore;
|
||||
}, [loadMore]);
|
||||
|
||||
const [members, setMembers] = useState<RoomMemberResponse[]>([]);
|
||||
const [membersLoading, setMembersLoading] = useState(false);
|
||||
|
||||
@ -399,8 +424,20 @@ export function RoomProvider({
|
||||
// Use ref to get current activeRoomId to avoid stale closure
|
||||
if (payload.room_id === activeRoomIdRef.current) {
|
||||
setMessages((prev) => {
|
||||
// Deduplicate by both ID (for normal) and seq (for optimistic replacement)
|
||||
if (prev.some((m) => m.id === payload.id)) {
|
||||
// Check if this is a reaction-update event (same ID, different reactions).
|
||||
// publish_reaction_event sends RoomMessageEvent with reactions field set.
|
||||
const existingIdx = prev.findIndex((m) => m.id === payload.id);
|
||||
if (existingIdx !== -1) {
|
||||
// Message already exists — update reactions if provided.
|
||||
// Reaction events have empty content/sender_type.
|
||||
if (payload.reactions !== undefined) {
|
||||
const updated = [...prev];
|
||||
updated[existingIdx] = { ...updated[existingIdx], reactions: payload.reactions };
|
||||
const msg = updated[existingIdx];
|
||||
saveMessage(msg).catch(() => {});
|
||||
return updated;
|
||||
}
|
||||
// Duplicate of a real message — ignore
|
||||
return prev;
|
||||
}
|
||||
// Also check if there's an optimistic message with the same seq that should be replaced
|
||||
@ -480,15 +517,18 @@ export function RoomProvider({
|
||||
}
|
||||
},
|
||||
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
|
||||
// Guard: ignore events for rooms that are no longer active.
|
||||
// Without this, a WS event arriving after room switch could update
|
||||
// the wrong room's message list (same message ID, different room).
|
||||
if (!activeRoomIdRef.current) return;
|
||||
|
||||
setMessages((prev) => {
|
||||
const updated = prev.map((m) =>
|
||||
m.id === payload.message_id
|
||||
? { ...m, reactions: payload.reactions }
|
||||
: m,
|
||||
);
|
||||
const existingIdx = prev.findIndex((m) => m.id === payload.message_id);
|
||||
if (existingIdx === -1) return prev;
|
||||
const updated = [...prev];
|
||||
updated[existingIdx] = { ...updated[existingIdx], reactions: payload.reactions };
|
||||
// Persist reaction update to IndexedDB
|
||||
const msg = updated.find((m) => m.id === payload.message_id);
|
||||
if (msg) saveMessage(msg).catch(() => {});
|
||||
saveMessage(updated[existingIdx]).catch(() => {});
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
|
||||
@ -1,553 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
type AiStreamChunkPayload,
|
||||
type RoomMessagePayload,
|
||||
type RoomWsStatus,
|
||||
type WsOutEvent,
|
||||
} from '@/lib/room';
|
||||
import { requestWsToken, buildWsUrlWithToken } from '@/lib/ws-token';
|
||||
import { client } from '@/client/client.gen';
|
||||
import type { AxiosResponse } from 'axios';
|
||||
import type { RoomMemberResponse } from '@/client';
|
||||
|
||||
const RECONNECT_BASE_DELAY = 1_000;
|
||||
const RECONNECT_MAX_DELAY = 15_000;
|
||||
|
||||
/** A message as held in the UI state */
|
||||
export type UiMessage = RoomMessagePayload & {
|
||||
/** Set while the server is streaming an AI reply into this message */
|
||||
is_streaming?: boolean;
|
||||
/** Accumulated streaming content; flushed to content on `done: true` */
|
||||
streaming_content?: string;
|
||||
/** Display name resolved from sender_id; undefined if not yet resolved */
|
||||
display_name?: string;
|
||||
/** Avatar URL resolved from members list */
|
||||
avatar_url?: string;
|
||||
/** For optimistic UI: message failed to send */
|
||||
isOptimisticError?: boolean;
|
||||
/** Reply to message ID */
|
||||
in_reply_to?: string | null;
|
||||
/** Edited timestamp */
|
||||
edited_at?: string | null;
|
||||
/** Revoked timestamp */
|
||||
revoked?: string | null;
|
||||
/** Revoked by user ID */
|
||||
revoked_by?: string | null;
|
||||
};
|
||||
|
||||
type RoomMessageCacheEntry = {
|
||||
messages: UiMessage[];
|
||||
isHistoryLoaded: boolean;
|
||||
/** seq of the latest message, used as cursor for pagination */
|
||||
nextCursor: number | null;
|
||||
};
|
||||
|
||||
interface MessageListResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: { messages: RestMessage[]; total: number };
|
||||
}
|
||||
|
||||
/** REST message shape (matches RoomMessageResponse from the SDK) */
|
||||
interface RestMessage {
|
||||
id: string;
|
||||
seq: number;
|
||||
room: string;
|
||||
sender_type: string;
|
||||
sender_id?: string | null;
|
||||
display_name?: string | null;
|
||||
thread?: string | null;
|
||||
in_reply_to?: string | null;
|
||||
content: string;
|
||||
content_type: string;
|
||||
edited_at?: string | null;
|
||||
send_at: string;
|
||||
revoked?: string | null;
|
||||
revoked_by?: string | null;
|
||||
}
|
||||
|
||||
/** Display name and avatar URL resolved from a message's sender */
|
||||
interface SenderInfo {
|
||||
displayName: string;
|
||||
avatarUrl: string | undefined;
|
||||
}
|
||||
|
||||
/** Resolve displayName and avatar URL for a message sender.
|
||||
* - AI messages: use sender_id (the model UUID)
|
||||
* - User messages: look up user_info from members list, fall back to sender_id */
|
||||
function resolveSender(payload: RoomMessagePayload, members: RoomMemberResponse[]): SenderInfo {
|
||||
if (payload.sender_type === 'ai') {
|
||||
return { displayName: payload.sender_id ?? 'AI', avatarUrl: undefined };
|
||||
}
|
||||
if (payload.sender_id) {
|
||||
const member = members.find((m) => m.user === payload.sender_id);
|
||||
if (member) {
|
||||
const username = member.user_info?.username ?? member.user;
|
||||
return { displayName: username, avatarUrl: member.user_info?.avatar_url ?? undefined };
|
||||
}
|
||||
}
|
||||
if (payload.sender_type === 'system') return { displayName: 'System', avatarUrl: undefined };
|
||||
return { displayName: payload.sender_type, avatarUrl: undefined };
|
||||
}
|
||||
|
||||
function compareMessages(a: UiMessage, b: UiMessage): number {
|
||||
const timeDiff = new Date(a.send_at).getTime() - new Date(b.send_at).getTime();
|
||||
return timeDiff !== 0 ? timeDiff : a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
function insertSorted(arr: UiMessage[], msg: UiMessage): UiMessage[] {
|
||||
const result = [...arr];
|
||||
let lo = 0;
|
||||
let hi = result.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (compareMessages(result[mid], msg) < 0) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
result.splice(lo, 0, msg);
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface UseRoomWsOptions {
|
||||
/** VITE_API_BASE_URL value (without /ws suffix) */
|
||||
baseUrl: string;
|
||||
/** Currently open room ID */
|
||||
roomId: string | null;
|
||||
/** Limit for initial history load */
|
||||
historyLimit?: number;
|
||||
/** Room members, used to resolve display_name for user messages */
|
||||
members?: RoomMemberResponse[];
|
||||
/** Called when the AI streaming chunk arrives */
|
||||
onAiStreamChunk?: (payload: AiStreamChunkPayload) => void;
|
||||
}
|
||||
|
||||
export interface UseRoomWsReturn {
|
||||
messages: UiMessage[];
|
||||
status: RoomWsStatus;
|
||||
errorMessage: string | null;
|
||||
isHistoryLoaded: boolean;
|
||||
isLoadingMore: boolean;
|
||||
nextCursor: number | null;
|
||||
/** Load older messages (called when user scrolls to top) */
|
||||
loadMore: (cursor?: number | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages a WebSocket connection for a single room.
|
||||
*
|
||||
* Features:
|
||||
* - Auto-reconnect with exponential back-off
|
||||
* - Per-room message cache so switching rooms preserves scroll position
|
||||
* - AI streaming chunk accumulation via `streaming_content`
|
||||
* - `loadMore` for cursor-based history pagination
|
||||
*/
|
||||
export function useRoomWs({
|
||||
baseUrl,
|
||||
roomId,
|
||||
historyLimit = 50,
|
||||
members = [],
|
||||
onAiStreamChunk,
|
||||
}: UseRoomWsOptions): UseRoomWsReturn {
|
||||
const [messages, setMessages] = useState<UiMessage[]>([]);
|
||||
const [status, setStatus] = useState<RoomWsStatus>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [nextCursor, setNextCursor] = useState<number | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const activeRoomIdRef = useRef<string | null>(null);
|
||||
const shouldReconnectRef = useRef(true);
|
||||
const reconnectAttemptRef = useRef(0);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const wsTokenRef = useRef<string | null>(null);
|
||||
|
||||
const roomCacheRef = useRef<Map<string, RoomMessageCacheEntry>>(new Map());
|
||||
/** Ref to current messages for use inside event handlers */
|
||||
const messagesRef = useRef<UiMessage[]>([]);
|
||||
messagesRef.current = messages;
|
||||
|
||||
/** Ref to current nextCursor */
|
||||
const nextCursorRef = useRef<number | null>(null);
|
||||
nextCursorRef.current = nextCursor;
|
||||
|
||||
/** Ref to members, used for display_name resolution */
|
||||
const membersRef = useRef<RoomMemberResponse[]>([]);
|
||||
membersRef.current = members;
|
||||
|
||||
/** Ref for AI streaming RAF batch */
|
||||
const streamingBatchRef = useRef<Map<string, { content: string; done: boolean; room_id: string }>>(new Map());
|
||||
const streamingRafRef = useRef<number | null>(null);
|
||||
|
||||
/** Flush streaming batch to state */
|
||||
const flushStreamingBatch = useCallback(() => {
|
||||
const batch = streamingBatchRef.current;
|
||||
if (batch.size === 0) return;
|
||||
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
let changed = false;
|
||||
|
||||
for (const [messageId, chunk] of batch) {
|
||||
const idx = next.findIndex((m) => m.id === messageId);
|
||||
if (idx === -1) {
|
||||
const placeholder: UiMessage = {
|
||||
id: messageId,
|
||||
room_id: chunk.room_id ?? next.find(() => true)?.room_id ?? '',
|
||||
sender_type: 'ai',
|
||||
content: chunk.done ? chunk.content : '',
|
||||
content_type: 'text',
|
||||
send_at: new Date().toISOString(),
|
||||
seq: 0,
|
||||
display_name: 'AI',
|
||||
is_streaming: !chunk.done,
|
||||
streaming_content: chunk.done ? undefined : chunk.content,
|
||||
};
|
||||
next.push(placeholder);
|
||||
changed = true;
|
||||
} else {
|
||||
const updated = { ...next[idx] };
|
||||
if (chunk.done) {
|
||||
updated.is_streaming = false;
|
||||
updated.content = chunk.content;
|
||||
updated.streaming_content = undefined;
|
||||
} else {
|
||||
updated.is_streaming = true;
|
||||
updated.streaming_content = (updated.streaming_content ?? updated.content) + chunk.content;
|
||||
}
|
||||
next[idx] = updated;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
streamingBatchRef.current.clear();
|
||||
streamingRafRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const room = activeRoomIdRef.current;
|
||||
if (!room) return;
|
||||
roomCacheRef.current.set(room, {
|
||||
messages,
|
||||
isHistoryLoaded,
|
||||
nextCursor,
|
||||
});
|
||||
}, [messages, isHistoryLoaded, nextCursor]);
|
||||
|
||||
const connectWs = useCallback(
|
||||
async (roomUid: string) => {
|
||||
if (!shouldReconnectRef.current || activeRoomIdRef.current !== roomUid) return;
|
||||
|
||||
// Build URL with token if available
|
||||
const url = buildWsUrlWithToken(baseUrl, `/ws/rooms/${roomUid}`, wsTokenRef.current);
|
||||
console.debug('[useRoomWs] connecting to', url, { baseUrl, roomUid });
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setStatus('connecting');
|
||||
setErrorMessage(null);
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (activeRoomIdRef.current !== roomUid) return;
|
||||
reconnectAttemptRef.current = 0;
|
||||
setStatus('open');
|
||||
setErrorMessage(null);
|
||||
console.debug('[useRoomWs] ws opened for room', roomUid);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev: MessageEvent<string>) => {
|
||||
if (activeRoomIdRef.current !== roomUid) return;
|
||||
|
||||
let event: WsOutEvent;
|
||||
try {
|
||||
event = JSON.parse(ev.data) as WsOutEvent;
|
||||
} catch {
|
||||
console.warn('[useRoomWs] parse error, data:', ev.data);
|
||||
setErrorMessage('Invalid WebSocket message');
|
||||
return;
|
||||
}
|
||||
|
||||
if ('error' in event && event.error) {
|
||||
console.warn('[useRoomWs] error event:', event.error);
|
||||
setErrorMessage(event.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!('event' in event) || !event.event) {
|
||||
console.warn('[useRoomWs] no event field, raw:', event);
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('[useRoomWs] received event type:', event.event.type, event.event);
|
||||
|
||||
switch (event.event.type) {
|
||||
case 'room_message': {
|
||||
// Backend sends payload flat on event.event (no data wrapper); also support nested data
|
||||
const raw = (event.event as any);
|
||||
const incoming: RoomMessagePayload = raw.data ?? raw;
|
||||
console.debug('[useRoomWs] room_message:', incoming.id, incoming.content);
|
||||
|
||||
// Use Set for O(1) duplicate check instead of O(n) Array.some
|
||||
const existingIds = new Set(messagesRef.current.map((m) => m.id));
|
||||
if (existingIds.has(incoming.id)) {
|
||||
console.debug('[useRoomWs] duplicate message, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const sender = resolveSender(incoming, membersRef.current);
|
||||
const display_name = incoming.display_name ?? sender.displayName;
|
||||
const avatar_url = sender.avatarUrl;
|
||||
|
||||
setMessages((prev) =>
|
||||
insertSorted(prev, { ...incoming, display_name, avatar_url }),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ai_stream_chunk': {
|
||||
const raw = event.event as any;
|
||||
const chunk: AiStreamChunkPayload = raw.data ?? raw;
|
||||
onAiStreamChunk?.(chunk);
|
||||
|
||||
// Batch streaming chunks using RAF to reduce re-render frequency
|
||||
streamingBatchRef.current.set(chunk.message_id, {
|
||||
content: chunk.content,
|
||||
done: chunk.done,
|
||||
room_id: chunk.room_id,
|
||||
});
|
||||
|
||||
if (streamingRafRef.current == null) {
|
||||
streamingRafRef.current = requestAnimationFrame(() => {
|
||||
flushStreamingBatch();
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (ev: CloseEvent) => {
|
||||
console.debug('[useRoomWs] WebSocket closed', { code: ev.code, reason: ev.reason, wasClean: ev.wasClean });
|
||||
const activeSocket = wsRef.current;
|
||||
if (activeSocket !== ws) return;
|
||||
wsRef.current = null;
|
||||
if (activeRoomIdRef.current !== roomUid) return;
|
||||
|
||||
setStatus('closed');
|
||||
if (shouldReconnectRef.current) {
|
||||
const attempt = ++reconnectAttemptRef.current;
|
||||
const delay = Math.min(RECONNECT_BASE_DELAY * 2 ** (attempt - 1), RECONNECT_MAX_DELAY);
|
||||
reconnectTimeoutRef.current = setTimeout(() => connectWs(roomUid), delay);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (ev: Event) => {
|
||||
console.error('[useRoomWs] WebSocket error', ev);
|
||||
if (activeRoomIdRef.current !== roomUid) return;
|
||||
setErrorMessage('WebSocket error');
|
||||
};
|
||||
},
|
||||
[baseUrl, onAiStreamChunk],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const prevRoom = activeRoomIdRef.current;
|
||||
|
||||
if (!roomId) {
|
||||
// Disconnect
|
||||
activeRoomIdRef.current = null;
|
||||
shouldReconnectRef.current = false;
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
setMessages([]);
|
||||
setStatus('idle');
|
||||
setErrorMessage(null);
|
||||
setIsHistoryLoaded(false);
|
||||
setNextCursor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save previous room's state
|
||||
if (prevRoom && prevRoom !== roomId) {
|
||||
roomCacheRef.current.set(prevRoom, {
|
||||
messages: messagesRef.current,
|
||||
isHistoryLoaded,
|
||||
nextCursor: nextCursorRef.current,
|
||||
});
|
||||
}
|
||||
|
||||
activeRoomIdRef.current = roomId;
|
||||
shouldReconnectRef.current = true;
|
||||
reconnectAttemptRef.current = 0;
|
||||
|
||||
// Fetch WS token before connecting
|
||||
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;
|
||||
}
|
||||
|
||||
// Restore from cache or start fresh
|
||||
const cached = roomCacheRef.current.get(roomId);
|
||||
if (cached) {
|
||||
setMessages(cached.messages);
|
||||
setIsHistoryLoaded(cached.isHistoryLoaded);
|
||||
setNextCursor(cached.nextCursor);
|
||||
} else {
|
||||
setMessages([]);
|
||||
setIsHistoryLoaded(false);
|
||||
setNextCursor(null);
|
||||
// Load initial history via REST (WS is push-only, can't request history)
|
||||
if (roomId) {
|
||||
client
|
||||
.get({ url: `/api/rooms/${roomId}/messages`, params: { limit: historyLimit } })
|
||||
.then((resp) => {
|
||||
const r = resp as AxiosResponse<MessageListResponse>;
|
||||
if (activeRoomIdRef.current !== roomId) return;
|
||||
const msgs = (r.data?.data?.messages ?? []).map((m) => {
|
||||
const sender = resolveSender({ ...m, room_id: m.room } as RoomMessagePayload, members);
|
||||
const display_name = m.display_name ?? sender.displayName;
|
||||
const avatar_url = sender.avatarUrl;
|
||||
return {
|
||||
...m,
|
||||
room_id: m.room,
|
||||
thread_id: m.thread ?? null,
|
||||
display_name,
|
||||
avatar_url,
|
||||
};
|
||||
});
|
||||
setMessages(msgs);
|
||||
setNextCursor(msgs.length > 0 ? msgs[msgs.length - 1].seq : null);
|
||||
setIsHistoryLoaded(msgs.length < historyLimit);
|
||||
})
|
||||
.catch(() => {
|
||||
if (activeRoomIdRef.current !== roomId) return;
|
||||
toast.error('Failed to load message history');
|
||||
setIsHistoryLoaded(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Close other connections (shouldn't be any in practice)
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
connectWs(roomId);
|
||||
};
|
||||
|
||||
connectWithToken();
|
||||
|
||||
return () => {
|
||||
// Save state before unmounting
|
||||
const room = activeRoomIdRef.current;
|
||||
if (room) {
|
||||
roomCacheRef.current.set(room, {
|
||||
messages: messagesRef.current,
|
||||
isHistoryLoaded: isHistoryLoaded,
|
||||
nextCursor: nextCursorRef.current,
|
||||
});
|
||||
}
|
||||
shouldReconnectRef.current = false;
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
if (streamingRafRef.current != null) {
|
||||
cancelAnimationFrame(streamingRafRef.current);
|
||||
streamingRafRef.current = null;
|
||||
}
|
||||
activeRoomIdRef.current = null;
|
||||
};
|
||||
}, [roomId, connectWs]);
|
||||
|
||||
const loadMore = useCallback(
|
||||
async (cursor?: number | null) => {
|
||||
if (!roomId || isLoadingMore) return;
|
||||
// Use REST API for history pagination — WS is push-only
|
||||
const effectiveCursor = cursor ?? nextCursorRef.current;
|
||||
if (effectiveCursor == null) return;
|
||||
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const resp = await client.get({ url: `/api/rooms/${roomId}/messages`, params: { before_seq: effectiveCursor, limit: historyLimit } }) as AxiosResponse<MessageListResponse>;
|
||||
const older = (resp.data?.data?.messages ?? []).map((m) => {
|
||||
const sender = resolveSender({ ...m, room_id: m.room } as RoomMessagePayload, membersRef.current);
|
||||
const display_name = m.display_name ?? sender.displayName;
|
||||
const avatar_url = sender.avatarUrl;
|
||||
return {
|
||||
...m,
|
||||
room_id: m.room,
|
||||
thread_id: m.thread ?? null,
|
||||
display_name,
|
||||
avatar_url,
|
||||
};
|
||||
});
|
||||
if (older.length === 0) {
|
||||
setIsHistoryLoaded(true);
|
||||
return;
|
||||
}
|
||||
// Prepend older messages (they arrive in ascending seq order)
|
||||
setMessages((prev) => {
|
||||
const existingIds = new Set(prev.map((m) => m.id));
|
||||
const newOnes = older.filter((m) => !existingIds.has(m.id));
|
||||
if (newOnes.length === 0) {
|
||||
setIsHistoryLoaded(true);
|
||||
return prev;
|
||||
}
|
||||
// New cursor = smallest seq among loaded messages
|
||||
const newCursor = newOnes[newOnes.length - 1].seq;
|
||||
setNextCursor(newCursor > 0 ? newCursor : null);
|
||||
return [...newOnes, ...prev];
|
||||
});
|
||||
} catch {
|
||||
// Non-critical — show toast so user knows the load failed
|
||||
toast.error('Failed to load more messages');
|
||||
setIsHistoryLoaded(true);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[roomId, historyLimit, isLoadingMore],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
messages,
|
||||
status,
|
||||
errorMessage,
|
||||
isHistoryLoaded,
|
||||
isLoadingMore,
|
||||
nextCursor,
|
||||
loadMore,
|
||||
}),
|
||||
[messages, status, errorMessage, isHistoryLoaded, isLoadingMore, nextCursor, loadMore],
|
||||
);
|
||||
}
|
||||
@ -133,7 +133,7 @@ export class RoomWsClient {
|
||||
return new Set(this.subscribedProjects);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
async connect(forceNewToken = false): Promise<void> {
|
||||
if (this.ws && this.status === 'open') {
|
||||
return;
|
||||
}
|
||||
@ -141,28 +141,31 @@ export class RoomWsClient {
|
||||
this.shouldReconnect = true;
|
||||
this.setStatus('connecting');
|
||||
|
||||
// Fetch a fresh token for each connection attempt (backend consumes token on use)
|
||||
try {
|
||||
const tokenResp = await fetch(`${this.baseUrl}/api/ws/token`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!tokenResp.ok) {
|
||||
const text = await tokenResp.text().catch(() => '');
|
||||
console.error(`[RoomWs] Token fetch failed: ${tokenResp.status} ${tokenResp.statusText} — ${text}`);
|
||||
throw new Error(`Token fetch failed: ${tokenResp.status}`);
|
||||
// Fetch a fresh token unless we have a valid existing one and not forcing.
|
||||
// When forceNewToken=false (reconnect path), try existing token first.
|
||||
if (forceNewToken || !this.wsToken) {
|
||||
try {
|
||||
const tokenResp = await fetch(`${this.baseUrl}/api/ws/token`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!tokenResp.ok) {
|
||||
const text = await tokenResp.text().catch(() => '');
|
||||
console.error(`[RoomWs] Token fetch failed: ${tokenResp.status} ${tokenResp.statusText} — ${text}`);
|
||||
throw new Error(`Token fetch failed: ${tokenResp.status}`);
|
||||
}
|
||||
const tokenData = await tokenResp.json();
|
||||
this.wsToken = tokenData.data?.token || null;
|
||||
if (!this.wsToken) {
|
||||
console.error('[RoomWs] Token is empty — not logged in?');
|
||||
throw new Error('No WS token received');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RoomWs] Failed to fetch WS token:', err);
|
||||
this.setStatus('error');
|
||||
this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err)));
|
||||
throw err;
|
||||
}
|
||||
const tokenData = await tokenResp.json();
|
||||
this.wsToken = tokenData.data?.token || null;
|
||||
if (!this.wsToken) {
|
||||
console.error('[RoomWs] Token is empty — not logged in?');
|
||||
throw new Error('No WS token received');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RoomWs] Failed to fetch WS token:', err);
|
||||
this.setStatus('error');
|
||||
this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err)));
|
||||
throw err;
|
||||
}
|
||||
|
||||
const wsUrl = this.buildWsUrl();
|
||||
@ -172,6 +175,12 @@ export class RoomWsClient {
|
||||
// Guard: if ws is closed before handlers are set, skip
|
||||
if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
|
||||
console.warn('[RoomWs] WebSocket closed immediately');
|
||||
// If we used an existing token and it was immediately rejected, retry with a new token
|
||||
if (!forceNewToken && this.wsToken) {
|
||||
console.debug('[RoomWs] Existing token rejected — fetching new token and retrying');
|
||||
this.wsToken = null;
|
||||
return this.connect(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -183,6 +183,44 @@ export async function clearRoomMessages(roomId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Load older messages from IDB (seq < beforeSeq), sorted ascending, up to `limit` */
|
||||
export async function loadOlderMessagesFromIdb(
|
||||
roomId: string,
|
||||
beforeSeq: number,
|
||||
limit = 50,
|
||||
): Promise<MessageWithMeta[]> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
const tx = db.transaction(STORE_MESSAGES, 'readonly');
|
||||
const index = tx.objectStore(STORE_MESSAGES).index('by_room_seq');
|
||||
|
||||
// Compound key range: roomId + any seq less than beforeSeq
|
||||
const range = IDBKeyRange.bound([roomId, 0], [roomId, beforeSeq - 1]);
|
||||
const request = index.openCursor(range, 'prev'); // 'prev' = descending seq (newest first)
|
||||
// We want oldest before `beforeSeq`, so after getting `limit` items in 'prev' order,
|
||||
// reverse to ascending seq.
|
||||
const collected: StoredMessage[] = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (cursor && collected.length < limit) {
|
||||
collected.push(cursor.value);
|
||||
cursor.continue();
|
||||
} else {
|
||||
// Reverse back to ascending order (oldest first)
|
||||
const msgs = collected.reverse().map(storedToMsg);
|
||||
resolve(msgs);
|
||||
}
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[IDB] loadOlderMessagesFromIdb failed:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the highest seq number for a room (for dedup) */
|
||||
export async function getMaxSeq(roomId: string): Promise<number> {
|
||||
try {
|
||||
|
||||
@ -166,6 +166,8 @@ export interface RoomMessagePayload {
|
||||
send_at: string;
|
||||
seq: number;
|
||||
display_name?: string;
|
||||
/** Present when this event carries reaction updates for the message */
|
||||
reactions?: ReactionItem[];
|
||||
}
|
||||
|
||||
export interface ProjectEventPayload {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user