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)"
- />
+ >
+
+
+ {{ channel.unread_count }}
+
+
+
@@ -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)"
- />
+ >
+
+
+ {{ item.Channel.unread_count }}
+
+
+
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