Skip to main content

nodedb_types/sync/wire/
presence.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Presence / awareness messages.
4
5use serde::{Deserialize, Serialize};
6
7/// Presence update message (client → server, 0x80).
8///
9/// Sends ephemeral user state to a channel. The server broadcasts the state
10/// to all other subscribers of the same channel. Presence is NOT persisted,
11/// NOT CRDT-merged — it is fire-and-forget with latest-state-wins semantics.
12///
13/// Sending a `PresenceUpdate` implicitly subscribes the sender to the channel
14/// (if not already subscribed).
15#[derive(
16    Debug, Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack,
17)]
18pub struct PresenceUpdateMsg {
19    /// Channel scoping key (e.g., `"doc:doc-123"`, `"workspace:ws-acme"`).
20    pub channel: String,
21    /// Opaque user state (MessagePack-encoded application-defined payload).
22    /// Common fields: user_id, user_name, cursor_position, selection_range,
23    /// active_document_id, color, avatar_url.
24    pub state: Vec<u8>,
25}
26
27/// A single peer's presence state within a channel.
28#[derive(
29    Debug, Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack,
30)]
31pub struct PeerPresence {
32    /// User identifier.
33    pub user_id: String,
34    /// Opaque user state (same format as `PresenceUpdateMsg::state`).
35    pub state: Vec<u8>,
36    /// Milliseconds since this peer's last update.
37    pub last_seen_ms: u64,
38}
39
40/// Presence broadcast message (server → all subscribers except sender, 0x81).
41///
42/// Contains the full set of currently-present peers in the channel.
43/// Sent whenever any peer updates their state or leaves.
44#[derive(
45    Debug, Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack,
46)]
47pub struct PresenceBroadcastMsg {
48    /// Channel this broadcast belongs to.
49    pub channel: String,
50    /// All currently-present peers and their latest state.
51    pub peers: Vec<PeerPresence>,
52}
53
54/// Presence leave message (server → all subscribers, 0x82).
55///
56/// Emitted when a peer disconnects (WebSocket close) or when their
57/// presence TTL expires (no heartbeat within `presence_ttl_ms`).
58#[derive(
59    Debug, Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack,
60)]
61pub struct PresenceLeaveMsg {
62    /// Channel the user left.
63    pub channel: String,
64    /// User who left.
65    pub user_id: String,
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use crate::sync::wire::{SyncFrame, SyncMessageType};
72
73    #[test]
74    fn presence_update_roundtrip() {
75        let msg = PresenceUpdateMsg {
76            channel: "doc:doc-123".into(),
77            state: b"user_id:user-42,cursor:blk-7:42".to_vec(),
78        };
79        let frame = SyncFrame::new_msgpack(SyncMessageType::PresenceUpdate, &msg).unwrap();
80        let bytes = frame.to_bytes();
81        assert_eq!(bytes[0], SyncFrame::FORMAT_VERSION);
82        assert_eq!(bytes[1], 0x80);
83        let decoded: PresenceUpdateMsg = SyncFrame::from_bytes(&bytes)
84            .unwrap()
85            .decode_body()
86            .unwrap();
87        assert_eq!(decoded.channel, "doc:doc-123");
88        assert!(!decoded.state.is_empty());
89    }
90
91    #[test]
92    fn presence_broadcast_roundtrip() {
93        let msg = PresenceBroadcastMsg {
94            channel: "doc:doc-123".into(),
95            peers: vec![
96                PeerPresence {
97                    user_id: "user-42".into(),
98                    state: vec![0xDE, 0xAD],
99                    last_seen_ms: 150,
100                },
101                PeerPresence {
102                    user_id: "user-99".into(),
103                    state: vec![0xBE, 0xEF],
104                    last_seen_ms: 2300,
105                },
106            ],
107        };
108        let frame = SyncFrame::new_msgpack(SyncMessageType::PresenceBroadcast, &msg).unwrap();
109        let decoded: PresenceBroadcastMsg = SyncFrame::from_bytes(&frame.to_bytes())
110            .unwrap()
111            .decode_body()
112            .unwrap();
113        assert_eq!(decoded.channel, "doc:doc-123");
114        assert_eq!(decoded.peers.len(), 2);
115        assert_eq!(decoded.peers[0].user_id, "user-42");
116        assert_eq!(decoded.peers[1].last_seen_ms, 2300);
117    }
118
119    #[test]
120    fn presence_leave_roundtrip() {
121        let msg = PresenceLeaveMsg {
122            channel: "doc:doc-123".into(),
123            user_id: "user-42".into(),
124        };
125        let frame = SyncFrame::new_msgpack(SyncMessageType::PresenceLeave, &msg).unwrap();
126        let bytes = frame.to_bytes();
127        assert_eq!(bytes[0], SyncFrame::FORMAT_VERSION);
128        assert_eq!(bytes[1], 0x82);
129        let decoded: PresenceLeaveMsg = SyncFrame::from_bytes(&bytes)
130            .unwrap()
131            .decode_body()
132            .unwrap();
133        assert_eq!(decoded.channel, "doc:doc-123");
134        assert_eq!(decoded.user_id, "user-42");
135    }
136}