use chrono::Utc; use uuid::Uuid; use crate::event::{RoomInfo, UserInfo, conversation}; use crate::{ChannelBus, ChannelResult}; use super::WsHandler; use super::WsOutEvent; impl WsHandler { pub(super) async fn conversation_pin( bus: &ChannelBus, user_id: Uuid, room: Uuid, pin: bool, ) -> ChannelResult> { Self::ensure_room_access(bus, user_id, room).await?; let now = Utc::now(); db::sqlx::query( "INSERT INTO user_room_state (\"user\", room, is_pinned, updated_at) \ VALUES ($1, $2, $3, $4) \ ON CONFLICT (\"user\", room) DO UPDATE \ SET is_pinned = $3, updated_at = $4", ) .bind(user_id) .bind(room) .bind(pin) .bind(now) .execute(bus.inner.db.writer()) .await?; let room_info = bus .lookup_room(room) .await .unwrap_or_else(|_| RoomInfo::unknown(room)); let user_info = bus .lookup_user(user_id) .await .unwrap_or_else(|_| UserInfo::unknown(user_id)); if pin { let data = conversation::ConversationPinnedService { user: user_info, room: room_info.clone(), pinned_at: now, }; bus.emit_to_user(user_id, "conversation.pinned", &data) .await?; Ok(Some(WsOutEvent::ConversationPinned { room: room_info, data, })) } else { let data = conversation::ConversationUnpinnedService { user: user_info, room: room_info.clone(), unpinned_at: now, }; bus.emit_to_user(user_id, "conversation.unpinned", &data) .await?; Ok(Some(WsOutEvent::ConversationUnpinned { room: room_info, data, })) } } pub(super) async fn conversation_mute( bus: &ChannelBus, user_id: Uuid, room: Uuid, mute: bool, ) -> ChannelResult> { Self::ensure_room_access(bus, user_id, room).await?; let now = Utc::now(); db::sqlx::query( "INSERT INTO user_room_state (\"user\", room, is_muted, updated_at) \ VALUES ($1, $2, $3, $4) \ ON CONFLICT (\"user\", room) DO UPDATE \ SET is_muted = $3, updated_at = $4", ) .bind(user_id) .bind(room) .bind(mute) .bind(now) .execute(bus.inner.db.writer()) .await?; let room_info = bus .lookup_room(room) .await .unwrap_or_else(|_| RoomInfo::unknown(room)); let user_info = bus .lookup_user(user_id) .await .unwrap_or_else(|_| UserInfo::unknown(user_id)); if mute { let data = conversation::ConversationMutedService { user: user_info, room: room_info.clone(), muted_at: now, }; bus.emit_to_user(user_id, "conversation.muted", &data) .await?; Ok(Some(WsOutEvent::ConversationMuted { room: room_info, data, })) } else { let data = conversation::ConversationUnmutedService { user: user_info, room: room_info.clone(), unmuted_at: now, }; bus.emit_to_user(user_id, "conversation.unmuted", &data) .await?; Ok(Some(WsOutEvent::ConversationUnmuted { room: room_info, data, })) } } pub(super) async fn conversation_notify_level( bus: &ChannelBus, user_id: Uuid, room: Uuid, notify_level: String, ) -> ChannelResult> { Self::ensure_room_access(bus, user_id, room).await?; let valid = matches!(notify_level.as_str(), "all" | "mentions" | "none"); if !valid { return Err(crate::ChannelError::Internal( "notify_level must be 'all', 'mentions', or 'none'".to_string(), )); } let now = Utc::now(); let old_level: Option<(String,)> = db::sqlx::query_as( "SELECT notify_level FROM user_room_state \ WHERE \"user\" = $1 AND room = $2", ) .bind(user_id) .bind(room) .fetch_optional(bus.inner.db.reader()) .await?; let old = old_level.map(|r| r.0).unwrap_or_else(|| "all".to_string()); db::sqlx::query( "INSERT INTO user_room_state (\"user\", room, notify_level, updated_at) \ VALUES ($1, $2, $3, $4) \ ON CONFLICT (\"user\", room) DO UPDATE \ SET notify_level = $3, updated_at = $4", ) .bind(user_id) .bind(room) .bind(¬ify_level) .bind(now) .execute(bus.inner.db.writer()) .await?; let room_info = bus .lookup_room(room) .await .unwrap_or_else(|_| RoomInfo::unknown(room)); let user_info = bus .lookup_user(user_id) .await .unwrap_or_else(|_| UserInfo::unknown(user_id)); let data = conversation::ConversationNotifyLevelChangedService { user: user_info, room: room_info.clone(), old_level: old, new_level: notify_level, updated_at: now, }; bus.emit_to_user(user_id, "conversation.notify_level_changed", &data) .await?; Ok(None) } pub(super) async fn conversation_list( bus: &ChannelBus, user_id: Uuid, ) -> ChannelResult> { let rooms = crate::rooms::user_rooms( &bus.inner.db, &bus.inner.cache, &bus.inner.config, user_id, ) .await?; if rooms.is_empty() { return Ok(Some(WsOutEvent::ConversationList { data: vec![] })); } let rows = db::sqlx::query_as::<_, ( Uuid, // room id String, // room name String, // room type bool, // is_pinned bool, // is_muted String, // notify_level i64, // last_read_seq i64, // max seq from room_message )>( "SELECT r.id, r.name, r.room_type, \ COALESCE(s.is_pinned, false), \ COALESCE(s.is_muted, false), \ COALESCE(s.notify_level, 'all'), \ COALESCE(s.last_read_seq, 0), \ COALESCE((SELECT MAX(seq) FROM room_message \ WHERE room = r.id AND deleted_at IS NULL), 0) \ FROM room r \ LEFT JOIN user_room_state s ON s.room = r.id AND s.\"user\" = $1 \ WHERE r.id = ANY($2) AND r.deleted_at IS NULL AND r.is_archived = false \ ORDER BY COALESCE(s.is_pinned, false) DESC, r.name", ) .bind(user_id) .bind(&rooms) .fetch_all(bus.inner.db.reader()) .await?; let summaries: Vec = rows .into_iter() .map( |( id, name, room_type, is_pinned, is_muted, notify_level, last_read_seq, max_seq, )| { let unread = (max_seq - last_read_seq).max(0); conversation::ConversationSummary { room: id, room_name: name, room_type, is_pinned, is_muted, notify_level, last_read_seq, max_seq, unread_count: unread, last_read_at: None, } }, ) .collect(); Ok(Some(WsOutEvent::ConversationList { data: summaries })) } }