Compare commits

..

19 Commits

Author SHA1 Message Date
ZhenYi
a171d691c6 fix(room): align ReactionGroup types with frontend and guard reaction update handler
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions
- Fix ReactionGroup.count: i64 -> i32 and users: Vec<Uuid> -> Vec<String>
  to match frontend ReactionItem (count: number, users: string[]).
  Mismatched types caused the WS reaction update to silently fail.
  Also update ReactionItem in api/ws_types.rs to match.
- Add activeRoomIdRef guard in onRoomReactionUpdated to prevent stale
  room state from processing outdated events after room switch.
- Switch from prev.map() to targeted findIndex+spread in onRoomReactionUpdated
  to avoid unnecessary array recreation.
2026-04-17 23:00:52 +08:00
ZhenYi
047782e585 fix(room): handle reaction updates in onRoomMessage WS handler
Root cause: publish_reaction_event sends a RoomMessageEvent (not
reaction_added) with reactions field set. The onRoomMessage handler
previously returned prev immediately when a duplicate ID was found,
skipping the reaction update entirely.

Fix:
- Add reactions field to RoomMessagePayload so TypeScript knows it's there
- When a duplicate message ID is found AND payload carries reactions,
  update the existing message's reactions field instead of ignoring
- ReactionItem and ReactionGroup have identical shapes, so assignment works
2026-04-17 22:37:20 +08:00
ZhenYi
0cbf6d6aa1 fix(room): accept comma-separated message_ids string in batch reaction endpoint 2026-04-17 22:28:24 +08:00
ZhenYi
7152346be8 fix(room): make reaction Popover controlled so it closes after select 2026-04-17 22:16:40 +08:00
ZhenYi
a1ddb5d5bc fix(room): add HTTP batch reactions endpoint and clean up dead code
Backend:
- Add reaction_batch handler: GET /api/rooms/{room_id}/messages/reactions/batch?message_ids=...
- Register route in libs/api/room/mod.rs
- Backend already had message_reactions_batch service method, just needed HTTP exposure

Frontend:
- Add ReactionListData import to room-context.tsx
- Fix thisLoadReactions client type (was using broken NonNullable<ReturnType<>>)
- Remove unused oldRoomId variable
- Delete unused useRoomWs.ts hook (RoomWsClient has no on/off methods)
- Remove unused EmojiPicker function and old manual overlay picker from RoomMessageBubble
- Remove unused savedToken variable in room-ws-client
2026-04-17 22:12:10 +08:00
ZhenYi
2f1ed69b31 fix(room): restore useState import removed in refactor 2026-04-17 22:02:55 +08:00
ZhenYi
ef1adb663d fix(room): replace manual emoji picker positioning with Popover
Manual getBoundingClientRect positioning caused the picker to appear at
the far right of the room and shift content. Replaced with shadcn
Popover which handles anchor positioning, flipping, and portal rendering
automatically.
2026-04-17 21:50:50 +08:00
ZhenYi
4767e1d692 fix(room): IDB load no longer waits for WS connection
loadMore(null) fires immediately — cached messages render instantly.
WS connect runs in parallel; subscribeRoom and reaction batch-fetch
use WS-first request() which falls back to HTTP transparently.
2026-04-17 21:45:55 +08:00
ZhenYi
44c9f2c772 fix(room): ensure WS connect before subscribe; reactions load for IDB path
- setup() now awaits client.connect() before subscribeRoom and loadMore,
  ensuring the connection is truly open so WS is used for both
- subscribeRoom / reactionListBatch: already WS-first via RoomWsClient.request()
- IDB paths (initial + loadMore) now call thisLoadReactions to batch-fetch
  reactions via WS with HTTP fallback, fixing the missing reactions bug
2026-04-17 21:44:45 +08:00
ZhenYi
50f9cc40fe fix(room): load reactions for IDB-cached messages via WS-first fallback
- thisLoadReactions helper: batch-fetches reactions for loaded messages
  via WS (RoomWsClient.request() does WS-first → HTTP fallback automatically)
- Called after both IDB paths (initial load + loadMore) so reactions are
  populated even when messages come from IndexedDB cache
- Also deduplicated API-path reaction loading to use the same helper
2026-04-17 21:43:47 +08:00
ZhenYi
b70d91866c fix(room-ws): try reconnect with existing token before requesting new
On auto-reconnect (scheduleReconnect), attempt connection with the stored
wsToken first. If the WS closes immediately (server rejected the token),
fall back to fetching a fresh token and retrying. Only requests a new
token when the existing one fails or when connect() is called manually
with forceNewToken=true.
2026-04-17 21:40:07 +08:00
ZhenYi
309bc50e86 perf(room): prioritize IndexedDB cache for instant loads
- Remove clearRoomMessages on room switch: IDB cache now persists across
  visits, enabling instant re-entry without API round-trip
- Increase initial API limit from 50 → 200: more messages cached upfront
- Add loadOlderMessagesFromIdb: uses 'by_room_seq' compound index to
  serve scroll-back history from IDB without API call
- loadMore now tries IDB first before falling back to API
2026-04-17 21:36:55 +08:00
ZhenYi
bab675cf60 perf(room): increase virtualizer overscan to 30 for smoother scrolling 2026-04-17 21:31:36 +08:00
ZhenYi
5cd4c66445 perf(room): simplify scroll handler and stabilize callback refs
- Remove useTransition/useDeferredValue from RoomMessageList
- Wrap component in memo to prevent unnecessary re-renders
- Use requestAnimationFrame to defer scroll state updates
- Remove isUserScrolling state (no longer needed)
- Simplify auto-scroll effect: sync distance check + RAF deferred scroll
- Add replyMap memo to decouple reply lookup from row computation
- Stabilize handleEditConfirm to depend on editingMessage?.id only
- Remove Performance Stats panel (RoomPerformanceMonitor)
2026-04-17 21:28:58 +08:00
ZhenYi
991d86237b fix: remove stale onRenderedCountChange prop from RoomMessageList usage 2026-04-17 21:23:03 +08:00
ZhenYi
70381006cf chore(room): remove Performance Stats panel
Unused debug overlay that was tracking virtualized row counts.
2026-04-17 21:21:59 +08:00
ZhenYi
f2a2ae5d7f fix(room): use WS for message fetching, eliminate duplicate WS connection
- Fix initial room load being skipped: `setup()` called `loadMoreRef.current`
  which was null on first mount (ref assigned in later effect). Call `loadMore`
  directly so the initial fetch always fires. WS message.list used when
  connected, HTTP fallback otherwise.
