@strapi-community/plugin-io 5.0.6 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,6 +39,12 @@ Add real-time capabilities to your Strapi application with WebSocket support. Au
39
39
  - **Role-Based Access Control** - Built-in permission checks for JWT and API tokens
40
40
  - **Multi-Client Support** - Handle 2500+ concurrent connections efficiently
41
41
 
42
+ ### Live Presence (NEW)
43
+ - **Real-Time Presence Awareness** - See who else is editing the same content
44
+ - **Typing Indicator** - See when someone is typing and in which field
45
+ - **Admin Panel Sidebar** - Live presence panel integrated into Content Manager
46
+ - **Session-Based Auth** - Secure admin authentication for Socket.IO connections
47
+
42
48
  ### Developer Experience
43
49
  - **Visual Admin Panel** - Configure everything through the Strapi admin interface
44
50
  - **TypeScript Support** - Full type definitions for IntelliSense
@@ -764,6 +770,130 @@ Configure permissions in the Strapi admin panel:
764
770
 
765
771
  ---
766
772
 
773
+ ## Security
774
+
775
+ The plugin implements multiple security layers to protect your real-time connections.
776
+
777
+ ### Admin Session Tokens
778
+
779
+ For admin panel connections (Live Presence), the plugin uses secure session tokens:
780
+
781
+ ```
782
+ +------------------+ +------------------+ +------------------+
783
+ | Admin Browser | ---> | Session Endpoint| ---> | Socket.IO |
784
+ | (Strapi Admin) | | /io/presence/ | | Server |
785
+ +------------------+ +------------------+ +------------------+
786
+ | | |
787
+ | 1. Request session | |
788
+ | (Admin JWT in header) | |
789
+ +------------------------>| |
790
+ | | |
791
+ | 2. Return session token | |
792
+ | (UUID, 10 min TTL) | |
793
+ |<------------------------+ |
794
+ | | |
795
+ | 3. Connect Socket.IO | |
796
+ | (Session token in auth) | |
797
+ +-------------------------------------------------->|
798
+ | | |
799
+ | 4. Validate & connect | |
800
+ |<--------------------------------------------------+
801
+ ```
802
+
803
+ **Security Features:**
804
+ - **Token Hashing**: Tokens stored as SHA-256 hashes (plaintext never persisted)
805
+ - **Short TTL**: 10-minute expiration with automatic refresh at 70%
806
+ - **Usage Limits**: Max 10 reconnects per token to prevent replay attacks
807
+ - **Rate Limiting**: 30-second cooldown between token requests
808
+ - **Minimal Data**: Only essential user info stored (ID, firstname, lastname)
809
+
810
+ ### Rate Limiting
811
+
812
+ Prevent abuse with configurable rate limits:
813
+
814
+ ```javascript
815
+ // In config/plugins.js
816
+ module.exports = {
817
+ io: {
818
+ enabled: true,
819
+ config: {
820
+ security: {
821
+ rateLimit: {
822
+ enabled: true,
823
+ maxEventsPerSecond: 10, // Max events per socket per second
824
+ maxConnectionsPerIp: 50 // Max connections from single IP
825
+ }
826
+ }
827
+ }
828
+ }
829
+ };
830
+ ```
831
+
832
+ ### IP Whitelisting/Blacklisting
833
+
834
+ Restrict access by IP address:
835
+
836
+ ```javascript
837
+ // In config/plugins.js
838
+ module.exports = {
839
+ io: {
840
+ enabled: true,
841
+ config: {
842
+ security: {
843
+ ipWhitelist: ['192.168.1.0/24', '10.0.0.1'], // Only these IPs allowed
844
+ ipBlacklist: ['203.0.113.50'], // These IPs blocked
845
+ requireAuthentication: true // Require JWT/API token
846
+ }
847
+ }
848
+ }
849
+ };
850
+ ```
851
+
852
+ ### Security Monitoring API
853
+
854
+ Monitor active sessions via admin API:
855
+
856
+ ```bash
857
+ # Get session statistics
858
+ GET /io/security/sessions
859
+ Authorization: Bearer <admin-jwt>
860
+
861
+ # Response:
862
+ {
863
+ "data": {
864
+ "activeSessions": 5,
865
+ "expiringSoon": 1,
866
+ "activeSocketConnections": 3,
867
+ "sessionTTL": 600000,
868
+ "refreshCooldown": 30000
869
+ }
870
+ }
871
+
872
+ # Force logout a user (invalidate all their sessions)
873
+ POST /io/security/invalidate/:userId
874
+ Authorization: Bearer <admin-jwt>
875
+
876
+ # Response:
877
+ {
878
+ "data": {
879
+ "userId": 1,
880
+ "invalidatedSessions": 2,
881
+ "message": "Successfully invalidated 2 session(s)"
882
+ }
883
+ }
884
+ ```
885
+
886
+ ### Best Practices
887
+
888
+ 1. **Always use HTTPS** in production for encrypted WebSocket connections
889
+ 2. **Enable authentication** for sensitive content types
890
+ 3. **Configure CORS** to only allow your frontend domains
891
+ 4. **Monitor connections** via the admin dashboard
892
+ 5. **Set reasonable rate limits** based on your use case
893
+ 6. **Review access logs** periodically for suspicious activity
894
+
895
+ ---
896
+
767
897
  ## Admin Panel
