@devchitchat/chat 4.5.0 → 5.0.1

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 (39) hide show
  1. package/index.js +0 -9
  2. package/package.json +2 -3
  3. package/pages/_layout.html +0 -7
  4. package/pages/admin/_layout.html +0 -7
  5. package/pages/channels/[channelId].phtml +17 -42
  6. package/pages/design/_layout.html +349 -0
  7. package/pages/design/_layout.js +13 -0
  8. package/pages/design/components/index.js +3 -0
  9. package/pages/design/components/index.phtml +380 -0
  10. package/pages/design/index.js +3 -0
  11. package/pages/design/index.phtml +78 -0
  12. package/pages/design/principles/index.js +3 -0
  13. package/pages/design/principles/index.phtml +147 -0
  14. package/pages/design/tokens/index.js +3 -0
  15. package/pages/design/tokens/index.phtml +236 -0
  16. package/pages/public/client/app.js +171 -13
  17. package/pages/public/client/controllers/ChatController.js +204 -0
  18. package/pages/public/client/controllers/WebSocketController.js +191 -0
  19. package/pages/public/client/model/AppModel.js +351 -0
  20. package/pages/public/client/model/events.js +41 -0
  21. package/pages/public/client/resizable.js +74 -0
  22. package/pages/public/client/rtc-peer-manager.js +5 -2
  23. package/pages/public/client/settings-sync.js +45 -7
  24. package/pages/public/client/shared/messages.js +21 -9
  25. package/pages/public/client/theme.js +6 -4
  26. package/pages/public/client/views/CallView.js +754 -0
  27. package/pages/public/client/views/ChatHeaderView.js +67 -0
  28. package/pages/public/client/views/ComposerView.js +491 -0
  29. package/pages/public/client/views/MessageListView.js +461 -0
  30. package/pages/public/client/views/SidebarView.js +977 -0
  31. package/pages/public/client/views/ThreadPanelView.js +260 -0
  32. package/pages/public/client/views/shared/EmojiPickerSingleton.js +201 -0
  33. package/pages/public/client/views/shared/MentionPicker.js +139 -0
  34. package/pages/public/client/views/shared/MessageInteractions.js +353 -0
  35. package/pages/public/themes/base.css +29 -31
  36. package/src/ws/ChatServer.js +2 -1
  37. package/src/ws/handlers/rtcHandlers.js +7 -0
  38. package/pages/public/client/islands/call.js +0 -2282
  39. package/pages/public/client/islands/sidebar.js +0 -1198