- Rewrite useRoomWs to use shared RoomWsClient instead of creating its own
  raw WebSocket, eliminating duplicate WS connection per room.
- Remove dead loadMoreRef now that setup calls loadMore directly.
2026-04-17 21:18:56 +08:00
ZhenYi
cf5c728286 fix(room): fix scrolling lag, N+1 queries, and multiple WS token requests
Frontend:
- P0: Replace constant estimateSize(40px) with content-based estimation
  using line count and reply presence for accurate virtual list scroll
- P1: Replace Shadow DOM custom elements with styled spans for @mentions,
  eliminating expensive attachShadow calls per mention instance
- P1: Remove per-message ResizeObserver (one per bubble), replace with
  static inline toolbar layout to avoid observer overhead
- P2: Fix WS token re-fetch on every room switch by preserving token
  across navigation and not clearing activeRoomIdRef on cleanup

Backend:
- P1: Fix reaction check+insert race condition by moving into transaction
  instead of separate query + on-conflict insert
- P2: Fix N+1 queries in get_mention_notifications with batch fetch
  for users and rooms using IN clauses
- P2: Update room_last_activity in broadcast_stream_chunk to prevent
  idle room cleanup during active AI streaming
- P3: Use enum comparison instead of to_string() in room_member_leave
2026-04-17 21:08:40 +08:00
ZhenYi
60d8c3a617 fix(room): resolve remaining defects from second review
- reaction.rs: query before insert to detect new vs duplicate reactions,
  only publish Redis event when a reaction was actually added
- room.rs: delete Redis seq key on room deletion to prevent seq
  collision on re-creation
- message.rs: use Redis-atomic next_room_message_seq_internal for
  concurrent safety; look up sender display name once for both
  mention notifications and response body; add warn log when
  should_ai_respond fails instead of silent unwrap_or(false)
- ws_universal.rs: re-check room access permission when re-subscribing
  dead streams after error to prevent revoked permissions being bypassed
- RoomChatPanel.tsx: truncate reply preview content to 80 chars
- RoomMessageList.tsx: remove redundant inline style on message row div
2026-04-17 20:28:45 +08:00
23 changed files with 597 additions and 1376 deletions

View File