768
898
 
769
899
  The plugin provides a full admin interface for configuration and monitoring.
@@ -833,6 +963,39 @@ Navigate to **Settings > Socket.IO > Monitoring** for live statistics:
833
963
  - Send test events
834
964
  - Reset statistics
835
965
 
966
+ ### Live Presence Panel
967
+
968
+ When editing content in the Content Manager, a **Live Presence** panel appears in the sidebar showing:
969
+
970
+ - **Connection Status** - Live indicator showing real-time sync is active
971
+ - **Active Editors** - List of other users editing the same content
972
+ - **Typing Indicator** - Shows when someone is typing and in which field
973
+
974
+ **How It Works:**
975
+
976
+ 1. When you open a content entry, the panel connects via Socket.IO
977
+ 2. Other editors on the same entry appear in the panel
978
+ 3. Typing in any field broadcasts a typing indicator to others
979
+ 4. When you leave, others are notified
980
+
981
+ **Example Display:**
982
+
983
+ ```
984
+ +-----------------------------+
985
+ | Live Presence |
986
+ +-----------------------------+
987
+ | [*] Live |
988
+ | Real-time sync active |
989
+ +-----------------------------+
990
+ | ALSO EDITING (1) |
991
+ | +-------------------------+ |
992
+ | | SA Sarah Admin | |
993
+ | | Typing in: title | |
994
+ | | [Typing...] | |
995
+ | +-------------------------+ |
996
+ +-----------------------------+
997
+ ```
998
+
836
999
  ---
837
1000
 
838
1001
  ## Monitoring Service
@@ -1282,7 +1445,17 @@ Copyright (c) 2024 Strapi Community
1282
1445
 
1283
1446
  ## Changelog
1284
1447
 
1285
- ### v5.0.0 (Latest)
1448
+ ### v5.1.0 (Latest)
1449
+ - **Live Presence System** - Real-time presence awareness in Content Manager
1450
+ - **Typing Indicator** - See when others are typing and in which field
1451
+ - **Admin Panel Sidebar** - Live presence panel integrated into edit view
1452
+ - **Admin Session Authentication** - Secure session tokens for Socket.IO
1453
+ - **Admin JWT Strategy** - New authentication strategy for admin users
1454
+ - **Enhanced Security** - Token hashing (SHA-256), usage limits, rate limiting
1455
+ - **Automatic Token Refresh** - Tokens auto-refresh at 70% of TTL
1456
+ - **Security Monitoring API** - Session stats and force-logout endpoints
1457
+
1458
+ ### v5.0.0
1286
1459
  - Strapi v5 support
1287
1460
  - Package renamed to `@strapi-community/plugin-io`
