diff --git a/frontend/src/utils/emoji.ts b/frontend/src/utils/emoji.ts new file mode 100644 index 0000000..5ea6056 --- /dev/null +++ b/frontend/src/utils/emoji.ts @@ -0,0 +1,27 @@ +export type SkinTone = 1 | 2 | 3 | 4 | 5 + +const SKIN_TONE_BASE = 0x1f3fb +const SKIN_TONE_MIN = 0x1f3fb +const SKIN_TONE_MAX = 0x1f3ff +const VARIATION_SELECTOR_16 = 0xfe0f + +/** + * Applies a Mattermost-style skin tone to an RGI emoji sequence. + * + * The tone is inserted after the first emoji code point and before an + * optional presentation selector, which also works for ZWJ sequences such + * as woman technologist. + */ +export function applySkinTone(sequence: string, tone: SkinTone | null): string { + if (tone === null) return sequence + + const codePoints = Array.from(sequence) + .map((character) => character.codePointAt(0)!) + .filter((codePoint) => codePoint < SKIN_TONE_MIN || codePoint > SKIN_TONE_MAX) + + const modifier = SKIN_TONE_BASE + tone - 1 + const insertionIndex = codePoints[1] === VARIATION_SELECTOR_16 ? 1 : codePoints.length > 0 ? 1 : 0 + codePoints.splice(insertionIndex, 0, modifier) + + return String.fromCodePoint(...codePoints) +} diff --git a/migration/src/m20220101_000001_create_table.rs b/migration/src/m20220101_000001_create_table.rs index 21d72b0..03e3e1a 100644 --- a/migration/src/m20220101_000001_create_table.rs +++ b/migration/src/m20220101_000001_create_table.rs @@ -712,6 +712,81 @@ impl MigrationTrait for Migration { seed_unicode_emojis(manager).await?; + manager + .create_table( + Table::create() + .table(Alias::new("message_reaction")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("message_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("user_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("emoji_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("skin_tone")) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .name("fk_message_reaction_message") + .from(Alias::new("message_reaction"), Alias::new("message_id")) + .to(Alias::new("message"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_message_reaction_user") + .from(Alias::new("message_reaction"), Alias::new("user_id")) + .to(Alias::new("user"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_message_reaction_emoji") + .from(Alias::new("message_reaction"), Alias::new("emoji_id")) + .to(Alias::new("emoji"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("uq_message_reaction") + .table(Alias::new("message_reaction")) + .col(Alias::new("message_id")) + .col(Alias::new("user_id")) + .col(Alias::new("emoji_id")) + .col(Alias::new("skin_tone")) + .unique() + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_message_reaction_message_id") + .table(Alias::new("message_reaction")) + .col(Alias::new("message_id")) + .to_owned(), + ) + .await?; + manager .create_table( Table::create() @@ -976,6 +1051,7 @@ impl MigrationTrait for Migration { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { let tables = [ "computed_permission", + "message_reaction", "emoji", "channel_user_read_state", "channel_user_permission", diff --git a/src/domain/dto/message.rs b/src/domain/dto/message.rs index 9b632ed..984b869 100644 --- a/src/domain/dto/message.rs +++ b/src/domain/dto/message.rs @@ -1,3 +1,4 @@ +use crate::domain::dto::reaction::ReactionGroupResponse; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -13,6 +14,7 @@ pub struct MessageResponse { pub created_at: DateTime, pub updated_at: Option>, pub reply_to_id: Option, + pub reactions: Vec, } #[derive(Debug, Serialize, ToSchema)] diff --git a/src/domain/dto/mod.rs b/src/domain/dto/mod.rs index b0ba6a3..76b3e0b 100644 --- a/src/domain/dto/mod.rs +++ b/src/domain/dto/mod.rs @@ -5,6 +5,7 @@ pub mod channel; pub mod core; pub mod emoji; pub mod message; +pub mod reaction; pub mod role; pub mod server; pub mod user; diff --git a/src/domain/dto/reaction.rs b/src/domain/dto/reaction.rs new file mode 100644 index 0000000..89713a3 --- /dev/null +++ b/src/domain/dto/reaction.rs @@ -0,0 +1,36 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +pub struct CreateReactionRequest { + pub emoji_id: Uuid, + pub skin_tone: Option, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct DeleteReactionQuery { + pub skin_tone: Option, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ReactionResponse { + pub id: Uuid, + pub message_id: Uuid, + pub user_id: Uuid, + pub emoji_id: Uuid, + pub skin_tone: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ReactionGroupResponse { + pub emoji_id: Uuid, + pub name: String, + pub unicode_sequence: Option, + pub asset_url: Option, + pub skin_tone: Option, + pub count: u64, + pub user_ids: Vec, +} diff --git a/src/domain/events/message.rs b/src/domain/events/message.rs index bd52602..67b0f10 100644 --- a/src/domain/events/message.rs +++ b/src/domain/events/message.rs @@ -1,4 +1,5 @@ use crate::models::message; +use crate::models::message_reaction; use uuid::Uuid; #[derive(Debug, Clone)] @@ -21,3 +22,17 @@ pub struct MessageDeletedEvent { pub channel_id: Uuid, pub message: message::Model, } + +#[derive(Debug, Clone)] +pub struct MessageReactionAddedEvent { + pub server_id: Option, + pub channel_id: Uuid, + pub reaction: message_reaction::Model, +} + +#[derive(Debug, Clone)] +pub struct MessageReactionRemovedEvent { + pub server_id: Option, + pub channel_id: Uuid, + pub reaction: message_reaction::Model, +} diff --git a/src/models/emoji.rs b/src/models/emoji.rs index 391e181..c8e8c80 100644 --- a/src/models/emoji.rs +++ b/src/models/emoji.rs @@ -20,6 +20,8 @@ pub struct Model { pub sha256: Option, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, + #[sea_orm(has_many)] + pub reactions: HasMany, } #[async_trait] diff --git a/src/models/message.rs b/src/models/message.rs index 66196d2..2056aa5 100644 --- a/src/models/message.rs +++ b/src/models/message.rs @@ -19,6 +19,8 @@ pub struct Model { pub reply_to_id: Option, #[sea_orm(has_many)] pub attachments: HasMany, + #[sea_orm(has_many)] + pub reactions: HasMany, #[sea_orm( belongs_to, from = "channel_id", diff --git a/src/models/message_reaction.rs b/src/models/message_reaction.rs new file mode 100644 index 0000000..adfa7a6 --- /dev/null +++ b/src/models/message_reaction.rs @@ -0,0 +1,32 @@ +use sea_orm::Set; +use sea_orm::entity::prelude::*; +use sea_orm::prelude::async_trait::async_trait; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "message_reaction")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub message_id: Uuid, + pub user_id: Uuid, + pub emoji_id: Uuid, + pub skin_tone: i32, + pub created_at: DateTimeUtc, + #[sea_orm(belongs_to, from = "message_id", to = "id", on_delete = "Cascade")] + pub message: HasOne, + #[sea_orm(belongs_to, from = "user_id", to = "id", on_delete = "Cascade")] + pub user: HasOne, + #[sea_orm(belongs_to, from = "emoji_id", to = "id", on_delete = "Cascade")] + pub emoji: HasOne, +} + +#[async_trait] +impl ActiveModelBehavior for ActiveModel { + fn new() -> Self { + Self { + id: Set(Uuid::now_v7()), + ..Default::default() + } + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 05f2593..4348018 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -12,6 +12,7 @@ pub mod channel_user_read_state; pub mod computed_permission; pub mod emoji; pub mod message; +pub mod message_reaction; pub mod role; pub mod role_user; pub mod server; diff --git a/src/models/prelude.rs b/src/models/prelude.rs index a024e27..ba6b186 100644 --- a/src/models/prelude.rs +++ b/src/models/prelude.rs @@ -8,6 +8,7 @@ pub use super::channel_user_read_state::Entity as ChannelUserReadState; pub use super::computed_permission::Entity as ComputedPermission; pub use super::emoji::Entity as Emoji; pub use super::message::Entity as Message; +pub use super::message_reaction::Entity as MessageReaction; pub use super::role::Entity as Group; pub use super::role_user::Entity as GroupMember; pub use super::server::Entity as Server; diff --git a/src/models/user.rs b/src/models/user.rs index 4c8135d..1c9fe1a 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -23,6 +23,8 @@ pub struct Model { #[sea_orm(has_many)] pub messages: HasMany, #[sea_orm(has_many)] + pub message_reactions: HasMany, + #[sea_orm(has_many)] pub server_users: HasMany, } diff --git a/src/repositories/message_reaction.rs b/src/repositories/message_reaction.rs new file mode 100644 index 0000000..9af491c --- /dev/null +++ b/src/repositories/message_reaction.rs @@ -0,0 +1,65 @@ +use crate::models::message_reaction; +use crate::repositories::{AnyResult, RepositoryContext}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter}; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Clone, Debug)] +pub struct MessageReactionRepository { + pub context: Arc, +} + +impl MessageReactionRepository { + pub async fn find( + &self, + message_id: Uuid, + user_id: Uuid, + emoji_id: Uuid, + skin_tone: i32, + ) -> AnyResult> { + Ok(message_reaction::Entity::find() + .filter(message_reaction::Column::MessageId.eq(message_id)) + .filter(message_reaction::Column::UserId.eq(user_id)) + .filter(message_reaction::Column::EmojiId.eq(emoji_id)) + .filter(message_reaction::Column::SkinTone.eq(skin_tone)) + .one(&self.context.db) + .await?) + } + + pub async fn list_by_message_ids( + &self, + message_ids: &[Uuid], + ) -> AnyResult> { + if message_ids.is_empty() { + return Ok(Vec::new()); + } + Ok(message_reaction::Entity::find() + .filter(message_reaction::Column::MessageId.is_in(message_ids.iter().copied())) + .all(&self.context.db) + .await?) + } + + pub async fn create( + &self, + active: message_reaction::ActiveModel, + ) -> AnyResult { + Ok(active.insert(&self.context.db).await?) + } + + pub async fn delete( + &self, + message_id: Uuid, + user_id: Uuid, + emoji_id: Uuid, + skin_tone: i32, + ) -> AnyResult> { + let existing = self.find(message_id, user_id, emoji_id, skin_tone).await?; + let Some(existing) = existing else { + return Ok(None); + }; + message_reaction::Entity::delete_by_id(existing.id) + .exec(&self.context.db) + .await?; + Ok(Some(existing)) + } +} diff --git a/src/repositories/mod.rs b/src/repositories/mod.rs index f403773..a318dc2 100644 --- a/src/repositories/mod.rs +++ b/src/repositories/mod.rs @@ -5,6 +5,7 @@ use crate::repositories::channel::ChannelRepository; use crate::repositories::computed_permission::ComputedPermissionRepository; use crate::repositories::emoji::EmojiRepository; use crate::repositories::message::MessageRepository; +use crate::repositories::message_reaction::MessageReactionRepository; use crate::repositories::read_state::ReadStateRepository; use crate::repositories::role::RoleRepository; use crate::repositories::server::ServerRepository; @@ -19,6 +20,7 @@ mod channel; mod computed_permission; pub mod emoji; mod message; +mod message_reaction; mod read_state; mod role; mod server; @@ -39,6 +41,7 @@ pub struct Repositories { pub channel: ChannelRepository, pub role: RoleRepository, pub message: MessageRepository, + pub message_reaction: MessageReactionRepository, pub read_state: ReadStateRepository, pub user: UserRepository, pub computed_permission: ComputedPermissionRepository, @@ -67,6 +70,9 @@ impl Repositories { message: MessageRepository { context: context.clone(), }, + message_reaction: MessageReactionRepository { + context: context.clone(), + }, read_state: ReadStateRepository { context: context.clone(), }, diff --git a/src/routes/gateway/mod.rs b/src/routes/gateway/mod.rs index 501ac2b..62d3b0f 100644 --- a/src/routes/gateway/mod.rs +++ b/src/routes/gateway/mod.rs @@ -1,10 +1,14 @@ use crate::domain::events::message::{ - MessageCreatedEvent, MessageDeletedEvent, MessageUpdatedEvent, + MessageCreatedEvent, MessageDeletedEvent, MessageReactionAddedEvent, + MessageReactionRemovedEvent, MessageUpdatedEvent, }; use crate::models::user::Model as User; use crate::routes::category::mapper::category_model_to_category_response; use crate::routes::channel::mapper::channel_model_to_channel_response; -use crate::routes::message::mapper::message_model_to_message_response_with_server_id; +use crate::routes::message::mapper::{ + message_model_to_message_response_with_reactions, + message_model_to_message_response_with_server_id, reaction_model_to_response, +}; use crate::routes::server::mapper::server_model_to_server_response; use crate::services::Services; use axum::extract::ws::Message; @@ -70,12 +74,22 @@ impl GatewayManager { event_bus.on_async::("message_updated", move |event| { let manager = Arc::clone(&manager); async move { + let message_id = event.message.id; + let reactions = manager + .services + .message_reaction + .grouped_for_messages(&[message_id]) + .await + .ok() + .and_then(|mut groups| groups.remove(&message_id)) + .unwrap_or_default(); manager.broadcast_message( event.channel_id, "update", - message_model_to_message_response_with_server_id( + message_model_to_message_response_with_reactions( event.message, event.server_id, + reactions, ), ); } @@ -88,6 +102,36 @@ impl GatewayManager { manager.broadcast_message(event.channel_id, "remove", event.message.id); } }); + + let manager = Arc::clone(self); + event_bus.on_async::( + "message_reaction_added", + move |event| { + let manager = Arc::clone(&manager); + async move { + manager.broadcast_reaction( + event.channel_id, + "add", + reaction_model_to_response(event.reaction), + ); + } + }, + ); + + let manager = Arc::clone(self); + event_bus.on_async::( + "message_reaction_removed", + move |event| { + let manager = Arc::clone(&manager); + async move { + manager.broadcast_reaction( + event.channel_id, + "remove", + reaction_model_to_response(event.reaction), + ); + } + }, + ); } pub(crate) fn add_client(&self, gateway_client: GatewayClient) { @@ -126,6 +170,33 @@ impl GatewayManager { } } } + + fn broadcast_reaction( + &self, + channel_id: Uuid, + action: &'static str, + content: T, + ) { + let event = GatewayEvent { + namespace: "Reaction", + action, + content, + }; + let Ok(json) = serde_json::to_string(&event) else { + return; + }; + + let users = self + .services + .realtime_registry + .users_for_channel(channel_id); + let clients = self.clients.read(); + for (key, client) in clients.iter() { + if users.contains(&key.user_id) { + let _ = client.sender.send(Message::Text(json.clone().into())); + } + } + } } impl GatewayClient { diff --git a/src/routes/message/handlers.rs b/src/routes/message/handlers.rs index 1c05769..c1aaec4 100644 --- a/src/routes/message/handlers.rs +++ b/src/routes/message/handlers.rs @@ -3,6 +3,7 @@ use crate::domain::dto::message::{ CreateMessageRequest, MessagePageResponse, MessageQueryParams, MessageResponse, UpdateMessageRequest, }; +use crate::domain::dto::reaction::{CreateReactionRequest, DeleteReactionQuery, ReactionResponse}; use crate::http::context::CurrentUser; use crate::http::error::HTTPError; use crate::routes::message::mapper; @@ -39,6 +40,12 @@ pub async fn get_all( let params = mapper::query_params_to_message_filter(filters); let page = state.repositories.message.filter(params).await?; + let message_ids: Vec<_> = page.messages.iter().map(|message| message.id).collect(); + let mut reactions = state + .services + .message_reaction + .grouped_for_messages(&message_ids) + .await?; let oldest_id = page.messages.first().map(|message| message.id); let newest_id = page.messages.last().map(|message| message.id); @@ -46,7 +53,10 @@ pub async fn get_all( messages: page .messages .into_iter() - .map(mapper::message_model_to_message_response) + .map(|message| { + let groups = reactions.remove(&message.id).unwrap_or_default(); + mapper::message_model_to_message_response_with_reactions(message, None, groups) + }) .collect(), oldest_id, newest_id, @@ -80,7 +90,17 @@ pub async fn get_by_id( .await? .ok_or(HTTPError::NotFound)?; - Ok(Json(mapper::message_model_to_message_response(message))) + let reactions = state + .services + .message_reaction + .grouped_for_messages(&[id]) + .await? + .remove(&id) + .unwrap_or_default(); + + Ok(Json( + mapper::message_model_to_message_response_with_reactions(message, None, reactions), + )) } /// Crée un nouveau message @@ -181,7 +201,81 @@ pub async fn update( .update_message(id, payload.content) .await?; - Ok(Json(mapper::message_model_to_message_response(message))) + let reactions = state + .services + .message_reaction + .grouped_for_messages(&[id]) + .await? + .remove(&id) + .unwrap_or_default(); + Ok(Json( + mapper::message_model_to_message_response_with_reactions(message, None, reactions), + )) +} + +#[utoipa::path( + post, + path = "/messages/{message_id}/reactions", + request_body = CreateReactionRequest, + responses( + (status = 201, description = "Réaction ajoutée", body = ReactionResponse), + (status = 200, description = "Réaction déjà présente", body = ReactionResponse), + (status = 400, description = "Emoji ou ton invalide"), + (status = 404, description = "Message non trouvé") + ), + params(("message_id" = Uuid, Path)), + tag = "Messages", + security(("bearerAuth" = [])) +)] +pub async fn add_reaction( + user: CurrentUser, + State(state): State, + Path(message_id): Path, + Json(payload): Json, +) -> Result<(StatusCode, Json), HTTPError> { + let (reaction, created) = state + .services + .message_reaction + .add(message_id, user.id, payload.emoji_id, payload.skin_tone) + .await?; + Ok(( + if created { + StatusCode::CREATED + } else { + StatusCode::OK + }, + Json(mapper::reaction_model_to_response(reaction)), + )) +} + +#[utoipa::path( + delete, + path = "/messages/{message_id}/reactions/{emoji_id}", + responses( + (status = 204, description = "Réaction supprimée"), + (status = 400, description = "Emoji ou ton invalide"), + (status = 404, description = "Réaction non trouvée") + ), + params( + ("message_id" = Uuid, Path), + ("emoji_id" = Uuid, Path), + DeleteReactionQuery + ), + tag = "Messages", + security(("bearerAuth" = [])) +)] +pub async fn remove_reaction( + user: CurrentUser, + State(state): State, + Path((message_id, emoji_id)): Path<(Uuid, Uuid)>, + Query(query): Query, +) -> Result { + state + .services + .message_reaction + .remove(message_id, user.id, emoji_id, query.skin_tone) + .await?; + Ok(StatusCode::NO_CONTENT) } /// Supprime un message diff --git a/src/routes/message/mapper.rs b/src/routes/message/mapper.rs index a2cb4fb..fc63da5 100644 --- a/src/routes/message/mapper.rs +++ b/src/routes/message/mapper.rs @@ -1,7 +1,10 @@ use crate::domain::dto::message::{ CreateMessageRequest, MessageQueryParams, MessageResponse, UpdateMessageRequest, }; +use crate::domain::dto::reaction::ReactionGroupResponse; +use crate::domain::dto::reaction::ReactionResponse; use crate::models::message; +use crate::models::message_reaction; use crate::repositories::types::MessageFilter; use chrono::Utc; use sea_orm::Set; @@ -14,6 +17,14 @@ pub fn message_model_to_message_response(model: message::Model) -> MessageRespon pub fn message_model_to_message_response_with_server_id( model: message::Model, server_id: Option, +) -> MessageResponse { + message_model_to_message_response_with_reactions(model, server_id, Vec::new()) +} + +pub fn message_model_to_message_response_with_reactions( + model: message::Model, + server_id: Option, + reactions: Vec, ) -> MessageResponse { MessageResponse { id: model.id, @@ -24,6 +35,7 @@ pub fn message_model_to_message_response_with_server_id( created_at: model.created_at, updated_at: model.updated_at, reply_to_id: model.reply_to_id, + reactions, } } @@ -62,3 +74,14 @@ pub fn query_params_to_message_filter(params: MessageQueryParams) -> MessageFilt limit: params.limit, } } + +pub fn reaction_model_to_response(model: message_reaction::Model) -> ReactionResponse { + ReactionResponse { + id: model.id, + message_id: model.message_id, + user_id: model.user_id, + emoji_id: model.emoji_id, + skin_tone: (model.skin_tone != 0).then_some(model.skin_tone as u8), + created_at: model.created_at, + } +} diff --git a/src/routes/message/routes.rs b/src/routes/message/routes.rs index 56cdaef..81cd29a 100644 --- a/src/routes/message/routes.rs +++ b/src/routes/message/routes.rs @@ -11,4 +11,12 @@ pub fn router() -> Router { .put(handlers::update) .delete(handlers::delete), ) + .route( + "/messages/{message_id}/reactions", + axum::routing::post(handlers::add_reaction), + ) + .route( + "/messages/{message_id}/reactions/{emoji_id}", + axum::routing::delete(handlers::remove_reaction), + ) } diff --git a/src/routes/openapi.rs b/src/routes/openapi.rs index 1a1c054..432aed2 100644 --- a/src/routes/openapi.rs +++ b/src/routes/openapi.rs @@ -41,6 +41,8 @@ use utoipa::{Modify, OpenApi}; message::handlers::create, message::handlers::update, message::handlers::delete, + message::handlers::add_reaction, + message::handlers::remove_reaction, core::handlers::join, emoji::handlers::get_all, emoji::handlers::get_by_id, @@ -73,6 +75,9 @@ use utoipa::{Modify, OpenApi}; crate::domain::dto::message::MessageResponse, crate::domain::dto::message::CreateMessageRequest, crate::domain::dto::message::UpdateMessageRequest, + crate::domain::dto::reaction::CreateReactionRequest, + crate::domain::dto::reaction::ReactionResponse, + crate::domain::dto::reaction::ReactionGroupResponse, crate::domain::dto::core::JoinRequest, ChannelType, crate::domain::dto::emoji::EmojiResponse, diff --git a/src/services/message_reaction.rs b/src/services/message_reaction.rs new file mode 100644 index 0000000..d1a3f23 --- /dev/null +++ b/src/services/message_reaction.rs @@ -0,0 +1,253 @@ +use crate::domain::dto::reaction::ReactionGroupResponse; +use crate::domain::events::message::{MessageReactionAddedEvent, MessageReactionRemovedEvent}; +use crate::http::error::HTTPError; +use crate::models::{emoji, message_reaction}; +use crate::services::ServicesContext; +use event_bus::Scope; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set}; +use std::collections::HashMap; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct MessageReactionService { + context: Arc, +} + +impl MessageReactionService { + pub fn new(context: Arc) -> Self { + Self { context } + } + + fn normalize_skin_tone(skin_tone: Option) -> Result { + match skin_tone { + None => Ok(0), + Some(tone @ 1..=5) => Ok(tone as i32), + Some(_) => Err(HTTPError::BadRequest( + "skin_tone must be between 1 and 5".into(), + )), + } + } + + async fn validate( + &self, + message_id: Uuid, + emoji_id: Uuid, + skin_tone: Option, + ) -> Result< + ( + crate::models::message::Model, + emoji::Model, + i32, + Option, + ), + HTTPError, + > { + let message = self + .context + .repositories + .message + .get_by_id(message_id) + .await? + .ok_or(HTTPError::NotFound)?; + let channel = self + .context + .repositories + .channel + .get_by_id(message.channel_id) + .await? + .ok_or(HTTPError::NotFound)?; + let emoji = self + .context + .repositories + .emoji + .get_by_id(emoji_id) + .await? + .ok_or_else(|| HTTPError::BadRequest("Emoji not found".into()))?; + + if emoji.server_id.is_some() && emoji.server_id != channel.server_id { + return Err(HTTPError::BadRequest( + "Emoji is not available in this message scope".into(), + )); + } + + let normalized_tone = Self::normalize_skin_tone(skin_tone)?; + if normalized_tone != 0 && (emoji.emoji_type != "unicode" || !emoji.supports_skin_tone) { + return Err(HTTPError::BadRequest( + "This emoji does not support skin tones".into(), + )); + } + + Ok((message, emoji, normalized_tone, channel.server_id)) + } + + pub async fn add( + &self, + message_id: Uuid, + user_id: Uuid, + emoji_id: Uuid, + skin_tone: Option, + ) -> Result<(message_reaction::Model, bool), HTTPError> { + let (message, _, skin_tone, server_id) = + self.validate(message_id, emoji_id, skin_tone).await?; + + if let Some(existing) = self + .context + .repositories + .message_reaction + .find(message_id, user_id, emoji_id, skin_tone) + .await? + { + return Ok((existing, false)); + } + + let reaction = self + .context + .repositories + .message_reaction + .create(message_reaction::ActiveModel { + id: Set(Uuid::now_v7()), + message_id: Set(message_id), + user_id: Set(user_id), + emoji_id: Set(emoji_id), + skin_tone: Set(skin_tone), + ..Default::default() + }) + .await?; + + let mut scopes = vec![Scope::uuid("channel", message.channel_id)]; + if let Some(server_id) = server_id { + scopes.push(Scope::uuid("server", server_id)); + } + self.context.event_bus.emit_scoped( + "message_reaction_added", + scopes, + MessageReactionAddedEvent { + server_id, + channel_id: message.channel_id, + reaction: reaction.clone(), + }, + ); + + Ok((reaction, true)) + } + + pub async fn remove( + &self, + message_id: Uuid, + user_id: Uuid, + emoji_id: Uuid, + skin_tone: Option, + ) -> Result { + let (message, _, skin_tone, server_id) = + self.validate(message_id, emoji_id, skin_tone).await?; + let reaction = self + .context + .repositories + .message_reaction + .delete(message_id, user_id, emoji_id, skin_tone) + .await? + .ok_or(HTTPError::NotFound)?; + + let mut scopes = vec![Scope::uuid("channel", message.channel_id)]; + if let Some(server_id) = server_id { + scopes.push(Scope::uuid("server", server_id)); + } + self.context.event_bus.emit_scoped( + "message_reaction_removed", + scopes, + MessageReactionRemovedEvent { + server_id, + channel_id: message.channel_id, + reaction: reaction.clone(), + }, + ); + + Ok(reaction) + } + + pub async fn grouped_for_messages( + &self, + message_ids: &[Uuid], + ) -> Result>, HTTPError> { + let reactions = self + .context + .repositories + .message_reaction + .list_by_message_ids(message_ids) + .await?; + if reactions.is_empty() { + return Ok(HashMap::new()); + } + let emoji_ids: Vec<_> = reactions.iter().map(|reaction| reaction.emoji_id).collect(); + let emojis: HashMap<_, _> = emoji::Entity::find() + .filter(emoji::Column::Id.is_in(emoji_ids)) + .all(&self.context.repositories.emoji.context.db) + .await? + .into_iter() + .map(|emoji| (emoji.id, emoji)) + .collect(); + + let mut grouped: HashMap> = + HashMap::new(); + for reaction in reactions { + let Some(emoji) = emojis.get(&reaction.emoji_id) else { + continue; + }; + let groups = grouped.entry(reaction.message_id).or_default(); + let group = groups + .entry((reaction.emoji_id, reaction.skin_tone)) + .or_insert_with(|| ReactionGroupResponse { + emoji_id: emoji.id, + name: emoji.name.clone(), + unicode_sequence: emoji.unicode_sequence.clone(), + asset_url: emoji + .file_path + .as_ref() + .map(|_| format!("/api/emojis/{}/asset", emoji.id)), + skin_tone: (reaction.skin_tone != 0).then_some(reaction.skin_tone as u8), + count: 0, + user_ids: Vec::new(), + }); + group.count += 1; + group.user_ids.push(reaction.user_id); + } + + Ok(grouped + .into_iter() + .map(|(message_id, groups)| { + let mut groups: Vec<_> = groups.into_values().collect(); + for group in &mut groups { + group.user_ids.sort_unstable(); + } + groups.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then(left.skin_tone.cmp(&right.skin_tone)) + }); + (message_id, groups) + }) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::MessageReactionService; + + #[test] + fn skin_tone_validation_accepts_only_the_supported_range() { + assert_eq!( + MessageReactionService::normalize_skin_tone(None).unwrap(), + 0 + ); + for tone in 1..=5 { + assert_eq!( + MessageReactionService::normalize_skin_tone(Some(tone)).unwrap(), + tone as i32 + ); + } + assert!(MessageReactionService::normalize_skin_tone(Some(0)).is_err()); + assert!(MessageReactionService::normalize_skin_tone(Some(6)).is_err()); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index ff49d3a..2ee058d 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -3,6 +3,7 @@ use crate::services::category::CategoryService; use crate::services::channel::ChannelService; use crate::services::emoji::EmojiService; use crate::services::message::MessageService; +use crate::services::message_reaction::MessageReactionService; use crate::services::permission::PermissionService; use crate::services::permission_sync::PermissionSyncService; use crate::services::role::RoleService; @@ -16,6 +17,7 @@ pub mod category; pub mod channel; pub mod emoji; pub mod message; +pub mod message_reaction; mod permission; pub mod permission_sync; pub mod realtime_registry; @@ -40,6 +42,7 @@ pub struct Services { pub server: Arc, pub category: Arc, pub message: Arc, + pub message_reaction: Arc, pub user: Arc, pub role: Arc, pub permission: Arc, @@ -60,6 +63,7 @@ impl Services { let server = Arc::new(ServerService::new(service_context.clone())); let category = Arc::new(CategoryService::new(service_context.clone())); let message = Arc::new(MessageService::new(service_context.clone())); + let message_reaction = Arc::new(MessageReactionService::new(service_context.clone())); let user = Arc::new(UserService::new(service_context.clone())); let role = Arc::new(RoleService::new(service_context.clone())); let permission = Arc::new(PermissionService::new(service_context.clone())); @@ -73,6 +77,7 @@ impl Services { server, category, message, + message_reaction, user, role, permission,