Skip to main content

mdcs_sdk/
presence.rs

1//! Presence and awareness for collaborative editing.
2
3use mdcs_db::presence::{Cursor, PresenceTracker, UserId, UserInfo, UserStatus};
4use parking_lot::RwLock;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::sync::Arc;
8use tokio::sync::broadcast;
9
10/// Cursor information for a user.
11#[derive(Clone, Debug, Serialize, Deserialize)]
12pub struct CursorInfo {
13    pub user_id: String,
14    pub user_name: String,
15    pub document_id: String,
16    pub position: usize,
17    pub selection_start: Option<usize>,
18    pub selection_end: Option<usize>,
19    pub color: String,
20}
21
22/// User presence information.
23#[derive(Clone, Debug, Serialize, Deserialize)]
24pub struct UserPresenceInfo {
25    pub user_id: String,
26    pub name: String,
27    pub status: UserStatus,
28    pub color: String,
29    pub cursors: HashMap<String, CursorInfo>,
30}
31
32/// Events for presence changes.
33#[derive(Clone, Debug)]
34pub enum AwarenessEvent {
35    /// A user's presence was updated.
36    UserUpdated(UserPresenceInfo),
37    /// A user went offline.
38    UserOffline(String),
39    /// Cursor moved.
40    CursorMoved(CursorInfo),
41}
42
43/// Awareness manager for a document or session.
44pub struct Awareness {
45    local_user_id: String,
46    local_user_name: String,
47    local_color: String,
48    tracker: Arc<RwLock<PresenceTracker>>,
49    event_tx: broadcast::Sender<AwarenessEvent>,
50}
51
52impl Awareness {
53    /// Create a new awareness manager for a local user.
54    ///
55    /// The local user is initialized with a default color and an online
56    /// presence record in the underlying tracker.
57    pub fn new(local_user_id: impl Into<String>, local_user_name: impl Into<String>) -> Self {
58        let local_user_id = local_user_id.into();
59        let local_user_name = local_user_name.into();
60        let user_id = UserId::new(&local_user_id);
61        let info = UserInfo::new(&local_user_name, "#0066cc");
62
63        let (event_tx, _) = broadcast::channel(100);
64
65        Self {
66            local_user_id,
67            local_user_name,
68            local_color: "#0066cc".to_string(),
69            tracker: Arc::new(RwLock::new(PresenceTracker::new(user_id, info))),
70            event_tx,
71        }
72    }
73
74    /// Return the local user identifier.
75    pub fn local_user_id(&self) -> &str {
76        &self.local_user_id
77    }
78
79    /// Return the local user display name.
80    pub fn local_user_name(&self) -> &str {
81        &self.local_user_name
82    }
83
84    /// Set the local user's cursor position for a document.
85    ///
86    /// Emits [`AwarenessEvent::CursorMoved`].
87    pub fn set_cursor(&self, document_id: &str, position: usize) {
88        let cursor = Cursor::at(position);
89        self.tracker.write().set_cursor(document_id, cursor);
90
91        let cursor_info = CursorInfo {
92            user_id: self.local_user_id.clone(),
93            user_name: self.local_user_name.clone(),
94            document_id: document_id.to_string(),
95            position,
96            selection_start: None,
97            selection_end: None,
98            color: self.local_color.clone(),
99        };
100
101        let _ = self.event_tx.send(AwarenessEvent::CursorMoved(cursor_info));
102    }
103
104    /// Set the local user's selection range for a document.
105    ///
106    /// Emits [`AwarenessEvent::CursorMoved`].
107    pub fn set_selection(&self, document_id: &str, start: usize, end: usize) {
108        let cursor = Cursor::with_selection(start, end);
109        self.tracker.write().set_cursor(document_id, cursor);
110
111        let cursor_info = CursorInfo {
112            user_id: self.local_user_id.clone(),
113            user_name: self.local_user_name.clone(),
114            document_id: document_id.to_string(),
115            position: end,
116            selection_start: Some(start),
117            selection_end: Some(end),
118            color: self.local_color.clone(),
119        };
120
121        let _ = self.event_tx.send(AwarenessEvent::CursorMoved(cursor_info));
122    }
123
124    /// Update the local user's presence status.
125    pub fn set_status(&self, status: UserStatus) {
126        self.tracker.write().set_status(status);
127    }
128
129    /// Return a snapshot of all known users and cursor states.
130    pub fn get_users(&self) -> Vec<UserPresenceInfo> {
131        let tracker = self.tracker.read();
132
133        tracker
134            .all_users()
135            .map(|presence| {
136                let cursors: HashMap<String, CursorInfo> = presence
137                    .cursors
138                    .iter()
139                    .map(|(doc_id, cursor): (&String, &Cursor)| {
140                        let (sel_start, sel_end) = cursor
141                            .selection_range()
142                            .map(|(s, e)| (Some(s), Some(e)))
143                            .unwrap_or((None, None));
144                        (
145                            doc_id.clone(),
146                            CursorInfo {
147                                user_id: presence.user_id.0.clone(),
148                                user_name: presence.info.name.clone(),
149                                document_id: doc_id.clone(),
150                                position: cursor.position,
151                                selection_start: sel_start,
152                                selection_end: sel_end,
153                                color: presence.info.color.clone(),
154                            },
155                        )
156                    })
157                    .collect();
158
159                UserPresenceInfo {
160                    user_id: presence.user_id.0.clone(),
161                    name: presence.info.name.clone(),
162                    status: presence.status.clone(),
163                    color: presence.info.color.clone(),
164                    cursors,
165                }
166            })
167            .collect()
168    }
169
170    /// Return cursor information for users active in a specific document.
171    pub fn get_cursors(&self, document_id: &str) -> Vec<CursorInfo> {
172        self.get_users()
173            .into_iter()
174            .filter_map(|u| u.cursors.get(document_id).cloned())
175            .collect()
176    }
177
178    /// Return the local user's assigned color string.
179    pub fn get_local_color(&self) -> &str {
180        &self.local_color
181    }
182
183    /// Subscribe to awareness updates.
184    ///
185    /// This uses a broadcast channel; late subscribers receive only future
186    /// events.
187    pub fn subscribe(&self) -> broadcast::Receiver<AwarenessEvent> {
188        self.event_tx.subscribe()
189    }
190
191    /// Remove stale user entries that have not been active recently.
192    pub fn cleanup_stale(&self) {
193        self.tracker.write().cleanup_stale();
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_awareness_basic() {
203        let awareness = Awareness::new("user-1", "Alice");
204
205        awareness.set_cursor("doc-1", 42);
206        awareness.set_status(UserStatus::Online);
207
208        let users = awareness.get_users();
209        assert_eq!(users.len(), 1);
210        assert_eq!(users[0].user_id, "user-1");
211    }
212
213    #[test]
214    fn test_cursor_tracking() {
215        let awareness = Awareness::new("user-1", "Alice");
216
217        awareness.set_cursor("doc-1", 10);
218        awareness.set_selection("doc-1", 10, 20);
219
220        let cursors = awareness.get_cursors("doc-1");
221        assert_eq!(cursors.len(), 1);
222    }
223}