@prsm/realtime 1.0.0 → 1.0.2

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.
Files changed (40) hide show
  1. package/README.md +9 -0
  2. package/package.json +2 -7
  3. package/src/adapters/postgres.js +3 -3
  4. package/src/client/client.js +10 -10
  5. package/src/client/connection.js +1 -1
  6. package/src/client/subscriptions/channels.js +3 -3
  7. package/src/client/subscriptions/collections.js +2 -2
  8. package/src/client/subscriptions/presence.js +5 -5
  9. package/src/client/subscriptions/records.js +3 -3
  10. package/src/client/subscriptions/rooms.js +3 -3
  11. package/src/server/managers/channels.js +4 -4
  12. package/src/server/managers/collections.js +5 -5
  13. package/src/server/managers/connections.js +3 -3
  14. package/src/server/managers/instance.js +20 -20
  15. package/src/server/managers/presence.js +7 -7
  16. package/src/server/managers/pubsub.js +14 -14
  17. package/src/server/managers/rooms.js +7 -7
  18. package/src/server/server.js +26 -26
  19. package/src/server/utils/constants.js +4 -4
  20. package/src/shared/logger.js +1 -1
  21. package/src/devtools/client/dist/assets/index-CGm1NqOQ.css +0 -1
  22. package/src/devtools/client/dist/assets/index-w2FI7RvC.js +0 -168
  23. package/src/devtools/client/dist/index.html +0 -16
  24. package/src/devtools/client/index.html +0 -15
  25. package/src/devtools/client/package.json +0 -17
  26. package/src/devtools/client/src/App.vue +0 -173
  27. package/src/devtools/client/src/components/ConnectionPicker.vue +0 -38
  28. package/src/devtools/client/src/components/JsonView.vue +0 -18
  29. package/src/devtools/client/src/composables/useApi.js +0 -71
  30. package/src/devtools/client/src/composables/useHighlight.js +0 -57
  31. package/src/devtools/client/src/main.js +0 -5
  32. package/src/devtools/client/src/style.css +0 -440
  33. package/src/devtools/client/src/views/ChannelsView.vue +0 -27
  34. package/src/devtools/client/src/views/CollectionsView.vue +0 -61
  35. package/src/devtools/client/src/views/MetadataView.vue +0 -108
  36. package/src/devtools/client/src/views/RecordsView.vue +0 -30
  37. package/src/devtools/client/src/views/RoomsView.vue +0 -39
  38. package/src/devtools/client/vite.config.js +0 -17
  39. package/src/devtools/demo/server.js +0 -144
  40. package/src/devtools/index.js +0 -186
