NOT-OMEGA commited on
Commit
7e09d8b
·
verified ·
1 Parent(s): 85355b5

Rename presence.py to presence.hpp

Browse files
Files changed (2) hide show
  1. presence.hpp +213 -0
  2. presence.py +0 -155
presence.hpp ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #pragma once
2
+ /*
3
+ * CollabDocs C++ — Presence Manager
4
+ *
5
+ * Tracks active users per doc:
6
+ * color, name, cursor_pos, websocket connection pointer.
7
+ */
8
+
9
+ #include <string>
10
+ #include <vector>
11
+ #include <unordered_map>
12
+ #include <unordered_set>
13
+ #include <mutex>
14
+ #include <memory>
15
+ #include <functional>
16
+ #include <chrono>
17
+ #include <cmath>
18
+ #include <nlohmann/json.hpp>
19
+
20
+ namespace collab {
21
+
22
+ static const std::vector<std::string> USER_COLORS = {
23
+ "#E63946","#2196F3","#FF9800","#9C27B0","#00BCD4",
24
+ "#4CAF50","#FF5722","#3F51B5","#009688","#F44336",
25
+ "#8BC34A","#FF4081","#00ACC1","#7E57C2","#FFA726",
26
+ };
27
+
28
+ static const std::vector<std::string> USER_NAMES = {
29
+ "Alice","Bob","Carol","Dave","Eve","Frank","Grace",
30
+ "Hank","Iris","Jack","Kate","Leo","Mia","Nick","Olivia",
31
+ "Pete","Quinn","Rosa","Sam","Tara",
32
+ };
33
+
34
+ static constexpr double STALE_TIMEOUT_SEC = 60.0;
35
+
36
+ inline double now_sec() {
37
+ return std::chrono::duration_cast<std::chrono::duration<double>>(
38
+ std::chrono::system_clock::now().time_since_epoch()).count();
39
+ }
40
+
41
+ // ─── WebSocket abstraction ────────────────────────────────────────────────────
42
+ // We store a send-callback so the presence manager is decoupled from
43
+ // Boost.Beast specifics (easier to test too).
44
+
45
+ using SendFn = std::function<void(const std::string&)>;
46
+
47
+ // ─── UserPresence ─────────────────────────────────────────────────────────────
48
+
49
+ struct UserPresence {
50
+ std::string user_id;
51
+ std::string name;
52
+ std::string color;
53
+ int cursor_pos = 0;
54
+ int selection_start = -1;
55
+ int selection_end = -1;
56
+ double last_seen = 0.0;
57
+ SendFn send_fn; // call this to push JSON to this client
58
+
59
+ void ping() { last_seen = now_sec(); }
60
+
61
+ nlohmann::json to_json() const {
62
+ return {
63
+ {"user_id", user_id},
64
+ {"name", name},
65
+ {"color", color},
66
+ {"cursor_pos", cursor_pos},
67
+ {"selection_start", selection_start},
68
+ {"selection_end", selection_end},
69
+ };
70
+ }
71
+ };
72
+
73
+ // ─── PresenceManager ─────────────────────────────────────────────────────────
74
+
75
+ class PresenceManager {
76
+ public:
77
+ // Called when a user opens a document
78
+ UserPresence& join(const std::string& doc_id,
79
+ const std::string& user_id,
80
+ SendFn send_fn)
81
+ {
82
+ std::lock_guard<std::mutex> lk(mu_);
83
+ auto& users = doc_users_[doc_id];
84
+
85
+ auto it = users.find(user_id);
86
+ if (it == users.end()) {
87
+ UserPresence p;
88
+ p.user_id = user_id;
89
+ p.color = pick_color(doc_id);
90
+ p.name = USER_NAMES[
91
+ std::hash<std::string>{}(user_id) % USER_NAMES.size()];
92
+ p.last_seen = now_sec();
93
+ p.send_fn = std::move(send_fn);
94
+ users[user_id] = std::move(p);
95
+ } else {
96
+ it->second.send_fn = std::move(send_fn);
97
+ it->second.ping();
98
+ }
99
+ return users[user_id];
100
+ }
101
+
102
+ void leave(const std::string& doc_id, const std::string& user_id) {
103
+ std::lock_guard<std::mutex> lk(mu_);
104
+ auto dit = doc_users_.find(doc_id);
105
+ if (dit == doc_users_.end()) return;
106
+ auto uit = dit->second.find(user_id);
107
+ if (uit == dit->second.end()) return;
108
+ release_color(doc_id, uit->second.color);
109
+ dit->second.erase(uit);
110
+ }
111
+
112
+ void update_cursor(const std::string& doc_id, const std::string& user_id,
113
+ int pos, int sel_start = -1, int sel_end = -1) {
114
+ std::lock_guard<std::mutex> lk(mu_);
115
+ auto& users = doc_users_[doc_id];
116
+ auto it = users.find(user_id);
117
+ if (it == users.end()) return;
118
+ it->second.cursor_pos = pos;
119
+ it->second.selection_start = sel_start;
120
+ it->second.selection_end = sel_end;
121
+ it->second.ping();
122
+ }
123
+
124
+ // Get snapshot of a user's presence data (thread-safe)
125
+ std::optional<UserPresence> get_user(const std::string& doc_id,
126
+ const std::string& user_id) {
127
+ std::lock_guard<std::mutex> lk(mu_);
128
+ auto dit = doc_users_.find(doc_id);
129
+ if (dit == doc_users_.end()) return std::nullopt;
130
+ auto uit = dit->second.find(user_id);
131
+ if (uit == dit->second.end()) return std::nullopt;
132
+ return uit->second;
133
+ }
134
+
135
+ // Get list of active user JSON objects
136
+ std::vector<nlohmann::json> get_users_json(const std::string& doc_id) {
137
+ std::lock_guard<std::mutex> lk(mu_);
138
+ std::vector<nlohmann::json> out;
139
+ auto dit = doc_users_.find(doc_id);
140
+ if (dit == doc_users_.end()) return out;
141
+ double cutoff = now_sec() - STALE_TIMEOUT_SEC;
142
+ for (auto& [uid, p] : dit->second)
143
+ if (p.last_seen >= cutoff)
144
+ out.push_back(p.to_json());
145
+ return out;
146
+ }
147
+
148
+ // Broadcast to all users in doc except one; calls their send_fn.
149
+ // Returns list of user_ids whose send_fn threw (dead connections).
150
+ std::vector<std::string> broadcast(const std::string& doc_id,
151
+ const std::string& json_str,
152
+ const std::string& exclude = "") {
153
+ // Snapshot so we don't hold lock while calling send_fn
154
+ std::vector<std::pair<std::string, SendFn>> targets;
155
+ {
156
+ std::lock_guard<std::mutex> lk(mu_);
157
+ auto dit = doc_users_.find(doc_id);
158
+ if (dit == doc_users_.end()) return {};
159
+ for (auto& [uid, p] : dit->second)
160
+ if (uid != exclude && p.send_fn)
161
+ targets.emplace_back(uid, p.send_fn);
162
+ }
163
+
164
+ std::vector<std::string> dead;
165
+ for (auto& [uid, fn] : targets) {
166
+ try { fn(json_str); }
167
+ catch (...) { dead.push_back(uid); }
168
+ }
169
+ return dead;
170
+ }
171
+
172
+ void send_to_user(const std::string& doc_id,
173
+ const std::string& user_id,
174
+ const std::string& json_str) {
175
+ std::lock_guard<std::mutex> lk(mu_);
176
+ auto dit = doc_users_.find(doc_id);
177
+ if (dit == doc_users_.end()) return;
178
+ auto uit = dit->second.find(user_id);
179
+ if (uit == dit->second.end() || !uit->second.send_fn) return;
180
+ try { uit->second.send_fn(json_str); } catch (...) {}
181
+ }
182
+
183
+ size_t user_count(const std::string& doc_id) {
184
+ std::lock_guard<std::mutex> lk(mu_);
185
+ auto it = doc_users_.find(doc_id);
186
+ return (it != doc_users_.end()) ? it->second.size() : 0;
187
+ }
188
+
189
+ private:
190
+ std::mutex mu_;
191
+ std::unordered_map<std::string,
192
+ std::unordered_map<std::string, UserPresence>> doc_users_;
193
+ std::unordered_map<std::string,
194
+ std::unordered_set<std::string>> used_colors_;
195
+
196
+ std::string pick_color(const std::string& doc_id) {
197
+ auto& used = used_colors_[doc_id];
198
+ for (auto& c : USER_COLORS)
199
+ if (!used.count(c)) { used.insert(c); return c; }
200
+ // All used — recycle
201
+ auto& c = USER_COLORS[used.size() % USER_COLORS.size()];
202
+ return c;
203
+ }
204
+
205
+ void release_color(const std::string& doc_id, const std::string& color) {
206
+ auto it = used_colors_.find(doc_id);
207
+ if (it != used_colors_.end()) it->second.erase(color);
208
+ }
209
+ };
210
+
211
+ inline PresenceManager g_presence;
212
+
213
+ } // namespace collab
presence.py DELETED
@@ -1,155 +0,0 @@
1
- """
2
- Presence Manager
3
- Tracks active users per document: cursor positions, selections, colors.
4
-
5
- Fixes over original:
6
- - Color reclamation in leave() actually works (was dead code before)
7
- - Stale user pruning doesn't mutate the dict while iterating
8
- - get_connections() returns a snapshot copy to avoid mutation during broadcast
9
- - User names are preserved on reconnect (same user_id)
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import random
15
- import time
16
- from dataclasses import dataclass, field
17
- from typing import Dict, List, Optional, Set
18
-
19
- from fastapi import WebSocket
20
-
21
- USER_COLORS = [
22
- "#E63946", "#2196F3", "#FF9800", "#9C27B0", "#00BCD4",
23
- "#4CAF50", "#FF5722", "#3F51B5", "#009688", "#F44336",
24
- "#8BC34A", "#FF4081", "#00ACC1", "#7E57C2", "#FFA726",
25
- ]
26
-
27
- USER_NAMES = [
28
- "Alice", "Bob", "Carol", "Dave", "Eve",
29
- "Frank", "Grace", "Hank", "Iris", "Jack",
30
- "Kate", "Leo", "Mia", "Nick", "Olivia",
31
- "Pete", "Quinn", "Rosa", "Sam", "Tara",
32
- ]
33
-
34
- STALE_TIMEOUT_SECONDS = 30
35
-
36
-
37
- @dataclass
38
- class UserPresence:
39
- user_id: str
40
- name: str
41
- color: str
42
- cursor_pos: int = 0
43
- selection_start: int = -1
44
- selection_end: int = -1
45
- last_seen: float = field(default_factory=time.time)
46
- websocket: Optional[object] = None
47
-
48
- def to_dict(self) -> dict:
49
- return {
50
- "user_id": self.user_id,
51
- "name": self.name,
52
- "color": self.color,
53
- "cursor_pos": self.cursor_pos,
54
- "selection_start": self.selection_start,
55
- "selection_end": self.selection_end,
56
- }
57
-
58
- def ping(self):
59
- self.last_seen = time.time()
60
-
61
-
62
- class PresenceManager:
63
- def __init__(self):
64
- self._doc_users: Dict[str, Dict[str, UserPresence]] = {}
65
- self._connections: Dict[str, Dict[str, WebSocket]] = {}
66
- self._used_colors: Dict[str, Set[str]] = {}
67
-
68
- def _get_color(self, doc_id: str) -> str:
69
- used = self._used_colors.get(doc_id, set())
70
- available = [c for c in USER_COLORS if c not in used]
71
- if not available:
72
- # All colors taken — recycle but pick least-used visually distinct
73
- available = USER_COLORS
74
- color = random.choice(available)
75
- self._used_colors.setdefault(doc_id, set()).add(color)
76
- return color
77
-
78
- def _release_color(self, doc_id: str, color: str):
79
- if doc_id in self._used_colors:
80
- self._used_colors[doc_id].discard(color)
81
-
82
- def join(self, doc_id: str, user_id: str, ws: WebSocket) -> UserPresence:
83
- if doc_id not in self._doc_users:
84
- self._doc_users[doc_id] = {}
85
- self._connections[doc_id] = {}
86
-
87
- if user_id not in self._doc_users[doc_id]:
88
- color = self._get_color(doc_id)
89
- # Assign name deterministically from user_id hash for consistency
90
- name_idx = abs(hash(user_id)) % len(USER_NAMES)
91
- name = USER_NAMES[name_idx]
92
- presence = UserPresence(
93
- user_id=user_id,
94
- name=name,
95
- color=color,
96
- websocket=ws,
97
- )
98
- self._doc_users[doc_id][user_id] = presence
99
- else:
100
- # Reconnect: preserve name/color, just update ws
101
- self._doc_users[doc_id][user_id].websocket = ws
102
- self._doc_users[doc_id][user_id].ping()
103
-
104
- self._connections[doc_id][user_id] = ws
105
- return self._doc_users[doc_id][user_id]
106
-
107
- def leave(self, doc_id: str, user_id: str):
108
- if doc_id in self._doc_users:
109
- user = self._doc_users[doc_id].pop(user_id, None)
110
- self._connections[doc_id].pop(user_id, None)
111
- # Actually release the color now (original had dead code here)
112
- if user is not None:
113
- self._release_color(doc_id, user.color)
114
-
115
- def update_cursor(
116
- self,
117
- doc_id: str,
118
- user_id: str,
119
- cursor_pos: int,
120
- sel_start: int = -1,
121
- sel_end: int = -1,
122
- ):
123
- users = self._doc_users.get(doc_id, {})
124
- if user_id in users:
125
- p = users[user_id]
126
- p.cursor_pos = cursor_pos
127
- p.selection_start = sel_start
128
- p.selection_end = sel_end
129
- p.ping()
130
-
131
- def get_users(self, doc_id: str) -> List[dict]:
132
- users = self._doc_users.get(doc_id, {})
133
- now = time.time()
134
- # Build a new dict instead of mutating while iterating
135
- active = {
136
- uid: u
137
- for uid, u in users.items()
138
- if now - u.last_seen < STALE_TIMEOUT_SECONDS
139
- }
140
- stale = set(users) - set(active)
141
- for uid in stale:
142
- self._release_color(doc_id, users[uid].color)
143
- self._doc_users[doc_id] = active
144
- return [u.to_dict() for u in active.values()]
145
-
146
- def get_connections(self, doc_id: str) -> Dict[str, WebSocket]:
147
- # Return a snapshot so broadcast can't mutate the live dict mid-iteration
148
- return dict(self._connections.get(doc_id, {}))
149
-
150
- def get_user(self, doc_id: str, user_id: str) -> Optional[UserPresence]:
151
- return self._doc_users.get(doc_id, {}).get(user_id)
152
-
153
-
154
- # Global singleton
155
- presence_manager = PresenceManager()