use models::chat::{messages, reactions}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::ChatError; use crate::services::service::ChatService; use crate::share::lookup; use crate::share::realtime::RoomEvent; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ReactionAggDto { pub emoji: String, pub count: i64, pub me: bool, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ReactionUserDto { pub emoji: String, pub user: api::users::UserDto, pub created_at: OffsetDateTime, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct ReactionCmd { pub emoji: String, } impl ChatService { pub async fn add_reaction( &self, caller: i64, room_id: Uuid, message_id: Uuid, cmd: ReactionCmd, ) -> Result, ChatError> { let emoji = cmd.emoji.trim().to_string(); if emoji.is_empty() || emoji.chars().count() > self.settings.chat.max_reaction_emoji_len { return Err(ChatError::BadRequest("invalid emoji".to_string())); } let _ctx = lookup::require_room_readable(self, room_id, caller).await?; let message = messages::Entity::find_by_id(message_id) .filter(messages::Column::RoomId.eq(room_id)) .one(self.database.read()) .await? .filter(|m| m.deleted_at.is_none()) .ok_or(ChatError::NotFound)?; let _ = message; let now = OffsetDateTime::now_utc(); let result = reactions::Entity::insert(reactions::ActiveModel { message_id: Set(message_id), emoji: Set(emoji.clone()), user_id: Set(caller), created_at: Set(now), }) .exec(self.database.write()) .await; match result { Ok(_) => {} Err(err) => { let msg = err.to_string().to_lowercase(); if !(msg.contains("unique") || msg.contains("duplicate")) { return Err(ChatError::from(err)); } } } self.publish_reactions(room_id, message_id, caller).await } pub async fn remove_reaction( &self, caller: i64, room_id: Uuid, message_id: Uuid, cmd: ReactionCmd, ) -> Result, ChatError> { let emoji = cmd.emoji.trim().to_string(); let _ctx = lookup::require_room_readable(self, room_id, caller).await?; reactions::Entity::delete_many() .filter(reactions::Column::MessageId.eq(message_id)) .filter(reactions::Column::Emoji.eq(&emoji)) .filter(reactions::Column::UserId.eq(caller)) .exec(self.database.write()) .await?; self.publish_reactions(room_id, message_id, caller).await } pub async fn list_message_reactions( &self, caller: i64, room_id: Uuid, message_id: Uuid, ) -> Result, ChatError> { let _ctx = lookup::require_room_readable(self, room_id, caller).await?; let rows = reactions::Entity::find() .filter(reactions::Column::MessageId.eq(message_id)) .order_by_asc(reactions::Column::Emoji) .order_by_asc(reactions::Column::CreatedAt) .all(self.database.read()) .await?; let uids: Vec = rows.iter().map(|r| r.user_id).collect(); let user_map = self.audience.load_user_briefs(&uids).await?; Ok(rows .into_iter() .filter_map(|r| { user_map.get(&r.user_id).map(|u| ReactionUserDto { emoji: r.emoji, user: u.clone(), created_at: r.created_at, }) }) .collect()) } async fn publish_reactions( &self, room_id: Uuid, message_id: Uuid, viewer: i64, ) -> Result, ChatError> { let rows = reactions::Entity::find() .filter(reactions::Column::MessageId.eq(message_id)) .all(self.database.read()) .await?; let mut agg: std::collections::HashMap = std::collections::HashMap::new(); for r in rows { let entry = agg.entry(r.emoji).or_insert((0, false)); entry.0 += 1; if r.user_id == viewer { entry.1 = true; } } let mut result: Vec = agg .clone() .into_iter() .map(|(emoji, (count, me))| ReactionAggDto { emoji, count, me }) .collect(); result.sort_by(|a, b| a.emoji.cmp(&b.emoji)); // The room-wide event must be viewer-neutral: `me` is per-recipient // state and only belongs in the HTTP response above. let shared: Vec = agg .into_iter() .map(|(emoji, (count, _))| serde_json::json!({ "emoji": emoji, "count": count })) .collect(); let value = serde_json::to_value(&shared)?; let event = RoomEvent::ReactionUpdated { room_id, user_id: viewer, message_id, reactions: value, }; self.audience.realtime.publish_room(&event).await; Ok(result) } }