@@ -0,0 +1,353 @@
1
+ /**
2
+ * MessageInteractions.js — shared click delegation for message containers.
3
+ *
4
+ * Attach to any container that holds <article class="message"> elements:
5
+ * attachMessageInteractions(containerEl, { model, isThread })
6
+ *
7
+ * Both MessageListView and ThreadPanelView call this — zero duplication.
8
+ * Actions are dispatched as document CustomEvents so ChatController handles them.
9
+ *
10
+ * Dispatched events:
11
+ * 'react' { msgId, channelId, emoji }
12
+ * 'unreact' { msgId, channelId, emoji }
13
+ * 'open-thread' { msgId } — only when !isThread
14
+ * 'open-dm' { targetUserId }
15
+ * 'delete-message' { msgId, channelId }
16
+ * 'task-toggle' { msgId, channelId, text }
17
+ */
18
+
19
+ import {
20
+ openEmojiPicker,
21
+ saveRecentEmoji,
22
+ } from './EmojiPickerSingleton.js'
23
+ import { escHtml } from '../../shared/messages.js'
24
+ import { showActionSheet, dismiss as dismissActionSheet, getItemsContainer } from '../../action-sheet.js'
25
+ import { addLongPress } from '../../long-press.js'
26
+ import { dispatch } from '../../controllers/ChatController.js'
27
+
28
+ /**
29
+ * @param {HTMLElement} containerEl
30
+ * @param {{ model: AppModel, isThread?: boolean }} opts
31
+ */
32
+ export function attachMessageInteractions(containerEl, { model, isThread = false }) {
33
+ // ── Quick-react buttons (recent emoji, no picker) ──────────────────────────
34
+ containerEl.addEventListener('click', e => {
35
+ const btn = e.target.closest('.btn-quick-react')
36
+ if (!btn) return
37
+ e.stopPropagation()
38
+ const emoji = btn.dataset.emoji
39
+ const article = btn.closest('article.message')
40
+ const msgId = article?.dataset.msgId
41
+ const channelId = _channelId(model)
42
+ if (!emoji || !msgId) return
43
+ saveRecentEmoji(emoji)
44
+ dispatch('react', { msgId, channelId, emoji })
45
+ })
46
+
47
+ // ── Reaction pills — toggle react/unreact ──────────────────────────────────
48
+ containerEl.addEventListener('click', e => {
49
+ const pill = e.target.closest('.reaction-pill')
50
+ if (!pill) return
51
+ e.stopPropagation()
52
+ const emoji = pill.dataset.emoji
53
+ const msgId = pill.dataset.msgId
54
+ const channelId = _channelId(model)
55
+ if (!emoji || !msgId) return
56
+ if (pill.classList.contains('reacted')) {
57
+ dispatch('unreact', { msgId, channelId, emoji })
58
+ } else {
59
+ dispatch('react', { msgId, channelId, emoji })
60
+ }
61
+ })
62
+
63
+ // ── Emoji picker trigger (.btn-react / .reaction-add) ─────────────────────
64
+ containerEl.addEventListener('click', e => {
65
+ const addBtn = e.target.closest('.reaction-add')
66
+ const reactBtn = e.target.closest('.btn-react')
67
+ const btn = addBtn ?? reactBtn
68
+ if (!btn) return
69
+ e.stopPropagation()
70
+ const msgId = addBtn?.dataset.msgId ?? btn.closest('article.message')?.dataset.msgId
71
+ const channelId = _channelId(model)
72
+ if (!msgId) return
73
+ openEmojiPicker(btn, msgId, emoji => {
74
+ dispatch('react', { msgId, channelId, emoji })
75
+ })
76
+ })
77
+
78
+ // ── Reply button → open thread panel ──────────────────────────────────────
79
+ if (!isThread) {
80
+ containerEl.addEventListener('click', e => {
81
+ const btn = e.target.closest('.btn-reply')
82
+ if (!btn) return
83
+ e.stopPropagation()
84
+ const msgId = btn.closest('article.message')?.dataset.msgId
85
+ if (!msgId) return
86
+ dispatch('open-thread', { msgId })
87
+ })
88
+
89
+ // "View N replies" link → open thread panel
90
+ containerEl.addEventListener('click', e => {
91
+ const link = e.target.closest('.thread-replies-link')
92
+ if (!link) return
93
+ e.preventDefault()
94
+ e.stopPropagation()
95
+ const msgId = link.dataset.msgId ?? link.closest('article.message')?.dataset.msgId
96
+ if (!msgId) return
97
+ dispatch('open-thread', { msgId })
98
+ })
99
+ }
100
+
101
+ // ── DM trigger (sender handle → open DM) ──────────────────────────────────
102
+ containerEl.addEventListener('click', e => {
103
+ const handle = e.target.closest('.dm-trigger')
104
+ if (!handle) return
105
+ const targetUserId = handle.dataset.userId
106
+ if (!targetUserId || targetUserId === model.userId) return
107
+ dispatch('open-dm', { targetUserId })
108
+ })
109
+
110
+ // ── Task-list checkboxes ───────────────────────────────────────────────────
111
+ containerEl.addEventListener('click', e => {
112
+ const cb = e.target.closest('.task-list-item-checkbox')
113
+ if (!cb) return
114
+ e.preventDefault()
115
+ const article = cb.closest('article.message')
116
+ if (!article) return
117
+ const rawText = article.dataset.rawText
118
+ if (!rawText) return
119
+ const allCbs = Array.from(article.querySelectorAll('.task-list-item-checkbox'))
120
+ const idx = allCbs.indexOf(cb)
121
+ if (idx === -1) return
122
+ let count = 0
123
+ const newText = rawText.replace(/\[([ xX])\]/g, (match, state) => {
124
+ if (count++ !== idx) return match
125
+ return state.trim() === '' ? '[x]' : '[ ]'
126
+ })
127
+ if (newText === rawText) return
128
+ cb.checked = !cb.checked
129
+ article.dataset.rawText = newText
130
+ dispatch('task-toggle', {
131
+ msgId: article.dataset.msgId,
132
+ channelId: _channelId(model),
133
+ text: newText,
134
+ })
135
+ })
136
+
137
+ // ── … (message actions) button → context menu ─────────────────────────────
138
+ containerEl.addEventListener('click', e => {
139
+ const btn = e.target.closest('.btn-msg-actions')
140
+ if (!btn) return
141
+ e.stopPropagation()
142
+ const article = btn.closest('article.message')
143
+ if (article) _showContextMenu(article, btn, model)
144
+ })
145
+
146
+ // ── Mobile long-press → action sheet ─────────────────────────────────────
147
+ addLongPress(containerEl, e => {
148
+ const article = e.target.closest?.('article.message')
149
+ const msgId = article?.dataset.msgId
150
+ const channelId = _channelId(model)
151
+ if (!msgId) return
152
+
153
+ // Open sheet first (which clears the container), then populate it.
154
+ showActionSheet({ label: 'React to this message', items: [] })
155
+
156
+ const wrapper = document.createElement('div')
157
+ wrapper.className = 'action-sheet-emoji-picker-wrap'
158
+ getItemsContainer().appendChild(wrapper)
159
+
160
+ openEmojiPicker(wrapper, msgId, emoji => {
161
+ saveRecentEmoji(emoji)
162
+ dismissActionSheet()
163
+ dispatch('react', { msgId, channelId, emoji })
164
+ })
165
+ })
166
+ }
167
+
168
+ // ─────────────────────────────────────────────────────────────────────────────
169
+ // Inline edit (called from context menu or keyboard shortcut)
170
+ // ─────────────────────────────────────────────────────────────────────────────
171
+
172
+ let activeEditCancel = null
173
+
174
+ export function startInlineEdit(article, model) {
175
+ if (article.querySelector('.message-edit-wrap')) return
176
+ const textEl = article.querySelector('.message-text')
177
+ if (!textEl) return
178
+ const rawText = article.dataset.rawText ?? ''
179
+
180
+ const wrap = document.createElement('div')
181
+ wrap.className = 'message-edit-wrap'
182
+
183
+ const tabStrip = document.createElement('div')
184
+ tabStrip.className = 'message-edit-tabs'
185
+ tabStrip.setAttribute('role', 'tablist')
186
+ tabStrip.innerHTML = `
187
+ <button class="message-edit-tab message-edit-tab--active" data-tab="write"
188
+ role="tab" aria-selected="true" type="button">Write</button>
189
+ <button class="message-edit-tab" data-tab="preview"
190
+ role="tab" aria-selected="false" type="button">Preview</button>`
191
+
192
+ const textarea = document.createElement('textarea')
193
+ textarea.className = 'message-edit-input'
194
+ textarea.value = rawText
195
+
196
+ const preview = document.createElement('div')
197
+ preview.className = 'message-edit-preview message-text'
198
+ preview.hidden = true
199
+ preview.setAttribute('aria-live', 'polite')
200
+
201
+ const toolbar = document.createElement('div')
202
+ toolbar.className = 'message-edit-toolbar'
203
+ toolbar.innerHTML = `
204
+ <span class="message-edit-hint">Ctrl+Enter to save · Esc to cancel</span>
205
+ <button class="btn-ghost btn-edit-cancel" type="button">Cancel</button>
206
+ <button class="btn-primary btn-edit-save" type="button">Save</button>`
207
+
208
+ wrap.append(tabStrip, textarea, preview, toolbar)
209
+ textEl.replaceWith(wrap)
210
+ textarea.focus()
211
+ textarea.setSelectionRange(rawText.length, rawText.length)
212
+
213
+ const cancel = () => {
214
+ wrap.replaceWith(textEl)
215
+ activeEditCancel = null
216
+ }
217
+
218
+ const save = () => {
219
+ const text = textarea.value.trim()
220
+ if (!text) return
221
+ dispatch('edit-message', {
222
+ msgId: article.dataset.msgId,
223
+ channelId: _channelId(model),
224
+ text,
225
+ })
226
+ cancel()
227
+ }
228
+
229
+ activeEditCancel = cancel
230
+
231
+ // Tab switching
232
+ tabStrip.addEventListener('click', async e => {
233
+ const btn = e.target.closest('.message-edit-tab')
234
+ if (!btn) return
235
+ const tab = btn.dataset.tab
236
+ tabStrip.querySelectorAll('.message-edit-tab').forEach(b => {
237
+ b.classList.toggle('message-edit-tab--active', b === btn)
238
+ b.setAttribute('aria-selected', String(b === btn))
239
+ })
240
+ textarea.hidden = tab !== 'write'
241
+ preview.hidden = tab !== 'preview'
242
+ if (tab === 'preview') {
243
+ const text = textarea.value.trim()
244
+ if (!text) { preview.innerHTML = '<p style="color:var(--text-muted)">Nothing to preview yet.</p>'; return }
245
+ let html = null
246
+ await new Promise(resolve => {
247
+ dispatch('preview-text', { text, resolve: h => { html = h; resolve() } })
248
+ })
249
+ preview.innerHTML = html ? _sanitize(html) : escHtml(text)
250
+ }
251
+ })
252
+
253
+ toolbar.querySelector('.btn-edit-cancel').addEventListener('click', cancel)
254
+ toolbar.querySelector('.btn-edit-save').addEventListener('click', save)
255
+
256
+ textarea.addEventListener('keydown', e => {
257
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save() }
258
+ if (e.key === 'Escape') { e.preventDefault(); cancel() }
259
+ })
260
+ }
261
+
262
+ export function cancelActiveEdit() {
263
+ activeEditCancel?.()
264
+ }
265
+
266
+ // ─────────────────────────────────────────────────────────────────────────────
267
+ // Private helpers
268
+ // ─────────────────────────────────────────────────────────────────────────────
269
+
270
+ function _channelId(model) {
271
+ return model.currentChannelId
272
+ }
273
+
274
+ function _sanitize(html) {
275
+ return String(html ?? '').replaceAll('<script>', '').replaceAll('</script>', '')
276
+ }
277
+
278
+ function _showContextMenu(article, anchorEl, model) {
279
+ const msgId = article.dataset.msgId
280
+ const channelId = _channelId(model)
281
+ const items = [
282
+ { label: 'Edit', action: () => startInlineEdit(article, model) },
283
+ { label: 'Delete', danger: true, action: () => dispatch('delete-message', { msgId, channelId }) },
284
+ ]
285
+
286
+ // Touch devices → bottom sheet; pointer devices → floating popover
287
+ if (window.matchMedia('(pointer: coarse)').matches) {
288
+ showActionSheet({ label: 'Message actions', items })
289
+ } else {
290
+ _showPopover(anchorEl, items)
291
+ }
292
+ }
293
+
294
+ // ─────────────────────────────────────────────────────────────────────────────
295
+ // Floating popover for desktop (replaces action sheet on pointer devices)
296
+ // ─────────────────────────────────────────────────────────────────────────────
297
+
298
+ let _popoverEl = null
299
+ let _popoverCleanup = null
300
+
301
+ function _showPopover(anchorEl, items) {
302
+ _dismissPopover()
303
+
304
+ const el = document.createElement('div')
305
+ el.className = 'msg-context-menu'
306
+ el.setAttribute('role', 'menu')
307
+ for (const item of items) {
308
+ const btn = document.createElement('button')
309
+ btn.type = 'button'
310
+ btn.className = 'msg-context-menu-item' + (item.danger ? ' msg-context-menu-item--danger' : '')
311
+ btn.setAttribute('role', 'menuitem')
312
+ btn.textContent = item.label
313
+ btn.addEventListener('click', () => {
314
+ _dismissPopover()
315
+ item.action()
316
+ })
317
+ el.appendChild(btn)
318
+ }
319
+ document.body.appendChild(el)
320
+ _popoverEl = el
321
+
322
+ // Position: fixed, so coordinates are viewport-relative (no scroll offset needed)
323
+ const rect = anchorEl.getBoundingClientRect()
324
+ const gap = 4
325
+ let top = rect.bottom + gap
326
+ let left = rect.right - el.offsetWidth
327
+ if (left < 4) left = 4
328
+
329
+ el.style.left = `${left}px`
330
+ el.style.top = `${top}px`
331
+
332
+ // Flip up if the menu overflows the bottom of the viewport
333
+ const elRect = el.getBoundingClientRect()
334
+ if (elRect.bottom > window.innerHeight - 8) {
335
+ el.style.top = `${rect.top - el.offsetHeight - gap}px`
336
+ }
337
+
338
+ const onKey = e => { if (e.key === 'Escape') _dismissPopover() }
339
+ const onClick = e => { if (!el.contains(e.target)) _dismissPopover() }
340
+ document.addEventListener('keydown', onKey, { capture: true })
341
+ document.addEventListener('click', onClick, { capture: true })
342
+ _popoverCleanup = () => {
343
+ document.removeEventListener('keydown', onKey, { capture: true })
344
+ document.removeEventListener('click', onClick, { capture: true })
345
+ }
346
+ }
347
+
348
+ function _dismissPopover() {
349
+ _popoverEl?.remove()
350
+ _popoverEl = null
351
+ _popoverCleanup?.()
352
+ _popoverCleanup = null
353
+ }
@@ -19,6 +19,8 @@ input, textarea, select, button { font: inherit; }
19
19
  --radius-pill: 999px;
