@devchitchat/chat 5.0.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.
@@ -14,17 +14,13 @@
14
14
 
15
15
  import { escHtml } from '../shared/messages.js'
16
16
  import { RtcPeerManager } from '../rtc-peer-manager.js'
17
- import { patchSettings } from '../settings-sync.js'
17
+ import { patchSettings, getPref, setPref } from '../settings-sync.js'
18
18
  import { navigateTo } from '../router.js'
19
19
  import * as Ev from '../model/events.js'
20
20
 
21
- const DEVICES_KEY = 'devchitchat_devices'
22
- const LAYOUT_KEY = 'devchitchat_tile_layout'
23
-
24
21
  export class CallView {
25
22
  #model
26
23
  #ws
27
- #root // .chat-panel
28
24
 
29
25
  // DOM refs
30
26
  #tilePanelEl
@@ -34,7 +30,6 @@ export class CallView {
34
30
  #callStatusAvatars
35
31
  #callControlsEl
36
32
  #peerCountEl
37
- #btnStartCall
38
33
  #btnJoinCall
39
34
  #btnLeaveCall
40
35
  #ctrlMic
@@ -77,10 +72,9 @@ export class CallView {
77
72
  * @param {WsClient} ws
78
73
  * @param {HTMLElement} rootEl — .chat-panel
79
74
  */
80
- constructor(model, ws, rootEl) {
75
+ constructor(model, ws) {
81
76
  this.#model = model
82
77
  this.#ws = ws
83
- this.#root = rootEl
84
78
 
85
79
  this.#grabDomRefs()
86
80
  this.#buildRtcManager()
@@ -103,7 +97,6 @@ export class CallView {
103
97
  this.#callStatusAvatars = q('call-status-avatars')
104
98
  this.#callControlsEl = q('call-controls-bar')
105
99
  this.#peerCountEl = q('call-peer-count')
106
- this.#btnStartCall = q('btn-start-call')
107
100
  this.#btnJoinCall = q('btn-join-call')
108
101
  this.#btnLeaveCall = q('btn-leave-call')
109
102
  this.#ctrlMic = q('ctrl-mic')
@@ -142,14 +135,12 @@ export class CallView {
142
135
  }
143
136
 
144
137
  #restoreLayoutState() {
145
- try {
146
- const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
147
- if (saved.collapsed) this.#tilePanelEl?.classList.add('collapsed')
148
- if (saved.overlayRight && saved.overlayTop && this.#tilePanelEl) {
149
- this.#tilePanelEl.style.right = saved.overlayRight
150
- this.#tilePanelEl.style.top = saved.overlayTop
151
- }
152
- } catch { /* ignore */ }
138
+ const saved = getPref('tile_layout', {})
139
+ if (saved.collapsed) this.#tilePanelEl?.classList.add('collapsed')
140
+ if (saved.overlayRight && saved.overlayTop && this.#tilePanelEl) {
141
+ this.#tilePanelEl.style.right = saved.overlayRight
142
+ this.#tilePanelEl.style.top = saved.overlayTop
143
+ }
153
144
  this.#attachOverlayDrag(this.#tilePanelEl)
154
145
  }
155
146
 
@@ -158,12 +149,7 @@ export class CallView {
158
149
  // ─────────────────────────────────────────────────────────────────────────
159
150
 
160
151
  #bindControls() {
161
- // Mobile "back to sidebar" button
162
- this.#root.querySelector('.btn-back-mobile')?.addEventListener('click', () => {
163
- document.body.classList.add('sidebar-open')
164
- })
165
-
166
- this.#btnStartCall?.addEventListener('click', () => {
152
+ document.addEventListener('call:start-requested', () => {
167
153
  const channelId = this.#model.currentChannelId
168
154
  this.#ws.send({ t: 'rtc.call_create', body: { channel_id: channelId, kind: 'mesh' } })
169
155
  })
@@ -188,8 +174,8 @@ export class CallView {
188
174
  document.getElementById('tile-panel-collapse')?.addEventListener('click', () => {
189
175
  const collapsed = this.#tilePanelEl?.classList.toggle('collapsed')
190
176
  try {
191
- const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
192
- localStorage.setItem(LAYOUT_KEY, JSON.stringify({ ...saved, collapsed: !!collapsed }))
177
+ const saved = getPref('tile_layout', {})
178
+ setPref('tile_layout', { ...saved, collapsed: !!collapsed })
193
179
  } catch { /* ignore */ }
194
180
  })
195
181
  }
@@ -268,6 +254,11 @@ export class CallView {
268
254
  await this.#rtcManager.handleIceCandidate(from_peer_id, candidate)
269
255
  })
270
256
 
257
+ ws.on('rtc.stream_removed_event', ({ peer_id, kind }) => {
258
+ const slotName = kind === 'screen' ? 'screen' : 'cam'
259
+ this.#removeTile(`${peer_id}-${slotName}`)
260
+ })
261
+
271
262
  ws.on('rtc.call_end', ({ call_id }) => {
272
263
  if (call_id === this.#callId) this.#teardownCall()
273
264
  })
@@ -323,12 +314,12 @@ export class CallView {
323
314
  #showCallControls() {
324
315
  if (this.#callStatusEl) this.#callStatusEl.hidden = true
325
316
  this.#callControlsEl?.classList.add('active')
326
- if (this.#btnStartCall) this.#btnStartCall.hidden = true
317
+ document.dispatchEvent(new CustomEvent('call:state-changed', { detail: { inCall: true } }))
327
318
  }
328
319
 
329
320
  #hideCallControls() {
330
321
  this.#callControlsEl?.classList.remove('active')
331
- if (this.#btnStartCall) this.#btnStartCall.hidden = false
322
+ document.dispatchEvent(new CustomEvent('call:state-changed', { detail: { inCall: false } }))
332
323
  }
333
324
 
334
325
  #showTilePanel() {
@@ -384,6 +375,7 @@ export class CallView {
384
375
  this.#removeTile('local-cam')
385
376
  this.#videoStream = null
386
377
  this.#camOff = true
378
+ this.#ws.send({ t: 'rtc.stream_removed', body: { call_id: this.#callId, kind: 'camera' } })
387
379
  for (const peerId of this.#rtcManager.peerIds()) this.#rtcManager.negotiate(peerId)
388
380
  if (this.#ctrlCam) this.#ctrlCam.textContent = '📷'
389
381
  return
@@ -410,6 +402,7 @@ export class CallView {
410
402
  this.#removeTile('local-screen')
411
403
  this.#screenStream = null
412
404
  this.#screenSharing = false
405
+ this.#ws.send({ t: 'rtc.stream_removed', body: { call_id: this.#callId, kind: 'screen' } })
413
406
  for (const peerId of this.#rtcManager.peerIds()) this.#rtcManager.negotiate(peerId)
414
407
  if (this.#ctrlScreen) this.#ctrlScreen.textContent = '🖥'
415
408
  return
@@ -547,11 +540,11 @@ export class CallView {
547
540
  // ─────────────────────────────────────────────────────────────────────────
548
541
 
549
542
  #loadSavedDevices() {
550
- try { return JSON.parse(localStorage.getItem(DEVICES_KEY) ?? '{}') } catch { return {} }
543
+ return getPref('devices', {})
551
544
  }
552
545
 
553
546
  #saveDevices(patch) {
554
- localStorage.setItem(DEVICES_KEY, JSON.stringify({ ...this.#loadSavedDevices(), ...patch }))
547
+ setPref('devices', { ...this.#loadSavedDevices(), ...patch })
555
548
  }
556
549
 
557
550
  async #refreshDevices() {
@@ -740,8 +733,8 @@ export class CallView {
740
733
  document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onEnd)
741
734
  document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onEnd)
742
735
  try {
743
- const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
744
- localStorage.setItem(LAYOUT_KEY, JSON.stringify({ ...saved, overlayRight: panel.style.right, overlayTop: panel.style.top }))
736
+ const saved = getPref('tile_layout', {})
737
+ setPref('tile_layout', { ...saved, overlayRight: panel.style.right, overlayTop: panel.style.top })
745
738
  } catch { /* ignore */ }
746
739
  }
747
740
 
@@ -0,0 +1,67 @@
1
+ /**
2
+ * ChatHeaderView.js — owns the <header class="chat-header"> element.
3
+ *
4
+ * Responsibilities:
5
+ * - Update channel title and topic on CHANNEL_SELECTED
6
+ * - Dispatch 'call:start-requested' when the Start Call button is clicked
7
+ * - Show/hide the Start Call button in response to 'call:state-changed'
8
+ * - Handle the mobile back button (show sidebar)
9
+ */
10
+
11
+ import * as Ev from '../model/events.js'
12
+
13
+ export class ChatHeaderView {
14
+ #model
15
+ #headerEl
16
+ #titleEl
17
+ #topicEl
18
+ #startCallBtn
19
+
20
+ /**
21
+ * @param {AppModel} model
22
+ * @param {HTMLElement} headerEl — <header class="chat-header">
23
+ */
24
+ constructor(model, headerEl) {
25
+ this.#model = model
26
+ this.#headerEl = headerEl
27
+ this.#titleEl = headerEl.querySelector('.chat-title')
28
+ this.#topicEl = headerEl.querySelector('.chat-topic')
29
+ this.#startCallBtn = headerEl.querySelector('#btn-start-call')
30
+
31
+ this.#bindModelEvents()
32
+ this.#bindInteractions()
33
+ }
34
+
35
+ // ─────────────────────────────────────────────────────────────────────────
36
+ // Model events
37
+ // ─────────────────────────────────────────────────────────────────────────
38
+
39
+ #bindModelEvents() {
40
+ this.#model.addEventListener(Ev.CHANNEL_SELECTED, e => {
41
+ const { name = '', topic = '' } = e.detail.meta ?? {}
42
+ if (this.#titleEl) this.#titleEl.textContent = name
43
+ if (this.#topicEl) this.#topicEl.textContent = topic
44
+ })
45
+ }
46
+
47
+ // ─────────────────────────────────────────────────────────────────────────
48
+ // User interactions
49
+ // ─────────────────────────────────────────────────────────────────────────
50
+
51
+ #bindInteractions() {
52
+ // Mobile: back to sidebar
53
+ this.#headerEl.querySelector('.btn-back-mobile')?.addEventListener('click', () => {
54
+ document.body.classList.add('sidebar-open')
55
+ })
56
+
57
+ // Start Call button → let CallView handle the WebRTC side
58
+ this.#startCallBtn?.addEventListener('click', () => {
59
+ document.dispatchEvent(new CustomEvent('call:start-requested'))
60
+ })
61
+
62
+ // CallView signals when a call is entered or left
63
+ document.addEventListener('call:state-changed', e => {
64
+ if (this.#startCallBtn) this.#startCallBtn.hidden = e.detail?.inCall ?? false
65
+ })
66
+ }
67
+ }
@@ -82,11 +82,14 @@ export class ComposerView {
82
82
  .filter(m => m.handle)
83
83
  })
84
84
 
85
- this.#model.addEventListener(Ev.CHANNEL_SELECTED, () => {
85
+ this.#model.addEventListener(Ev.CHANNEL_SELECTED, e => {
86
86
  // Clear pending state on navigation
87
87
  this.#pendingAttachments = []
88
88
  this.#renderChips()
89
- this.#textareaEl && (this.#textareaEl.value = '')
89
+ if (this.#textareaEl) {
90
+ this.#textareaEl.value = ''
91
+ this.#textareaEl.placeholder = `Message in ${e.detail?.meta?.name ?? ''}`
92
+ }
90
93
  })
91
94
  }
92
95
 
@@ -180,7 +180,7 @@ export class MessageListView {
180
180
  // Event handlers
181
181
  // ─────────────────────────────────────────────────────────────────────────
182
182
 
183
- #onChannelSelected({ channelId, prev }) {
183
+ #onChannelSelected({ channelId }) {
184
184
  this.#channelId = channelId
185
185
  cancelActiveEdit()
186
186
 
@@ -281,7 +281,7 @@ export class MessageListView {
281
281
  }
282
282
  }
283
283
 
284
- #onLoadingMoreChanged({ loading }) {
284
+ #onLoadingMoreChanged(_e) {
285
285
  // The sentinel visibility is managed in #onMessagesPrepended.
286
286
  // Nothing to do here unless we want a loading spinner.
287
287
  }
@@ -31,6 +31,7 @@ export class SidebarView {
31
31
  #ws
32
32
  #root // <aside>
33
33
  #dmListEl // #dm-list
34
+ #canManage = false // false for Guests
34
35
  #mentionedChannels = new Set() // channelId → mentioned
35
36
  #urgentChannels = new Set() // channelId → urgent
36
37
  #dmUnread = new Set() // channelId → unread DM
@@ -46,6 +47,22 @@ export class SidebarView {
46
47
  this.#root = rootEl
47
48
  this.#dmListEl = rootEl.querySelector('#dm-list')
48
49
 
50
+ const roles = document.querySelector('.chat-panel')?.dataset.userRoles ?? ''
51
+ this.#canManage = !roles.toLowerCase().includes('guest')
52
+
53
+ // Stamp data-channel-id / data-hub-id onto SSR-rendered <li> elements
54
+ // so drag-and-drop and context-menu handlers can read them before #renderHubs runs.
55
+ for (const li of rootEl.querySelectorAll('li.channel-item')) {
56
+ if (!li.dataset.channelId) {
57
+ const link = li.querySelector('[data-channel-id]')
58
+ if (link?.dataset.channelId) li.dataset.channelId = link.dataset.channelId
59
+ }
60
+ if (!li.dataset.hubId) {
61
+ const details = li.closest('details[data-hub-id]')
62
+ if (details?.dataset.hubId) li.dataset.hubId = details.dataset.hubId
63
+ }
64
+ }
65
+
49
66
  // Seed model from DOM on first load
50
67
  const hubs = _populateHubsFromDom(rootEl)
51
68
  const dms = _populateDmsFromDom(rootEl)
@@ -193,20 +210,18 @@ export class SidebarView {
193
210
  data-channel-topic="${escHtml(ch.topic ?? '')}"
194
211
  data-channel-visibility="${escHtml(ch.visibility ?? 'public')}"
195
212
  data-hub-id="${escHtml(hub.hub_id)}">
196
- # ${escHtml(ch.name)}
213
+ ${escHtml(ch.name)}
197
214
  </a>
198
- <button class="btn-channel-gear btn-icon" type="button"
199
- title="Channel settings" aria-label="Channel settings">⚙</button>
200
215
  </li>`
201
216
  }).join('')
217
+ const addBtn = this.#canManage
218
+ ? `<button class="btn-hub-add btn-icon" type="button" title="Add channel" aria-label="Add channel">+</button>`
219
+ : ''
202
220
  return `
203
221
  <details class="hub-header" data-hub-id="${escHtml(hub.hub_id)}" ${open}>
204
222
  <summary class="hub-name" data-hub-id="${escHtml(hub.hub_id)}">
205
223
  <span>${escHtml(hub.name)}</span>
206
- <button class="btn-hub-gear btn-icon" type="button"
207
- title="Hub settings" aria-label="Hub settings">⚙</button>
208
- <button class="btn-hub-add btn-icon" type="button"
209
- title="Add channel" aria-label="Add channel">+</button>
224
+ ${addBtn}
210
225
  </summary>
211
226
  <ul class="channel-list">${channels}</ul>
212
227
  </details>`
@@ -326,56 +341,65 @@ export class SidebarView {
326
341
  // ─────────────────────────────────────────────────────────────────────────
327
342
 
328
343
  #bindAdminHandlers() {
329
- const root = this.#root
330
- const ws = this.#ws
344
+ const root = this.#root
345
+ const ws = this.#ws
331
346
  const model = this.#model
332
347
 
333
- // New hub button
348
+ // New hub button (always visible — creating a hub is not a management action)
334
349
  root.querySelector('#btn-new-hub')?.addEventListener('click', () => {
335
- isTouch()
336
- ? _openCreateHubSheet(ws)
337
- : _openCreateHubModal(ws)
350
+ isTouch() ? _openCreateHubSheet(ws) : _openCreateHubModal(ws)
338
351
  })
339
352
 
340
- // Delegated: hub gear, hub add-channel, channel gear
353
+ if (!this.#canManage) return
354
+
355
+ // Add-channel button (delegated — rendered only for non-guests)
341
356
  root.addEventListener('click', e => {
342
- if (e.target.closest('.btn-hub-gear')) {
343
- e.stopPropagation()
344
- const summary = e.target.closest('.hub-name')
345
- const hubId = summary?.dataset.hubId
346
- if (!hubId) return
347
- const hub = model.hubs.find(h => h.hub_id === hubId)
348
- _openHubModal(hubId, hub?.name ?? '', hub?.description ?? null, hub?.visibility ?? 'public', ws)
349
- return
350
- }
357
+ const btn = e.target.closest('.btn-hub-add')
358
+ if (!btn) return
359
+ e.stopPropagation()
360
+ const hubId = btn.closest('.hub-name')?.dataset.hubId
361
+ if (!hubId) return
362
+ const hub = model.hubs.find(h => h.hub_id === hubId)
363
+ isTouch()
364
+ ? (() => { showActionSheet({ label: `New channel in ${hub?.name ?? ''}`, items: [] }); _buildCreateChannelForm(getItemsContainer(), { hubId, ws, dismiss: dismissSheet }) })()
365
+ : _openCreateChannelModal(hubId, hub?.name ?? '', ws)
366
+ })
351
367
 
352
- if (e.target.closest('.btn-hub-add')) {
353
- e.stopPropagation()
368
+ // Desktop: right-click context menus
369
+ if (!isTouch()) {
370
+ root.addEventListener('contextmenu', e => {
354
371
  const summary = e.target.closest('.hub-name')
355
- const hubId = summary?.dataset.hubId
356
- if (!hubId) return
357
- const hub = model.hubs.find(h => h.hub_id === hubId)
358
- _openCreateChannelModal(hubId, hub?.name ?? '', ws)
359
- return
360
- }
361
-
362
- if (e.target.closest('.btn-channel-gear')) {
363
- e.preventDefault()
364
- const li = e.target.closest('.channel-item')
365
- const link = li?.querySelector('.channel-link')
366
- const channelId = link?.dataset.channelId
367
- if (!channelId) return
368
- let ch = null
369
- for (const hub of model.hubs) {
370
- ch = (hub.channels ?? []).find(c => c.channel_id === channelId)
371
- if (ch) break
372
+ if (summary) {
373
+ e.preventDefault()
374
+ const hubId = summary.dataset.hubId
375
+ if (!hubId) return
376
+ const hub = model.hubs.find(h => h.hub_id === hubId)
377
+ _showSidebarPopover(e, [
378
+ { label: 'Edit hub', action: () => _openHubModal(hubId, hub?.name ?? '', hub?.description ?? null, hub?.visibility ?? 'public', ws) },
379
+ { label: 'New channel', action: () => _openCreateChannelModal(hubId, hub?.name ?? '', ws) },
380
+ { label: 'Delete hub', danger: true, action: () => { ws.send({ t: 'hub.delete', body: { hub_id: hubId } }) } },
381
+ ])
382
+ return
372
383
  }
373
- _openChannelModal(channelId, ch?.name ?? '', ch?.topic ?? null, ch?.visibility ?? 'public', ws)
374
- return
375
- }
376
- })
384
+ const li = e.target.closest('.channel-item')
385
+ if (li) {
386
+ e.preventDefault()
387
+ const channelId = li.dataset.channelId
388
+ if (!channelId) return
389
+ let ch = null
390
+ for (const hub of model.hubs) {
391
+ ch = (hub.channels ?? []).find(c => c.channel_id === channelId)
392
+ if (ch) break
393
+ }
394
+ _showSidebarPopover(e, [
395
+ { label: 'Edit channel', action: () => _openChannelModal(channelId, ch?.name ?? '', ch?.topic ?? null, ch?.visibility ?? 'public', ws) },
396
+ { label: 'Delete channel', danger: true, action: () => { ws.send({ t: 'channel.delete', body: { channel_id: channelId } }) } },
397
+ ])
398
+ }
399
+ })
400
+ }
377
401
 
378
- // Long-press on touch (hub summary or channel link)
402
+ // Mobile: long-press action sheet
379
403
  if (isTouch()) {
380
404
  addLongPress(root, e => {
381
405
  const target = e.target ?? e.touches?.[0]?.target
@@ -850,6 +874,57 @@ function _buildCreateChannelForm(container, { hubId, ws, dismiss }) {
850
874
  requestAnimationFrame(() => container.querySelector('#new-ch-name')?.focus())
851
875
  }
852
876
 
877
+ // ─── Desktop context-menu popover ────────────────────────────────────────────
878
+
879
+ let _popoverEl = null
880
+ let _popoverCleanup = null
881
+
882
+ function _dismissSidebarPopover() {
883
+ _popoverEl?.remove()
884
+ _popoverEl = null
885
+ _popoverCleanup?.()
886
+ _popoverCleanup = null
887
+ }
888
+
889
+ function _showSidebarPopover(mouseEvent, items) {
890
+ _dismissSidebarPopover()
891
+
892
+ const el = document.createElement('div')
893
+ el.className = 'msg-context-menu'
894
+ el.setAttribute('role', 'menu')
895
+ for (const item of items) {
896
+ const btn = document.createElement('button')
897
+ btn.type = 'button'
898
+ btn.className = 'msg-context-menu-item' + (item.danger ? ' msg-context-menu-item--danger' : '')
899
+ btn.setAttribute('role', 'menuitem')
900
+ btn.textContent = item.label
901
+ btn.addEventListener('click', () => { _dismissSidebarPopover(); item.action() })
902
+ el.appendChild(btn)
903
+ }
904
+ document.body.appendChild(el)
905
+ _popoverEl = el
906
+
907
+ // Position at cursor, flip if needed
908
+ const gap = 4
909
+ let top = mouseEvent.clientY + gap
910
+ let left = mouseEvent.clientX + gap
911
+ el.style.left = `${left}px`
912
+ el.style.top = `${top}px`
913
+
914
+ const rect = el.getBoundingClientRect()
915
+ if (rect.right > window.innerWidth - 8) el.style.left = `${mouseEvent.clientX - rect.width - gap}px`
916
+ if (rect.bottom > window.innerHeight - 8) el.style.top = `${mouseEvent.clientY - rect.height - gap}px`
917
+
918
+ const onKey = e => { if (e.key === 'Escape') _dismissSidebarPopover() }
919
+ const onClick = e => { if (!el.contains(e.target)) _dismissSidebarPopover() }
920
+ document.addEventListener('keydown', onKey, { capture: true })
921
+ document.addEventListener('click', onClick, { capture: true })
922
+ _popoverCleanup = () => {
923
+ document.removeEventListener('keydown', onKey, { capture: true })
924
+ document.removeEventListener('click', onClick, { capture: true })
925
+ }
926
+ }
927
+
853
928
  // ─── Modal / sheet openers ────────────────────────────────────────────────────
854
929
 
855
930
  function _openCreateHubModal(ws) {
@@ -14,8 +14,8 @@
14
14
 
15
15
  import { CATEGORIES, EMOJI_NAMES } from '../../emoji-data.js'
16
16
  import { escHtml } from '../../shared/messages.js'
17
+ import { getPref, setPref } from '../../settings-sync.js'
17
18
 
18
- const RECENT_KEY = 'devchitchat_recent_emoji'
19
19
  const RECENT_MAX = 24
20
20
  const QUICK_PICKS_COUNT = 4
21
21
 
@@ -29,14 +29,14 @@ let currentOnPick = null
29
29
  // ── Public API ────────────────────────────────────────────────────────────────
30
30
 
31
31
  export function loadRecentEmoji() {
32
- try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') } catch { return [] }
32
+ return getPref('recent_emoji', [])
33
33
  }
34
34
 
35
35
  export function saveRecentEmoji(emoji) {
36
36
  let recents = loadRecentEmoji().filter(e => e !== emoji)
37
37
  recents.unshift(emoji)
38
38
  if (recents.length > RECENT_MAX) recents = recents.slice(0, RECENT_MAX)
39
- localStorage.setItem(RECENT_KEY, JSON.stringify(recents))
39
+ setPref('recent_emoji', recents)
40
40
  refreshAllQuickPicks()
41
41
  }
42
42
 
@@ -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; }
@@ -996,6 +1000,23 @@ details summary {
996
1000
  .mention-option-name { font-weight: 600; color: var(--text-primary); }
997
1001
  .mention-option-handle { color: var(--text-secondary); font-size: 13px; }
998
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
+
999
1020
  /* ── Auth page — Apple-style centered card ───────────────────────────────── */
1000
1021
  .auth-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
1001
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); }
@@ -1260,6 +1281,8 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
1260
1281
  background: var(--bg-sidebar);
1261
1282
  border-left: 1px solid var(--border);
1262
1283
  overflow: hidden;
1284
+ /* position: relative so the .resize-handle--left child anchors to this element */
1285
+ position: relative;
1263
1286
  }
1264
1287
  .tile-panel.active { display: flex; }
1265
1288
 
@@ -1293,7 +1316,7 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
1293
1316
  height: 100%;
1294
1317
  }
1295
1318
  .tile-panel {
1296
- width: 360px;
1319
+ width: var(--tile-panel-width);
1297
1320
  flex-shrink: 0;
1298
1321
  height: 100vh;
1299
1322
  overflow-y: auto;
@@ -1440,7 +1463,7 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
1440
1463
  position: absolute;
1441
1464
  top: 0;
1442
1465
  right: 0;
1443
- width: 380px;
1466
+ width: var(--thread-panel-width);
1444
1467
  height: 100%;
1445
1468
  z-index: 30;
1446
1469
  overflow: hidden;
@@ -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 || {}