1288
1461
  - Enhanced TypeScript support
@@ -0,0 +1,419 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const jsxRuntime = require("react/jsx-runtime");
4
+ const React = require("react");
5
+ const index = require("./index-DkTxsEqL.js");
6
+ const designSystem = require("@strapi/design-system");
7
+ const admin = require("@strapi/strapi/admin");
8
+ const styled = require("styled-components");
9
+ const socket_ioClient = require("socket.io-client");
10
+ const index$1 = require("./index-BEZDDgvZ.js");
11
+ const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
12
+ const styled__default = /* @__PURE__ */ _interopDefault(styled);
13
+ const pulse = styled.keyframes`
14
+ 0%, 100% {
15
+ box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2);
16
+ transform: scale(1);
17
+ }
18
+ 50% {
19
+ box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.1);
20
+ transform: scale(1.1);
21
+ }
22
+ `;
23
+ const StatusCard = styled__default.default.div`
24
+ background: ${(props) => props.theme.colors.neutral0};
25
+ border: 1px solid ${({ $status, theme }) => $status === "connected" ? "rgba(34, 197, 94, 0.3)" : $status === "error" ? "rgba(239, 68, 68, 0.3)" : theme.colors.neutral200};
26
+ border-radius: 10px;
27
+ padding: 14px 16px;
28
+ display: flex;
29
+ align-items: center;
30
+ gap: 12px;
31
+ `;
32
+ const StatusDot = styled__default.default.div`
33
+ width: 12px;
34
+ height: 12px;
35
+ border-radius: 50%;
36
+ flex-shrink: 0;
37
+ background: ${({ $status }) => $status === "connected" ? "#22c55e" : $status === "connecting" ? "#f59e0b" : $status === "error" ? "#ef4444" : "#94a3b8"};
38
+
39
+ ${({ $status }) => $status === "connected" && styled.css`
40
+ animation: ${pulse} 2s ease-in-out infinite;
41
+ `}
42
+ `;
43
+ const StatusText = styled__default.default.div`
44
+ display: flex;
45
+ flex-direction: column;
46
+ gap: 2px;
47
+ `;
48
+ const StatusLabel = styled__default.default.span`
49
+ font-size: 14px;
50
+ font-weight: 600;
51
+ color: ${({ $status, theme }) => $status === "connected" ? theme.colors.success600 : $status === "connecting" ? theme.colors.warning600 : $status === "error" ? theme.colors.danger600 : theme.colors.neutral600};
52
+ `;
53
+ const StatusSubtext = styled__default.default.span`
54
+ font-size: 12px;
55
+ color: ${(props) => props.theme.colors.neutral500};
56
+ `;
57
+ const SectionTitle = styled__default.default.div`
58
+ font-size: 11px;
59
+ font-weight: 600;
60
+ color: ${(props) => props.theme.colors.neutral600};
61
+ text-transform: uppercase;
62
+ letter-spacing: 0.5px;
63
+ margin-bottom: 10px;
64
+ `;
65
+ const EditorItem = styled__default.default.div`
66
+ display: flex;
67
+ align-items: center;
68
+ gap: 12px;
69
+ padding: 12px 14px;
70
+ background: ${(props) => props.theme.colors.neutral0};
71
+ border-radius: 10px;
72
+ border: 1px solid ${(props) => props.theme.colors.neutral150};
73
+ transition: all 0.2s ease;
74
+
75
+ &:hover {
76
+ border-color: ${(props) => props.theme.colors.primary200};
77
+ box-shadow: 0 2px 8px rgba(73, 69, 255, 0.08);
78
+ transform: translateY(-1px);
79
+ }
80
+ `;
81
+ const EDITOR_COLORS = [
82
+ "linear-gradient(135deg, #4945ff 0%, #7b79ff 100%)",
83
+ "linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)",
84
+ "linear-gradient(135deg, #10b981 0%, #34d399 100%)",
85
+ "linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)",
86
+ "linear-gradient(135deg, #ef4444 0%, #f87171 100%)",
87
+ "linear-gradient(135deg, #ec4899 0%, #f472b6 100%)"
88
+ ];
89
+ const EditorAvatar = styled__default.default.div`
90
+ width: 36px;
91
+ height: 36px;
92
+ border-radius: 50%;
93
+ background: ${({ $color }) => $color || EDITOR_COLORS[0]};
94
+ color: white;
95
+ font-size: 12px;
96
+ font-weight: 700;
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ flex-shrink: 0;
101
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
102
+ `;
103
+ const EditorInfo = styled__default.default.div`
104
+ flex: 1;
105
+ min-width: 0;
106
+ display: flex;
107
+ flex-direction: column;
108
+ gap: 2px;
109
+ `;
110
+ const EditorName = styled__default.default.span`
111
+ font-size: 13px;
112
+ font-weight: 600;
113
+ color: ${(props) => props.theme.colors.neutral800};
114
+ white-space: nowrap;
115
+ overflow: hidden;
116
+ text-overflow: ellipsis;
117
+ `;
118
+ const EditorEmail = styled__default.default.span`
119
+ font-size: 11px;
120
+ color: ${(props) => props.theme.colors.neutral500};
121
+ white-space: nowrap;
122
+ overflow: hidden;
123
+ text-overflow: ellipsis;
124
+ `;
125
+ const EditingBadge = styled__default.default.span`
126
+ font-size: 10px;
127
+ font-weight: 600;
128
+ color: #166534;
129
+ background: #dcfce7;
130
+ padding: 4px 8px;
131
+ border-radius: 12px;
132
+ flex-shrink: 0;
133
+ `;
134
+ const TypingBadge = styled__default.default.span`
135
+ font-size: 10px;
136
+ font-weight: 600;
137
+ color: #92400e;
138
+ background: #fef3c7;
139
+ padding: 4px 8px;
140
+ border-radius: 12px;
141
+ flex-shrink: 0;
142
+ `;
143
+ const EmptyState = styled__default.default.div`
144
+ text-align: center;
145
+ padding: 16px;
146
+ background: ${(props) => props.theme.colors.neutral100};
147
+ border-radius: 10px;
148
+ border: 1px dashed ${(props) => props.theme.colors.neutral300};
149
+ `;
150
+ const EmptyText = styled__default.default.span`
151
+ font-size: 13px;
152
+ color: ${(props) => props.theme.colors.neutral500};
153
+ `;
154
+ const getEditorInitials = (user = {}) => {
155
+ const first = (user.firstname?.[0] || user.username?.[0] || user.email?.[0] || "?").toUpperCase();
156
+ const last = (user.lastname?.[0] || "").toUpperCase();
157
+ return `${first}${last}`.trim();
158
+ };
159
+ const getEditorName = (user = {}) => {
160
+ if (user.firstname) {
161
+ return `${user.firstname} ${user.lastname || ""}`.trim();
162
+ }
163
+ return user.username || user.email || "Unknown";
164
+ };
165
+ const LivePresencePanel = ({ documentId, model, document }) => {
166
+ const { formatMessage } = index.useIntl();
167
+ const { post } = admin.useFetchClient();
168
+ const t = (id, defaultMessage, values) => formatMessage({ id: `${index$1.PLUGIN_ID}.${id}`, defaultMessage }, values);
169
+ const socketRef = React.useRef(null);
170
+ const [sessionData, setSessionData] = React.useState(null);
171
+ const [presenceState, setPresenceState] = React.useState({
172
+ status: "initializing",
173
+ editors: [],
174
+ typingUsers: [],
175
+ error: null
176
+ });
177
+ const uid = model?.uid || model;
178
+ React.useEffect(() => {
179
+ if (!uid || !documentId) {
180
+ setPresenceState((prev) => ({ ...prev, status: "disconnected", error: "No content" }));
181
+ return;
182
+ }
183
+ let cancelled = false;
184
+ let refreshTimeoutId = null;
185
+ const getSession = async (isRefresh = false) => {
186
+ try {
187
+ if (!isRefresh) {
188
+ setPresenceState((prev) => ({ ...prev, status: "requesting" }));
189
+ }
190
+ const { data } = await post(`/${index$1.PLUGIN_ID}/presence/session`, {});
191
+ if (cancelled) return;
192
+ if (!data || !data.token) {
193
+ throw new Error("Invalid session response");
194
+ }
195
+ console.log(`[${index$1.PLUGIN_ID}] Session ${isRefresh ? "refreshed" : "obtained"}:`, {
196
+ expiresIn: Math.round((data.expiresAt - Date.now()) / 1e3) + "s",
197
+ refreshAfter: Math.round((data.refreshAfter - Date.now()) / 1e3) + "s"
198
+ });
199
+ setSessionData(data);
200
+ if (!isRefresh) {
201
+ setPresenceState((prev) => ({ ...prev, status: "connecting" }));
202
+ }
203
+ if (data.refreshAfter) {
204
+ const refreshIn = data.refreshAfter - Date.now();
205
+ if (refreshIn > 0) {
206
+ console.log(`[${index$1.PLUGIN_ID}] Token refresh scheduled in ${Math.round(refreshIn / 1e3)}s`);
207
+ refreshTimeoutId = setTimeout(() => {
208
+ if (!cancelled) {
209
+ console.log(`[${index$1.PLUGIN_ID}] Refreshing session token...`);
210
+ getSession(true);
211
+ }
212
+ }, refreshIn);
213
+ }
214
+ }
215
+ } catch (error2) {
216
+ if (cancelled) return;
217
+ if (error2.response?.status === 429) {
218
+ console.warn(`[${index$1.PLUGIN_ID}] Rate limited, retrying in 30s...`);
219
+ refreshTimeoutId = setTimeout(() => {
220
+ if (!cancelled) getSession(isRefresh);
221
+ }, 3e4);
222
+ return;
223
+ }
224
+ console.error(`[${index$1.PLUGIN_ID}] Failed to get presence session:`, error2);
225
+ setPresenceState((prev) => ({
226
+ ...prev,
227
+ status: "error",
228
+ error: error2.message || "Failed to get session"
229
+ }));
230
+ }
231
+ };
232
+ getSession();
233
+ return () => {
234
+ cancelled = true;
235
+ if (refreshTimeoutId) {
236
+ clearTimeout(refreshTimeoutId);
237
+ }
238
+ };
239
+ }, [uid, documentId, post]);
240
+ React.useEffect(() => {
241
+ if (!sessionData?.token || !uid || !documentId) {
242
+ return;
243
+ }
244
+ const socketUrl = sessionData.wsUrl || `${window.location.protocol}//${window.location.host}`;
245
+ const socket = socket_ioClient.io(socketUrl, {
246
+ path: sessionData.wsPath || "/socket.io",
247
+ transports: ["websocket", "polling"],
248
+ auth: {
249
+ token: sessionData.token,
250
+ strategy: "admin-jwt",
251
+ isAdmin: true
252
+ },
253
+ reconnection: true,
254
+ reconnectionAttempts: 3
255
+ });
256
+ socketRef.current = socket;
257
+ let lastTypingEmit = 0;
258
+ const TYPING_THROTTLE = 2e3;
259
+ const getFieldName = (element) => {
260
+ const name = element.name || element.id || "";
261
+ const label = element.closest("label") || document.querySelector(`label[for="${element.id}"]`);
262
+ if (label) {
263
+ return label.textContent?.trim() || name;
264
+ }
265
+ const fieldWrapper = element.closest('[class*="Field"]');
266
+ if (fieldWrapper) {
267
+ const labelEl = fieldWrapper.querySelector('label, [class*="Label"]');
268
+ if (labelEl) {
269
+ return labelEl.textContent?.trim() || name;
270
+ }
271
+ }
272
+ return name || "unknown field";
273
+ };
274
+ const handleInput = (event) => {
275
+ const target = event.target;
276
+ if (!["INPUT", "TEXTAREA"].includes(target.tagName)) return;
277
+ const isInContentManager = target.closest('[class*="ContentLayout"]') || target.closest("main");
278
+ if (!isInContentManager) return;
279
+ const now = Date.now();
280
+ if (now - lastTypingEmit < TYPING_THROTTLE) return;
281
+ lastTypingEmit = now;
282
+ const fieldName = getFieldName(target);
283
+ if (socket.connected) {
284
+ socket.emit("presence:typing", { uid, documentId, fieldName });
285
+ console.log(`[${index$1.PLUGIN_ID}] Typing in field: ${fieldName}`);
286
+ }
287
+ };
288
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
289
+ document.addEventListener("input", handleInput, true);
290
+ }
291
+ socket.on("connect", () => {
292
+ console.log(`[${index$1.PLUGIN_ID}] Presence socket connected`);
293
+ setPresenceState((prev) => ({ ...prev, status: "connected", error: null }));
294
+ socket.emit("presence:join", { uid, documentId }, (response) => {
295
+ if (response?.success) {
296
+ setPresenceState((prev) => ({
297
+ ...prev,
298
+ editors: (response.editors || []).map((e) => ({
299
+ ...e,
300
+ isCurrentUser: e.socketId === socket.id
301
+ }))
302
+ }));
303
+ }
304
+ });
305
+ });
306
+ socket.on("disconnect", () => {
307
+ setPresenceState((prev) => ({ ...prev, status: "disconnected" }));
308
+ });
309
+ socket.on("connect_error", (err) => {
310
+ console.warn(`[${index$1.PLUGIN_ID}] Presence socket error:`, err.message);
311
+ setPresenceState((prev) => ({ ...prev, status: "error", error: err.message }));
312
+ });
313
+ socket.on("presence:update", (data) => {
314
+ if (data.uid === uid && data.documentId === documentId) {
315
+ setPresenceState((prev) => ({
316
+ ...prev,
317
+ editors: (data.editors || []).map((e) => ({
318
+ ...e,
319
+ isCurrentUser: e.socketId === socket.id
320
+ }))
321
+ }));
322
+ }
323
+ });
324
+ socket.on("presence:typing", (data) => {
325
+ if (data.uid === uid && data.documentId === documentId) {
326
+ setPresenceState((prev) => {
327
+ const newTyping = [...prev.typingUsers.filter((t2) => t2.user?.id !== data.user?.id)];
328
+ newTyping.push({ user: data.user, fieldName: data.fieldName, timestamp: Date.now() });
329
+ return { ...prev, typingUsers: newTyping };
330
+ });
331
+ setTimeout(() => {
332
+ setPresenceState((prev) => ({
333
+ ...prev,
334
+ typingUsers: prev.typingUsers.filter((t2) => t2.user?.id !== data.user?.id)
335
+ }));
336
+ }, 3e3);
337
+ }
338
+ });
339
+ const heartbeat = setInterval(() => {
340
+ if (socket.connected) {
341
+ socket.emit("presence:heartbeat");
342
+ }
343
+ }, 3e4);
344
+ return () => {
345
+ clearInterval(heartbeat);
346
+ if (typeof document !== "undefined" && typeof document.removeEventListener === "function") {
347
+ document.removeEventListener("input", handleInput, true);
348
+ }
349
+ if (socket.connected) {
350
+ socket.emit("presence:leave", { uid, documentId });
351
+ }
352
+ socket.disconnect();
353
+ socketRef.current = null;
354
+ };
355
+ }, [sessionData, uid, documentId]);
356
+ const { status, editors, typingUsers, error } = presenceState;
357
+ const otherEditors = React.useMemo(() => {
358
+ return editors.filter((e) => !e.isCurrentUser);
359
+ }, [editors]);
360
+ const getUserTypingInfo = React.useCallback((userId) => {
361
+ const typing = typingUsers.find((t2) => t2.user?.id === userId);
362
+ return typing || null;
363
+ }, [typingUsers]);
364
+ React.useCallback((userId) => {
365
+ return typingUsers.some((t2) => t2.user?.id === userId);
366
+ }, [typingUsers]);
367
+ const statusLabel = React.useMemo(() => {
368
+ switch (status) {
369
+ case "connected":
370
+ return t("presence.live", "Live");
371
+ case "connecting":
372
+ return t("presence.connecting", "Connecting...");
373
+ case "requesting":
374
+ return t("presence.requesting", "Authenticating...");
375
+ case "initializing":
376
+ return t("presence.initializing", "Initializing...");
377
+ case "error":
378
+ return t("presence.error", "Connection Error");
379
+ case "disconnected":
380
+ return t("presence.disconnected", "Disconnected");
381
+ default:
382
+ return t("presence.offline", "Offline");
383
+ }
384
+ }, [status, t]);
385
+ const isConnected = status === "connected";
386
+ console.log(`[${index$1.PLUGIN_ID}] LivePresencePanel render:`, { uid, documentId, status, editors: otherEditors.length });
387
+ return {
388
+ title: t("presence.title", "Live Presence"),
389
+ content: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { direction: "column", gap: 4, alignItems: "stretch", style: { width: "100%" }, children: [
390
+ /* @__PURE__ */ jsxRuntime.jsxs(StatusCard, { $status: status, children: [
391
+ /* @__PURE__ */ jsxRuntime.jsx(StatusDot, { $status: status }),
392
+ /* @__PURE__ */ jsxRuntime.jsxs(StatusText, { children: [
393
+ /* @__PURE__ */ jsxRuntime.jsx(StatusLabel, { $status: status, children: statusLabel }),
394
+ /* @__PURE__ */ jsxRuntime.jsx(StatusSubtext, { children: isConnected ? t("presence.realtimeActive", "Real-time sync active") : error || t("presence.establishing", "Establishing connection...") })
395
+ ] })
396
+ ] }),
397
+ isConnected && otherEditors.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
398
+ /* @__PURE__ */ jsxRuntime.jsx(SectionTitle, { children: t("presence.activeEditors", "Also Editing ({count})", { count: otherEditors.length }) }),
399
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { direction: "column", gap: 2, alignItems: "stretch", children: otherEditors.map((editor, idx) => {
400
+ const user = editor.user || {};
401
+ const typingInfo = getUserTypingInfo(user.id);
402
+ return /* @__PURE__ */ jsxRuntime.jsxs(EditorItem, { children: [
403
+ /* @__PURE__ */ jsxRuntime.jsx(EditorAvatar, { $color: EDITOR_COLORS[idx % EDITOR_COLORS.length], children: getEditorInitials(user) }),
404
+ /* @__PURE__ */ jsxRuntime.jsxs(EditorInfo, { children: [
405
+ /* @__PURE__ */ jsxRuntime.jsx(EditorName, { children: getEditorName(user) }),
406
+ typingInfo?.fieldName ? /* @__PURE__ */ jsxRuntime.jsxs(EditorEmail, { children: [
407
+ "Typing in: ",
408
+ typingInfo.fieldName
409
+ ] }) : user.email && user.firstname ? /* @__PURE__ */ jsxRuntime.jsx(EditorEmail, { children: user.email }) : null
410
+ ] }),
411
+ typingInfo ? /* @__PURE__ */ jsxRuntime.jsx(TypingBadge, { children: t("presence.typing", "Typing...") }) : /* @__PURE__ */ jsxRuntime.jsx(EditingBadge, { children: t("presence.editing", "Editing") })
412
+ ] }, editor.socketId || idx);
413
+ }) })
414
+ ] }),
415
+ isConnected && otherEditors.length === 0 && /* @__PURE__ */ jsxRuntime.jsx(EmptyState, { children: /* @__PURE__ */ jsxRuntime.jsx(EmptyText, { children: t("presence.workingAlone", "You are the only editor") }) })
416
+ ] })
417
+ };
418
+ };
419
+ exports.default = LivePresencePanel;