20
20
 
21
21
  --sidebar-width: 260px;
22
+ --thread-panel-width: 380px;
23
+ --tile-panel-width: 360px;
22
24
  --composer-height: 80px;
23
25
  --call-bar-height: 0px; /* expands when a call is active */
24
26
 
@@ -62,6 +64,8 @@ details summary {
62
64
  top: 0;
63
65
  z-index: 1;
64
66
  padding-top: env(safe-area-inset-top);
67
+ /* needed for the resize handle to anchor correctly */
68
+ overflow-x: visible;
65
69
  }
66
70
 
67
71
  .hub-group { padding: 0 0 4px; }
@@ -588,6 +592,8 @@ details summary {
588
592
  .result-handle { font-weight: 600; margin-right: 8px; }
589
593
 
590
594
  .messages {
595
+ flex: 1;
596
+ min-height: 0;
591
597
  overflow-y: auto;
592
598
  overflow-x: clip;
593
599
  touch-action: pan-y;
@@ -697,34 +703,6 @@ details summary {
697
703
  .reaction-add:hover { background: var(--fill-tertiary); color: var(--accent); }
698
704
 
699
705
  /* ── Message context menu (right-click) — Apple-style popover ────────────── */
700
- .msg-context-menu {
701
- position: absolute;
702
- background: var(--bg-sidebar);
703
- border: none;
704
- border-radius: var(--radius-lg);
705
- box-shadow: var(--shadow-md);
706
- z-index: 300;
707
- min-width: 160px;
708
- overflow: hidden;
709
- }
710
- .msg-context-menu-item {
711
- display: block;
712
- width: 100%;
713
- padding: 0 16px;
714
- height: 40px;
715
- background: none;
716
- border: none;
717
- border-top: 0.5px solid var(--border);
718
- cursor: pointer;
719
- text-align: left;
720
- color: var(--text-primary);
721
- font-size: 15px;
722
- transition: background var(--transition);
723
- }
724
- .msg-context-menu-item:first-child { border-top: none; }
725
- .msg-context-menu-item:hover { background: var(--bg-hover); }
726
- .msg-context-menu-item--danger { color: var(--color-danger); }
727
- .msg-context-menu-item--danger:hover { background: color-mix(in srgb, var(--color-danger) 8%, transparent); }
728
706
 
729
707
  /* ── Emoji picker ────────────────────────────────────────────────────────── */
730
708
  .emoji-picker {
@@ -1022,6 +1000,23 @@ details summary {
1022
1000
  .mention-option-name { font-weight: 600; color: var(--text-primary); }
1023
1001
  .mention-option-handle { color: var(--text-secondary); font-size: 13px; }
1024
1002
 
1003
+ /* ── Resize handles ──────────────────────────────────────────────────────── */
1004
+ .resize-handle {
1005
+ position: absolute;
1006
+ top: 0;
1007
+ bottom: 0;
1008
+ width: 5px;
1009
+ cursor: col-resize;
1010
+ z-index: 10;
1011
+ }
1012
+ .resize-handle--right { right: 0; }
1013
+ .resize-handle--left { left: 0; }
1014
+ .resize-handle:hover,
1015
+ .resize-handle:active {
1016
+ background: var(--accent);
1017
+ opacity: 0.4;
1018
+ }
1019
+
1025
1020
  /* ── Auth page — Apple-style centered card ───────────────────────────────── */
1026
1021
  .auth-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
1027
1022
  .auth-shell { width: 100%; max-width: 420px; padding: 40px 32px; background: var(--bg-sidebar); border-radius: var(--radius-xl); border: none; box-shadow: var(--shadow-lg); }
@@ -1286,6 +1281,8 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
1286
1281
  background: var(--bg-sidebar);
1287
1282
  border-left: 1px solid var(--border);
1288
1283
  overflow: hidden;
1284
+ /* position: relative so the .resize-handle--left child anchors to this element */
1285
+ position: relative;
1289
1286
  }
1290
1287
  .tile-panel.active { display: flex; }
1291
1288
 
@@ -1319,7 +1316,7 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
1319
1316
  height: 100%;
1320
1317
  }
1321
1318
  .tile-panel {
1322
- width: 360px;
1319
+ width: var(--tile-panel-width);
1323
1320
  flex-shrink: 0;
1324
1321
  height: 100vh;
1325
1322
  overflow-y: auto;
@@ -1466,7 +1463,7 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
1466
1463
  position: absolute;
1467
1464
  top: 0;
1468
1465
  right: 0;
1469
- width: 380px;
1466
+ width: var(--thread-panel-width);
1470
1467
  height: 100%;
1471
1468
  z-index: 30;
1472
1469
  overflow: hidden;
@@ -1543,6 +1540,7 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
1543
1540
  }
1544
1541
 
1545
1542
  .thread-composer {
1543
+ position: relative;
1546
1544
  display: flex;
1547
1545
  flex-direction: column;
1548
1546
  gap: 8px;
@@ -2312,7 +2310,7 @@ article.message:hover .message-hover-actions { opacity: 1; pointer-events: auto;
2312
2310
 
2313
2311
  /* Context menu */
2314
2312
  .msg-context-menu {
2315
- position: absolute;
2313
+ position: fixed;
2316
2314
  z-index: 300;
2317
2315
  background: var(--bg-sidebar);
2318
2316
  border: 1px solid var(--border);
@@ -23,7 +23,7 @@ import { handleHello, handleInviteRedeem, handleSignIn, handleSignOut, handleAdm
23
23
  import { handleHubList, handleHubCreate, handleHubUpdate, handleHubDelete, handleHubAddMember, handleHubRemoveMember, handleHubListMembers, handleHubReorder } from './handlers/hubHandlers.js'
24
24
  import { handleChannelList, handleChannelCreate, handleChannelUpdate, handleChannelDelete, handleChannelJoin, handleChannelLeave, handleChannelReorder, handleChannelAddMember, handleChannelRemoveMember, handleChannelListMembers, handleUserList, handleBotList, handleDmOpen, handleDmList } from './handlers/channelHandlers.js'
25
25
  import { handleMsgSend, handleMsgList, handleMsgEdit, handleMsgDelete, handleThreadList, handleSearchQuery, handlePresenceSubscribe } from './handlers/messageHandlers.js'
26
- import { handleRtcCallCreate, handleRtcJoin, handleRtcOffer, handleRtcAnswer, handleRtcIce, handleRtcStreamPublish, handleRtcLeave, handleRtcEndCall } from './handlers/rtcHandlers.js'
26
+ import { handleRtcCallCreate, handleRtcJoin, handleRtcOffer, handleRtcAnswer, handleRtcIce, handleRtcStreamPublish, handleRtcStreamRemoved, handleRtcLeave, handleRtcEndCall } from './handlers/rtcHandlers.js'
27
27
  import { handlePushSubscribe, handlePushUnsubscribe } from './handlers/pushHandlers.js'
28
28
  import { handleReactionAdd, handleReactionRemove } from './handlers/reactionHandlers.js'
29
29
  import { WebPushService } from '../services/WebPushService.js'
@@ -234,6 +234,7 @@ export class ChatServer {
234
234
  case 'rtc.answer': return handleRtcAnswer(ws, msg, ctx)
235
235
  case 'rtc.ice': return handleRtcIce(ws, msg, ctx)
236
236
  case 'rtc.stream_publish': return handleRtcStreamPublish(ws, msg, ctx)
237
+ case 'rtc.stream_removed': return handleRtcStreamRemoved(ws, msg, ctx)
237
238
  case 'rtc.leave': return handleRtcLeave(ws, msg, ctx)
238
239
  case 'rtc.end_call': return handleRtcEndCall(ws, msg, ctx)
239
240
  // Web Push
@@ -91,6 +91,13 @@ export function handleRtcStreamPublish(ws, msg, ctx) {
91
91
  publishCall(call_id, { t: 'rtc.stream_event', ok: true, body: { call_id, peer_id: ws.data.peerId, stream } })
92
92
  }
93
93
 
94
+ export function handleRtcStreamRemoved(ws, msg, ctx) {
95
+ const { publishCall } = ctx
96
+ const { call_id, kind } = msg.body || {}
97
+ if (!ws.data.peerId || ws.data.callId !== call_id) return
98
+ publishCall(call_id, { t: 'rtc.stream_removed_event', ok: true, body: { call_id, peer_id: ws.data.peerId, kind } })
99
+ }
100
+
94
101
  export function handleRtcLeave(ws, msg, ctx) {
95
102
  const { signalingService, sendWs, publishCall, publishChannel, publishCallState, peerConnections } = ctx
96
103
  const { call_id } = msg.body || {}