@@ -1,440 +0,0 @@
1
- :root {
2
- --bg: #0a0a0a;
3
- --bg-surface: #111111;
4
- --bg-raised: #1a1a1a;
5
- --bg-hover: #222222;
6
- --bg-active: #2a2a2a;
7
- --border: #2a2a2a;
8
- --border-subtle: #1e1e1e;
9
- --text: #d4d4d4;
10
- --text-bright: #e8e8e8;
11
- --text-muted: #555555;
12
- --accent: #34d399;
13
- --accent-dim: rgba(52, 211, 153, 0.12);
14
- --accent-text: #2dd4a2;
15
- --syn-string: #a5d6a7;
16
- --syn-number: #4dd0e1;
17
- --syn-boolean: #ce93d8;
18
- --syn-null: #666666;
19
- --syn-key: #b0b0b0;
20
- --syn-bracket: #555555;
21
- }
22
-
23
- * {
24
- box-sizing: border-box;
25
- margin: 0;
26
- padding: 0;
27
- }
28
-
29
- body {
30
- font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', 'Cascadia Code', monospace;
31
- font-size: 12px;
32
- line-height: 1.5;
33
- color: var(--text);
34
- background: var(--bg);
35
- -webkit-font-smoothing: antialiased;
36
- }
37
-
38
- ::selection {
39
- background: var(--accent-dim);
40
- color: var(--accent-text);
41
- }
42
-
43
- ::-webkit-scrollbar {
44
- width: 6px;
45
- height: 6px;
46
- }
47
-
48
- ::-webkit-scrollbar-track {
49
- background: transparent;
50
- }
51
-
52
- ::-webkit-scrollbar-thumb {
53
- background: var(--border);
54
- border-radius: 3px;
55
- }
56
-
57
- ::-webkit-scrollbar-thumb:hover {
58
- background: #3a3a3a;
59
- }
60
-
61
- .app {
62
- display: flex;
63
- flex-direction: column;
64
- height: 100vh;
65
- overflow: hidden;
66
- }
67
-
68
- .top-bar {
69
- display: flex;
70
- align-items: center;
71
- justify-content: space-between;
72
- padding: 0 16px;
73
- height: 40px;
74
- border-bottom: 1px solid var(--border);
75
- background: var(--bg-surface);
76
- flex-shrink: 0;
77
- }
78
-
79
- .top-bar-left {
80
- display: flex;
81
- align-items: center;
82
- gap: 12px;
83
- }
84
-
85
- .logo {
86
- font-size: 11px;
87
- font-weight: 600;
88
- letter-spacing: 0.5px;
89
- text-transform: uppercase;
90
- color: var(--text-muted);
91
- }
92
-
93
- .logo span {
94
- color: var(--accent);
95
- }
96
-
97
- .top-bar-right {
98
- display: flex;
99
- align-items: center;
100
- gap: 12px;
101
- }
102
-
103
- .pulse {
104
- width: 6px;
105
- height: 6px;
106
- border-radius: 50%;
107
- background: var(--accent);
108
- }
109
-
110
- .pulse.disconnected {
111
- background: #ef4444;
112
- }
113
-
114
- .instance-id {
115
- font-size: 10px;
116
- color: var(--text-muted);
117
- }
118
-
119
- .tab-bar {
120
- display: flex;
121
- align-items: center;
122
- gap: 0;
123
- border-bottom: 1px solid var(--border);
124
- background: var(--bg-surface);
125
- flex-shrink: 0;
126
- padding: 0 16px;
127
- }
128
-
129
- .tab {
130
- padding: 8px 16px;
131
- font-size: 11px;
132
- color: var(--text-muted);
133
- cursor: pointer;
134
- border-bottom: 2px solid transparent;
135
- user-select: none;
136
- }
137
-
138
- .tab:hover {
139
- color: var(--text);
140
- }
141
-
142
- .tab.active {
143
- color: var(--accent-text);
144
- border-bottom-color: var(--accent);
145
- }
146
-
147
- .tab .count {
148
- margin-left: 6px;
149
- font-size: 10px;
150
- color: var(--text-muted);
151
- }
152
-
153
- .tab.active .count {
154
- color: var(--accent);
155
- }
156
-
157
- .main {
158
- display: flex;
159
- flex: 1;
160
- overflow: hidden;
161
- }
162
-
163
- .sidebar {
164
- width: 280px;
165
- min-width: 280px;
166
- border-right: 1px solid var(--border);
167
- overflow-y: auto;
168
- background: var(--bg-surface);
169
- }
170
-
171
- .content {
172
- flex: 1;
173
- overflow-y: auto;
174
- padding: 16px;
175
- }
176
-
177
- .sidebar-section {
178
- border-bottom: 1px solid var(--border-subtle);
179
- }
180
-
181
- .sidebar-header {
182
- padding: 8px 12px;
183
- font-size: 10px;
184
- text-transform: uppercase;
185
- letter-spacing: 0.5px;
186
- color: var(--text-muted);
187
- background: var(--bg);
188
- }
189
-
190
- .sidebar-item {
191
- display: flex;
192
- align-items: center;
193
- justify-content: space-between;
194
- padding: 6px 12px;
195
- cursor: pointer;
196
- border-left: 2px solid transparent;
197
- }
198
-
199
- .sidebar-item:hover {
200
- background: var(--bg-hover);
201
- }
202
-
203
- .sidebar-item.active {
204
- background: var(--accent-dim);
205
- border-left-color: var(--accent);
206
- }
207
-
208
- .sidebar-item .label {
209
- font-size: 11px;
210
- color: var(--text);
211
- overflow: hidden;
212
- text-overflow: ellipsis;
213
- white-space: nowrap;
214
- }
215
-
216
- .sidebar-item.active .label {
217
- color: var(--accent-text);
218
- }
219
-
220
- .sidebar-item .meta {
221
- font-size: 10px;
222
- color: var(--text-muted);
223
- flex-shrink: 0;
224
- margin-left: 8px;
225
- }
226
-
227
- .badge {
228
- display: inline-flex;
229
- align-items: center;
230
- justify-content: center;
231
- min-width: 18px;
232
- height: 18px;
233
- padding: 0 5px;
234
- font-size: 10px;
235
- border-radius: 9px;
236
- background: #1f1f1f;
237
- color: #999;
238
- }
239
-
240
- .badge.accent {
241
- background: var(--accent-dim);
242
- color: var(--accent);
243
- }
244
-
245
- .section {
246
- margin-bottom: 20px;
247
- }
248
-
249
- .section-title {
250
- font-size: 10px;
251
- text-transform: uppercase;
252
- letter-spacing: 0.5px;
253
- color: var(--text-muted);
254
- margin-bottom: 8px;
255
- }
256
-
257
- .card {
258
- background: var(--bg-surface);
259
- border: 1px solid var(--border);
260
- border-radius: 4px;
261
- overflow: hidden;
262
- }
263
-
264
- .card + .card {
265
- margin-top: 8px;
266
- }
267
-
268
- .card-header {
269
- display: flex;
270
- align-items: center;
271
- justify-content: space-between;
272
- padding: 8px 12px;
273
- background: var(--bg);
274
- border-bottom: 1px solid var(--border-subtle);
275
- font-size: 11px;
276
- }
277
-
278
- .card-header .name {
279
- color: var(--text-bright);
280
- }
281
-
282
- .card-body {
283
- padding: 8px 12px;
284
- }
285
-
286
- .kv-row {
287
- display: flex;
288
- align-items: baseline;
289
- padding: 2px 0;
290
- font-size: 11px;
291
- }
292
-
293
- .kv-key {
294
- color: var(--text-muted);
295
- min-width: 100px;
296
- flex-shrink: 0;
297
- }
298
-
299
- .kv-value {
300
- color: var(--text);
301
- word-break: break-all;
302
- }
303
-
304
- .member-row {
305
- display: flex;
306
- align-items: center;
307
- justify-content: space-between;
308
- padding: 4px 12px;
309
- font-size: 11px;
310
- border-bottom: 1px solid var(--border-subtle);
311
- }
312
-
313
- .member-row:last-child {
314
- border-bottom: none;
315
- }
316
-
317
- .member-id {
318
- color: var(--text);
319
- font-size: 11px;
320
- }
321
-
322
- .member-presence {
323
- font-size: 10px;
324
- color: var(--accent);
325
- }
326
-
327
- .tag {
328
- display: inline-block;
329
- padding: 1px 6px;
330
- font-size: 10px;
331
- border-radius: 3px;
332
- background: #1f1f1f;
333
- color: #999;
334
- margin: 1px 2px;
335
- }
336
-
337
- .tag.accent {
338
- background: var(--accent-dim);
339
- color: var(--accent);
340
- }
341
-
342
- .empty {
343
- padding: 24px;
344
- text-align: center;
345
- color: var(--text-muted);
346
- font-size: 11px;
347
- }
348
-
349
- .view-hint {
350
- font-size: 11px;
351
- color: var(--text-muted);
352
- margin-bottom: 12px;
353
- padding-bottom: 8px;
354
- border-bottom: 1px solid var(--border-subtle);
355
- }
356
-
357
- .no-presence {
358
- font-size: 10px;
359
- color: #333;
360
- }
361
-
362
- .pattern-list {
363
- display: flex;
364
- flex-wrap: wrap;
365
- gap: 4px;
366
- margin-top: 4px;
367
- }
368
-
369
- .json-view {
370
- font-size: 11px;
371
- line-height: 1.6;
372
- white-space: pre-wrap;
373
- word-break: break-all;
374
- }
375
-
376
- .json-view .shiki {
377
- background: transparent !important;
378
- padding: 0;
379
- margin: 0;
380
- }
381
-
382
- .json-view .shiki code {
383
- font-family: inherit;
384
- font-size: inherit;
385
- }
386
-
387
- .select {
388
- background: var(--bg-raised);
389
- border: 1px solid var(--border);
390
- color: var(--text);
391
- font-family: inherit;
392
- font-size: 11px;
393
- padding: 4px 8px;
394
- border-radius: 3px;
395
- outline: none;
396
- cursor: pointer;
397
- }
398
-
399
- .select:focus {
400
- border-color: var(--accent);
401
- }
402
-
403
- .conn-link {
404
- cursor: pointer;
405
- text-decoration: underline;
406
- text-decoration-color: var(--border);
407
- text-underline-offset: 2px;
408
- }
409
-
410
- .conn-link:hover {
411
- color: var(--accent-text);
412
- text-decoration-color: var(--accent);
413
- }
414
-
415
- .exposed-row {
416
- display: flex;
417
- align-items: center;
418
- flex-wrap: wrap;
419
- gap: 3px;
420
- padding: 4px 12px;
421
- border-bottom: 1px solid var(--border-subtle);
422
- }
423
-
424
- .exposed-row:last-child {
425
- border-bottom: none;
426
- }
427
-
428
- .exposed-label {
429
- font-size: 10px;
430
- color: var(--text-muted);
431
- min-width: 70px;
432
- flex-shrink: 0;
433
- }
434
-
435
- .inline-json .s { color: var(--syn-string); }
436
- .inline-json .n { color: var(--syn-number); }
437
- .inline-json .b { color: var(--syn-boolean); }
438
- .inline-json .null { color: var(--syn-null); }
439
- .inline-json .k { color: var(--syn-key); }
440
- .inline-json .p { color: var(--syn-bracket); }
@@ -1,27 +0,0 @@
1
- <template>
2
- <div v-if="!channelEntries.length" class="empty">no channel subscriptions</div>
3
- <template v-else>
4
- <div class="view-hint">channels with active subscribers on this server instance</div>
5
- <div v-for="[channel, subscribers] in channelEntries" :key="channel" class="card">
6
- <div class="card-header">
7
- <span class="name">{{ channel }}</span>
8
- <span class="badge accent">{{ subscribers.length }} {{ subscribers.length === 1 ? 'subscriber' : 'subscribers' }}</span>
9
- </div>
10
- <div v-for="connId in subscribers" :key="connId" class="member-row">
11
- <span class="member-id conn-link" :title="connId" @click="$emit('navigate', connId)">{{ connId.slice(0, 8) }}</span>
12
- </div>
13
- </div>
14
- </template>
15
- </template>
16
-
17
- <script setup>
18
- import { computed } from 'vue'
19
-
20
- const props = defineProps({
21
- channels: { type: Object, default: () => ({}) }
22
- })
23
-
24
- defineEmits(['navigate'])
25
-
26
- const channelEntries = computed(() => Object.entries(props.channels))
27
- </script>
@@ -1,61 +0,0 @@
1
- <template>
2
- <div v-if="!collectionEntries.length" class="empty">no collection subscriptions</div>
3
- <template v-else>
4
- <div class="view-hint">collections with active subscribers - click "records" to see what the resolver returns for that connection</div>
5
- <div v-for="[collId, info] in collectionEntries" :key="collId" class="card">
6
- <div class="card-header">
7
- <span class="name">{{ collId }}</span>
8
- <span class="badge accent">{{ Object.keys(info.subscribers).length }} {{ Object.keys(info.subscribers).length === 1 ? 'subscriber' : 'subscribers' }}</span>
9
- </div>
10
- <div v-for="(sub, connId) in info.subscribers" :key="connId">
11
- <div class="card-body" style="border-top: 1px solid var(--border-subtle);">
12
- <div class="kv-row">
13
- <span class="kv-key conn-link" :title="connId" @click="$emit('navigate', connId)">{{ connId.slice(0, 8) }}</span>
14
- <span class="kv-value">v{{ sub.version }}</span>
15
- <span
16
- class="tag accent"
17
- style="margin-left: 8px; cursor: pointer;"
18
- @click="toggleRecords(collId, connId)"
19
- >{{ loadedRecords[recordKey(collId, connId)] ? 'hide' : 'records' }}</span>
20
- </div>
21
- </div>
22
- <div v-if="loadedRecords[recordKey(collId, connId)]" class="card-body" style="border-top: 1px solid var(--border-subtle); padding-left: 24px;">
23
- <div class="section-title">resolved records ({{ loadedRecords[recordKey(collId, connId)].recordIds.length }})</div>
24
- <div v-for="rec in loadedRecords[recordKey(collId, connId)].records" :key="rec.id" style="margin-bottom: 8px;">
25
- <div style="font-size: 10px; color: var(--text-muted); margin-bottom: 2px;">{{ rec.id }}</div>
26
- <JsonView :data="rec.data" />
27
- </div>
28
- <div v-if="!loadedRecords[recordKey(collId, connId)].records.length" class="empty" style="padding: 8px;">no records</div>
29
- </div>
30
- </div>
31
- </div>
32
- </template>
33
- </template>
34
-
35
- <script setup>
36
- import { computed, reactive } from 'vue'
37
- import JsonView from '../components/JsonView.vue'
38
-
39
- const props = defineProps({
40
- collections: { type: Object, default: () => ({}) },
41
- fetchRecords: { type: Function, required: true }
42
- })
43
-
44
- defineEmits(['navigate'])
45
-
46
- const collectionEntries = computed(() => Object.entries(props.collections))
47
- const loadedRecords = reactive({})
48
-
49
- function recordKey(collId, connId) {
50
- return `${collId}::${connId}`
51
- }
52
-
53
- async function toggleRecords(collId, connId) {
54
- const key = recordKey(collId, connId)
55
- if (loadedRecords[key]) {
56
- delete loadedRecords[key]
57
- return
58
- }
59
- loadedRecords[key] = await props.fetchRecords(collId, connId)
60
- }
61
- </script>
@@ -1,108 +0,0 @@
1
- <template>
2
- <div v-if="detail">
3
- <div class="section">
4
- <div class="section-title">connection</div>
5
- <div class="card">
6
- <div class="card-body">
7
- <div class="kv-row">
8
- <span class="kv-key">id</span>
9
- <span class="kv-value">{{ detail.id }}</span>
10
- </div>
11
- <div class="kv-row">
12
- <span class="kv-key">local</span>
13
- <span class="kv-value" :style="{ color: detail.local ? 'var(--accent)' : 'var(--text-muted)' }">{{ detail.local }}</span>
14
- </div>
15
- <div class="kv-row">
16
- <span class="kv-key">latency</span>
17
- <span class="kv-value">{{ detail.latency !== null ? detail.latency + 'ms' : '-' }}</span>
18
- </div>
19
- <div class="kv-row">
20
- <span class="kv-key">alive</span>
21
- <span class="kv-value" :style="{ color: detail.alive ? 'var(--accent)' : 'var(--syn-boolean)' }">{{ detail.alive }}</span>
22
- </div>
23
- <div class="kv-row" v-if="detail.remoteAddress">
24
- <span class="kv-key">address</span>
25
- <span class="kv-value">{{ detail.remoteAddress }}</span>
26
- </div>
27
- </div>
28
- </div>
29
- </div>
30
-
31
- <div class="section">
32
- <div class="section-title">metadata</div>
33
- <div class="card">
34
- <div class="card-body">
35
- <JsonView :data="detail.metadata" />
36
- </div>
37
- </div>
38
- </div>
39
-
40
- <div class="section" v-if="detail.rooms?.length">
41
- <div class="section-title">rooms ({{ detail.rooms.length }})</div>
42
- <div class="pattern-list">
43
- <span class="tag accent" v-for="room in detail.rooms" :key="room">{{ room }}</span>
44
- </div>
45
- </div>
46
-
47
- <div class="section" v-if="detail.channels?.length">
48
- <div class="section-title">channels ({{ detail.channels.length }})</div>
49
- <div class="pattern-list">
50
- <span class="tag accent" v-for="ch in detail.channels" :key="ch">{{ ch }}</span>
51
- </div>
52
- </div>
53
-
54
- <div class="section" v-if="detail.collections?.length">
55
- <div class="section-title">collections ({{ detail.collections.length }})</div>
56
- <div class="pattern-list">
57
- <span class="tag accent" v-for="col in detail.collections" :key="col.id">{{ col.id }} v{{ col.version }}</span>
58
- </div>
59
- </div>
60
-
61
- <div class="section" v-if="detail.records?.length">
62
- <div class="section-title">record subscriptions ({{ detail.records.length }})</div>
63
- <div v-for="rec in detail.records" :key="rec.id" class="kv-row">
64
- <span class="kv-key">{{ rec.id }}</span>
65
- <span class="kv-value">{{ rec.mode }}</span>
66
- </div>
67
- </div>
68
-
69
- <div class="section" v-if="detail.presence && Object.keys(detail.presence).length">
70
- <div class="section-title">presence state</div>
71
- <div v-for="(pstate, room) in detail.presence" :key="room" class="card">
72
- <div class="card-header">
73
- <span class="name">{{ room }}</span>
74
- </div>
75
- <div class="card-body">
76
- <JsonView :data="pstate" />
77
- </div>
78
- </div>
79
- </div>
80
- </div>
81
-
82
- <div v-else-if="connections.length">
83
- <div class="section-title" style="margin-bottom: 12px;">all connections</div>
84
- <div v-for="conn in connections" :key="conn.id" class="card">
85
- <div class="card-header">
86
- <span class="name">{{ conn.metadata?.name || conn.metadata?.username || conn.id.slice(0, 8) }}</span>
87
- <span class="meta" style="font-size: 10px;">
88
- <span v-if="conn.latency !== null" style="margin-right: 8px;">{{ conn.latency }}ms</span>
89
- <span :style="{ color: conn.alive ? 'var(--accent)' : 'var(--syn-boolean)' }">{{ conn.alive ? 'alive' : 'dead' }}</span>
90
- </span>
91
- </div>
92
- <div class="card-body" v-if="conn.metadata">
93
- <JsonView :data="conn.metadata" />
94
- </div>
95
- </div>
96
- </div>
97
-
98
- <div v-else class="empty">no connections</div>
99
- </template>
100
-
101
- <script setup>
102
- import JsonView from '../components/JsonView.vue'
103
-
104
- defineProps({
105
- detail: { type: Object, default: null },
106
- connections: { type: Array, default: () => [] }
107
- })
108
- </script>
@@ -1,30 +0,0 @@
1
- <template>
2
- <div v-if="!recordEntries.length" class="empty">no record subscriptions</div>
3
- <template v-else>
4
- <div class="view-hint">records being watched by at least one connection, and their subscription mode</div>
5
- <div v-for="[recordId, info] in recordEntries" :key="recordId" class="card">
6
- <div class="card-header">
7
- <span class="name">{{ recordId }}</span>
8
- <span class="badge accent">{{ Object.keys(info.subscribers).length }} {{ Object.keys(info.subscribers).length === 1 ? 'subscriber' : 'subscribers' }}</span>
9
- </div>
10
- <div class="card-body">
11
- <div v-for="(mode, connId) in info.subscribers" :key="connId" class="kv-row">
12
- <span class="kv-key conn-link" :title="connId" @click="$emit('navigate', connId)">{{ connId.slice(0, 8) }}</span>
13
- <span class="tag" :class="mode === 'full' ? 'accent' : ''">{{ mode }}</span>
14
- </div>
15
- </div>
16
- </div>
17
- </template>
18
- </template>
19
-
20
- <script setup>
21
- import { computed } from 'vue'
22
-
23
- const props = defineProps({
24
- records: { type: Object, default: () => ({}) }
25
- })
26
-
27
- defineEmits(['navigate'])
28
-
29
- const recordEntries = computed(() => Object.entries(props.records))
30
- </script>
@@ -1,39 +0,0 @@
1
- <template>
2
- <div v-if="!rooms.length" class="empty">no rooms</div>
3
- <template v-else>
4
- <div class="view-hint">active rooms, their members, and each member's presence state</div>
5
- <div v-for="room in rooms" :key="room.name" class="card">
6
- <div class="card-header">
7
- <span class="name">{{ room.name }}</span>
8
- <span class="badge accent">{{ room.members.length }} {{ room.members.length === 1 ? 'member' : 'members' }}</span>
9
- </div>
10
- <div v-for="memberId in room.members" :key="memberId" class="member-row">
11
- <span class="member-id conn-link" :title="memberId" @click="$emit('navigate', memberId)">{{ memberId.slice(0, 8) }}</span>
12
- <span class="member-presence" v-if="room.presence[memberId]">
13
- {{ formatPresence(room.presence[memberId]) }}
14
- </span>
15
- <span class="no-presence" v-else>no presence</span>
16
- </div>
17
- </div>
18
- </template>
19
- </template>
20
-
21
- <script setup>
22
- defineProps({
23
- rooms: { type: Array, default: () => [] }
24
- })
25
-
26
- defineEmits(['navigate'])
27
-
28
- function formatPresence(state) {
29
- if (typeof state === 'string') return state
30
- if (typeof state === 'object' && state !== null) {
31
- const keys = Object.keys(state)
32
- if (keys.length <= 3) {
33
- return keys.map(k => `${k}: ${JSON.stringify(state[k])}`).join(', ')
34
- }
35
- return `{${keys.length} fields}`
36
- }
37
- return String(state)
38
- }
39
- </script>