Compare commits
19 Commits
5256e72be7
...
a171d691c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a171d691c6 | ||
|
|
047782e585 | ||
|
|
0cbf6d6aa1 | ||
|
|
7152346be8 | ||
|
|
a1ddb5d5bc | ||
|
|
2f1ed69b31 | ||
|
|
ef1adb663d | ||
|
|
4767e1d692 | ||
|
|
44c9f2c772 | ||
|
|
50f9cc40fe | ||
|
|
b70d91866c | ||
|
|
309bc50e86 | ||
|
|
bab675cf60 | ||
|
|
5cd4c66445 | ||
|
|
991d86237b | ||
|
|
70381006cf | ||
|
|
f2a2ae5d7f | ||
|
|
cf5c728286 | ||
|
|
60d8c3a617 |
@ -118,6 +118,11 @@ pub fn init_room_routes(cfg: &mut web::ServiceConfig) {
|
|||||||
"/rooms/{room_id}/messages/{message_id}/reactions",
|
"/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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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)]
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -590,6 +590,12 @@ impl RoomConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn broadcast_stream_chunk(&self, event: RoomMessageStreamChunkEvent) {
|
pub async fn broadcast_stream_chunk(&self, event: RoomMessageStreamChunkEvent) {
|
||||||
|
// Update activity tracker to prevent idle cleanup during active streaming
|
||||||
|
{
|
||||||
|
let mut activity = self.room_last_activity.write().await;
|
||||||
|
activity.insert(event.room_id, Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
let event = Arc::new(event);
|
let event = Arc::new(event);
|
||||||
let is_final_chunk = event.done;
|
let is_final_chunk = event.done;
|
||||||
|
|
||||||
|
|||||||
@ -124,44 +124,73 @@ impl RoomService {
|
|||||||
.all(&self.db)
|
.all(&self.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut result = Vec::new();
|
// Batch fetch related users to avoid N+1 queries
|
||||||
for notification in notifications {
|
let related_user_ids: Vec<Uuid> = notifications
|
||||||
let mentioned_by =
|
.iter()
|
||||||
user_model::Entity::find_by_id(notification.related_user_id.unwrap_or_default())
|
.filter_map(|n| n.related_user_id)
|
||||||
.one(&self.db)
|
.collect();
|
||||||
.await?;
|
let users: std::collections::HashMap<Uuid, String> = if !related_user_ids.is_empty() {
|
||||||
|
user_model::Entity::find()
|
||||||
|
.filter(user_model::Column::Uid.is_in(related_user_ids))
|
||||||
|
.all(&self.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|u| (u.uid, u.display_name.unwrap_or(u.username)))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
std::collections::HashMap::new()
|
||||||
|
};
|
||||||
|
|
||||||
let room_name = if let Some(room_id) = notification.room {
|
// Batch fetch room names to avoid N+1 queries
|
||||||
models::rooms::room::Entity::find_by_id(room_id)
|
let room_ids: Vec<Uuid> = notifications
|
||||||
.one(&self.db)
|
.iter()
|
||||||
.await?
|
.filter_map(|n| n.room)
|
||||||
.map(|r| r.room_name)
|
.collect();
|
||||||
.unwrap_or_else(|| "Unknown Room".to_string())
|
let rooms: std::collections::HashMap<Uuid, String> = if !room_ids.is_empty() {
|
||||||
} else {
|
models::rooms::room::Entity::find()
|
||||||
"Unknown Room".to_string()
|
.filter(models::rooms::room::Column::Id.is_in(room_ids))
|
||||||
};
|
.all(&self.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| (r.id, r.room_name))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
std::collections::HashMap::new()
|
||||||
|
};
|
||||||
|
|
||||||
let mentioned_by_name = mentioned_by
|
let result = notifications
|
||||||
.map(|u| u.display_name.unwrap_or(u.username))
|
.into_iter()
|
||||||
.unwrap_or_else(|| "Unknown User".to_string());
|
.map(|notification| {
|
||||||
|
let mentioned_by_name = notification
|
||||||
|
.related_user_id
|
||||||
|
.and_then(|uid| users.get(&uid))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Unknown User".to_string());
|
||||||
|
|
||||||
let content_preview = notification
|
let room_name = notification
|
||||||
.content
|
.room
|
||||||
.unwrap_or_default()
|
.and_then(|rid| rooms.get(&rid))
|
||||||
.chars()
|
.cloned()
|
||||||
.take(100)
|
.unwrap_or_else(|| "Unknown Room".to_string());
|
||||||
.collect();
|
|
||||||
|
|
||||||
result.push(MentionNotificationResponse {
|
let content_preview = notification
|
||||||
message_id: notification.related_message_id.unwrap_or_default(),
|
.content
|
||||||
mentioned_by: notification.related_user_id.unwrap_or_default(),
|
.unwrap_or_default()
|
||||||
mentioned_by_name,
|
.chars()
|
||||||
content_preview,
|
.take(100)
|
||||||
room_id: notification.room.unwrap_or_default(),
|
.collect();
|
||||||
room_name,
|
|
||||||
created_at: notification.created_at,
|
MentionNotificationResponse {
|
||||||
});
|
message_id: notification.related_message_id.unwrap_or_default(),
|
||||||
}
|
mentioned_by: notification.related_user_id.unwrap_or_default(),
|
||||||
|
mentioned_by_name,
|
||||||
|
content_preview,
|
||||||
|
room_id: notification.room.unwrap_or_default(),
|
||||||
|
room_name,
|
||||||
|
created_at: notification.created_at,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(),
|
||||||
));
|
));
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -1,153 +0,0 @@
|
|||||||
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
|
|
||||||
interface PerformanceStats {
|
|
||||||
totalMessages: number;
|
|
||||||
renderedMessages: number;
|
|
||||||
virtualizationEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoomPerformanceMonitorProps {
|
|
||||||
messageCount: number;
|
|
||||||
renderedCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AUTO_CLOSE_DELAY = 5000; // auto-close after 5 seconds when stats are shown
|
|
||||||
|
|
||||||
export function RoomPerformanceMonitor({ messageCount, renderedCount }: RoomPerformanceMonitorProps) {
|
|
||||||
const [showStats, setShowStats] = useState(false);
|
|
||||||
const autoCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// Auto-close after AUTO_CLOSE_DELAY when stats are visible
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showStats) return;
|
|
||||||
autoCloseTimerRef.current = setTimeout(() => {
|
|
||||||
setShowStats(false);
|
|
||||||
}, AUTO_CLOSE_DELAY);
|
|
||||||
return () => {
|
|
||||||
if (autoCloseTimerRef.current) clearTimeout(autoCloseTimerRef.current);
|
|
||||||
};
|
|
||||||
}, [showStats]);
|
|
||||||
|
|
||||||
const stats = useMemo<PerformanceStats>(() => ({
|
|
||||||
totalMessages: messageCount,
|
|
||||||
renderedMessages: renderedCount ?? messageCount,
|
|
||||||
virtualizationEnabled: renderedCount !== undefined && renderedCount < messageCount,
|
|
||||||
}), [messageCount, renderedCount]);
|
|
||||||
|
|
||||||
// --- Drag state ---
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
|
||||||
const draggingRef = useRef(false);
|
|
||||||
const dragStartRef = useRef({ x: 0, y: 0 });
|
|
||||||
|
|
||||||
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
|
||||||
// Don't start drag if clicking the close button
|
|
||||||
if ((e.target as HTMLElement).closest('button')) return;
|
|
||||||
draggingRef.current = true;
|
|
||||||
dragStartRef.current = { x: e.clientX, y: e.clientY };
|
|
||||||
e.preventDefault();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!draggingRef.current) return;
|
|
||||||
const panel = panelRef.current;
|
|
||||||
if (!panel) return;
|
|
||||||
const dx = e.clientX - dragStartRef.current.x;
|
|
||||||
const dy = e.clientY - dragStartRef.current.y;
|
|
||||||
const rect = panel.getBoundingClientRect();
|
|
||||||
panel.style.left = `${rect.left + dx}px`;
|
|
||||||
panel.style.top = `${rect.top + dy}px`;
|
|
||||||
panel.style.right = 'auto';
|
|
||||||
panel.style.bottom = 'auto';
|
|
||||||
dragStartRef.current = { x: e.clientX, y: e.clientY };
|
|
||||||
};
|
|
||||||
const onMouseUp = () => {
|
|
||||||
draggingRef.current = false;
|
|
||||||
};
|
|
||||||
document.addEventListener('mousemove', onMouseMove);
|
|
||||||
document.addEventListener('mouseup', onMouseUp);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousemove', onMouseMove);
|
|
||||||
document.removeEventListener('mouseup', onMouseUp);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!showStats && import.meta.env.PROD) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="fixed bottom-4 left-4 z-50 h-7 w-7 rounded-full p-0 opacity-50 hover:opacity-100"
|
|
||||||
onClick={() => setShowStats(true)}
|
|
||||||
title="Show performance stats"
|
|
||||||
>
|
|
||||||
📊
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={panelRef}
|
|
||||||
className="fixed z-50 rounded-lg border bg-background/95 p-3 shadow-lg backdrop-blur-sm select-none"
|
|
||||||
style={{ bottom: '1rem', left: '1rem' }}
|
|
||||||
>
|
|
||||||
{/* Draggable header */}
|
|
||||||
<div
|
|
||||||
className="mb-2 flex items-center justify-between cursor-grab active:cursor-grabbing select-none"
|
|
||||||
onMouseDown={handleDragStart}
|
|
||||||
>
|
|
||||||
<h4 className="text-xs font-semibold text-foreground">Performance Stats</h4>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-5 w-5 p-0"
|
|
||||||
onClick={() => setShowStats(false)}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5 text-xs">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="text-muted-foreground">Total messages:</span>
|
|
||||||
<Badge variant="secondary" className="font-mono">{stats.totalMessages}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="text-muted-foreground">Rendered:</span>
|
|
||||||
<Badge variant="secondary" className="font-mono">{stats.renderedMessages}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{stats.virtualizationEnabled && (
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="text-muted-foreground">Skipped:</span>
|
|
||||||
<Badge variant="outline" className="font-mono text-green-600">
|
|
||||||
{stats.totalMessages - stats.renderedMessages}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="text-muted-foreground">Virtualization:</span>
|
|
||||||
<Badge
|
|
||||||
variant={stats.virtualizationEnabled ? 'default' : 'destructive'}
|
|
||||||
className="text-[10px]"
|
|
||||||
>
|
|
||||||
{stats.virtualizationEnabled ? '✓ Enabled' : '✗ Disabled'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{stats.virtualizationEnabled && (
|
|
||||||
<div className="pt-1 border-t border-border">
|
|
||||||
<span className="text-[10px] text-green-600">
|
|
||||||
⚡ Rendering {((stats.renderedMessages / stats.totalMessages) * 100).toFixed(0)}% of messages
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -25,6 +25,7 @@ import {
|
|||||||
type RoomMessagePayload,
|
type 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;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,553 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import {
|
|
||||||
type AiStreamChunkPayload,
|
|
||||||
type RoomMessagePayload,
|
|
||||||
type RoomWsStatus,
|
|
||||||
type WsOutEvent,
|
|
||||||
} from '@/lib/room';
|
|
||||||
import { requestWsToken, buildWsUrlWithToken } from '@/lib/ws-token';
|
|
||||||
import { client } from '@/client/client.gen';
|
|
||||||
import type { AxiosResponse } from 'axios';
|
|
||||||
import type { RoomMemberResponse } from '@/client';
|
|
||||||
|
|
||||||
const RECONNECT_BASE_DELAY = 1_000;
|
|
||||||
const RECONNECT_MAX_DELAY = 15_000;
|
|
||||||
|
|
||||||
/** A message as held in the UI state */
|
|
||||||
export type UiMessage = RoomMessagePayload & {
|
|
||||||
/** Set while the server is streaming an AI reply into this message */
|
|
||||||
is_streaming?: boolean;
|
|
||||||
/** Accumulated streaming content; flushed to content on `done: true` */
|
|
||||||
streaming_content?: string;
|
|
||||||
/** Display name resolved from sender_id; undefined if not yet resolved */
|
|
||||||
display_name?: string;
|
|
||||||
/** Avatar URL resolved from members list */
|
|
||||||
avatar_url?: string;
|
|
||||||
/** For optimistic UI: message failed to send */
|
|
||||||
isOptimisticError?: boolean;
|
|
||||||
/** Reply to message ID */
|
|
||||||
in_reply_to?: string | null;
|
|
||||||
/** Edited timestamp */
|
|
||||||
edited_at?: string | null;
|
|
||||||
/** Revoked timestamp */
|
|
||||||
revoked?: string | null;
|
|
||||||
/** Revoked by user ID */
|
|
||||||
revoked_by?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RoomMessageCacheEntry = {
|
|
||||||
messages: UiMessage[];
|
|
||||||
isHistoryLoaded: boolean;
|
|
||||||
/** seq of the latest message, used as cursor for pagination */
|
|
||||||
nextCursor: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface MessageListResponse {
|
|
||||||
code: number;
|
|
||||||
message: string;
|
|
||||||
data: { messages: RestMessage[]; total: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** REST message shape (matches RoomMessageResponse from the SDK) */
|
|
||||||
interface RestMessage {
|
|
||||||
id: string;
|
|
||||||
seq: number;
|
|
||||||
room: string;
|
|
||||||
sender_type: string;
|
|
||||||
sender_id?: string | null;
|
|
||||||
display_name?: string | null;
|
|
||||||
thread?: string | null;
|
|
||||||
in_reply_to?: string | null;
|
|
||||||
content: string;
|
|
||||||
content_type: string;
|
|
||||||
edited_at?: string | null;
|
|
||||||
send_at: string;
|
|
||||||
revoked?: string | null;
|
|
||||||
revoked_by?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Display name and avatar URL resolved from a message's sender */
|
|
||||||
interface SenderInfo {
|
|
||||||
displayName: string;
|
|
||||||
avatarUrl: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve displayName and avatar URL for a message sender.
|
|
||||||
* - AI messages: use sender_id (the model UUID)
|
|
||||||
* - User messages: look up user_info from members list, fall back to sender_id */
|
|
||||||
function resolveSender(payload: RoomMessagePayload, members: RoomMemberResponse[]): SenderInfo {
|
|
||||||
if (payload.sender_type === 'ai') {
|
|
||||||
return { displayName: payload.sender_id ?? 'AI', avatarUrl: undefined };
|
|
||||||
}
|
|
||||||
if (payload.sender_id) {
|
|
||||||
const member = members.find((m) => m.user === payload.sender_id);
|
|
||||||
if (member) {
|
|
||||||
const username = member.user_info?.username ?? member.user;
|
|
||||||
return { displayName: username, avatarUrl: member.user_info?.avatar_url ?? undefined };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (payload.sender_type === 'system') return { displayName: 'System', avatarUrl: undefined };
|
|
||||||
return { displayName: payload.sender_type, avatarUrl: undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareMessages(a: UiMessage, b: UiMessage): number {
|
|
||||||
const timeDiff = new Date(a.send_at).getTime() - new Date(b.send_at).getTime();
|
|
||||||
return timeDiff !== 0 ? timeDiff : a.id.localeCompare(b.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertSorted(arr: UiMessage[], msg: UiMessage): UiMessage[] {
|
|
||||||
const result = [...arr];
|
|
||||||
let lo = 0;
|
|
||||||
let hi = result.length;
|
|
||||||
while (lo < hi) {
|
|
||||||
const mid = (lo + hi) >>> 1;
|
|
||||||
if (compareMessages(result[mid], msg) < 0) lo = mid + 1;
|
|
||||||
else hi = mid;
|
|
||||||
}
|
|
||||||
result.splice(lo, 0, msg);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseRoomWsOptions {
|
|
||||||
/** VITE_API_BASE_URL value (without /ws suffix) */
|
|
||||||
baseUrl: string;
|
|
||||||
/** Currently open room ID */
|
|
||||||
roomId: string | null;
|
|
||||||
/** Limit for initial history load */
|
|
||||||
historyLimit?: number;
|
|
||||||
/** Room members, used to resolve display_name for user messages */
|
|
||||||
members?: RoomMemberResponse[];
|
|
||||||
/** Called when the AI streaming chunk arrives */
|
|
||||||
onAiStreamChunk?: (payload: AiStreamChunkPayload) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseRoomWsReturn {
|
|
||||||
messages: UiMessage[];
|
|
||||||
status: RoomWsStatus;
|
|
||||||
errorMessage: string | null;
|
|
||||||
isHistoryLoaded: boolean;
|
|
||||||
isLoadingMore: boolean;
|
|
||||||
nextCursor: number | null;
|
|
||||||
/** Load older messages (called when user scrolls to top) */
|
|
||||||
loadMore: (cursor?: number | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages a WebSocket connection for a single room.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Auto-reconnect with exponential back-off
|
|
||||||
* - Per-room message cache so switching rooms preserves scroll position
|
|
||||||
* - AI streaming chunk accumulation via `streaming_content`
|
|
||||||
* - `loadMore` for cursor-based history pagination
|
|
||||||
*/
|
|
||||||
export function useRoomWs({
|
|
||||||
baseUrl,
|
|
||||||
roomId,
|
|
||||||
historyLimit = 50,
|
|
||||||
members = [],
|
|
||||||
onAiStreamChunk,
|
|
||||||
}: UseRoomWsOptions): UseRoomWsReturn {
|
|
||||||
const [messages, setMessages] = useState<UiMessage[]>([]);
|
|
||||||
const [status, setStatus] = useState<RoomWsStatus>('idle');
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
|
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
||||||
const [nextCursor, setNextCursor] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
const activeRoomIdRef = useRef<string | null>(null);
|
|
||||||
const shouldReconnectRef = useRef(true);
|
|
||||||
const reconnectAttemptRef = useRef(0);
|
|
||||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const wsTokenRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const roomCacheRef = useRef<Map<string, RoomMessageCacheEntry>>(new Map());
|
|
||||||
/** Ref to current messages for use inside event handlers */
|
|
||||||
const messagesRef = useRef<UiMessage[]>([]);
|
|
||||||
messagesRef.current = messages;
|
|
||||||
|
|
||||||
/** Ref to current nextCursor */
|
|
||||||
const nextCursorRef = useRef<number | null>(null);
|
|
||||||
nextCursorRef.current = nextCursor;
|
|
||||||
|
|
||||||
/** Ref to members, used for display_name resolution */
|
|
||||||
const membersRef = useRef<RoomMemberResponse[]>([]);
|
|
||||||
membersRef.current = members;
|
|
||||||
|
|
||||||
/** Ref for AI streaming RAF batch */
|
|
||||||
const streamingBatchRef = useRef<Map<string, { content: string; done: boolean; room_id: string }>>(new Map());
|
|
||||||
const streamingRafRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
/** Flush streaming batch to state */
|
|
||||||
const flushStreamingBatch = useCallback(() => {
|
|
||||||
const batch = streamingBatchRef.current;
|
|
||||||
if (batch.size === 0) return;
|
|
||||||
|
|
||||||
setMessages((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
let changed = false;
|
|
||||||
|
|
||||||
for (const [messageId, chunk] of batch) {
|
|
||||||
const idx = next.findIndex((m) => m.id === messageId);
|
|
||||||
if (idx === -1) {
|
|
||||||
const placeholder: UiMessage = {
|
|
||||||
id: messageId,
|
|
||||||
room_id: chunk.room_id ?? next.find(() => true)?.room_id ?? '',
|
|
||||||
sender_type: 'ai',
|
|
||||||
content: chunk.done ? chunk.content : '',
|
|
||||||
content_type: 'text',
|
|
||||||
send_at: new Date().toISOString(),
|
|
||||||
seq: 0,
|
|
||||||
display_name: 'AI',
|
|
||||||
is_streaming: !chunk.done,
|
|
||||||
streaming_content: chunk.done ? undefined : chunk.content,
|
|
||||||
};
|
|
||||||
next.push(placeholder);
|
|
||||||
changed = true;
|
|
||||||
} else {
|
|
||||||
const updated = { ...next[idx] };
|
|
||||||
if (chunk.done) {
|
|
||||||
updated.is_streaming = false;
|
|
||||||
updated.content = chunk.content;
|
|
||||||
updated.streaming_content = undefined;
|
|
||||||
} else {
|
|
||||||
updated.is_streaming = true;
|
|
||||||
updated.streaming_content = (updated.streaming_content ?? updated.content) + chunk.content;
|
|
||||||
}
|
|
||||||
next[idx] = updated;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed ? next : prev;
|
|
||||||
});
|
|
||||||
|
|
||||||
streamingBatchRef.current.clear();
|
|
||||||
streamingRafRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const room = activeRoomIdRef.current;
|
|
||||||
if (!room) return;
|
|
||||||
roomCacheRef.current.set(room, {
|
|
||||||
messages,
|
|
||||||
isHistoryLoaded,
|
|
||||||
nextCursor,
|
|
||||||
});
|
|
||||||
}, [messages, isHistoryLoaded, nextCursor]);
|
|
||||||
|
|
||||||
const connectWs = useCallback(
|
|
||||||
async (roomUid: string) => {
|
|
||||||
if (!shouldReconnectRef.current || activeRoomIdRef.current !== roomUid) return;
|
|
||||||
|
|
||||||
// Build URL with token if available
|
|
||||||
const url = buildWsUrlWithToken(baseUrl, `/ws/rooms/${roomUid}`, wsTokenRef.current);
|
|
||||||
console.debug('[useRoomWs] connecting to', url, { baseUrl, roomUid });
|
|
||||||
|
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
|
||||||
reconnectTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus('connecting');
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
const ws = new WebSocket(url);
|
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
if (activeRoomIdRef.current !== roomUid) return;
|
|
||||||
reconnectAttemptRef.current = 0;
|
|
||||||
setStatus('open');
|
|
||||||
setErrorMessage(null);
|
|
||||||
console.debug('[useRoomWs] ws opened for room', roomUid);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (ev: MessageEvent<string>) => {
|
|
||||||
if (activeRoomIdRef.current !== roomUid) return;
|
|
||||||
|
|
||||||
let event: WsOutEvent;
|
|
||||||
try {
|
|
||||||
event = JSON.parse(ev.data) as WsOutEvent;
|
|
||||||
} catch {
|
|
||||||
console.warn('[useRoomWs] parse error, data:', ev.data);
|
|
||||||
setErrorMessage('Invalid WebSocket message');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('error' in event && event.error) {
|
|
||||||
console.warn('[useRoomWs] error event:', event.error);
|
|
||||||
setErrorMessage(event.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!('event' in event) || !event.event) {
|
|
||||||
console.warn('[useRoomWs] no event field, raw:', event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.debug('[useRoomWs] received event type:', event.event.type, event.event);
|
|
||||||
|
|
||||||
switch (event.event.type) {
|
|
||||||
case 'room_message': {
|
|
||||||
// Backend sends payload flat on event.event (no data wrapper); also support nested data
|
|
||||||
const raw = (event.event as any);
|
|
||||||
const incoming: RoomMessagePayload = raw.data ?? raw;
|
|
||||||
console.debug('[useRoomWs] room_message:', incoming.id, incoming.content);
|
|
||||||
|
|
||||||
// Use Set for O(1) duplicate check instead of O(n) Array.some
|
|
||||||
const existingIds = new Set(messagesRef.current.map((m) => m.id));
|
|
||||||
if (existingIds.has(incoming.id)) {
|
|
||||||
console.debug('[useRoomWs] duplicate message, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sender = resolveSender(incoming, membersRef.current);
|
|
||||||
const display_name = incoming.display_name ?? sender.displayName;
|
|
||||||
const avatar_url = sender.avatarUrl;
|
|
||||||
|
|
||||||
setMessages((prev) =>
|
|
||||||
insertSorted(prev, { ...incoming, display_name, avatar_url }),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'ai_stream_chunk': {
|
|
||||||
const raw = event.event as any;
|
|
||||||
const chunk: AiStreamChunkPayload = raw.data ?? raw;
|
|
||||||
onAiStreamChunk?.(chunk);
|
|
||||||
|
|
||||||
// Batch streaming chunks using RAF to reduce re-render frequency
|
|
||||||
streamingBatchRef.current.set(chunk.message_id, {
|
|
||||||
content: chunk.content,
|
|
||||||
done: chunk.done,
|
|
||||||
room_id: chunk.room_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (streamingRafRef.current == null) {
|
|
||||||
streamingRafRef.current = requestAnimationFrame(() => {
|
|
||||||
flushStreamingBatch();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = (ev: CloseEvent) => {
|
|
||||||
console.debug('[useRoomWs] WebSocket closed', { code: ev.code, reason: ev.reason, wasClean: ev.wasClean });
|
|
||||||
const activeSocket = wsRef.current;
|
|
||||||
if (activeSocket !== ws) return;
|
|
||||||
wsRef.current = null;
|
|
||||||
if (activeRoomIdRef.current !== roomUid) return;
|
|
||||||
|
|
||||||
setStatus('closed');
|
|
||||||
if (shouldReconnectRef.current) {
|
|
||||||
const attempt = ++reconnectAttemptRef.current;
|
|
||||||
const delay = Math.min(RECONNECT_BASE_DELAY * 2 ** (attempt - 1), RECONNECT_MAX_DELAY);
|
|
||||||
reconnectTimeoutRef.current = setTimeout(() => connectWs(roomUid), delay);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (ev: Event) => {
|
|
||||||
console.error('[useRoomWs] WebSocket error', ev);
|
|
||||||
if (activeRoomIdRef.current !== roomUid) return;
|
|
||||||
setErrorMessage('WebSocket error');
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[baseUrl, onAiStreamChunk],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const prevRoom = activeRoomIdRef.current;
|
|
||||||
|
|
||||||
if (!roomId) {
|
|
||||||
// Disconnect
|
|
||||||
activeRoomIdRef.current = null;
|
|
||||||
shouldReconnectRef.current = false;
|
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
|
||||||
reconnectTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
if (wsRef.current) {
|
|
||||||
wsRef.current.close();
|
|
||||||
wsRef.current = null;
|
|
||||||
}
|
|
||||||
setMessages([]);
|
|
||||||
setStatus('idle');
|
|
||||||
setErrorMessage(null);
|
|
||||||
setIsHistoryLoaded(false);
|
|
||||||
setNextCursor(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save previous room's state
|
|
||||||
if (prevRoom && prevRoom !== roomId) {
|
|
||||||
roomCacheRef.current.set(prevRoom, {
|
|
||||||
messages: messagesRef.current,
|
|
||||||
isHistoryLoaded,
|
|
||||||
nextCursor: nextCursorRef.current,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
activeRoomIdRef.current = roomId;
|
|
||||||
shouldReconnectRef.current = true;
|
|
||||||
reconnectAttemptRef.current = 0;
|
|
||||||
|
|
||||||
// Fetch WS token before connecting
|
|
||||||
const connectWithToken = async () => {
|
|
||||||
try {
|
|
||||||
const token = await requestWsToken();
|
|
||||||
wsTokenRef.current = token;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[useRoomWs] Failed to fetch WS token, falling back to cookie auth:', error);
|
|
||||||
wsTokenRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore from cache or start fresh
|
|
||||||
const cached = roomCacheRef.current.get(roomId);
|
|
||||||
if (cached) {
|
|
||||||
setMessages(cached.messages);
|
|
||||||
setIsHistoryLoaded(cached.isHistoryLoaded);
|
|
||||||
setNextCursor(cached.nextCursor);
|
|
||||||
} else {
|
|
||||||
setMessages([]);
|
|
||||||
setIsHistoryLoaded(false);
|
|
||||||
setNextCursor(null);
|
|
||||||
// Load initial history via REST (WS is push-only, can't request history)
|
|
||||||
if (roomId) {
|
|
||||||
client
|
|
||||||
.get({ url: `/api/rooms/${roomId}/messages`, params: { limit: historyLimit } })
|
|
||||||
.then((resp) => {
|
|
||||||
const r = resp as AxiosResponse<MessageListResponse>;
|
|
||||||
if (activeRoomIdRef.current !== roomId) return;
|
|
||||||
const msgs = (r.data?.data?.messages ?? []).map((m) => {
|
|
||||||
const sender = resolveSender({ ...m, room_id: m.room } as RoomMessagePayload, members);
|
|
||||||
const display_name = m.display_name ?? sender.displayName;
|
|
||||||
const avatar_url = sender.avatarUrl;
|
|
||||||
return {
|
|
||||||
...m,
|
|
||||||
room_id: m.room,
|
|
||||||
thread_id: m.thread ?? null,
|
|
||||||
display_name,
|
|
||||||
avatar_url,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setMessages(msgs);
|
|
||||||
setNextCursor(msgs.length > 0 ? msgs[msgs.length - 1].seq : null);
|
|
||||||
setIsHistoryLoaded(msgs.length < historyLimit);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (activeRoomIdRef.current !== roomId) return;
|
|
||||||
toast.error('Failed to load message history');
|
|
||||||
setIsHistoryLoaded(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close other connections (shouldn't be any in practice)
|
|
||||||
if (wsRef.current) {
|
|
||||||
wsRef.current.close();
|
|
||||||
wsRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectWs(roomId);
|
|
||||||
};
|
|
||||||
|
|
||||||
connectWithToken();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Save state before unmounting
|
|
||||||
const room = activeRoomIdRef.current;
|
|
||||||
if (room) {
|
|
||||||
roomCacheRef.current.set(room, {
|
|
||||||
messages: messagesRef.current,
|
|
||||||
isHistoryLoaded: isHistoryLoaded,
|
|
||||||
nextCursor: nextCursorRef.current,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
shouldReconnectRef.current = false;
|
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
|
||||||
reconnectTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
if (wsRef.current) {
|
|
||||||
wsRef.current.close();
|
|
||||||
wsRef.current = null;
|
|
||||||
}
|
|
||||||
if (streamingRafRef.current != null) {
|
|
||||||
cancelAnimationFrame(streamingRafRef.current);
|
|
||||||
streamingRafRef.current = null;
|
|
||||||
}
|
|
||||||
activeRoomIdRef.current = null;
|
|
||||||
};
|
|
||||||
}, [roomId, connectWs]);
|
|
||||||
|
|
||||||
const loadMore = useCallback(
|
|
||||||
async (cursor?: number | null) => {
|
|
||||||
if (!roomId || isLoadingMore) return;
|
|
||||||
// Use REST API for history pagination — WS is push-only
|
|
||||||
const effectiveCursor = cursor ?? nextCursorRef.current;
|
|
||||||
if (effectiveCursor == null) return;
|
|
||||||
|
|
||||||
setIsLoadingMore(true);
|
|
||||||
try {
|
|
||||||
const resp = await client.get({ url: `/api/rooms/${roomId}/messages`, params: { before_seq: effectiveCursor, limit: historyLimit } }) as AxiosResponse<MessageListResponse>;
|
|
||||||
const older = (resp.data?.data?.messages ?? []).map((m) => {
|
|
||||||
const sender = resolveSender({ ...m, room_id: m.room } as RoomMessagePayload, membersRef.current);
|
|
||||||
const display_name = m.display_name ?? sender.displayName;
|
|
||||||
const avatar_url = sender.avatarUrl;
|
|
||||||
return {
|
|
||||||
...m,
|
|
||||||
room_id: m.room,
|
|
||||||
thread_id: m.thread ?? null,
|
|
||||||
display_name,
|
|
||||||
avatar_url,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
if (older.length === 0) {
|
|
||||||
setIsHistoryLoaded(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Prepend older messages (they arrive in ascending seq order)
|
|
||||||
setMessages((prev) => {
|
|
||||||
const existingIds = new Set(prev.map((m) => m.id));
|
|
||||||
const newOnes = older.filter((m) => !existingIds.has(m.id));
|
|
||||||
if (newOnes.length === 0) {
|
|
||||||
setIsHistoryLoaded(true);
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
// New cursor = smallest seq among loaded messages
|
|
||||||
const newCursor = newOnes[newOnes.length - 1].seq;
|
|
||||||
setNextCursor(newCursor > 0 ? newCursor : null);
|
|
||||||
return [...newOnes, ...prev];
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Non-critical — show toast so user knows the load failed
|
|
||||||
toast.error('Failed to load more messages');
|
|
||||||
setIsHistoryLoaded(true);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingMore(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[roomId, historyLimit, isLoadingMore],
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
messages,
|
|
||||||
status,
|
|
||||||
errorMessage,
|
|
||||||
isHistoryLoaded,
|
|
||||||
isLoadingMore,
|
|
||||||
nextCursor,
|
|
||||||
loadMore,
|
|
||||||
}),
|
|
||||||
[messages, status, errorMessage, isHistoryLoaded, isLoadingMore, nextCursor, loadMore],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -133,7 +133,7 @@ export class RoomWsClient {
|
|||||||
return new Set(this.subscribedProjects);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user