This commit is contained in:
2026-08-09 10:30:54 +02:00
parent 93800e8460
commit 20beea24d5
11 changed files with 325 additions and 61 deletions
+9 -2
View File
@@ -1,7 +1,14 @@
<template> <template>
<router-view/> <router-view />
<v-snackbar v-model="notification.visible" :timeout="5000" location="bottom right">
<div class="font-weight-bold">{{ notification.title }}</div>
<div>{{ notification.message }}</div>
</v-snackbar>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
// import {useNotificationStore} from "@/stores/notification.ts";
const notification = useNotificationStore();
</script> </script>
+35 -3
View File
@@ -1,6 +1,9 @@
import {defineStore} from "pinia"; import {defineStore} from "pinia";
import {useApi} from "@/composables/useApi.ts"; import {useApi} from "@/composables/useApi.ts";
import {onGatewayEvent} from "@/plugins/events.ts"; import {onGatewayEvent} from "@/plugins/events.ts";
import {useServerStore} from "@/stores/server.ts";
import {useAuthStore} from "@/stores/auth.ts";
import {useNotificationStore} from "@/stores/notification.ts";
// Change this value to adjust the maximum number of messages kept in the DOM. // Change this value to adjust the maximum number of messages kept in the DOM.
// Directional loads automatically use half of this window. // Directional loads automatically use half of this window.
@@ -9,6 +12,7 @@ export const MESSAGE_SHIFT_SIZE = Math.max(1, Math.floor(MESSAGE_WINDOW_SIZE / 2
export interface Message { export interface Message {
id: string; id: string;
server_id: string | null;
channel_id: string; channel_id: string;
user_id: string; user_id: string;
content: string; content: string;
@@ -85,6 +89,7 @@ export const useMessageStore = defineStore("message", {
isAtBottom: true, isAtBottom: true,
scrollToBottomRequested: false, scrollToBottomRequested: false,
requestVersion: 0, requestVersion: 0,
seenRealtimeMessageIds: new Set<string>(),
}), }),
actions: { actions: {
@@ -242,8 +247,29 @@ export const useMessageStore = defineStore("message", {
return await response.json() as ReadStateResponse; return await response.json() as ReadStateResponse;
}, },
addRealtimeMessage(message: Message) { addRealtimeMessage(message: Message, fromGateway = false) {
if (message.channel_id !== this.activeChannelId) return; const serverStore = useServerStore();
const authStore = useAuthStore();
const notificationStore = useNotificationStore();
if (fromGateway) {
if (this.seenRealtimeMessageIds.has(message.id)) return;
this.seenRealtimeMessageIds.add(message.id);
if (this.seenRealtimeMessageIds.size > 1000) {
const oldest = this.seenRealtimeMessageIds.values().next().value;
if (oldest) this.seenRealtimeMessageIds.delete(oldest);
}
}
const isOwnMessage = authStore.currentUser?.id === message.user_id;
const isActiveChannel = message.channel_id === this.activeChannelId;
if (!isActiveChannel) {
if (fromGateway && !isOwnMessage) {
serverStore.applyIncomingMessage(message.server_id, message.channel_id);
notificationStore.show("Nouveau message", "Un nouveau message est arrivé dans un autre canal.");
}
return;
}
const existingIndex = this.messages.findIndex(current => current.id === message.id); const existingIndex = this.messages.findIndex(current => current.id === message.id);
if (existingIndex !== -1) { if (existingIndex !== -1) {
@@ -253,6 +279,9 @@ export const useMessageStore = defineStore("message", {
if (!this.isAtBottom && this.newestId && message.id > this.newestId) { if (!this.isAtBottom && this.newestId && message.id > this.newestId) {
this.hasMoreAfter = true; this.hasMoreAfter = true;
if (fromGateway && !isOwnMessage) {
serverStore.applyIncomingMessage(message.server_id, message.channel_id);
}
return; return;
} }
@@ -261,6 +290,8 @@ export const useMessageStore = defineStore("message", {
this.hasMoreAfter = false; this.hasMoreAfter = false;
if (this.isAtBottom) { if (this.isAtBottom) {
this.scrollToBottomRequested = true; this.scrollToBottomRequested = true;
} else if (fromGateway && !isOwnMessage) {
serverStore.applyIncomingMessage(message.server_id, message.channel_id);
} }
}, },
@@ -300,6 +331,7 @@ export const useMessageStore = defineStore("message", {
this.loadingAfter = false; this.loadingAfter = false;
this.isAtBottom = true; this.isAtBottom = true;
this.scrollToBottomRequested = false; this.scrollToBottomRequested = false;
this.seenRealtimeMessageIds.clear();
}, },
}, },
}); });
@@ -309,7 +341,7 @@ onGatewayEvent("Message", (payload) => {
switch (payload.action) { switch (payload.action) {
case "add": case "add":
store.addRealtimeMessage(payload.content as Message); store.addRealtimeMessage(payload.content as Message, true);
break; break;
case "update": case "update":
store.updateMessage(payload.content as Message); store.updateMessage(payload.content as Message);
+19
View File
@@ -0,0 +1,19 @@
import {defineStore} from "pinia";
export const useNotificationStore = defineStore("notification", {
state: () => ({
visible: false,
title: "",
message: "",
}),
actions: {
show(title: string, message: string) {
this.title = title;
this.message = message;
this.visible = true;
},
hide() {
this.visible = false;
},
},
});
+15
View File
@@ -131,6 +131,21 @@ export const useServerStore = defineStore("server", {
); );
} }
}, },
applyIncomingMessage(serverId: string | null, channelId: string) {
if (!serverId) return;
for (const item of this.currentTree) {
const channels = "Category" in item ? item.Category[1] : "Channel" in item ? [item.Channel] : [];
const channel = channels.find((candidate: { id: string }) => candidate.id === channelId);
if (channel) {
channel.unread_count = (channel.unread_count ?? 0) + 1;
break;
}
}
const server = this.servers.find(candidate => candidate.id === serverId);
if (server) server.unread_count = (server.unread_count ?? 0) + 1;
},
reset() { reset() {
this.servers = []; this.servers = [];
this.loading = false; this.loading = false;
+1
View File
@@ -36,6 +36,7 @@ impl App {
// Initialize gateway manager // Initialize gateway manager
let gateway = Arc::new(GatewayManager::default()); let gateway = Arc::new(GatewayManager::default());
gateway.start(event_bus.clone());
// Init one server if no one exist // Init one server if no one exist
let default_server = match repositories.server.get_default().await? { let default_server = match repositories.server.get_default().await? {
+1
View File
@@ -6,6 +6,7 @@ use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, ToSchema)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct MessageResponse { pub struct MessageResponse {
pub id: Uuid, pub id: Uuid,
pub server_id: Option<Uuid>,
pub channel_id: Uuid, pub channel_id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub content: String, pub content: String,
+53 -4
View File
@@ -1,17 +1,19 @@
use crate::core::AppState; use crate::core::AppState;
use crate::http::context::CurrentUser; use crate::http::context::CurrentUser;
use crate::models::user::Model as User; use crate::models::user::Model as User;
use crate::permissions::ChannelPermission;
use crate::routes::gateway::GatewayClient; use crate::routes::gateway::GatewayClient;
use axum::{ use axum::{
extract::{ extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State, State,
ws::{Message, WebSocket, WebSocketUpgrade},
}, },
response::IntoResponse, response::IntoResponse,
}; };
use futures_util::{sink::SinkExt, stream::StreamExt}; use futures_util::{sink::SinkExt, stream::StreamExt};
use serde::Deserialize; use serde::Deserialize;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use uuid::Uuid;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct WsQuery { pub struct WsQuery {
@@ -41,9 +43,17 @@ async fn handle_socket(socket: WebSocket, state: AppState, user: User) {
let (mut sender, mut receiver) = socket.split(); let (mut sender, mut receiver) = socket.split();
let (tx, mut rx) = mpsc::unbounded_channel::<Message>(); let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
let channel_ids = match accessible_channel_ids(&state, &user).await {
Ok(channel_ids) => channel_ids,
Err(error) => {
tracing::error!(user_id = %user.id, ?error, "Unable to resolve gateway channel access");
return;
}
};
let mut client = GatewayClient::new(user, tx, state.event_bus.clone()); let mut client = GatewayClient::new(user, tx, state.event_bus.clone());
client.on_connect().await; client.on_connect().await;
state.gateway.add_client(client.clone()); state.gateway.add_client(client.clone(), channel_ids);
// Task pour envoyer les messages du canal mpsc vers le WebSocket // Task pour envoyer les messages du canal mpsc vers le WebSocket
let mut send_task = tokio::spawn(async move { let mut send_task = tokio::spawn(async move {
@@ -56,7 +66,6 @@ async fn handle_socket(socket: WebSocket, state: AppState, user: User) {
// Task pour recevoir les messages du WebSocket // Task pour recevoir les messages du WebSocket
let client_clone = client.clone(); let client_clone = client.clone();
let state_clone = state.clone();
let mut recv_task = tokio::spawn(async move { let mut recv_task = tokio::spawn(async move {
while let Some(Ok(message)) = receiver.next().await { while let Some(Ok(message)) = receiver.next().await {
client_clone.on_message(message).await; client_clone.on_message(message).await;
@@ -69,7 +78,47 @@ async fn handle_socket(socket: WebSocket, state: AppState, user: User) {
_ = (&mut recv_task) => send_task.abort(), _ = (&mut recv_task) => send_task.abort(),
}; };
state.gateway.remove_client(client.clone()); state.gateway.remove_client(&client);
// // Déconnexion (Disconnect) // // Déconnexion (Disconnect)
client.on_disconnect().await; client.on_disconnect().await;
} }
async fn accessible_channel_ids(state: &AppState, user: &User) -> Result<Vec<Uuid>, anyhow::Error> {
if user.is_superuser {
return Ok(state
.repositories
.channel
.get_all()
.await?
.into_iter()
.map(|channel| channel.id)
.collect());
}
let mut channel_ids = Vec::new();
for server in state.repositories.server.get_all().await? {
if state
.repositories
.server
.get_user(server.id, user.id)
.await?
.is_none()
{
continue;
}
let tree = state
.repositories
.server_tree
.get_for_user(server.id, user.id)
.await?;
channel_ids.extend(tree.channels.into_iter().filter_map(|channel| {
channel
.permissions
.filter(|permissions| permissions.contains(ChannelPermission::READ_CHANNEL))
.map(|_| channel.channel.id)
}));
}
Ok(channel_ids)
}
+117 -42
View File
@@ -1,17 +1,16 @@
use crate::models::category; use crate::domain::events::message::{
use crate::models::channel; MessageCreatedEvent, MessageDeletedEvent, MessageUpdatedEvent,
use crate::models::message; };
use crate::models::server;
use crate::models::user::Model as User; use crate::models::user::Model as User;
use crate::routes::category::mapper::category_model_to_category_response; use crate::routes::category::mapper::category_model_to_category_response;
use crate::routes::channel::mapper::channel_model_to_channel_response; use crate::routes::channel::mapper::channel_model_to_channel_response;
use crate::routes::message::mapper::message_model_to_message_response; use crate::routes::message::mapper::message_model_to_message_response_with_server_id;
use crate::routes::server::mapper::server_model_to_server_response; use crate::routes::server::mapper::server_model_to_server_response;
use axum::extract::ws::Message; use axum::extract::ws::Message;
use event_bus::EventBus; use event_bus::EventBus;
use events::GatewayEvent; use events::GatewayEvent;
use parking_lot::RwLock; use parking_lot::RwLock;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
@@ -23,8 +22,15 @@ pub mod routes;
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct GatewayManager { pub struct GatewayManager {
// {UserID: {connection_id: GatewayClient}} // Chaque connexion est inscrite dans les groupes des canaux accessibles.
pub clients: RwLock<HashMap<Uuid, HashMap<Uuid, GatewayClient>>>, pub clients: RwLock<HashMap<ConnectionKey, GatewayClient>>,
channel_subscribers: RwLock<HashMap<Uuid, HashSet<ConnectionKey>>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ConnectionKey {
pub user_id: Uuid,
pub connection_id: Uuid,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -37,19 +43,98 @@ pub struct GatewayClient {
} }
impl GatewayManager { impl GatewayManager {
fn add_client(&self, gateway_client: GatewayClient) { /// Démarre les routeurs centraux des événements de messages.
let mut clients = self.clients.write(); pub fn start(self: &Arc<Self>, event_bus: Arc<EventBus>) {
let user_id = gateway_client.user.id; let manager = Arc::clone(self);
clients event_bus.on_async::<MessageCreatedEvent, _, _>("message_created", move |event| {
.entry(user_id) let manager = Arc::clone(&manager);
.or_insert_with(HashMap::new) async move {
.insert(gateway_client.connection_id, gateway_client); manager.broadcast_message(
event.channel_id,
"add",
message_model_to_message_response_with_server_id(
event.message,
event.server_id,
),
);
}
});
let manager = Arc::clone(self);
event_bus.on_async::<MessageUpdatedEvent, _, _>("message_updated", move |event| {
let manager = Arc::clone(&manager);
async move {
manager.broadcast_message(
event.channel_id,
"update",
message_model_to_message_response_with_server_id(
event.message,
event.server_id,
),
);
}
});
let manager = Arc::clone(self);
event_bus.on_async::<MessageDeletedEvent, _, _>("message_deleted", move |event| {
let manager = Arc::clone(&manager);
async move {
manager.broadcast_message(event.channel_id, "remove", event.message.id);
}
});
} }
fn remove_client(&self, gateway_client: GatewayClient) { pub(crate) fn add_client(
let mut clients = self.clients.write(); &self,
if let Some(client_list) = clients.get_mut(&gateway_client.user.id) { gateway_client: GatewayClient,
client_list.remove(&gateway_client.connection_id); channel_ids: impl IntoIterator<Item = Uuid>,
) {
let key = gateway_client.key();
self.clients.write().insert(key, gateway_client);
let mut subscribers = self.channel_subscribers.write();
for channel_id in channel_ids {
subscribers.entry(channel_id).or_default().insert(key);
}
}
pub(crate) fn remove_client(&self, gateway_client: &GatewayClient) {
let key = gateway_client.key();
self.clients.write().remove(&key);
let mut subscribers = self.channel_subscribers.write();
for channel_subscribers in subscribers.values_mut() {
channel_subscribers.remove(&key);
}
subscribers.retain(|_, values| !values.is_empty());
}
fn broadcast_message<T: serde::Serialize>(
&self,
channel_id: Uuid,
action: &'static str,
content: T,
) {
let event = GatewayEvent {
namespace: "Message",
action,
content,
};
let Ok(json) = serde_json::to_string(&event) else {
return;
};
let keys = self
.channel_subscribers
.read()
.get(&channel_id)
.cloned()
.unwrap_or_default();
let clients = self.clients.read();
for key in keys {
if let Some(client) = clients.get(&key) {
let _ = client.sender.send(Message::Text(json.clone().into()));
}
} }
} }
} }
@@ -60,16 +145,22 @@ impl GatewayClient {
sender: mpsc::UnboundedSender<Message>, sender: mpsc::UnboundedSender<Message>,
event_bus: Arc<EventBus>, event_bus: Arc<EventBus>,
) -> Self { ) -> Self {
let connection_id = Uuid::new_v4();
Self { Self {
user, user,
connection_id, connection_id: Uuid::new_v4(),
sender, sender,
event_bus, event_bus,
_event_handles: Vec::new(), _event_handles: Vec::new(),
} }
} }
pub fn key(&self) -> ConnectionKey {
ConnectionKey {
user_id: self.user.id,
connection_id: self.connection_id,
}
}
fn subscribe_event<T, F, R>( fn subscribe_event<T, F, R>(
&self, &self,
event_name: &'static str, event_name: &'static str,
@@ -105,22 +196,8 @@ impl GatewayClient {
pub fn subscribe_to_events(&mut self) { pub fn subscribe_to_events(&mut self) {
let mut handles = Vec::new(); let mut handles = Vec::new();
// Message // Les messages sont routés par GatewayManager selon le channel_id.
handles.push(self.subscribe_event(
"message_created",
"Message",
"add",
message_model_to_message_response,
));
handles.push(self.subscribe_event(
"message_updated",
"Message",
"update",
message_model_to_message_response,
));
handles.push(self.subscribe_event("message_deleted", "Message", "remove", |id: Uuid| id));
// Channel
handles.push(self.subscribe_event( handles.push(self.subscribe_event(
"channel_created", "channel_created",
"Channel", "Channel",
@@ -135,7 +212,6 @@ impl GatewayClient {
)); ));
handles.push(self.subscribe_event("channel_deleted", "Channel", "remove", |id: Uuid| id)); handles.push(self.subscribe_event("channel_deleted", "Channel", "remove", |id: Uuid| id));
// Category
handles.push(self.subscribe_event( handles.push(self.subscribe_event(
"category_created", "category_created",
"Category", "Category",
@@ -150,7 +226,6 @@ impl GatewayClient {
)); ));
handles.push(self.subscribe_event("category_deleted", "Category", "remove", |id: Uuid| id)); handles.push(self.subscribe_event("category_deleted", "Category", "remove", |id: Uuid| id));
// Server
handles.push(self.subscribe_event( handles.push(self.subscribe_event(
"server_created", "server_created",
"Server", "Server",
@@ -175,20 +250,20 @@ impl GatewayClient {
} }
async fn on_connect(&mut self) { async fn on_connect(&mut self) {
tracing::info!("Client connected: {:?}", self.user); tracing::info!(user_id = %self.user.id, "Client connected");
self.subscribe_to_events(); self.subscribe_to_events();
} }
async fn on_disconnect(&mut self) { async fn on_disconnect(&mut self) {
tracing::info!("Client disconnected: {:?}", self.user); tracing::info!(user_id = %self.user.id, "Client disconnected");
self.unsubscribe_all(); self.unsubscribe_all();
} }
async fn on_message(&self, message: Message) { async fn on_message(&self, message: Message) {
match message { match message {
Message::Binary(content) => {} Message::Binary(_) => {}
Message::Text(content) => { Message::Text(content) => {
tracing::info!("Received text message: {}", content); tracing::info!(user_id = %self.user.id, "Received text message: {}", content);
} }
Message::Ping(_) => {} Message::Ping(_) => {}
Message::Pong(_) => {} Message::Pong(_) => {}
+5 -2
View File
@@ -104,7 +104,7 @@ pub async fn create(
Json(payload): Json<CreateMessageRequest>, Json(payload): Json<CreateMessageRequest>,
) -> Result<(StatusCode, Json<MessageResponse>), HTTPError> { ) -> Result<(StatusCode, Json<MessageResponse>), HTTPError> {
// Vérifier que le canal existe // Vérifier que le canal existe
state let channel = state
.repositories .repositories
.channel .channel
.get_by_id(payload.channel_id) .get_by_id(payload.channel_id)
@@ -130,7 +130,10 @@ pub async fn create(
.await?; .await?;
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(mapper::message_model_to_message_response(message)), Json(mapper::message_model_to_message_response_with_server_id(
message,
channel.server_id,
)),
)) ))
} }
+8
View File
@@ -8,8 +8,16 @@ use sea_orm::Set;
use uuid::Uuid; use uuid::Uuid;
pub fn message_model_to_message_response(model: message::Model) -> MessageResponse { pub fn message_model_to_message_response(model: message::Model) -> MessageResponse {
message_model_to_message_response_with_server_id(model, None)
}
pub fn message_model_to_message_response_with_server_id(
model: message::Model,
server_id: Option<Uuid>,
) -> MessageResponse {
MessageResponse { MessageResponse {
id: model.id, id: model.id,
server_id,
channel_id: model.channel_id, channel_id: model.channel_id,
user_id: model.user_id, user_id: model.user_id,
content: model.content, content: model.content,
+62 -8
View File
@@ -1,7 +1,10 @@
use crate::services::ServicesContext; use crate::domain::events::message::{
MessageCreatedEvent, MessageDeletedEvent, MessageUpdatedEvent,
};
use crate::models::{channel, message}; use crate::models::{channel, message};
use crate::services::ServicesContext;
use event_bus::Scope; use event_bus::Scope;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, TransactionTrait, Set}; use sea_orm::{ActiveModelTrait, EntityTrait, QuerySelect, Set, TransactionTrait};
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
@@ -51,7 +54,15 @@ impl MessageService {
scopes.push(Scope::uuid("server", server_id)); scopes.push(Scope::uuid("server", server_id));
} }
event_bus.emit_scoped("message_created", scopes, msg.clone()); event_bus.emit_scoped(
"message_created",
scopes,
MessageCreatedEvent {
server_id,
channel_id: msg.channel_id,
message: msg.clone(),
},
);
Ok(msg) Ok(msg)
} }
@@ -78,7 +89,27 @@ impl MessageService {
txn.commit().await?; txn.commit().await?;
event_bus.emit("message_updated", msg.clone()); let server_id = channel::Entity::find_by_id(msg.channel_id)
.select_only()
.column(channel::Column::ServerId)
.into_tuple::<Option<Uuid>>()
.one(db)
.await?
.flatten();
let mut scopes = vec![Scope::uuid("channel", msg.channel_id)];
if let Some(server_id) = server_id {
scopes.push(Scope::uuid("server", server_id));
}
event_bus.emit_scoped(
"message_updated",
scopes,
MessageUpdatedEvent {
server_id,
channel_id: msg.channel_id,
message: msg.clone(),
},
);
Ok(msg) Ok(msg)
} }
@@ -89,16 +120,39 @@ impl MessageService {
let txn = db.begin().await?; let txn = db.begin().await?;
let res = message::Entity::delete_by_id(id) let existing = message::Entity::find_by_id(id).one(db).await?;
.exec(&txn) let Some(existing) = existing else {
.await?; return Ok(false);
};
let server_id = channel::Entity::find_by_id(existing.channel_id)
.select_only()
.column(channel::Column::ServerId)
.into_tuple::<Option<Uuid>>()
.one(db)
.await?
.flatten();
let res = message::Entity::delete_by_id(id).exec(&txn).await?;
let deleted = res.rows_affected > 0; let deleted = res.rows_affected > 0;
txn.commit().await?; txn.commit().await?;
if deleted { if deleted {
event_bus.emit("message_deleted", id); let mut scopes = vec![Scope::uuid("channel", existing.channel_id)];
if let Some(server_id) = server_id {
scopes.push(Scope::uuid("server", server_id));
}
event_bus.emit_scoped(
"message_deleted",
scopes,
MessageDeletedEvent {
server_id,
channel_id: existing.channel_id,
message: existing,
},
);
} }
Ok(deleted) Ok(deleted)