Skip to main content

mdcs_db/
presence.rs

1//! Presence System - Real-time user presence and cursor tracking.
2//!
3//! Provides collaborative awareness features:
4//! - Cursor positions and selections
5//! - User online/offline status
6//! - Custom user state (e.g., "typing", "away")
7//! - Automatic expiration of stale presence
8
9use mdcs_core::lattice::Lattice;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// Unique identifier for a user.
14#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub struct UserId(pub String);
16
17impl UserId {
18    pub fn new(id: impl Into<String>) -> Self {
19        Self(id.into())
20    }
21}
22
23impl std::fmt::Display for UserId {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        write!(f, "{}", self.0)
26    }
27}
28
29/// A cursor position in a document.
30#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Cursor {
32    /// The position (character offset) in the document.
33    pub position: usize,
34    /// Optional anchor for selection (selection goes from anchor to position).
35    pub anchor: Option<usize>,
36}
37
38impl Cursor {
39    /// Create a cursor at a position (no selection).
40    pub fn at(position: usize) -> Self {
41        Self {
42            position,
43            anchor: None,
44        }
45    }
46
47    /// Create a cursor with a selection.
48    pub fn with_selection(anchor: usize, position: usize) -> Self {
49        Self {
50            position,
51            anchor: Some(anchor),
52        }
53    }
54
55    /// Check if this cursor has a selection.
56    pub fn has_selection(&self) -> bool {
57        self.anchor.is_some() && self.anchor != Some(self.position)
58    }
59
60    /// Get the selection range (start, end).
61    pub fn selection_range(&self) -> Option<(usize, usize)> {
62        self.anchor.map(|anchor| {
63            if anchor < self.position {
64                (anchor, self.position)
65            } else {
66                (self.position, anchor)
67            }
68        })
69    }
70
71    /// Get the selection length.
72    pub fn selection_length(&self) -> usize {
73        self.selection_range()
74            .map(|(start, end)| end - start)
75            .unwrap_or(0)
76    }
77}
78
79/// User status.
80#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
81pub enum UserStatus {
82    /// User is online and active.
83    #[default]
84    Online,
85    /// User is online but idle.
86    Idle,
87    /// User is actively typing.
88    Typing,
89    /// User is away.
90    Away,
91    /// User is offline.
92    Offline,
93    /// Custom status.
94    Custom(String),
95}
96
97/// User information for display.
98#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99pub struct UserInfo {
100    /// User's display name.
101    pub name: String,
102    /// User's color (for cursor highlighting).
103    pub color: String,
104    /// Optional avatar URL.
105    pub avatar: Option<String>,
106}
107
108impl UserInfo {
109    pub fn new(name: impl Into<String>, color: impl Into<String>) -> Self {
110        Self {
111            name: name.into(),
112            color: color.into(),
113            avatar: None,
114        }
115    }
116
117    pub fn with_avatar(mut self, avatar: impl Into<String>) -> Self {
118        self.avatar = Some(avatar.into());
119        self
120    }
121}
122
123/// Presence data for a single user.
124#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
125pub struct UserPresence {
126    /// The user ID.
127    pub user_id: UserId,
128    /// User information.
129    pub info: UserInfo,
130    /// Current status.
131    pub status: UserStatus,
132    /// Cursor positions by document ID.
133    pub cursors: HashMap<String, Cursor>,
134    /// Custom user state data.
135    pub state: HashMap<String, String>,
136    /// Last update timestamp (milliseconds since epoch).
137    pub last_updated: u64,
138    /// Lamport timestamp for ordering.
139    pub timestamp: u64,
140}
141
142impl UserPresence {
143    /// Create new presence for a user.
144    pub fn new(user_id: UserId, info: UserInfo) -> Self {
145        Self {
146            user_id,
147            info,
148            status: UserStatus::Online,
149            cursors: HashMap::new(),
150            state: HashMap::new(),
151            last_updated: now_millis(),
152            timestamp: 0,
153        }
154    }
155
156    /// Update the cursor for a document.
157    pub fn set_cursor(&mut self, document_id: impl Into<String>, cursor: Cursor) {
158        self.cursors.insert(document_id.into(), cursor);
159        self.touch();
160    }
161
162    /// Remove the cursor for a document.
163    pub fn remove_cursor(&mut self, document_id: &str) {
164        self.cursors.remove(document_id);
165        self.touch();
166    }
167
168    /// Get the cursor for a document.
169    pub fn get_cursor(&self, document_id: &str) -> Option<&Cursor> {
170        self.cursors.get(document_id)
171    }
172
173    /// Set the status.
174    pub fn set_status(&mut self, status: UserStatus) {
175        self.status = status;
176        self.touch();
177    }
178
179    /// Set custom state data.
180    pub fn set_state(&mut self, key: impl Into<String>, value: impl Into<String>) {
181        self.state.insert(key.into(), value.into());
182        self.touch();
183    }
184
185    /// Get custom state data.
186    pub fn get_state(&self, key: &str) -> Option<&String> {
187        self.state.get(key)
188    }
189
190    /// Touch the update timestamp.
191    fn touch(&mut self) {
192        self.last_updated = now_millis();
193        self.timestamp += 1;
194    }
195
196    /// Check if this presence is stale (not updated within timeout).
197    pub fn is_stale(&self, timeout_ms: u64) -> bool {
198        let now = now_millis();
199        now.saturating_sub(self.last_updated) > timeout_ms
200    }
201}
202
203/// Delta for presence updates.
204#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
205pub struct PresenceDelta {
206    /// Updated presence records.
207    pub updates: Vec<UserPresence>,
208    /// Users that have left.
209    pub removals: Vec<UserId>,
210}
211
212impl PresenceDelta {
213    pub fn new() -> Self {
214        Self {
215            updates: Vec::new(),
216            removals: Vec::new(),
217        }
218    }
219
220    pub fn is_empty(&self) -> bool {
221        self.updates.is_empty() && self.removals.is_empty()
222    }
223}
224
225impl Default for PresenceDelta {
226    fn default() -> Self {
227        Self::new()
228    }
229}
230
231/// Presence tracker for a collaborative session.
232///
233/// Tracks all users' cursors, selections, and status.
234#[derive(Clone, Debug, PartialEq)]
235pub struct PresenceTracker {
236    /// The local user's ID.
237    local_user: UserId,
238    /// All user presence records.
239    users: HashMap<UserId, UserPresence>,
240    /// Timeout for stale presence (milliseconds).
241    stale_timeout: u64,
242    /// Pending delta for replication.
243    pending_delta: Option<PresenceDelta>,
244}
245
246impl PresenceTracker {
247    /// Create a new presence tracker.
248    pub fn new(local_user: UserId, info: UserInfo) -> Self {
249        let mut tracker = Self {
250            local_user: local_user.clone(),
251            users: HashMap::new(),
252            stale_timeout: 30_000, // 30 seconds default
253            pending_delta: None,
254        };
255
256        // Add local user
257        let presence = UserPresence::new(local_user, info);
258        tracker.users.insert(presence.user_id.clone(), presence);
259
260        tracker
261    }
262
263    /// Get the local user ID.
264    pub fn local_user(&self) -> &UserId {
265        &self.local_user
266    }
267
268    /// Set the stale timeout.
269    pub fn set_stale_timeout(&mut self, timeout_ms: u64) {
270        self.stale_timeout = timeout_ms;
271    }
272
273    /// Get the local user's presence.
274    pub fn local_presence(&self) -> Option<&UserPresence> {
275        self.users.get(&self.local_user)
276    }
277
278    // === Local User Operations ===
279
280    /// Update the local user's cursor.
281    pub fn set_cursor(&mut self, document_id: impl Into<String>, cursor: Cursor) {
282        let doc_id = document_id.into();
283        let local_user = self.local_user.clone();
284        if let Some(presence) = self.users.get_mut(&local_user) {
285            presence.set_cursor(&doc_id, cursor);
286            let presence_clone = presence.clone();
287            let delta = self.pending_delta.get_or_insert_with(PresenceDelta::new);
288            delta.updates.push(presence_clone);
289        }
290    }
291
292    /// Remove the local user's cursor from a document.
293    pub fn remove_cursor(&mut self, document_id: &str) {
294        let local_user = self.local_user.clone();
295        if let Some(presence) = self.users.get_mut(&local_user) {
296            presence.remove_cursor(document_id);
297            let presence_clone = presence.clone();
298            let delta = self.pending_delta.get_or_insert_with(PresenceDelta::new);
299            delta.updates.push(presence_clone);
300        }
301    }
302
303    /// Set the local user's status.
304    pub fn set_status(&mut self, status: UserStatus) {
305        let local_user = self.local_user.clone();
306        if let Some(presence) = self.users.get_mut(&local_user) {
307            presence.set_status(status);
308            let presence_clone = presence.clone();
309            let delta = self.pending_delta.get_or_insert_with(PresenceDelta::new);
310            delta.updates.push(presence_clone);
311        }
312    }
313
314    /// Set local user's custom state.
315    pub fn set_state(&mut self, key: impl Into<String>, value: impl Into<String>) {
316        let local_user = self.local_user.clone();
317        if let Some(presence) = self.users.get_mut(&local_user) {
318            presence.set_state(key, value);
319            let presence_clone = presence.clone();
320            let delta = self.pending_delta.get_or_insert_with(PresenceDelta::new);
321            delta.updates.push(presence_clone);
322        }
323    }
324
325    /// Send a heartbeat to keep presence alive.
326    pub fn heartbeat(&mut self) {
327        let local_user = self.local_user.clone();
328        if let Some(presence) = self.users.get_mut(&local_user) {
329            presence.touch();
330            let presence_clone = presence.clone();
331            let delta = self.pending_delta.get_or_insert_with(PresenceDelta::new);
332            delta.updates.push(presence_clone);
333        }
334    }
335
336    // === Query Operations ===
337
338    /// Get a user's presence.
339    pub fn get_user(&self, user_id: &UserId) -> Option<&UserPresence> {
340        self.users.get(user_id)
341    }
342
343    /// Get all users.
344    pub fn all_users(&self) -> impl Iterator<Item = &UserPresence> + '_ {
345        self.users.values()
346    }
347
348    /// Get all online users.
349    pub fn online_users(&self) -> impl Iterator<Item = &UserPresence> + '_ {
350        self.users
351            .values()
352            .filter(|p| !p.is_stale(self.stale_timeout) && !matches!(p.status, UserStatus::Offline))
353    }
354
355    /// Get users with cursors in a document.
356    pub fn users_in_document(&self, document_id: &str) -> Vec<&UserPresence> {
357        self.online_users()
358            .filter(|p| p.cursors.contains_key(document_id))
359            .collect()
360    }
361
362    /// Get all cursors in a document (excluding local user).
363    pub fn cursors_in_document(&self, document_id: &str) -> Vec<(&UserPresence, &Cursor)> {
364        self.online_users()
365            .filter(|p| p.user_id != self.local_user)
366            .filter_map(|p| p.get_cursor(document_id).map(|c| (p, c)))
367            .collect()
368    }
369
370    /// Count online users.
371    pub fn online_count(&self) -> usize {
372        self.online_users().count()
373    }
374
375    // === Sync Operations ===
376
377    /// Take the pending delta.
378    pub fn take_delta(&mut self) -> Option<PresenceDelta> {
379        self.pending_delta.take()
380    }
381
382    /// Apply a delta from another replica.
383    pub fn apply_delta(&mut self, delta: &PresenceDelta) {
384        // Apply updates
385        for presence in &delta.updates {
386            // Don't overwrite with older data
387            if let Some(existing) = self.users.get(&presence.user_id) {
388                if presence.timestamp <= existing.timestamp {
389                    continue;
390                }
391            }
392            self.users
393                .insert(presence.user_id.clone(), presence.clone());
394        }
395
396        // Apply removals
397        for user_id in &delta.removals {
398            if *user_id != self.local_user {
399                self.users.remove(user_id);
400            }
401        }
402    }
403
404    /// Clean up stale presence records.
405    pub fn cleanup_stale(&mut self) -> Vec<UserId> {
406        let stale: Vec<_> = self
407            .users
408            .iter()
409            .filter(|(id, p)| *id != &self.local_user && p.is_stale(self.stale_timeout))
410            .map(|(id, _)| id.clone())
411            .collect();
412
413        for id in &stale {
414            self.users.remove(id);
415        }
416
417        if !stale.is_empty() {
418            let delta = self.pending_delta.get_or_insert_with(PresenceDelta::new);
419            delta.removals.extend(stale.clone());
420        }
421
422        stale
423    }
424
425    /// Leave (remove local user).
426    pub fn leave(&mut self) {
427        let delta = self.pending_delta.get_or_insert_with(PresenceDelta::new);
428        delta.removals.push(self.local_user.clone());
429    }
430}
431
432impl Lattice for PresenceTracker {
433    fn bottom() -> Self {
434        Self {
435            local_user: UserId::new(""),
436            users: HashMap::new(),
437            stale_timeout: 30_000,
438            pending_delta: None,
439        }
440    }
441
442    fn join(&self, other: &Self) -> Self {
443        let mut result = self.clone();
444
445        for (user_id, other_presence) in &other.users {
446            result
447                .users
448                .entry(user_id.clone())
449                .and_modify(|p| {
450                    if other_presence.timestamp > p.timestamp {
451                        *p = other_presence.clone();
452                    }
453                })
454                .or_insert_with(|| other_presence.clone());
455        }
456
457        result
458    }
459}
460
461/// Get current time in milliseconds.
462fn now_millis() -> u64 {
463    std::time::SystemTime::now()
464        .duration_since(std::time::UNIX_EPOCH)
465        .unwrap_or_default()
466        .as_millis() as u64
467}
468
469/// Builder for creating cursors from selections.
470pub struct CursorBuilder {
471    document_id: String,
472}
473
474impl CursorBuilder {
475    pub fn for_document(id: impl Into<String>) -> Self {
476        Self {
477            document_id: id.into(),
478        }
479    }
480
481    pub fn at(self, position: usize) -> (String, Cursor) {
482        (self.document_id, Cursor::at(position))
483    }
484
485    pub fn selection(self, anchor: usize, head: usize) -> (String, Cursor) {
486        (self.document_id, Cursor::with_selection(anchor, head))
487    }
488}
489
490/// Color palette for user cursors.
491pub struct CursorColors;
492
493impl CursorColors {
494    pub const COLORS: [&'static str; 12] = [
495        "#E91E63", // Pink
496        "#9C27B0", // Purple
497        "#3F51B5", // Indigo
498        "#2196F3", // Blue
499        "#00BCD4", // Cyan
500        "#009688", // Teal
501        "#4CAF50", // Green
502        "#8BC34A", // Light Green
503        "#CDDC39", // Lime
504        "#FF9800", // Orange
505        "#FF5722", // Deep Orange
506        "#795548", // Brown
507    ];
508
509    /// Get a color for a user based on their ID.
510    pub fn color_for_user(user_id: &UserId) -> &'static str {
511        let hash: usize = user_id.0.bytes().map(|b| b as usize).sum();
512        Self::COLORS[hash % Self::COLORS.len()]
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn test_cursor_creation() {
522        let cursor = Cursor::at(10);
523        assert_eq!(cursor.position, 10);
524        assert!(!cursor.has_selection());
525
526        let selection = Cursor::with_selection(5, 15);
527        assert!(selection.has_selection());
528        assert_eq!(selection.selection_range(), Some((5, 15)));
529        assert_eq!(selection.selection_length(), 10);
530    }
531
532    #[test]
533    fn test_cursor_selection_backwards() {
534        let selection = Cursor::with_selection(15, 5);
535        assert_eq!(selection.selection_range(), Some((5, 15)));
536        assert_eq!(selection.selection_length(), 10);
537    }
538
539    #[test]
540    fn test_presence_tracker() {
541        let user_id = UserId::new("user1");
542        let info = UserInfo::new("Alice", "#E91E63");
543        let tracker = PresenceTracker::new(user_id.clone(), info);
544
545        assert_eq!(tracker.local_user(), &user_id);
546        assert!(tracker.local_presence().is_some());
547    }
548
549    #[test]
550    fn test_cursor_tracking() {
551        let user_id = UserId::new("user1");
552        let info = UserInfo::new("Alice", "#E91E63");
553        let mut tracker = PresenceTracker::new(user_id, info);
554
555        tracker.set_cursor("doc1", Cursor::at(42));
556
557        let presence = tracker.local_presence().unwrap();
558        let cursor = presence.get_cursor("doc1").unwrap();
559        assert_eq!(cursor.position, 42);
560    }
561
562    #[test]
563    fn test_status_changes() {
564        let user_id = UserId::new("user1");
565        let info = UserInfo::new("Alice", "#E91E63");
566        let mut tracker = PresenceTracker::new(user_id, info);
567
568        tracker.set_status(UserStatus::Typing);
569
570        let presence = tracker.local_presence().unwrap();
571        assert_eq!(presence.status, UserStatus::Typing);
572    }
573
574    #[test]
575    fn test_presence_sync() {
576        let user1 = UserId::new("user1");
577        let user2 = UserId::new("user2");
578
579        let mut tracker1 = PresenceTracker::new(user1.clone(), UserInfo::new("Alice", "#E91E63"));
580        let mut tracker2 = PresenceTracker::new(user2.clone(), UserInfo::new("Bob", "#2196F3"));
581
582        // User 1 sets cursor
583        tracker1.set_cursor("doc1", Cursor::at(10));
584
585        // Sync to user 2
586        let delta = tracker1.take_delta().unwrap();
587        tracker2.apply_delta(&delta);
588
589        // User 2 should see user 1's cursor
590        let users = tracker2.users_in_document("doc1");
591        assert_eq!(users.len(), 1);
592        assert_eq!(users[0].user_id, user1);
593    }
594
595    #[test]
596    fn test_multiple_users() {
597        let user1 = UserId::new("user1");
598        let info1 = UserInfo::new("Alice", "#E91E63");
599        let mut tracker = PresenceTracker::new(user1.clone(), info1);
600
601        // Simulate other users joining
602        let user2 = UserId::new("user2");
603        let presence2 = UserPresence::new(user2.clone(), UserInfo::new("Bob", "#2196F3"));
604        tracker.users.insert(user2.clone(), presence2);
605
606        let user3 = UserId::new("user3");
607        let presence3 = UserPresence::new(user3.clone(), UserInfo::new("Charlie", "#4CAF50"));
608        tracker.users.insert(user3.clone(), presence3);
609
610        assert_eq!(tracker.online_count(), 3);
611    }
612
613    #[test]
614    fn test_cursors_in_document() {
615        let user1 = UserId::new("user1");
616        let info1 = UserInfo::new("Alice", "#E91E63");
617        let mut tracker = PresenceTracker::new(user1, info1);
618
619        // Add another user with cursor
620        let user2 = UserId::new("user2");
621        let mut presence2 = UserPresence::new(user2.clone(), UserInfo::new("Bob", "#2196F3"));
622        presence2.set_cursor("doc1", Cursor::at(50));
623        tracker.users.insert(user2, presence2);
624
625        // Get cursors (excluding local user)
626        let cursors = tracker.cursors_in_document("doc1");
627        assert_eq!(cursors.len(), 1);
628        assert_eq!(cursors[0].1.position, 50);
629    }
630
631    #[test]
632    fn test_color_assignment() {
633        let user1 = UserId::new("alice");
634        let user2 = UserId::new("bob");
635
636        let color1 = CursorColors::color_for_user(&user1);
637        let color2 = CursorColors::color_for_user(&user2);
638
639        // Colors should be from the palette
640        assert!(CursorColors::COLORS.contains(&color1));
641        assert!(CursorColors::COLORS.contains(&color2));
642
643        // Same user should get same color
644        assert_eq!(color1, CursorColors::color_for_user(&user1));
645    }
646
647    #[test]
648    fn test_custom_state() {
649        let user_id = UserId::new("user1");
650        let info = UserInfo::new("Alice", "#E91E63");
651        let mut tracker = PresenceTracker::new(user_id, info);
652
653        tracker.set_state("view", "editor");
654        tracker.set_state("zoom", "100%");
655
656        let presence = tracker.local_presence().unwrap();
657        assert_eq!(presence.get_state("view"), Some(&"editor".to_string()));
658        assert_eq!(presence.get_state("zoom"), Some(&"100%".to_string()));
659    }
660
661    #[test]
662    fn test_cursor_builder() {
663        let (doc, cursor) = CursorBuilder::for_document("doc1").at(42);
664        assert_eq!(doc, "doc1");
665        assert_eq!(cursor.position, 42);
666
667        let (doc, cursor) = CursorBuilder::for_document("doc2").selection(10, 20);
668        assert_eq!(doc, "doc2");
669        assert_eq!(cursor.selection_range(), Some((10, 20)));
670    }
671}