From 42ab990f7d4e019f0989f5181c679fd5d5da534d Mon Sep 17 00:00:00 2001 From: Nell Date: Sat, 8 Aug 2026 19:56:57 +0200 Subject: [PATCH] init --- frontend/src/layouts/AppLayout.vue | 35 ++- frontend/src/pages/server/channel/index.vue | 31 ++- frontend/src/pages/server/index.vue | 30 ++- frontend/src/stores/channel.ts | 3 +- frontend/src/stores/message.ts | 18 ++ frontend/src/stores/server.ts | 32 ++- .../src/m20220101_000001_create_table.rs | 243 +++++++++++++----- src/domain/dto/channel.rs | 16 ++ src/domain/dto/server.rs | 2 + src/main.rs | 4 +- src/models/channel_user_read_state.rs | 33 +++ src/models/mod.rs | 1 + src/models/prelude.rs | 1 + src/repositories/mod.rs | 6 + src/repositories/read_state.rs | 152 +++++++++++ src/repositories/server_tree.rs | 34 ++- src/repositories/types.rs | 1 + src/routes/channel/handlers.rs | 90 ++++++- src/routes/channel/mapper.rs | 1 + src/routes/channel/routes.rs | 4 + src/routes/openapi.rs | 2 + src/routes/server/handlers.rs | 16 +- src/routes/server/mapper.rs | 19 +- 23 files changed, 681 insertions(+), 93 deletions(-) create mode 100644 src/models/channel_user_read_state.rs create mode 100644 src/repositories/read_state.rs diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index fff23a0..6b52dc5 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -145,13 +145,23 @@ function onServerContextMenu(event: MouseEvent, server: Server) { :to="`/server/${server.id}`" @contextmenu="onServerContextMenu($event, server)" > - - {{ getServerInitials(server.name) }} - + + {{ getServerInitials(server.name) }} + + diff --git a/frontend/src/pages/server/channel/index.vue b/frontend/src/pages/server/channel/index.vue index 5c5e0e8..cde4f70 100644 --- a/frontend/src/pages/server/channel/index.vue +++ b/frontend/src/pages/server/channel/index.vue @@ -3,6 +3,7 @@ import 'highlight.js/styles/github-dark.css' import {computed, nextTick, ref, watch} from 'vue'; import {storeToRefs} from 'pinia'; import {useMessageStore} from '@/stores/message'; +import {useServerStore} from '@/stores/server'; import {useUserStore} from "@/stores/user.ts"; import {useMarkdown} from '@/composables/useMarkdown' @@ -13,11 +14,12 @@ const props = defineProps<{ const channelId = computed(() => props.channelId); const messageStore = useMessageStore(); +const serverStore = useServerStore(); const userStore = useUserStore(); const {renderMarkdown} = useMarkdown() const messageContainer = ref(null); -const {messages, loading, loadingBefore, loadingAfter, hasMoreAfter, isAtBottom} = storeToRefs(messageStore); +const {messages, loading, loadingBefore, loadingAfter, hasMoreAfter, isAtBottom, newestId} = storeToRefs(messageStore); const newMessage = ref(''); const SCROLL_LOAD_THRESHOLD = 120; @@ -25,6 +27,24 @@ const SCROLL_BOTTOM_TOLERANCE = 4; const paginationLock = ref<'before' | 'after' | null>(null); const paginationLockScrollTop = ref(0); const lastScrollTop = ref(0); +const markedMessageByChannel = new Map(); + +const markCurrentChannelRead = async (targetChannelId: string) => { + if (messageStore.activeChannelId !== targetChannelId || !newestId.value) return; + + const messageId = newestId.value; + if (markedMessageByChannel.get(targetChannelId) === messageId) return; + + try { + const readState = await messageStore.markChannelRead(targetChannelId, messageId); + if (messageStore.activeChannelId !== targetChannelId) return; + + markedMessageByChannel.set(targetChannelId, messageId); + serverStore.applyChannelReadState(props.serverId, targetChannelId, readState.unread_count); + } catch (error) { + console.error('Erreur lors de la mise à jour de la lecture:', error); + } +}; const showRecentMessagesButton = computed(() => !loading.value && (hasMoreAfter.value || !isAtBottom.value), @@ -163,6 +183,7 @@ const returnToRecentMessages = async () => { paginationLock.value = null; await messageStore.fetchMessages(channelId.value); await scrollToBottom(); + await markCurrentChannelRead(channelId.value); }; // Only explicit initial loads and realtime messages received while at the @@ -173,9 +194,17 @@ watch(messages, async () => { } }, {deep: true, flush: 'post'}); +watch(isAtBottom, async (atBottom) => { + if (atBottom) { + await markCurrentChannelRead(channelId.value); + } +}); + watch(channelId, async (newChannelId) => { if (newChannelId) { await messageStore.fetchMessages(newChannelId); + await scrollToBottom(); + await markCurrentChannelRead(newChannelId); } }, {immediate: true}) diff --git a/frontend/src/pages/server/index.vue b/frontend/src/pages/server/index.vue index 5ab401a..e8d1473 100644 --- a/frontend/src/pages/server/index.vue +++ b/frontend/src/pages/server/index.vue @@ -177,9 +177,22 @@ function onChannelContextMenu(event: MouseEvent, channel: any) { :key="channel.id" :title="channel.name" :to="`/server/${serverId}/channel/${channel.id}`" + :class="{ 'font-weight-bold': (channel.unread_count ?? 0) > 0 }" link @contextmenu="onChannelContextMenu($event, channel)" - /> + > + + @@ -188,9 +201,22 @@ function onChannelContextMenu(event: MouseEvent, channel: any) { :key="item.Channel.id" :title="item.Channel.name" :to="`/server/${serverId}/channel/${item.Channel.id}`" + :class="{ 'font-weight-bold': (item.Channel.unread_count ?? 0) > 0 }" link @contextmenu="onChannelContextMenu($event, item.Channel)" - /> + > + + diff --git a/frontend/src/stores/channel.ts b/frontend/src/stores/channel.ts index 86db4aa..df684ac 100644 --- a/frontend/src/stores/channel.ts +++ b/frontend/src/stores/channel.ts @@ -9,6 +9,7 @@ interface Channel { category_id?: string | null created_at: string updated_at: string + unread_count?: number } /* @@ -65,4 +66,4 @@ export const useChannelStore = defineStore('channel', { this.error = null; } } -}) \ No newline at end of file +}) diff --git a/frontend/src/stores/message.ts b/frontend/src/stores/message.ts index 33ab082..4b60238 100644 --- a/frontend/src/stores/message.ts +++ b/frontend/src/stores/message.ts @@ -17,6 +17,13 @@ export interface Message { reply_to_id: string | null; } +export interface ReadStateResponse { + channel_id: string; + last_read_message_id: string | null; + updated_at: string | null; + unread_count: number; +} + interface MessagePage { messages: Message[]; oldest_id: string | null; @@ -224,6 +231,17 @@ export const useMessageStore = defineStore("message", { } }, + async markChannelRead(channelId: string, messageId: string): Promise { + const response = await useApi().put(`/channels/${channelId}/read-state`, { + last_read_message_id: messageId, + }); + if (!response.ok) { + throw new Error(`Read state update failed (${response.status})`); + } + + return await response.json() as ReadStateResponse; + }, + addRealtimeMessage(message: Message) { if (message.channel_id !== this.activeChannelId) return; diff --git a/frontend/src/stores/server.ts b/frontend/src/stores/server.ts index e2b895e..7f410c0 100644 --- a/frontend/src/stores/server.ts +++ b/frontend/src/stores/server.ts @@ -9,6 +9,7 @@ export interface Server { is_default: boolean created_at: string updated_at: string + unread_count?: number } export const useServerStore = defineStore("server", { @@ -33,7 +34,10 @@ export const useServerStore = defineStore("server", { const server: Server = await response.json(); const index = this.servers.findIndex(item => item.id === server.id); - if (index >= 0) this.servers[index] = server; + if (index >= 0) { + server.unread_count ??= this.servers[index].unread_count ?? 0; + this.servers[index] = server; + } else this.servers.push(server); return server; }, @@ -70,7 +74,10 @@ export const useServerStore = defineStore("server", { } const updated = await response.json(); const index = this.servers.findIndex(server => server.id === serverId); - if (index >= 0) this.servers[index] = updated; + if (index >= 0) { + updated.unread_count ??= this.servers[index].unread_count ?? 0; + this.servers[index] = updated; + } return updated; }, async fetchServerTree(serverId: string) { @@ -103,6 +110,27 @@ export const useServerStore = defineStore("server", { return tree.items; }, + applyChannelReadState(serverId: string, channelId: string, unreadCount: number) { + let previousUnreadCount = 0; + + 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) { + previousUnreadCount = channel.unread_count ?? 0; + channel.unread_count = unreadCount; + break; + } + } + + const server = this.servers.find(candidate => candidate.id === serverId); + if (server) { + server.unread_count = Math.max( + 0, + (server.unread_count ?? 0) - previousUnreadCount + unreadCount, + ); + } + }, reset() { this.servers = []; this.loading = false; diff --git a/migration/src/m20220101_000001_create_table.rs b/migration/src/m20220101_000001_create_table.rs index 5c02646..9ec88a9 100644 --- a/migration/src/m20220101_000001_create_table.rs +++ b/migration/src/m20220101_000001_create_table.rs @@ -138,13 +138,18 @@ impl MigrationTrait for Migration { .to(Alias::new("user"), Alias::new("id")) .on_delete(ForeignKeyAction::Cascade), ) - .index( - Index::create() - .name("uq_server_user") - .col(Alias::new("server_id")) - .col(Alias::new("user_id")) - .unique(), - ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("uq_server_user") + .table(Alias::new("server_user")) + .col(Alias::new("server_id")) + .col(Alias::new("user_id")) + .unique() .to_owned(), ) .await?; @@ -185,13 +190,18 @@ impl MigrationTrait for Migration { .to(Alias::new("server"), Alias::new("id")) .on_delete(ForeignKeyAction::Cascade), ) - .index( - Index::create() - .name("uq_role_server_name") - .col(Alias::new("server_id")) - .col(Alias::new("name")) - .unique(), - ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("uq_role_server_name") + .table(Alias::new("role")) + .col(Alias::new("server_id")) + .col(Alias::new("name")) + .unique() .to_owned(), ) .await?; @@ -376,14 +386,6 @@ impl MigrationTrait for Migration { .to(Alias::new("category"), Alias::new("id")) .on_delete(ForeignKeyAction::Cascade), ) - .index( - Index::create() - .name("uq_server_item_order_resource") - .col(Alias::new("server_id")) - .col(Alias::new("resource_type")) - .col(Alias::new("resource_id")) - .unique(), - ) .to_owned(), ) .await?; @@ -400,6 +402,19 @@ impl MigrationTrait for Migration { ) .await?; + manager + .create_index( + Index::create() + .name("uq_server_item_order_resource") + .table(Alias::new("server_item_order")) + .col(Alias::new("server_id")) + .col(Alias::new("resource_type")) + .col(Alias::new("resource_id")) + .unique() + .to_owned(), + ) + .await?; + // --------------------------------------------------------------------- // Membres des canaux // --------------------------------------------------------------------- @@ -443,13 +458,82 @@ impl MigrationTrait for Migration { .to(Alias::new("user"), Alias::new("id")) .on_delete(ForeignKeyAction::Cascade), ) - .index( - Index::create() - .name("uq_channel_user") - .col(Alias::new("channel_id")) - .col(Alias::new("user_id")) - .unique(), + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("uq_channel_user") + .table(Alias::new("channel_user")) + .col(Alias::new("channel_id")) + .col(Alias::new("user_id")) + .unique() + .to_owned(), + ) + .await?; + + // --------------------------------------------------------------------- + // Position de lecture des utilisateurs + // --------------------------------------------------------------------- + + manager + .create_table( + Table::create() + .table(Alias::new("channel_user_read_state")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .primary_key(), ) + .col(ColumnDef::new(Alias::new("channel_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("user_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("last_read_message_id")) + .uuid() + .null(), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .name("fk_channel_user_read_state_channel") + .from( + Alias::new("channel_user_read_state"), + Alias::new("channel_id"), + ) + .to(Alias::new("channel"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_channel_user_read_state_user") + .from( + Alias::new("channel_user_read_state"), + Alias::new("user_id"), + ) + .to(Alias::new("user"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("uq_channel_user_read_state") + .table(Alias::new("channel_user_read_state")) + .col(Alias::new("channel_id")) + .col(Alias::new("user_id")) + .unique() .to_owned(), ) .await?; @@ -505,12 +589,17 @@ impl MigrationTrait for Migration { .to(Alias::new("message"), Alias::new("id")) .on_delete(ForeignKeyAction::SetNull), ) - .index( - Index::create() - .name("idx_message_channel_id_id") - .col(Alias::new("channel_id")) - .col(Alias::new("id")), - ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_message_channel_id_id") + .table(Alias::new("message")) + .col(Alias::new("channel_id")) + .col(Alias::new("id")) .to_owned(), ) .await?; @@ -574,13 +663,6 @@ impl MigrationTrait for Migration { .not_null() .default(0), ) - .index( - Index::create() - .name("uq_server_user_permission") - .col(Alias::new("server_id")) - .col(Alias::new("user_id")) - .unique(), - ) .foreign_key( ForeignKey::create() .name("fk_server_user_permission_server") @@ -602,6 +684,18 @@ impl MigrationTrait for Migration { ) .await?; + manager + .create_index( + Index::create() + .name("uq_server_user_permission") + .table(Alias::new("server_user_permission")) + .col(Alias::new("server_id")) + .col(Alias::new("user_id")) + .unique() + .to_owned(), + ) + .await?; + manager .create_table( Table::create() @@ -638,13 +732,18 @@ impl MigrationTrait for Migration { .to(Alias::new("role"), Alias::new("id")) .on_delete(ForeignKeyAction::Cascade), ) - .index( - Index::create() - .name("uq_server_role_permission") - .col(Alias::new("server_id")) - .col(Alias::new("role_id")) - .unique(), - ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("uq_server_role_permission") + .table(Alias::new("server_role_permission")) + .col(Alias::new("server_id")) + .col(Alias::new("role_id")) + .unique() .to_owned(), ) .await?; @@ -685,13 +784,18 @@ impl MigrationTrait for Migration { .to(Alias::new("role"), Alias::new("id")) .on_delete(ForeignKeyAction::Cascade), ) - .index( - Index::create() - .name("uq_channel_role_permission") - .col(Alias::new("channel_id")) - .col(Alias::new("role_id")) - .unique(), - ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("uq_channel_role_permission") + .table(Alias::new("channel_role_permission")) + .col(Alias::new("channel_id")) + .col(Alias::new("role_id")) + .unique() .to_owned(), ) .await?; @@ -732,13 +836,18 @@ impl MigrationTrait for Migration { .to(Alias::new("user"), Alias::new("id")) .on_delete(ForeignKeyAction::Cascade), ) - .index( - Index::create() - .name("uq_channel_user_permission") - .col(Alias::new("channel_id")) - .col(Alias::new("user_id")) - .unique(), - ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("uq_channel_user_permission") + .table(Alias::new("channel_user_permission")) + .col(Alias::new("channel_id")) + .col(Alias::new("user_id")) + .unique() .to_owned(), ) .await?; @@ -799,6 +908,7 @@ impl MigrationTrait for Migration { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { let tables = [ "computed_permission", + "channel_user_read_state", "channel_user_permission", "channel_role_permission", "server_user_permission", @@ -827,15 +937,6 @@ impl MigrationTrait for Migration { .await?; } - manager - .drop_index( - Index::drop() - .name("idx_server_item_order_scope") - .table(Alias::new("server_item_order")) - .to_owned(), - ) - .await?; - Ok(()) } } diff --git a/src/domain/dto/channel.rs b/src/domain/dto/channel.rs index e09a658..7e3e74a 100644 --- a/src/domain/dto/channel.rs +++ b/src/domain/dto/channel.rs @@ -42,12 +42,28 @@ pub struct ChannelResponse { pub created_at: DateTime, pub updated_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub unread_count: Option, + /// None : contexte sans permissions (champ ignoré dans le JSON). /// Some(value) : valeur de computed_permission (0 si absente). #[serde(skip_serializing_if = "Option::is_none")] pub permission: Option, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ReadStateResponse { + pub channel_id: Uuid, + pub last_read_message_id: Option, + pub updated_at: Option>, + pub unread_count: u64, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct SetReadStateRequest { + pub last_read_message_id: Option, +} + #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SetChannelPermissionRequest { /// Bitmask des permissions à appliquer. diff --git a/src/domain/dto/server.rs b/src/domain/dto/server.rs index 23bf29e..2f9ba23 100644 --- a/src/domain/dto/server.rs +++ b/src/domain/dto/server.rs @@ -28,6 +28,8 @@ pub struct ServerResponse { pub is_default: bool, pub created_at: DateTime, pub updated_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub unread_count: Option, } #[derive(Debug, Serialize, Deserialize, ToSchema)] diff --git a/src/main.rs b/src/main.rs index b270d30..cc87362 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,5 @@ -use migration::{Migrator, MigratorTrait}; use oxspeak_server_lib::config::AppConfig; use oxspeak_server_lib::core::App; -use oxspeak_server_lib::database::Database; #[tokio::main] async fn main() -> Result<(), Box> { @@ -10,7 +8,7 @@ async fn main() -> Result<(), Box> { .with_env_filter( std::env::var("RUST_LOG") // .unwrap_or_else(|_| "info,sqlx=debug,sea_orm=debug,sea_orm_migration=info".into()), - .unwrap_or_else(|_| "info,sqlx=info,sea_orm=info,sea_orm_migration=info".into()), + .unwrap_or_else(|_| "info,sqlx=debug,sea_orm=debug,sea_orm_migration=debug".into()), ) .with_target(true) .with_level(true) diff --git a/src/models/channel_user_read_state.rs b/src/models/channel_user_read_state.rs new file mode 100644 index 0000000..5c0586e --- /dev/null +++ b/src/models/channel_user_read_state.rs @@ -0,0 +1,33 @@ +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 = "channel_user_read_state")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub channel_id: Uuid, + pub user_id: Uuid, + pub last_read_message_id: Option, + pub updated_at: DateTimeUtc, + #[sea_orm( + belongs_to, + from = "channel_id", + to = "id", + on_update = "NoAction", + on_delete = "Cascade" + )] + pub channel: HasOne, + #[sea_orm( + belongs_to, + from = "user_id", + to = "id", + on_update = "NoAction", + on_delete = "Cascade" + )] + pub user: HasOne, +} + +#[async_trait] +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/models/mod.rs b/src/models/mod.rs index f13e722..e496f73 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -7,6 +7,7 @@ pub mod category; pub mod channel; pub mod channel_role_permission; pub mod channel_user; +pub mod channel_user_read_state; pub mod channel_user_permission; pub mod computed_permission; pub mod message; diff --git a/src/models/prelude.rs b/src/models/prelude.rs index 1ceec3f..7114d8d 100644 --- a/src/models/prelude.rs +++ b/src/models/prelude.rs @@ -4,6 +4,7 @@ pub use super::attachment::Entity as Attachment; pub use super::category::Entity as Category; pub use super::channel::Entity as Channel; pub use super::channel_user::Entity as ChannelUser; +pub use super::channel_user_read_state::Entity as ChannelUserReadState; pub use super::computed_permission::Entity as ComputedPermission; pub use super::message::Entity as Message; pub use super::role::Entity as Group; diff --git a/src/repositories/mod.rs b/src/repositories/mod.rs index e770c97..b0a7c10 100644 --- a/src/repositories/mod.rs +++ b/src/repositories/mod.rs @@ -4,6 +4,7 @@ use crate::repositories::category::CategoryRepository; use crate::repositories::channel::ChannelRepository; use crate::repositories::computed_permission::ComputedPermissionRepository; use crate::repositories::message::MessageRepository; +use crate::repositories::read_state::ReadStateRepository; use crate::repositories::role::RoleRepository; use crate::repositories::server::ServerRepository; use crate::repositories::server_item_order::ServerItemOrderRepository; @@ -16,6 +17,7 @@ mod category; mod channel; mod computed_permission; mod message; +mod read_state; mod role; mod server; mod server_item_order; @@ -35,6 +37,7 @@ pub struct Repositories { pub channel: ChannelRepository, pub role: RoleRepository, pub message: MessageRepository, + pub read_state: ReadStateRepository, pub user: UserRepository, pub computed_permission: ComputedPermissionRepository, pub server_item_order: ServerItemOrderRepository, @@ -61,6 +64,9 @@ impl Repositories { message: MessageRepository { context: context.clone(), }, + read_state: ReadStateRepository { + context: context.clone(), + }, user: UserRepository { context: context.clone(), }, diff --git a/src/repositories/read_state.rs b/src/repositories/read_state.rs new file mode 100644 index 0000000..a7066a3 --- /dev/null +++ b/src/repositories/read_state.rs @@ -0,0 +1,152 @@ +use crate::models::{channel, channel_user_read_state, message}; +use crate::repositories::{AnyResult, RepositoryContext}; +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set}; +use std::collections::HashMap; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Clone, Debug)] +pub struct ReadStateRepository { + pub context: Arc, +} + +impl ReadStateRepository { + pub async fn get( + &self, + channel_id: Uuid, + user_id: Uuid, + ) -> AnyResult> { + Ok(channel_user_read_state::Entity::find() + .filter(channel_user_read_state::Column::ChannelId.eq(channel_id)) + .filter(channel_user_read_state::Column::UserId.eq(user_id)) + .one(&self.context.db) + .await?) + } + + pub async fn set( + &self, + channel_id: Uuid, + user_id: Uuid, + last_read_message_id: Option, + ) -> AnyResult { + let now = Utc::now(); + let active = channel_user_read_state::ActiveModel { + id: Set(Uuid::now_v7()), + channel_id: Set(channel_id), + user_id: Set(user_id), + last_read_message_id: Set(last_read_message_id), + updated_at: Set(now), + }; + + if let Some(existing) = self.get(channel_id, user_id).await? { + if existing.last_read_message_id >= last_read_message_id { + return Ok(existing); + } + let mut active: channel_user_read_state::ActiveModel = existing.into(); + active.last_read_message_id = Set(last_read_message_id); + active.updated_at = Set(now); + return Ok(active.update(&self.context.db).await?); + } + + Ok(active.insert(&self.context.db).await?) + } + + pub async fn unread_counts( + &self, + channel_ids: &[Uuid], + user_id: Uuid, + ) -> AnyResult> { + if channel_ids.is_empty() { + return Ok(HashMap::new()); + } + + let states = channel_user_read_state::Entity::find() + .filter(channel_user_read_state::Column::UserId.eq(user_id)) + .filter(channel_user_read_state::Column::ChannelId.is_in(channel_ids.to_vec())) + .all(&self.context.db) + .await?; + let cursors: HashMap> = states + .into_iter() + .map(|state| (state.channel_id, state.last_read_message_id)) + .collect(); + + let messages = message::Entity::find() + .select_only() + .column(message::Column::ChannelId) + .column(message::Column::Id) + .filter(message::Column::ChannelId.is_in(channel_ids.to_vec())) + .into_tuple::<(Uuid, Uuid)>() + .all(&self.context.db) + .await?; + + let mut counts = HashMap::new(); + for (channel_id, message_id) in messages { + let unread = match cursors.get(&channel_id) { + Some(Some(cursor)) => message_id > *cursor, + _ => true, + }; + if unread { + *counts.entry(channel_id).or_insert(0) += 1; + } + } + + Ok(counts) + } + + pub async fn unread_counts_by_server( + &self, + user_id: Uuid, + ) -> AnyResult> { + let channels = channel::Entity::find() + .select_only() + .column(channel::Column::Id) + .column(channel::Column::ServerId) + .filter(channel::Column::ServerId.is_not_null()) + .into_tuple::<(Uuid, Option)>() + .all(&self.context.db) + .await?; + + let channel_to_server: HashMap = channels + .into_iter() + .filter_map(|(channel_id, server_id)| server_id.map(|server_id| (channel_id, server_id))) + .collect(); + if channel_to_server.is_empty() { + return Ok(HashMap::new()); + } + + let channel_ids: Vec = channel_to_server.keys().copied().collect(); + let states = channel_user_read_state::Entity::find() + .filter(channel_user_read_state::Column::UserId.eq(user_id)) + .filter(channel_user_read_state::Column::ChannelId.is_in(channel_ids.clone())) + .all(&self.context.db) + .await?; + let cursors: HashMap> = states + .into_iter() + .map(|state| (state.channel_id, state.last_read_message_id)) + .collect(); + + let messages = message::Entity::find() + .select_only() + .column(message::Column::ChannelId) + .column(message::Column::Id) + .filter(message::Column::ChannelId.is_in(channel_ids)) + .into_tuple::<(Uuid, Uuid)>() + .all(&self.context.db) + .await?; + + let mut counts = HashMap::new(); + for (channel_id, message_id) in messages { + let unread = match cursors.get(&channel_id) { + Some(Some(cursor)) => message_id > *cursor, + _ => true, + }; + if unread { + let server_id = channel_to_server[&channel_id]; + *counts.entry(server_id).or_insert(0) += 1; + } + } + + Ok(counts) + } +} diff --git a/src/repositories/server_tree.rs b/src/repositories/server_tree.rs index a8db6f5..c7ef3f1 100644 --- a/src/repositories/server_tree.rs +++ b/src/repositories/server_tree.rs @@ -1,8 +1,8 @@ -use crate::models::{category, channel, computed_permission, server_item_order}; +use crate::models::{category, channel, channel_user_read_state, computed_permission, message, server_item_order}; use crate::permissions::ChannelPermission; use crate::repositories::types::{CategoryWithPermissions, ChannelWithPermissions, ServerTreeData}; use crate::repositories::{AnyResult, RepositoryContext}; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; @@ -70,6 +70,35 @@ impl ServerTreeRepository { }) .collect(); + let channel_ids: Vec = channel_models.iter().map(|channel| channel.id).collect(); + let read_states = channel_user_read_state::Entity::find() + .filter(channel_user_read_state::Column::UserId.eq(user_id)) + .filter(channel_user_read_state::Column::ChannelId.is_in(channel_ids.clone())) + .all(&self.context.db) + .await?; + let cursors: HashMap> = read_states + .into_iter() + .map(|state| (state.channel_id, state.last_read_message_id)) + .collect(); + let messages = message::Entity::find() + .select_only() + .column(message::Column::ChannelId) + .column(message::Column::Id) + .filter(message::Column::ChannelId.is_in(channel_ids)) + .into_tuple::<(Uuid, Uuid)>() + .all(&self.context.db) + .await?; + let mut unread_counts = HashMap::new(); + for (channel_id, message_id) in messages { + let unread = match cursors.get(&channel_id) { + Some(Some(cursor)) => message_id > *cursor, + _ => true, + }; + if unread { + *unread_counts.entry(channel_id).or_insert(0) += 1; + } + } + let channels = channel_models .into_iter() .map(|channel| { @@ -87,6 +116,7 @@ impl ServerTreeRepository { orders, categories, channels, + unread_counts, }) } } diff --git a/src/repositories/types.rs b/src/repositories/types.rs index 5f9a863..633a851 100644 --- a/src/repositories/types.rs +++ b/src/repositories/types.rs @@ -76,4 +76,5 @@ pub struct ServerTreeData { pub orders: Vec, pub categories: Vec, pub channels: Vec, + pub unread_counts: std::collections::HashMap, } diff --git a/src/routes/channel/handlers.rs b/src/routes/channel/handlers.rs index fc10b90..e658895 100644 --- a/src/routes/channel/handlers.rs +++ b/src/routes/channel/handlers.rs @@ -1,9 +1,10 @@ use crate::core::state::AppState; -use crate::http::context::Superuser; +use crate::http::context::{CurrentUser, Superuser}; use crate::http::error::HTTPError; use crate::domain::dto::channel::{ ChannelQueryParams, ChannelResponse, ChannelPermissionsResponse, ChannelRolePermissionResponse, - ChannelUserPermissionResponse, CreateChannelRequest, SetChannelPermissionRequest, + ChannelUserPermissionResponse, CreateChannelRequest, ReadStateResponse, + SetChannelPermissionRequest, SetReadStateRequest, UpdateChannelRequest, }; use crate::routes::channel::mapper; @@ -41,6 +42,91 @@ pub async fn get_all( )) } +#[utoipa::path( + get, + path = "/channels/{channel_id}/read-state", + params(("channel_id" = Uuid, Path, description = "ID du canal")), + responses((status = 200, body = ReadStateResponse), (status = 404, description = "Canal non trouvé")), + tag = "Channels", + security(("bearerAuth" = [])) +)] +pub async fn get_read_state( + user: CurrentUser, + State(state): State, + Path(channel_id): Path, +) -> Result, HTTPError> { + state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?; + let read_state = state.repositories.read_state.get(channel_id, user.id).await?; + let unread_count = state + .repositories + .read_state + .unread_counts(&[channel_id], user.id) + .await? + .get(&channel_id) + .copied() + .unwrap_or(0); + + Ok(Json(ReadStateResponse { + channel_id, + last_read_message_id: read_state.as_ref().and_then(|value| value.last_read_message_id), + updated_at: read_state.map(|value| value.updated_at), + unread_count, + })) +} + +#[utoipa::path( + put, + path = "/channels/{channel_id}/read-state", + request_body = SetReadStateRequest, + params(("channel_id" = Uuid, Path, description = "ID du canal")), + responses((status = 200, body = ReadStateResponse), (status = 400, description = "Message invalide"), (status = 404, description = "Canal non trouvé")), + tag = "Channels", + security(("bearerAuth" = [])) +)] +pub async fn set_read_state( + user: CurrentUser, + State(state): State, + Path(channel_id): Path, + Json(payload): Json, +) -> Result, HTTPError> { + state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?; + + if let Some(message_id) = payload.last_read_message_id { + let message = state + .repositories + .message + .get_by_id(message_id) + .await? + .ok_or(HTTPError::BadRequest("Message not found".to_string()))?; + if message.channel_id != channel_id { + return Err(HTTPError::BadRequest( + "Message does not belong to this channel".to_string(), + )); + } + } + + let read_state = state + .repositories + .read_state + .set(channel_id, user.id, payload.last_read_message_id) + .await?; + let unread_count = state + .repositories + .read_state + .unread_counts(&[channel_id], user.id) + .await? + .get(&channel_id) + .copied() + .unwrap_or(0); + + Ok(Json(ReadStateResponse { + channel_id, + last_read_message_id: read_state.last_read_message_id, + updated_at: Some(read_state.updated_at), + unread_count, + })) +} + /// Récupère un channel par son ID #[utoipa::path( get, diff --git a/src/routes/channel/mapper.rs b/src/routes/channel/mapper.rs index 604fb1f..0f2a185 100644 --- a/src/routes/channel/mapper.rs +++ b/src/routes/channel/mapper.rs @@ -23,6 +23,7 @@ pub fn channel_model_to_channel_response_with_permission( name: model.name, created_at: model.created_at, updated_at: model.updated_at, + unread_count: None, permission, } } diff --git a/src/routes/channel/routes.rs b/src/routes/channel/routes.rs index 3762942..e875760 100644 --- a/src/routes/channel/routes.rs +++ b/src/routes/channel/routes.rs @@ -27,4 +27,8 @@ pub fn router() -> Router { .put(handlers::set_role_permission) .delete(handlers::remove_role_permission), ) + .route( + "/channels/{channel_id}/read-state", + get(handlers::get_read_state).put(handlers::set_read_state), + ) } diff --git a/src/routes/openapi.rs b/src/routes/openapi.rs index 45f3694..86c4818 100644 --- a/src/routes/openapi.rs +++ b/src/routes/openapi.rs @@ -60,6 +60,8 @@ use utoipa::{Modify, OpenApi}; crate::domain::dto::channel::ChannelResponse, crate::domain::dto::channel::CreateChannelRequest, crate::domain::dto::channel::UpdateChannelRequest, + crate::domain::dto::channel::ReadStateResponse, + crate::domain::dto::channel::SetReadStateRequest, crate::domain::dto::role::RoleResponse, crate::domain::dto::role::CreateRoleRequest, crate::domain::dto::role::UpdateRoleRequest, diff --git a/src/routes/server/handlers.rs b/src/routes/server/handlers.rs index 3b54dab..cdd1146 100644 --- a/src/routes/server/handlers.rs +++ b/src/routes/server/handlers.rs @@ -43,16 +43,29 @@ async fn require_server_permission( (status = 200, description = "Liste des serveurs récupérée avec succès", body = [ServerResponse]), (status = 500, description = "Erreur interne du serveur") ), + security(("bearerAuth" = [])), tag = "Servers" )] pub async fn get_all( + user: CurrentUser, State(state): State, ) -> Result>, HTTPError> { let servers = state.repositories.server.get_all().await?; + let unread_counts = state + .repositories + .read_state + .unread_counts_by_server(user.id) + .await?; Ok(Json( servers .into_iter() - .map(mapper::server_model_to_server_response) + .map(|server| { + let server_id = server.id; + mapper::server_model_to_server_response_with_unread_count( + server, + unread_counts.get(&server_id).copied().unwrap_or(0), + ) + }) .collect(), )) } @@ -456,5 +469,6 @@ pub async fn get_tree( tree.orders, tree.channels, tree.categories, + tree.unread_counts, ))) } diff --git a/src/routes/server/mapper.rs b/src/routes/server/mapper.rs index 470e288..0d99205 100644 --- a/src/routes/server/mapper.rs +++ b/src/routes/server/mapper.rs @@ -17,9 +17,19 @@ pub fn server_model_to_server_response(model: server::Model) -> ServerResponse { is_default: model.is_default, created_at: model.created_at, updated_at: model.updated_at, + unread_count: None, } } +pub fn server_model_to_server_response_with_unread_count( + model: server::Model, + unread_count: u64, +) -> ServerResponse { + let mut response = server_model_to_server_response(model); + response.unread_count = Some(unread_count); + response +} + pub fn create_request_to_am(req: CreateServerRequest) -> server::ActiveModel { server::ActiveModel { id: Set(Uuid::new_v4()), @@ -66,6 +76,7 @@ pub fn build_server_tree( orders: Vec, channels: Vec, categories: Vec, + unread_counts: HashMap, ) -> ServerTreeResponse { let order_map: HashMap<(Option, Uuid), i64> = orders .into_iter() @@ -119,7 +130,9 @@ pub fn build_server_tree( .into_iter() .map(|c| { let chan_perm_bits = c.permissions.map(|p| p.bits()).unwrap_or(0); - channel_model_to_channel_response_with_permission(c.channel, Some(chan_perm_bits)) + let mut response = channel_model_to_channel_response_with_permission(c.channel, Some(chan_perm_bits)); + response.unread_count = Some(*unread_counts.get(&response.id).unwrap_or(&0)); + response }) .collect(); @@ -135,10 +148,11 @@ pub fn build_server_tree( .copied() .unwrap_or(i64::MAX); let chan_perm_bits = chan_with_perm.permissions.map(|p| p.bits()).unwrap_or(0); - let chan_response = channel_model_to_channel_response_with_permission( + let mut chan_response = channel_model_to_channel_response_with_permission( chan_with_perm.channel, Some(chan_perm_bits), ); + chan_response.unread_count = Some(*unread_counts.get(&chan_response.id).unwrap_or(&0)); root_items.push(( ServerExplorerItemResponse::Channel(chan_response), order_key, @@ -232,6 +246,7 @@ mod tests { channel(second_id, Some(category_id), "first"), ], vec![category], + HashMap::new(), ); let ServerExplorerItemResponse::Category(_, channels) = &response.items[0] else {