@ -118,6 +118,11 @@ pub fn init_room_routes(cfg: &mut web::ServiceConfig) {
"/rooms/{room_id}/messages/{message_id}/reactions", "/rooms/{room_id}/messages/{message_id}/reactions",
web::get().to(reaction::reaction_get), web::get().to(reaction::reaction_get),
) )
// batch reactions
.route(
"/rooms/{room_id}/messages/reactions/batch",
web::get().to(reaction::reaction_batch),
)
// message search // message search
.route( .route(
"/rooms/{room_id}/messages/search", "/rooms/{room_id}/messages/search",

View File

@ -18,6 +18,12 @@ pub struct MessageSearchQuery {
pub offset: Option<u64>, pub offset: Option<u64>,
} }
#[derive(Debug, serde::Deserialize, IntoParams)]
pub struct ReactionBatchQuery {
/// Comma-separated list of message IDs
pub message_ids: String,
}
#[utoipa::path( #[utoipa::path(
post, post,
path = "/api/rooms/{room_id}/messages/{message_id}/reactions", path = "/api/rooms/{room_id}/messages/{message_id}/reactions",
@ -119,6 +125,43 @@ pub async fn reaction_get(
Ok(ApiResponse::ok(resp).to_response()) 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( #[utoipa::path(
get, get,
path = "/api/rooms/{room_id}/messages/search", path = "/api/rooms/{room_id}/messages/search",

View File

@ -449,9 +449,9 @@ impl From<room::MessageReactionsResponse> for ReactionListData {
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct ReactionItem { pub struct ReactionItem {
pub emoji: String, pub emoji: String,
pub count: i64, pub count: i32,
pub reacted_by_me: bool, pub reacted_by_me: bool,
pub users: Vec<Uuid>, pub users: Vec<String>,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]

View File

@ -196,7 +196,7 @@ pub async fn ws_universal(
let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await; let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await;
break; 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 { match push_event {
Some(WsPushEvent::RoomMessage { room_id, event }) => { Some(WsPushEvent::RoomMessage { room_id, event }) => {
let payload = serde_json::json!({ let payload = serde_json::json!({
@ -372,6 +372,7 @@ pub async fn ws_universal(
async fn poll_push_streams( async fn poll_push_streams(
streams: &mut PushStreams, streams: &mut PushStreams,
manager: &Arc<RoomConnectionManager>, manager: &Arc<RoomConnectionManager>,
service: &Arc<AppService>,
user_id: Uuid, user_id: Uuid,
) -> Option<WsPushEvent> { ) -> Option<WsPushEvent> {
loop { 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 { for room_id in dead_rooms {
if streams.remove(&room_id).is_some() { if streams.remove(&room_id).is_some() {
if let Ok(rx) = manager.subscribe(room_id, user_id).await { if service.room.check_room_access(room_id, user_id).await.is_ok() {
let stream_rx = manager.subscribe_room_stream(room_id).await; if let Ok(rx) = manager.subscribe(room_id, user_id).await {
streams.insert(room_id, ( let stream_rx = manager.subscribe_room_stream(room_id).await;
BroadcastStream::new(rx), streams.insert(room_id, (
BroadcastStream::new(stream_rx), BroadcastStream::new(rx),
)); BroadcastStream::new(stream_rx),
));
}
} }
// If access check fails, silently skip re-subscribe (user was removed)
} }
} }

View File

@ -40,9 +40,10 @@ pub struct RoomMessageEvent {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReactionGroup { pub struct ReactionGroup {
pub emoji: String, pub emoji: String,
pub count: i64, pub count: i32,
pub reacted_by_me: bool, 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 { impl From<RoomMessageEnvelope> for RoomMessageEvent {

View File

@ -590,6 +590,12 @@ impl RoomConnectionManager {
} }
pub async fn broadcast_stream_chunk(&self, event: RoomMessageStreamChunkEvent) { pub async fn broadcast_stream_chunk(&self, event: RoomMessageStreamChunkEvent) {
// Update activity tracker to prevent idle cleanup during active streaming
{
let mut activity = self.room_last_activity.write().await;
activity.insert(event.room_id, Instant::now());
}
let event = Arc::new(event); let event = Arc::new(event);
let is_final_chunk = event.done; let is_final_chunk = event.done;

View File

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

View File

@ -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( pub async fn utils_find_project_by_name(
&self, &self,
name: String, name: String,

View File

@ -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 now = Utc::now();
let id = Uuid::now_v7(); let id = Uuid::now_v7();
let project_id = room_model.project; let project_id = room_model.project;
@ -207,6 +207,17 @@ impl RoomService {
.await; .await;
let mentioned_users = self.resolve_mentions(&request.content).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 { for mentioned_user_id in mentioned_users {
if mentioned_user_id == user_id { if mentioned_user_id == user_id {
continue; continue;
@ -215,7 +226,7 @@ impl RoomService {
.notification_create(super::NotificationCreateRequest { .notification_create(super::NotificationCreateRequest {
notification_type: super::NotificationType::Mention, notification_type: super::NotificationType::Mention,
user_id: mentioned_user_id, 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()), content: Some(content.clone()),
room_id: Some(room_id), room_id: Some(room_id),
project_id, project_id,
@ -228,7 +239,13 @@ impl RoomService {
.await; .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 let is_text_message = request
.content_type .content_type
.as_ref() .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 { Ok(super::RoomMessageResponse {
id, id,
seq, seq,
room: room_id, room: room_id,
sender_type: "member".to_string(), sender_type: "member".to_string(),
sender_id: Some(user_id), sender_id: Some(user_id),
display_name, display_name: Some(sender_display_name),
thread: thread_id, thread: thread_id,
in_reply_to, in_reply_to,
content: request.content, content: request.content,

View File

@ -6,15 +6,14 @@ use models::rooms::room_message_reaction;
use models::users::user as user_model; use models::users::user as user_model;
use queue::ReactionGroup; use queue::ReactionGroup;
use sea_orm::*; use sea_orm::*;
use sea_query::OnConflict;
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] #[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
pub struct ReactionGroupResponse { pub struct ReactionGroupResponse {
pub emoji: String, pub emoji: String,
pub count: i64, pub count: i32,
pub reacted_by_me: bool, pub reacted_by_me: bool,
pub users: Vec<Uuid>, pub users: Vec<String>,
} }
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] #[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
@ -45,6 +44,21 @@ impl RoomService {
let now = Utc::now(); let now = Utc::now();
// Use a transaction to atomically check-and-insert, preventing race conditions
let txn = self.db.begin().await?;
let existing = room_message_reaction::Entity::find()
.filter(room_message_reaction::Column::Message.eq(message_id))
.filter(room_message_reaction::Column::User.eq(user_id))
.filter(room_message_reaction::Column::Emoji.eq(&emoji))
.one(&txn)
.await?;
if existing.is_some() {
txn.commit().await?;
return self.get_message_reactions(message_id, Some(user_id)).await;
}
let reaction = room_message_reaction::ActiveModel { let reaction = room_message_reaction::ActiveModel {
id: Set(Uuid::now_v7()), id: Set(Uuid::now_v7()),
room: Set(message.room), room: Set(message.room),
@ -54,37 +68,29 @@ impl RoomService {
created_at: Set(now), created_at: Set(now),
}; };
let result = room_message_reaction::Entity::insert(reaction) room_message_reaction::Entity::insert(reaction)
.on_conflict( .exec(&txn)
OnConflict::columns([ .await?;
room_message_reaction::Column::Message,
room_message_reaction::Column::User,
room_message_reaction::Column::Emoji,
])
.do_nothing()
.to_owned(),
)
.exec(&self.db)
.await;
if result.is_ok() { txn.commit().await?;
let reactions = self
.get_message_reactions(message_id, Some(user_id)) // Only publish if we actually inserted a new reaction
.await?; let reactions = self
let reaction_groups = reactions .get_message_reactions(message_id, Some(user_id))
.reactions .await?;
.into_iter() let reaction_groups = reactions
.map(|g| ReactionGroup { .reactions
emoji: g.emoji, .into_iter()
count: g.count, .map(|g| ReactionGroup {
reacted_by_me: g.reacted_by_me, emoji: g.emoji,
users: g.users, count: g.count as i32,
}) reacted_by_me: g.reacted_by_me,
.collect(); users: g.users.into_iter().map(|u| u.to_string()).collect(),
self.queue })
.publish_reaction_event(message.room, message_id, reaction_groups) .collect();
.await; self.queue
} .publish_reaction_event(message.room, message_id, reaction_groups)
.await;
self.get_message_reactions(message_id, Some(user_id)).await self.get_message_reactions(message_id, Some(user_id)).await
} }
@ -115,9 +121,9 @@ impl RoomService {
.into_iter() .into_iter()
.map(|g| ReactionGroup { .map(|g| ReactionGroup {
emoji: g.emoji, emoji: g.emoji,
count: g.count, count: g.count as i32,
reacted_by_me: g.reacted_by_me, reacted_by_me: g.reacted_by_me,
users: g.users, users: g.users.into_iter().map(|u| u.to_string()).collect(),
}) })
.collect(); .collect();
self.queue self.queue
@ -252,11 +258,15 @@ impl RoomService {
grouped grouped
.into_iter() .into_iter()
.map(|(emoji, user_reactions)| { .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 let reacted_by_me = current_user_id
.map(|uid| user_reactions.iter().any(|r| r.user == uid)) .map(|uid| user_reactions.iter().any(|r| r.user == uid))
.unwrap_or(false); .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 { ReactionGroupResponse {
emoji, emoji,

View File

@ -273,6 +273,19 @@ impl RoomService {
self.room_manager.shutdown_room(room_id).await; 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 { let event = ProjectRoomEvent {
event_type: super::RoomEventType::RoomDeleted.as_str().into(), event_type: super::RoomEventType::RoomDeleted.as_str().into(),
project_id, project_id,

View File

@ -256,7 +256,7 @@ impl RoomService {
.await? .await?
.ok_or_else(|| RoomError::NotFound("You are not a member of this room".to_string()))?; .ok_or_else(|| RoomError::NotFound("You are not a member of this room".to_string()))?;
if member.role.to_string() == "owner" { if member.role == models::rooms::RoomMemberRole::Owner {
return Err(RoomError::BadRequest( return Err(RoomError::BadRequest(
"Owner cannot leave the room. Transfer ownership first.".to_string(), "Owner cannot leave the room. Transfer ownership first.".to_string(),
)); ));

View File

@ -1112,7 +1112,7 @@ impl RoomService {
Vec::new() Vec::new()
} }
async fn next_room_message_seq_internal( pub(crate) async fn next_room_message_seq_internal(
room_id: Uuid, room_id: Uuid,
db: &AppDatabase, db: &AppDatabase,
cache: &AppCache, cache: &AppCache,

View File

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

View File

@ -22,7 +22,6 @@ import { RoomMessageEditHistoryDialog } from './RoomMessageEditHistoryDialog';
import { RoomMessageList } from './RoomMessageList'; import { RoomMessageList } from './RoomMessageList';
import { RoomParticipantsPanel } from './RoomParticipantsPanel'; import { RoomParticipantsPanel } from './RoomParticipantsPanel';
import { RoomSettingsPanel } from './RoomSettingsPanel'; import { RoomSettingsPanel } from './RoomSettingsPanel';
import { RoomPerformanceMonitor } from './RoomPerformanceMonitor';
import { RoomMessageSearch } from './RoomMessageSearch'; import { RoomMessageSearch } from './RoomMessageSearch';
import { RoomMentionPanel } from './RoomMentionPanel'; import { RoomMentionPanel } from './RoomMentionPanel';
import { RoomThreadPanel } from './RoomThreadPanel'; import { RoomThreadPanel } from './RoomThreadPanel';
@ -131,7 +130,7 @@ const ChatInputArea = memo(function ChatInputArea({
{replyingTo && ( {replyingTo && (
<div className="mb-2 flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2 text-xs"> <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="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"> <button onClick={onCancelReply} className="ml-auto text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
@ -262,7 +261,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
const [showMentions, setShowMentions] = useState(false); const [showMentions, setShowMentions] = useState(false);
const [isUpdatingRoom, setIsUpdatingRoom] = useState(false); const [isUpdatingRoom, setIsUpdatingRoom] = useState(false);
const [activeThread, setActiveThread] = useState<{ thread: RoomThreadResponse; parentMessage: MessageWithMeta } | null>(null); const [activeThread, setActiveThread] = useState<{ thread: RoomThreadResponse; parentMessage: MessageWithMeta } | null>(null);
const [renderedMessageCount, setRenderedMessageCount] = useState<number | undefined>(undefined);
// Draft management // Draft management
const { draft, setDraft, clearDraft } = useRoomDraft(room.id); 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); sendMessage(content, 'text', replyingTo?.id ?? undefined);
setReplyingTo(null); setReplyingTo(null);
}, },
// sendMessage from useRoom is already stable; replyingTo changes trigger handleSend rebuild (acceptable)
[sendMessage, replyingTo], [sendMessage, replyingTo],
); );
@ -310,7 +309,8 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
setEditingMessage(null); setEditingMessage(null);
toast.success('Message updated'); toast.success('Message updated');
}, },
[editingMessage, editMessage], // Only rebuild when editingMessage.id actually changes, not on every new message
[editingMessage?.id, editMessage],
); );
const handleRevoke = useCallback( const handleRevoke = useCallback(
@ -321,6 +321,7 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
[revokeMessage], [revokeMessage],
); );
// Stable: chatInputRef is stable, no deps that change on message updates
const handleMention = useCallback((name: string, type: 'user' | 'ai') => { const handleMention = useCallback((name: string, type: 'user' | 'ai') => {
chatInputRef.current?.insertMention(name, type); chatInputRef.current?.insertMention(name, type);
}, []); }, []);
@ -520,7 +521,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
onMention={handleMention} onMention={handleMention}
onOpenThread={handleOpenThread} onOpenThread={handleOpenThread}
onCreateThread={handleCreateThread} onCreateThread={handleCreateThread}
onRenderedCountChange={setRenderedMessageCount}
/> />
</div> </div>
@ -604,7 +604,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
roomId={room.id} roomId={room.id}
/> />
<RoomPerformanceMonitor messageCount={messages.length} renderedCount={renderedMessageCount} />
</section> </section>
); );
} }

View File

@ -6,9 +6,10 @@ import { parseFunctionCalls, type FunctionCall } from '@/lib/functionCallParser'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { AlertCircle, AlertTriangle, ChevronDown, ChevronUp, Copy, Edit2, Reply as ReplyIcon, Trash2, History, MoreHorizontal, MessageSquare } from 'lucide-react'; 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { SmilePlus } from 'lucide-react'; import { SmilePlus } from 'lucide-react';
import { useUser, useRoom } from '@/contexts'; import { useUser, useRoom } from '@/contexts';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useMemo, useState, useCallback, useRef } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ModelIcon } from './icon-match'; import { ModelIcon } from './icon-match';
import { FunctionCallBadge } from './FunctionCallBadge'; import { FunctionCallBadge } from './FunctionCallBadge';
@ -80,10 +81,7 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
const [editContent, setEditContent] = useState(message.content); const [editContent, setEditContent] = useState(message.content);
const [isSavingEdit, setIsSavingEdit] = useState(false); const [isSavingEdit, setIsSavingEdit] = useState(false);
const [showReactionPicker, setShowReactionPicker] = useState(false); const [showReactionPicker, setShowReactionPicker] = useState(false);
const [isNarrow, setIsNarrow] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const reactionButtonRef = useRef<HTMLButtonElement>(null);
const [reactionPickerPosition, setReactionPickerPosition] = useState<{ top: number; left: number } | null>(null);
const isAi = ['ai', 'system', 'tool'].includes(message.sender_type); const isAi = ['ai', 'system', 'tool'].includes(message.sender_type);
const isSystem = message.sender_type === 'system'; const isSystem = message.sender_type === 'system';
@ -107,19 +105,8 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
? streamingMessages.get(message.id)! ? streamingMessages.get(message.id)!
: message.content; : message.content;
// Detect narrow container width // Detect narrow container width using CSS container query instead of ResizeObserver
useEffect(() => { // The .group/narrow class on the container enables CSS container query support
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
// Collapse toolbar when container < 300px
setIsNarrow(entry.contentRect.width < 300);
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const handleReaction = useCallback(async (emoji: string) => { const handleReaction = useCallback(async (emoji: string) => {
if (!wsClient) return; if (!wsClient) return;
@ -136,17 +123,6 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
setShowReactionPicker(false); setShowReactionPicker(false);
}, [roomId, message.id, message.reactions, wsClient]); }, [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[]>( const functionCalls = useMemo<FunctionCall[]>(
() => () =>
message.content_type === 'text' || message.content_type === 'Text' message.content_type === 'text' || message.content_type === 'Text'
@ -416,212 +392,116 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
</div> </div>
)} )}
{/* Action toolbar - inline icons when wide, collapsed to dropdown when narrow */} {/* Action toolbar - inline icon buttons */}
{!isEditing && !isRevoked && !isPending && ( {!isEditing && !isRevoked && !isPending && (
<div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{isNarrow ? ( {/* Add reaction */}
/* Narrow: all actions in dropdown */ <Popover open={showReactionPicker} onOpenChange={setShowReactionPicker}>
<DropdownMenu> <PopoverTrigger
<DropdownMenuTrigger render={
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 <Button
key={emoji}
variant="ghost" variant="ghost"
size="sm" size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground" onClick={() => handleReaction(emoji)}
title="More actions" className="size-7 p-0 text-base hover:bg-accent"
title={emoji}
> >
<MoreHorizontal className="size-3.5" /> {emoji}
</Button> </Button>
} ))}
/> </div>
<DropdownMenuContent align="end"> </PopoverContent>
<DropdownMenuItem </Popover>
onSelect={(e) => { {/* Reply */}
e.preventDefault(); {onReply && (
setShowReactionPicker(true); <Button
}} variant="ghost"
> size="sm"
<SmilePlus className="mr-2 size-4" /> className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
Add reaction onClick={() => onReply(message)}
</DropdownMenuItem> title="Reply"
{onReply && ( >
<DropdownMenuItem onClick={() => onReply(message)}> <ReplyIcon className="size-3.5" />
<ReplyIcon className="mr-2 size-4" /> </Button>
Reply
</DropdownMenuItem>
)}
{onCreateThread && !message.thread_id && (
<DropdownMenuItem onClick={() => onCreateThread(message)}>
<MessageSquare className="mr-2 size-4" />
Create thread
</DropdownMenuItem>
)}
{message.content_type === 'text' && (
<DropdownMenuItem
onClick={async () => {
try {
await navigator.clipboard.writeText(message.content);
toast.success('Message copied');
} catch {
toast.error('Failed to copy');
}
}}
>
<Copy className="mr-2 size-4" />
Copy
</DropdownMenuItem>
)}
{message.edited_at && onViewHistory && (
<DropdownMenuItem onClick={() => onViewHistory(message)}>
<History className="mr-2 size-4" />
View edit history
</DropdownMenuItem>
)}
{isOwner && message.content_type === 'text' && (
<DropdownMenuItem onClick={handleStartEdit}>
<Edit2 className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}
{isOwner && onRevoke && (
<DropdownMenuItem onClick={() => onRevoke(message)} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
/* Wide: inline icon buttons */
<>
{/* Add reaction */}
<Button
variant="ghost"
size="sm"
ref={reactionButtonRef}
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={handleOpenReactionPicker}
title="Add reaction"
>
<SmilePlus className="size-3.5" />
</Button>
{/* Reply */}
{onReply && (
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => onReply(message)}
title="Reply"
>
<ReplyIcon className="size-3.5" />
</Button>
)}
{/* Copy */}
{message.content_type === 'text' && (
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={async () => {
try {
await navigator.clipboard.writeText(message.content);
toast.success('Message copied');
} catch {
toast.error('Failed to copy');
}
}}
title="Copy"
>
<Copy className="size-3.5" />
</Button>
)}
{/* More menu */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
title="More"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
{onCreateThread && !message.thread_id && (
<DropdownMenuItem onClick={() => onCreateThread(message)}>
<MessageSquare className="mr-2 size-4" />
Create thread
</DropdownMenuItem>
)}
{message.edited_at && onViewHistory && (
<DropdownMenuItem onClick={() => onViewHistory(message)}>
<History className="mr-2 size-4" />
View edit history
</DropdownMenuItem>
)}
{isOwner && message.content_type === 'text' && (
<DropdownMenuItem onClick={handleStartEdit}>
<Edit2 className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}
{isOwner && onRevoke && (
<DropdownMenuItem onClick={() => onRevoke(message)} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)} )}
{/* Copy */}
{message.content_type === 'text' && (
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={async () => {
try {
await navigator.clipboard.writeText(message.content);
toast.success('Message copied');
} catch {
toast.error('Failed to copy');
}
}}
title="Copy"
>
<Copy className="size-3.5" />
</Button>
)}
{/* More menu */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
title="More"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
{onCreateThread && !message.thread_id && (
<DropdownMenuItem onClick={() => onCreateThread(message)}>
<MessageSquare className="mr-2 size-4" />
Create thread
</DropdownMenuItem>
)}
{message.edited_at && onViewHistory && (
<DropdownMenuItem onClick={() => onViewHistory(message)}>
<History className="mr-2 size-4" />
View edit history
</DropdownMenuItem>
)}
{isOwner && message.content_type === 'text' && (
<DropdownMenuItem onClick={handleStartEdit}>
<Edit2 className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}
{isOwner && onRevoke && (
<DropdownMenuItem onClick={() => onRevoke(message)} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
)} )}
{/* 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> </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>
);
}

View File

@ -1,7 +1,7 @@
import type { MessageWithMeta } from '@/contexts'; import type { MessageWithMeta } from '@/contexts';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ArrowDown, Loader2 } from 'lucide-react'; 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 { useVirtualizer } from '@tanstack/react-virtual';
import { RoomMessageBubble } from './RoomMessageBubble'; import { RoomMessageBubble } from './RoomMessageBubble';
import { getSenderModelId } from './sender'; import { getSenderModelId } from './sender';
@ -27,8 +27,6 @@ interface RoomMessageListProps {
isLoadingMore?: boolean; isLoadingMore?: boolean;
onOpenThread?: (message: MessageWithMeta) => void; onOpenThread?: (message: MessageWithMeta) => void;
onCreateThread?: (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 { interface MessageRow {
@ -37,7 +35,6 @@ interface MessageRow {
message?: MessageWithMeta; message?: MessageWithMeta;
grouped?: boolean; grouped?: boolean;
replyMessage?: MessageWithMeta | null; replyMessage?: MessageWithMeta | null;
/** Unique key for the virtualizer */
key: string; key: string;
} }
@ -74,12 +71,20 @@ function getSenderKey(message: MessageWithMeta): string {
return `sender:${message.sender_type}`; return `sender:${message.sender_type}`;
} }
/** Estimated height for a message row in pixels (used as initial guess before measurement) */ /** Estimate message row height based on content characteristics */
const ESTIMATED_ROW_HEIGHT = 40; function estimateMessageRowHeight(message: MessageWithMeta): number {
/** Estimated height for a date divider row in pixels */ 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; const ESTIMATED_DIVIDER_HEIGHT = 30;
export const RoomMessageList = memo(function RoomMessageList({ const RoomMessageListInner = memo(function RoomMessageListInner({
roomId, roomId,
messages, messages,
messagesEndRef, messagesEndRef,
@ -94,39 +99,32 @@ export const RoomMessageList = memo(function RoomMessageList({
isLoadingMore = false, isLoadingMore = false,
onOpenThread, onOpenThread,
onCreateThread, onCreateThread,
onRenderedCountChange,
}: RoomMessageListProps) { }: RoomMessageListProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const topSentinelRef = useRef<HTMLDivElement>(null); const topSentinelRef = useRef<HTMLDivElement>(null);
const prevScrollHeightRef = useRef<number | null>(null); const prevScrollHeightRef = useRef<number | null>(null);
const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [isUserScrolling, setIsUserScrolling] = useState(false);
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isRestoringScrollRef = useRef(false); 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); const firstVisibleMessageIdRef = useRef<string | null>(null);
// Defer messages so React can prioritize scroll/interaction state updates. // Build reply lookup map (stable reference, recomputes only when messages change)
// When messages arrive rapidly (e.g. WS stream), React renders the deferred const replyMap = useMemo(() => {
// version in a lower-priority work window, preventing scroll jank.
const deferredMessages = useDeferredValue(messages);
const messageMap = useMemo(() => {
const map = new Map<string, MessageWithMeta>(); 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; return map;
}, [deferredMessages]); }, [messages]);
// Build rows: date dividers + 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 rows = useMemo<MessageRow[]>(() => {
const result: MessageRow[] = []; const result: MessageRow[] = [];
let lastDateKey: string | null = null; let lastDateKey: string | null = null;
let lastSenderKey: 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 dateKey = getDateKey(message.send_at);
const senderKey = getSenderKey(message); const senderKey = getSenderKey(message);
@ -145,36 +143,42 @@ export const RoomMessageList = memo(function RoomMessageList({
type: 'message', type: 'message',
message, message,
grouped, 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, key: message.id,
}); });
lastSenderKey = senderKey; lastSenderKey = senderKey;
} }
return result; return result;
}, [deferredMessages, messageMap]); }, [messages, replyMap]);
const scrollToBottom = useCallback((smooth = true) => { const scrollToBottom = useCallback((smooth = true) => {
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' }); messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
}, [messagesEndRef]); }, [messagesEndRef]);
// Track user scroll to detect if user is at bottom. // Lightweight scroll handler: only update the "show" flag, decoupled from layout reads
// Wrapped in startTransition so React knows these state updates are non-urgent // Reads scroll position synchronously but defers state updates so browser can paint first.
// and can be interrupted if a higher-priority update (e.g., new message) comes in.
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
if (!container) return; if (!container) return;
// Synchronous read of scroll position (triggers layout, unavoidable)
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
const nearBottom = distanceFromBottom < 100; const nearBottom = distanceFromBottom < 100;
startScrollTransition(() => { // Update state asynchronously — browser can process other frames before committing
requestAnimationFrame(() => {
setShowScrollToBottom(!nearBottom); setShowScrollToBottom(!nearBottom);
setIsUserScrolling(true);
}); });
// Reset user scrolling flag after a delay // Reset user-scrolling flag after delay
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = setTimeout(() => { 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); }, 500);
}, []); }, []);
@ -188,30 +192,34 @@ export const RoomMessageList = memo(function RoomMessageList({
}; };
}, [handleScroll]); }, [handleScroll]);
// Auto-scroll to bottom when new messages arrive (only if user was already at bottom). // Auto-scroll when new messages arrive (only if user was already at bottom)
// Uses deferredMessages.length so auto-scroll waits for the deferred render to settle.
useEffect(() => { useEffect(() => {
if (!isUserScrolling && deferredMessages.length > 0) { if (messages.length === 0) return;
scrollToBottom(false); // 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({ const virtualizer = useVirtualizer({
count: rows.length, count: rows.length,
getScrollElement: () => scrollContainerRef.current, getScrollElement: () => scrollContainerRef.current,
estimateSize: (index) => { estimateSize: (index) => {
const row = rows[index]; const row = rows[index];
if (row?.type === 'divider') return ESTIMATED_DIVIDER_HEIGHT; if (row?.type === 'divider') return ESTIMATED_DIVIDER_HEIGHT;
return ESTIMATED_ROW_HEIGHT; if (row?.type === 'message' && row.message) return estimateMessageRowHeight(row.message);
return 60;
}, },
overscan: 5, overscan: 30,
gap: 0, gap: 0,
}); });
const virtualItems = virtualizer.getVirtualItems(); const virtualItems = virtualizer.getVirtualItems();
// IntersectionObserver for load more (only when scrolled to top) // IntersectionObserver for load more
useEffect(() => { useEffect(() => {
const sentinel = topSentinelRef.current; const sentinel = topSentinelRef.current;
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
@ -220,16 +228,12 @@ export const RoomMessageList = memo(function RoomMessageList({
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0]?.isIntersecting && !isLoadingMore && hasMore) { if (entries[0]?.isIntersecting && !isLoadingMore && hasMore) {
const scrollContainer = scrollContainerRef.current; prevScrollHeightRef.current = container.scrollHeight;
if (scrollContainer) { const items = virtualizer.getVirtualItems();
prevScrollHeightRef.current = scrollContainer.scrollHeight; if (items.length > 0) {
// Record the ID of the first visible message const firstVisibleRow = rows[items[0].index];
const virtualItems = virtualizer.getVirtualItems(); if (firstVisibleRow?.type === 'message' && firstVisibleRow.message) {
if (virtualItems.length > 0) { firstVisibleMessageIdRef.current = firstVisibleRow.message.id;
const firstVisibleRow = rows[virtualItems[0].index];
if (firstVisibleRow?.type === 'message' && firstVisibleRow.message) {
firstVisibleMessageIdRef.current = firstVisibleRow.message.id;
}
} }
} }
isRestoringScrollRef.current = true; isRestoringScrollRef.current = true;
@ -241,28 +245,23 @@ export const RoomMessageList = memo(function RoomMessageList({
observer.observe(sentinel); observer.observe(sentinel);
return () => observer.disconnect(); return () => observer.disconnect();
}, [onLoadMore, hasMore, isLoadingMore, rows]); }, [onLoadMore, hasMore, isLoadingMore, rows, virtualizer]);
// Maintain scroll position after loading more messages // Maintain scroll position after loading more messages
useEffect(() => { useEffect(() => {
// Only run this effect when we're restoring scroll from a load-more operation
if (!isRestoringScrollRef.current) return; if (!isRestoringScrollRef.current) return;
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
if (!container || prevScrollHeightRef.current === null) { if (!container || prevScrollHeightRef.current === null) {
isRestoringScrollRef.current = false; isRestoringScrollRef.current = false;
return; return;
} }
const newScrollHeight = container.scrollHeight; const delta = container.scrollHeight - prevScrollHeightRef.current;
const delta = newScrollHeight - prevScrollHeightRef.current;
// Method 1: Try to find the previously recorded first visible message
if (firstVisibleMessageIdRef.current) { if (firstVisibleMessageIdRef.current) {
const messageElement = container.querySelector(`[data-message-id="${firstVisibleMessageIdRef.current}"]`); const el = container.querySelector(`[data-message-id="${firstVisibleMessageIdRef.current}"]`);
if (messageElement) { if (el) {
// Use scrollIntoView to precisely scroll to the previously visible message el.scrollIntoView({ block: 'start' });
messageElement.scrollIntoView({ block: 'start' });
prevScrollHeightRef.current = null; prevScrollHeightRef.current = null;
firstVisibleMessageIdRef.current = null; firstVisibleMessageIdRef.current = null;
isRestoringScrollRef.current = false; 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; prevScrollHeightRef.current = null;
firstVisibleMessageIdRef.current = null; firstVisibleMessageIdRef.current = null;
isRestoringScrollRef.current = false; 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) { if (messages.length === 0) {
return ( return (
<div className="flex flex-1 items-center justify-center px-6 py-12"> <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%', width: '100%',
}} }}
> >
{/* Top sentinel for load more */}
<div ref={topSentinelRef} className="absolute top-0 h-1 w-full" /> <div ref={topSentinelRef} className="absolute top-0 h-1 w-full" />
{isLoadingMore && ( {isLoadingMore && (
@ -345,12 +334,8 @@ export const RoomMessageList = memo(function RoomMessageList({
return ( return (
<div <div
key={row.key} key={row.key}
className="absolute left-0 w-full" className="absolute left-0 top-0 w-full"
style={{ style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
}} }}
> >
@ -381,7 +366,6 @@ export const RoomMessageList = memo(function RoomMessageList({
); );
})} })}
{/* Bottom sentinel for auto-scroll */}
<div <div
ref={messagesEndRef} ref={messagesEndRef}
className="absolute left-0 w-full" className="absolute left-0 w-full"
@ -407,3 +391,6 @@ export const RoomMessageList = memo(function RoomMessageList({
</div> </div>
); );
}); });
export { RoomMessageListInner };
export const RoomMessageList = RoomMessageListInner;

View File

@ -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>
);
}

View File

@ -25,6 +25,7 @@ import {
type RoomMessagePayload, type RoomMessagePayload,
type RoomCategoryResponse as WsRoomCategoryResponse, type RoomCategoryResponse as WsRoomCategoryResponse,
type RoomReactionUpdatedPayload, type RoomReactionUpdatedPayload,
type ReactionListData,
} from '@/lib/room-ws-client'; } from '@/lib/room-ws-client';
import { requestWsToken } from '@/lib/ws-token'; import { requestWsToken } from '@/lib/ws-token';
import { useUser } from '@/contexts'; import { useUser } from '@/contexts';
@ -32,8 +33,8 @@ import {
saveMessage, saveMessage,
saveMessages, saveMessages,
loadMessages as loadMessagesFromIdb, loadMessages as loadMessagesFromIdb,
loadOlderMessagesFromIdb,
deleteMessage as deleteMessageFromIdb, deleteMessage as deleteMessageFromIdb,
clearRoomMessages,
} from '@/lib/storage/indexed-db'; } from '@/lib/storage/indexed-db';
export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client'; export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client';
@ -221,7 +222,6 @@ export function RoomProvider({
useEffect(() => { useEffect(() => {
if (prevRoomIdRef.current !== activeRoomId) { if (prevRoomIdRef.current !== activeRoomId) {
const oldRoomId = prevRoomIdRef.current;
prevRoomIdRef.current = activeRoomId; prevRoomIdRef.current = activeRoomId;
loadMessagesAbortRef.current?.abort(); loadMessagesAbortRef.current?.abort();
loadMessagesAbortRef.current = null; loadMessagesAbortRef.current = null;
@ -231,37 +231,68 @@ export function RoomProvider({
setMessages([]); setMessages([]);
setIsHistoryLoaded(false); setIsHistoryLoaded(false);
setNextCursor(null); setNextCursor(null);
// Clear old room's IDB cache asynchronously (fire and forget) // NOTE: intentionally NOT clearing IndexedDB — keeping it enables instant
if (oldRoomId) { // load when the user returns to this room without waiting for API.
clearRoomMessages(oldRoomId).catch(() => {});
}
} }
}, [activeRoomId]); }, [activeRoomId]);
const loadMoreRef = useRef<((cursor?: number | null) => Promise<void>) | null>(null);
useEffect(() => { useEffect(() => {
const client = wsClientRef.current; const client = wsClientRef.current;
if (!activeRoomId || !client) return; if (!activeRoomId || !client) return;
const setup = async () => { const setup = async () => {
if (client.getStatus() !== 'open') { // IDB load does NOT need WS — show cached messages immediately.
await client.connect(); // loadMore checks IDB first, then falls back to API (WS-first + HTTP).
} loadMore(null);
// Re-check: activeRoomId may have changed while we were waiting for connect.
// Use activeRoomIdRef to get the *current* room, not the stale closure value. // Connect WS in parallel for real-time push + reactions batch-fetch.
const roomId = activeRoomIdRef.current; // connect() is idempotent — no-op if already connecting/open.
if (!roomId) return; // subscribeRoom uses WS-first request() with HTTP fallback.
await client.subscribeRoom(roomId); await client.connect();
loadMoreRef.current?.(null); if (activeRoomIdRef.current !== activeRoomId) return;
client.subscribeRoom(activeRoomId).catch(() => {});
}; };
setup(); setup().catch(() => {});
return () => { return () => {
client.unsubscribeRoom(activeRoomId).catch(() => {}); client.unsubscribeRoom(activeRoomId).catch(() => {});
}; };
}, [activeRoomId, wsClient]); }, [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( const loadMore = useCallback(
async (cursor?: number | null) => { async (cursor?: number | null) => {
const client = wsClientRef.current; const client = wsClientRef.current;
@ -274,31 +305,60 @@ export function RoomProvider({
setIsLoadingMore(true); setIsLoadingMore(true);
try { try {
// Initial load: check IndexedDB first for fast render const isInitial = cursor === null || cursor === undefined;
if (cursor === null || cursor === undefined) { const limit = isInitial ? 200 : 50;
// --- Initial load: try IndexedDB first for instant render ---
if (isInitial) {
const cached = await loadMessagesFromIdb(activeRoomId); const cached = await loadMessagesFromIdb(activeRoomId);
if (cached.length > 0) { if (cached.length > 0) {
setMessages(cached); setMessages(cached);
setIsTransitioningRoom(false); setIsTransitioningRoom(false);
// Derive cursor from IDB data (oldest message's seq = cursor)
const minSeq = cached[0].seq; const minSeq = cached[0].seq;
setNextCursor(minSeq > 0 ? minSeq - 1 : null); 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); 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; 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, { const resp = await client.messageList(activeRoomId, {
beforeSeq: cursor ?? undefined, beforeSeq: cursor ?? undefined,
limit: 50, limit,
}); });
if (abortController.signal.aborted) { if (abortController.signal.aborted) return;
return;
}
const newMessages = resp.messages.map((m) => ({ const newMessages = resp.messages.map((m) => ({
...m, ...m,
@ -308,17 +368,11 @@ export function RoomProvider({
})); }));
setMessages((prev) => { setMessages((prev) => {
// Double-check room hasn't changed if (abortController.signal.aborted) return prev;
if (abortController.signal.aborted) { if (isInitial) {
return prev;
}
// If initial load (cursor=null), replace instead of merge (room switching)
if (cursor === null || cursor === undefined) {
// Clear transitioning state
setIsTransitioningRoom(false); setIsTransitioningRoom(false);
return newMessages; return newMessages;
} }
// loadMore: prepend older messages before existing
const existingIds = new Set(prev.map((m) => m.id)); const existingIds = new Set(prev.map((m) => m.id));
const filtered = newMessages.filter((m) => !existingIds.has(m.id)); const filtered = newMessages.filter((m) => !existingIds.has(m.id));
let merged = [...filtered, ...prev]; let merged = [...filtered, ...prev];
@ -329,44 +383,19 @@ export function RoomProvider({
return merged; return merged;
}); });
// Persist new messages to IndexedDB
if (newMessages.length > 0) { if (newMessages.length > 0) {
saveMessages(activeRoomId, newMessages).catch(() => {}); saveMessages(activeRoomId, newMessages).catch(() => {});
} }
if (resp.messages.length < 50) { if (resp.messages.length < limit) {
setIsHistoryLoaded(true); 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); 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) // Fetch reactions for all loaded messages (WS-first with HTTP fallback)
const msgIds = newMessages.map((m) => m.id); thisLoadReactions(activeRoomId, client, newMessages);
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
}
}
} catch (error) { } catch (error) {
// Ignore abort errors if (abortController.signal.aborted) return;
if (abortController.signal.aborted) {
return;
}
handleRoomError('Load messages', error); handleRoomError('Load messages', error);
} finally { } finally {
setIsLoadingMore(false); setIsLoadingMore(false);
@ -376,10 +405,6 @@ export function RoomProvider({
[activeRoomId], [activeRoomId],
); );
useEffect(() => {
loadMoreRef.current = loadMore;
}, [loadMore]);
const [members, setMembers] = useState<RoomMemberResponse[]>([]); const [members, setMembers] = useState<RoomMemberResponse[]>([]);
const [membersLoading, setMembersLoading] = useState(false); const [membersLoading, setMembersLoading] = useState(false);
@ -399,8 +424,20 @@ export function RoomProvider({
// Use ref to get current activeRoomId to avoid stale closure // Use ref to get current activeRoomId to avoid stale closure
if (payload.room_id === activeRoomIdRef.current) { if (payload.room_id === activeRoomIdRef.current) {
setMessages((prev) => { setMessages((prev) => {
// Deduplicate by both ID (for normal) and seq (for optimistic replacement) // Check if this is a reaction-update event (same ID, different reactions).
if (prev.some((m) => m.id === payload.id)) { // 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; return prev;
} }
// Also check if there's an optimistic message with the same seq that should be replaced // 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) => { 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) => { setMessages((prev) => {
const updated = prev.map((m) => const existingIdx = prev.findIndex((m) => m.id === payload.message_id);
m.id === payload.message_id if (existingIdx === -1) return prev;
? { ...m, reactions: payload.reactions } const updated = [...prev];
: m, updated[existingIdx] = { ...updated[existingIdx], reactions: payload.reactions };
);
// Persist reaction update to IndexedDB // Persist reaction update to IndexedDB
const msg = updated.find((m) => m.id === payload.message_id); saveMessage(updated[existingIdx]).catch(() => {});
if (msg) saveMessage(msg).catch(() => {});
return updated; return updated;
}); });
}, },

View File

@ -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],
);
}

View File

@ -133,7 +133,7 @@ export class RoomWsClient {
return new Set(this.subscribedProjects); return new Set(this.subscribedProjects);
} }
async connect(): Promise<void> { async connect(forceNewToken = false): Promise<void> {
if (this.ws && this.status === 'open') { if (this.ws && this.status === 'open') {
return; return;
} }
@ -141,28 +141,31 @@ export class RoomWsClient {
this.shouldReconnect = true; this.shouldReconnect = true;
this.setStatus('connecting'); this.setStatus('connecting');
// Fetch a fresh token for each connection attempt (backend consumes token on use) // Fetch a fresh token unless we have a valid existing one and not forcing.
try { // When forceNewToken=false (reconnect path), try existing token first.
const tokenResp = await fetch(`${this.baseUrl}/api/ws/token`, { if (forceNewToken || !this.wsToken) {
method: 'POST', try {
credentials: 'include', const tokenResp = await fetch(`${this.baseUrl}/api/ws/token`, {
}); method: 'POST',
if (!tokenResp.ok) { credentials: 'include',
const text = await tokenResp.text().catch(() => ''); });
console.error(`[RoomWs] Token fetch failed: ${tokenResp.status} ${tokenResp.statusText}${text}`); if (!tokenResp.ok) {
throw new Error(`Token fetch failed: ${tokenResp.status}`); 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(); const wsUrl = this.buildWsUrl();
@ -172,6 +175,12 @@ export class RoomWsClient {
// Guard: if ws is closed before handlers are set, skip // Guard: if ws is closed before handlers are set, skip
if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) { if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
console.warn('[RoomWs] WebSocket closed immediately'); 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; return;
} }

View File

@ -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) */ /** Get the highest seq number for a room (for dedup) */
export async function getMaxSeq(roomId: string): Promise<number> { export async function getMaxSeq(roomId: string): Promise<number> {
try { try {

View File

@ -166,6 +166,8 @@ export interface RoomMessagePayload {
send_at: string; send_at: string;
seq: number; seq: number;
display_name?: string; display_name?: string;
/** Present when this event carries reaction updates for the message */
reactions?: ReactionItem[];
} }
export interface ProjectEventPayload { export interface ProjectEventPayload {