@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,977 @@
1
+ /**
2
+ * SidebarView.js — hub/channel/DM sidebar.
3
+ *
4
+ * Replaces islands/sidebar.js. No rdbljs; pure EventTarget + CustomEvent.
5
+ *
6
+ * Model events handled:
7
+ * hubs-changed → re-render hub/channel list (preserve <details> open state)
8
+ * dms-changed → re-render DM list
9
+ * channel-selected → mark active channel, clear mention dots
10
+ * presence-updated → update online dot for a specific user
11
+ *
12
+ * User actions dispatched as document CustomEvents → ChatController:
13
+ * 'select-channel' { channelId, meta }
14
+ *
15
+ * Admin actions (hub/channel CRUD, drag-reorder, file-drop) call ws.send()
16
+ * directly because they are one-off form interactions that don't need to go
17
+ * through the model — the server response events keep the model in sync.
18
+ */
19
+
20
+ import * as Ev from '../model/events.js'
21
+ import { escHtml } from '../shared/messages.js'
22
+ import { showActionSheet, dismiss as dismissSheet, getItemsContainer } from '../action-sheet.js'
23
+ import { showModal, dismiss as dismissModal } from '../modal.js'
24
+ import { addLongPress } from '../long-press.js'
25
+
26
+ const BASE = () => window.__BASE_PATH__ ?? ''
27
+ const isTouch = () => window.matchMedia('(pointer: coarse)').matches
28
+
29
+ export class SidebarView {
30
+ #model
31
+ #ws
32
+ #root // <aside>
33
+ #dmListEl // #dm-list
34
+ #canManage = false // false for Guests
35
+ #mentionedChannels = new Set() // channelId → mentioned
36
+ #urgentChannels = new Set() // channelId → urgent
37
+ #dmUnread = new Set() // channelId → unread DM
38
+
39
+ /**
40
+ * @param {AppModel} model
41
+ * @param {WsClient} ws — for admin CRUD operations
42
+ * @param {HTMLElement} rootEl — <aside>
43
+ */
44
+ constructor(model, ws, rootEl) {
45
+ this.#model = model
46
+ this.#ws = ws
47
+ this.#root = rootEl
48
+ this.#dmListEl = rootEl.querySelector('#dm-list')
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
+
66
+ // Seed model from DOM on first load
67
+ const hubs = _populateHubsFromDom(rootEl)
68
+ const dms = _populateDmsFromDom(rootEl)
69
+ if (hubs.length > 0) model.setHubs(hubs)
70
+ if (dms.length > 0) model.setDms(dms)
71
+
72
+ this.#bindModelEvents()
73
+ this.#bindInteractions()
74
+ this.#bindAdminHandlers()
75
+ this.#bindPushSubscription()
76
+
77
+ // Fetch DM list on open — session cookie already authenticates the socket
78
+ ws.on('open', () => ws.send({ t: 'dm.list', body: {} }))
79
+
80
+ ws.on('dm.list_result', ({ dms: list }) => {
81
+ model.setDms(list ?? [])
82
+ })
83
+
84
+ ws.on('dm.opened', ({ channel_id, with_user, notify_only }) => {
85
+ const dms = model.dms
86
+ if (!dms.some(d => d.channel_id === channel_id)) {
87
+ model.setDms([{ channel_id, with_user }, ...dms])
88
+ }
89
+ if (notify_only) {
90
+ this.#dmUnread.add(channel_id)
91
+ this.#renderDms()
92
+ } else {
93
+ window.location.href = `${BASE()}/channels/${channel_id}`
94
+ }
95
+ })
96
+
97
+ ws.on('msg.event', ({ channel_id }) => {
98
+ if (channel_id === model.currentChannelId) return
99
+ if (!model.dms.some(d => d.channel_id === channel_id)) return
100
+ this.#dmUnread.add(channel_id)
101
+ this.#renderDms()
102
+ })
103
+
104
+ ws.on('notification.mention', ({ channel_id, priority }) => {
105
+ if (channel_id === model.currentChannelId) return
106
+ if (priority === 'now') {
107
+ this.#urgentChannels.add(channel_id)
108
+ } else {
109
+ this.#mentionedChannels.add(channel_id)
110
+ }
111
+ this.#updateMentionDots()
112
+ })
113
+
114
+ ws.on('notification.digest', ({ channels }) => {
115
+ for (const c of channels ?? []) {
116
+ if (c.urgent) this.#urgentChannels.add(c.channel_id)
117
+ else if (c.mentions > 0) this.#mentionedChannels.add(c.channel_id)
118
+ }
119
+ this.#updateMentionDots()
120
+ })
121
+
122
+ ws.on('hub.member_added', ({ hub_id, user_id }) => {
123
+ if (user_id !== model.userId) return
124
+ ws.once('hub.list_result', ({ hubs: serverHubs }) => {
125
+ const existing = new Set(model.hubs.map(h => h.hub_id))
126
+ const newHubs = (serverHubs ?? []).filter(h => !existing.has(h.hub_id))
127
+ if (newHubs.length > 0) {
128
+ model.setHubs([...model.hubs, ...newHubs.map(h => ({ ...h, channels: [] }))])
129
+ }
130
+ })
131
+ ws.send({ t: 'hub.list', body: {} })
132
+ })
133
+
134
+ ws.on('hub.member_removed', ({ hub_id, user_id }) => {
135
+ if (user_id !== model.userId) return
136
+ const removedHub = model.hubs.find(h => h.hub_id === hub_id)
137
+ const affectsCurrent = (removedHub?.channels ?? []).some(c => c.channel_id === model.currentChannelId)
138
+ model.removeHub(hub_id)
139
+ if (affectsCurrent) _navigateAfterDeletion(model.hubs)
140
+ })
141
+
142
+ ws.on('hub.reordered', ({ hubs: updated }) => {
143
+ const channelMap = new Map(model.hubs.map(h => [h.hub_id, h.channels]))
144
+ model.setHubs((updated ?? []).map(h => ({ ...h, channels: channelMap.get(h.hub_id) ?? [] })))
145
+ })
146
+
147
+ ws.on('channel.reordered', ({ hub_id, channels }) => {
148
+ const hub = model.hubs.find(h => h.hub_id === hub_id)
149
+ if (!hub) return
150
+ const channelMap = new Map((hub.channels ?? []).map(c => [c.channel_id, c]))
151
+ const reordered = (channels ?? []).map(c => ({
152
+ ...channelMap.get(c.channel_id),
153
+ ...c,
154
+ url: `${BASE()}/channels/${c.channel_id}`,
155
+ }))
156
+ model.setHubs(model.hubs.map(h =>
157
+ h.hub_id === hub_id ? { ...h, channels: reordered } : h
158
+ ))
159
+ })
160
+ }
161
+
162
+ // ─────────────────────────────────────────────────────────────────────────
163
+ // Model event bindings
164
+ // ─────────────────────────────────────────────────────────────────────────
165
+
166
+ #bindModelEvents() {
167
+ const m = this.#model
168
+
169
+ m.addEventListener(Ev.HUBS_CHANGED, e => this.#renderHubs(e.detail.hubs))
170
+ m.addEventListener(Ev.DMS_CHANGED, () => this.#renderDms())
171
+ m.addEventListener(Ev.CHANNEL_SELECTED, e => this.#onChannelSelected(e.detail))
172
+ m.addEventListener(Ev.PRESENCE_UPDATED, e => this.#onPresenceUpdated(e.detail))
173
+ }
174
+
175
+ // ─────────────────────────────────────────────────────────────────────────
176
+ // Rendering
177
+ // ─────────────────────────────────────────────────────────────────────────
178
+
179
+ #renderHubs(hubs) {
180
+ // Preserve open/closed state of each <details> by hub_id.
181
+ // Initial DOM uses data-key on <details>; re-renders use data-hub-id.
182
+ const openHubIds = new Set(
183
+ [...this.#root.querySelectorAll('details.hub-header[open]')]
184
+ .map(d => d.dataset.hubId ?? d.querySelector('summary[data-hub-id]')?.dataset.hubId ?? d.dataset.key)
185
+ .filter(Boolean)
186
+ )
187
+
188
+ // Replace hub list HTML — hub details live inside .hub-group section
189
+ const hubListEl = this.#root.querySelector('.hub-group') ?? this.#root.querySelector('section')
190
+ if (!hubListEl) return
191
+
192
+ const currentChannelId = this.#model.currentChannelId
193
+ hubListEl.innerHTML = hubs.map(hub => {
194
+ const open = openHubIds.has(hub.hub_id) || openHubIds.size === 0 ? 'open' : ''
195
+ const channels = (hub.channels ?? []).map(ch => {
196
+ const isActive = ch.channel_id === currentChannelId
197
+ const hasMention = this.#mentionedChannels.has(ch.channel_id)
198
+ const hasUrgent = this.#urgentChannels.has(ch.channel_id)
199
+ const mentionAttr = hasUrgent ? ' data-urgent=""' : hasMention ? ' data-mention=""' : ''
200
+ return `
201
+ <li class="channel-item${isActive ? ' active' : ''}"
202
+ data-channel-id="${escHtml(ch.channel_id)}"
203
+ data-hub-id="${escHtml(hub.hub_id)}"
204
+ draggable="true"
205
+ ${mentionAttr}>
206
+ <a class="channel-link"
207
+ href="${escHtml(ch.url ?? `${BASE()}/channels/${ch.channel_id}`)}"
208
+ data-channel-id="${escHtml(ch.channel_id)}"
209
+ data-channel-name="${escHtml(ch.name)}"
210
+ data-channel-topic="${escHtml(ch.topic ?? '')}"
211
+ data-channel-visibility="${escHtml(ch.visibility ?? 'public')}"
212
+ data-hub-id="${escHtml(hub.hub_id)}">
213
+ ${escHtml(ch.name)}
214
+ </a>
215
+ </li>`
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
+ : ''
220
+ return `
221
+ <details class="hub-header" data-hub-id="${escHtml(hub.hub_id)}" ${open}>
222
+ <summary class="hub-name" data-hub-id="${escHtml(hub.hub_id)}">
223
+ <span>${escHtml(hub.name)}</span>
224
+ ${addBtn}
225
+ </summary>
226
+ <ul class="channel-list">${channels}</ul>
227
+ </details>`
228
+ }).join('')
229
+
230
+ this.#attachDragHandlers()
231
+ this.#attachFileDropHandlers()
232
+ }
233
+
234
+ #renderDms() {
235
+ const dmListEl = this.#dmListEl
236
+ if (!dmListEl) return
237
+ const list = this.#model.dms
238
+ const current = this.#model.currentChannelId
239
+
240
+ if (list.length === 0) {
241
+ dmListEl.innerHTML = '<li class="dm-empty">No messages yet.</li>'
242
+ return
243
+ }
244
+ dmListEl.innerHTML = list.map(d => {
245
+ const name = escHtml(d.with_user?.display_name ?? d.channel_id)
246
+ const selected = d.channel_id === current ? ' dm-selected' : ''
247
+ const unread = this.#dmUnread.has(d.channel_id) ? ' data-mention=""' : ''
248
+ return `
249
+ <li class="dm-item${selected}" data-channel-id="${escHtml(d.channel_id)}"${unread}>
250
+ <a class="dm-link channel-link"
251
+ href="${BASE()}/channels/${escHtml(d.channel_id)}"
252
+ data-channel-id="${escHtml(d.channel_id)}">
253
+ <span class="dm-name">${name}</span>
254
+ </a>
255
+ </li>`
256
+ }).join('')
257
+ }
258
+
259
+ #onChannelSelected({ channelId, prev }) {
260
+ // Update active channel in hub list
261
+ this.#root.querySelectorAll('.channel-item').forEach(li => {
262
+ li.classList.toggle('active', li.dataset.channelId === channelId)
263
+ })
264
+ // Update active DM
265
+ this.#root.querySelectorAll('.dm-item').forEach(li => {
266
+ li.classList.toggle('dm-selected', li.dataset.channelId === channelId)
267
+ })
268
+ // Clear mention/urgent dots for newly selected channel
269
+ if (channelId) {
270
+ this.#mentionedChannels.delete(channelId)
271
+ this.#urgentChannels.delete(channelId)
272
+ this.#dmUnread.delete(channelId)
273
+ this.#updateMentionDots()
274
+ }
275
+ }
276
+
277
+ #onPresenceUpdated({ userId, status, bulk }) {
278
+ const entries = bulk ?? [{ user_id: userId, status }]
279
+ for (const { user_id, status: st } of entries) {
280
+ for (const el of this.#root.querySelectorAll(`[data-user-id="${user_id}"] .presence-dot`)) {
281
+ el.dataset.status = st
282
+ }
283
+ }
284
+ }
285
+
286
+ #updateMentionDots() {
287
+ this.#root.querySelectorAll('.channel-item').forEach(li => {
288
+ const channelId = li.dataset.channelId
289
+ if (!channelId) return
290
+ if (this.#urgentChannels.has(channelId)) {
291
+ li.dataset.urgent = ''
292
+ delete li.dataset.mention
293
+ } else if (this.#mentionedChannels.has(channelId)) {
294
+ li.dataset.mention = ''
295
+ delete li.dataset.urgent
296
+ } else {
297
+ delete li.dataset.mention
298
+ delete li.dataset.urgent
299
+ }
300
+ })
301
+ }
302
+
303
+ // ─────────────────────────────────────────────────────────────────────────
304
+ // Interaction (navigation + DM)
305
+ // ─────────────────────────────────────────────────────────────────────────
306
+
307
+ #bindInteractions() {
308
+ this.#root.addEventListener('click', e => {
309
+ // Channel or DM link
310
+ const link = e.target.closest('.channel-link')
311
+ if (!link) return
312
+
313
+ const channelId = link.dataset.channelId
314
+ if (!channelId) return
315
+
316
+ // Clear dots
317
+ this.#mentionedChannels.delete(channelId)
318
+ this.#urgentChannels.delete(channelId)
319
+ this.#dmUnread.delete(channelId)
320
+ this.#updateMentionDots()
321
+
322
+ // Mobile: close sidebar
323
+ if (window.matchMedia('(max-width: 700px)').matches) {
324
+ document.body.classList.remove('sidebar-open')
325
+ }
326
+ })
327
+
328
+ // Handle navigation event fired by router.js (SPA navigation)
329
+ document.addEventListener('chatpanel:navigated', e => {
330
+ const { channelId } = e.detail
331
+ this.#onChannelSelected({ channelId })
332
+ if (this.#dmUnread.has(channelId)) {
333
+ this.#dmUnread.delete(channelId)
334
+ this.#renderDms()
335
+ }
336
+ })
337
+ }
338
+
339
+ // ─────────────────────────────────────────────────────────────────────────
340
+ // Admin CRUD (delegated, wired once)
341
+ // ─────────────────────────────────────────────────────────────────────────
342
+
343
+ #bindAdminHandlers() {
344
+ const root = this.#root
345
+ const ws = this.#ws
346
+ const model = this.#model
347
+
348
+ // New hub button (always visible — creating a hub is not a management action)
349
+ root.querySelector('#btn-new-hub')?.addEventListener('click', () => {
350
+ isTouch() ? _openCreateHubSheet(ws) : _openCreateHubModal(ws)
351
+ })
352
+
353
+ if (!this.#canManage) return
354
+
355
+ // Add-channel button (delegated — rendered only for non-guests)
356
+ root.addEventListener('click', e => {
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
+ })
367
+
368
+ // Desktop: right-click context menus
369
+ if (!isTouch()) {
370
+ root.addEventListener('contextmenu', e => {
371
+ const summary = e.target.closest('.hub-name')
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
383
+ }
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
+ }
401
+
402
+ // Mobile: long-press → action sheet
403
+ if (isTouch()) {
404
+ addLongPress(root, e => {
405
+ const target = e.target ?? e.touches?.[0]?.target
406
+ const summary = target?.closest?.('.hub-name')
407
+ if (summary) {
408
+ const hubId = summary.dataset.hubId
409
+ if (!hubId) return
410
+ const hub = model.hubs.find(h => h.hub_id === hubId)
411
+ _openHubSheet(hubId, hub?.name ?? '', hub?.description ?? null, hub?.visibility ?? 'public', ws)
412
+ return
413
+ }
414
+ const link = target?.closest?.('.channel-link')
415
+ if (link) {
416
+ const channelId = link.dataset.channelId
417
+ if (!channelId) return
418
+ let ch = null
419
+ for (const hub of model.hubs) {
420
+ ch = (hub.channels ?? []).find(c => c.channel_id === channelId)
421
+ if (ch) break
422
+ }
423
+ _openChannelSheet(channelId, ch?.name ?? '', ch?.topic ?? null, ch?.visibility ?? 'public', ws)
424
+ }
425
+ })
426
+ }
427
+ }
428
+
429
+ // ─────────────────────────────────────────────────────────────────────────
430
+ // Drag-and-drop reordering
431
+ // ─────────────────────────────────────────────────────────────────────────
432
+
433
+ #attachDragHandlers() {
434
+ const root = this.#root
435
+ const ws = this.#ws
436
+ const model = this.#model
437
+
438
+ let dragSrcChannelId = null
439
+ let dragSrcHubId = null
440
+ let dragSrcHubHub = null // hub-level drag
441
+
442
+ const clearIndicators = () => {
443
+ root.querySelectorAll('.drop-before, .drop-after, .dragging').forEach(el => {
444
+ el.classList.remove('drop-before', 'drop-after', 'dragging')
445
+ })
446
+ }
447
+
448
+ const before = (e, el) => e.clientY < el.getBoundingClientRect().top + el.offsetHeight / 2
449
+
450
+ root.addEventListener('dragstart', e => {
451
+ // Hub drag (from summary)
452
+ const hubSummary = e.target.closest('.hub-header > summary')
453
+ if (hubSummary && !e.target.closest('.channel-item')) {
454
+ const details = hubSummary.closest('.hub-header')
455
+ dragSrcHubHub = details?.dataset.hubId ?? null
456
+ dragSrcChannelId = null
457
+ if (!dragSrcHubHub) return
458
+ details.classList.add('dragging')
459
+ e.dataTransfer.effectAllowed = 'move'
460
+ e.stopPropagation()
461
+ return
462
+ }
463
+ // Channel drag
464
+ const li = e.target.closest('.channel-item')
465
+ if (!li) return
466
+ dragSrcChannelId = li.dataset.channelId
467
+ dragSrcHubId = li.dataset.hubId
468
+ dragSrcHubHub = null
469
+ if (!dragSrcChannelId) return
470
+ li.classList.add('dragging')
471
+ e.dataTransfer.effectAllowed = 'move'
472
+ })
473
+
474
+ root.addEventListener('dragend', () => {
475
+ clearIndicators()
476
+ dragSrcChannelId = null
477
+ dragSrcHubId = null
478
+ dragSrcHubHub = null
479
+ })
480
+
481
+ root.addEventListener('dragover', e => {
482
+ if (dragSrcHubHub) {
483
+ // Hub-level drag
484
+ if (e.target.closest('.channel-item')) return
485
+ const targetDetails = e.target.closest('.hub-header')
486
+ if (!targetDetails) return
487
+ const targetHubId = targetDetails.dataset.hubId
488
+ if (!targetHubId || targetHubId === dragSrcHubHub) return
489
+ e.preventDefault()
490
+ clearIndicators()
491
+ targetDetails.querySelector('summary')?.classList.add(before(e, targetDetails) ? 'drop-before' : 'drop-after')
492
+ return
493
+ }
494
+ if (dragSrcChannelId) {
495
+ const targetLi = e.target.closest('.channel-item')
496
+ if (!targetLi || targetLi.dataset.channelId === dragSrcChannelId) return
497
+ if (targetLi.dataset.hubId !== dragSrcHubId) return
498
+ e.preventDefault()
499
+ clearIndicators()
500
+ targetLi.classList.add(before(e, targetLi) ? 'drop-before' : 'drop-after')
501
+ }
502
+ })
503
+
504
+ root.addEventListener('dragleave', e => {
505
+ const li = e.target.closest('.channel-item')
506
+ if (li) li.classList.remove('drop-before', 'drop-after')
507
+ const summary = e.target.closest('.hub-header > summary')
508
+ if (summary) summary.classList.remove('drop-before', 'drop-after')
509
+ })
510
+
511
+ root.addEventListener('drop', e => {
512
+ clearIndicators()
513
+
514
+ if (dragSrcHubHub) {
515
+ if (e.target.closest('.channel-item')) return
516
+ const targetDetails = e.target.closest('.hub-header')
517
+ const targetHubId = targetDetails?.dataset.hubId
518
+ if (!targetHubId || targetHubId === dragSrcHubHub) return
519
+ e.preventDefault()
520
+ const ids = model.hubs.map(h => h.hub_id)
521
+ const fromIdx = ids.indexOf(dragSrcHubHub)
522
+ const toIdx = ids.indexOf(targetHubId)
523
+ if (fromIdx === -1 || toIdx === -1) return
524
+ const isBefore = before(e, targetDetails)
525
+ ids.splice(fromIdx, 1)
526
+ ids.splice(isBefore ? ids.indexOf(targetHubId) : ids.indexOf(targetHubId) + 1, 0, dragSrcHubHub)
527
+ ws.send({ t: 'hub.reorder', body: { hub_ids: ids } })
528
+ return
529
+ }
530
+
531
+ if (dragSrcChannelId) {
532
+ const targetLi = e.target.closest('.channel-item')
533
+ const targetChannelId = targetLi?.dataset.channelId
534
+ if (!targetChannelId || targetChannelId === dragSrcChannelId) return
535
+ if (targetLi.dataset.hubId !== dragSrcHubId) return
536
+ e.preventDefault()
537
+ const hub = model.hubs.find(h => h.hub_id === dragSrcHubId)
538
+ if (!hub) return
539
+ const ids = (hub.channels ?? []).map(c => c.channel_id)
540
+ const fromIdx = ids.indexOf(dragSrcChannelId)
541
+ const toIdx = ids.indexOf(targetChannelId)
542
+ if (fromIdx === -1 || toIdx === -1) return
543
+ const isBefore = before(e, targetLi)
544
+ ids.splice(fromIdx, 1)
545
+ ids.splice(isBefore ? ids.indexOf(targetChannelId) : ids.indexOf(targetChannelId) + 1, 0, dragSrcChannelId)
546
+ ws.send({ t: 'channel.reorder', body: { hub_id: dragSrcHubId, channel_ids: ids } })
547
+ }
548
+ })
549
+ }
550
+
551
+ // ─────────────────────────────────────────────────────────────────────────
552
+ // File-drop on channel links
553
+ // ─────────────────────────────────────────────────────────────────────────
554
+
555
+ #attachFileDropHandlers() {
556
+ const root = this.#root
557
+ const ws = this.#ws
558
+ let hoverTimer = null
559
+ let hoverTarget = null
560
+
561
+ const clearHover = () => {
562
+ clearTimeout(hoverTimer)
563
+ hoverTimer = null
564
+ if (hoverTarget) { hoverTarget.classList.remove('file-drop-hover'); hoverTarget = null }
565
+ }
566
+
567
+ const showToast = text => {
568
+ const toast = document.createElement('div')
569
+ toast.className = 'sidebar-toast'
570
+ toast.textContent = text
571
+ root.appendChild(toast)
572
+ setTimeout(() => toast.remove(), 3000)
573
+ }
574
+
575
+ root.addEventListener('dragover', e => {
576
+ const link = e.target.closest('.channel-link')
577
+ if (!link) { clearHover(); return }
578
+ if (!e.dataTransfer.types.includes('Files')) return
579
+ e.preventDefault()
580
+ e.dataTransfer.dropEffect = 'copy'
581
+ if (link !== hoverTarget) {
582
+ clearHover()
583
+ hoverTarget = link
584
+ hoverTimer = setTimeout(() => link.classList.add('file-drop-hover'), 600)
585
+ }
586
+ })
587
+
588
+ root.addEventListener('dragleave', e => {
589
+ if (hoverTarget && !hoverTarget.contains(e.relatedTarget)) clearHover()
590
+ })
591
+
592
+ root.addEventListener('drop', async e => {
593
+ const link = e.target.closest('.channel-link')
594
+ clearHover()
595
+ if (!link) return
596
+ if (!e.dataTransfer.types.includes('Files')) return
597
+ e.preventDefault()
598
+ e.stopPropagation()
599
+
600
+ const targetChannelId = link.dataset.channelId
601
+ const targetChannelName = link.dataset.channelName ?? targetChannelId
602
+ if (!targetChannelId) return
603
+
604
+ const files = [...e.dataTransfer.files]
605
+ if (files.length === 0) return
606
+
607
+ ws.send({ t: 'channel.join', body: { channel_id: targetChannelId } })
608
+
609
+ const uploaded = []
610
+ for (const file of files) {
611
+ const formData = new FormData()
612
+ formData.append('file', file)
613
+ formData.append('channel_id', targetChannelId)
614
+ try {
615
+ const res = await fetch(`${BASE()}/api/uploads`, { method: 'POST', body: formData })
616
+ if (!res.ok) {
617
+ const body = await res.json().catch(() => ({}))
618
+ showToast(`Upload failed: ${body.error ?? res.statusText}`)
619
+ continue
620
+ }
621
+ const a = await res.json()
622
+ uploaded.push({ upload_id: a.upload_id, url: a.url, filename: a.original_name, mime_type: a.mime_type, size_bytes: a.size_bytes })
623
+ } catch { showToast('Upload failed: network error') }
624
+ }
625
+
626
+ if (uploaded.length === 0) return
627
+
628
+ ws.send({
629
+ t: 'msg.send',
630
+ body: { channel_id: targetChannelId, text: '', client_msg_id: `drop_${Date.now()}`, attachments: uploaded },
631
+ })
632
+ showToast(`Sent to #${targetChannelName}`)
633
+ })
634
+ }
635
+
636
+ // ─────────────────────────────────────────────────────────────────────────
637
+ // Web push subscription
638
+ // ─────────────────────────────────────────────────────────────────────────
639
+
640
+ #bindPushSubscription() {
641
+ const root = this.#root
642
+ const ws = this.#ws
643
+ const vapidKey = root.dataset.vapidKey ?? ''
644
+ if (!vapidKey || !('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return
645
+
646
+ const toUint8 = b64url => {
647
+ const padded = b64url + '==='.slice((b64url.length + 3) % 4)
648
+ return Uint8Array.from(atob(padded.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
649
+ }
650
+
651
+ const subscribe = async swReg => {
652
+ try {
653
+ const sub = await swReg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: toUint8(vapidKey) })
654
+ ws.send({ t: 'push.subscribe', body: { subscription: sub.toJSON() } })
655
+ } catch { /* user blocked — ignore */ }
656
+ }
657
+
658
+ let swReg = null
659
+ let enableBtn = null
660
+
661
+ const showEnableButton = () => {
662
+ if (enableBtn || Notification.permission === 'granted') return
663
+ const footer = root.querySelector('.sidebar-footer') ?? root
664
+ enableBtn = document.createElement('button')
665
+ enableBtn.className = 'btn-enable-notifications'
666
+ enableBtn.textContent = '🔔 Enable notifications'
667
+ footer.appendChild(enableBtn)
668
+ enableBtn.addEventListener('click', async () => {
669
+ const perm = await Notification.requestPermission()
670
+ if (perm === 'granted' && swReg) {
671
+ await subscribe(swReg)
672
+ enableBtn?.remove()
673
+ enableBtn = null
674
+ } else if (perm === 'denied') {
675
+ if (enableBtn) enableBtn.textContent = '🔕 Notifications blocked in browser settings'
676
+ }
677
+ })
678
+ }
679
+
680
+ navigator.serviceWorker
681
+ .register(`${BASE()}/sw.js`, { scope: `${BASE()}/` })
682
+ .then(async reg => {
683
+ swReg = reg
684
+ try { await reg.pushManager.getSubscription() } catch { return }
685
+ if (Notification.permission === 'granted') subscribe(reg)
686
+ else showEnableButton()
687
+ })
688
+ .catch(() => {})
689
+ }
690
+ }
691
+
692
+ // ─────────────────────────────────────────────────────────────────────────────
693
+ // DOM → model seed helpers
694
+ // ─────────────────────────────────────────────────────────────────────────────
695
+
696
+ function _populateHubsFromDom(root) {
697
+ return Array.from(root.querySelectorAll('details.hub-header')).map(el => {
698
+ // Initial SSR uses data-key; re-renders use data-hub-id; summary has data-hub-id
699
+ const hub_id = el.dataset.hubId
700
+ ?? el.querySelector('summary[data-hub-id]')?.dataset.hubId
701
+ ?? el.dataset.key
702
+ return {
703
+ hub_id,
704
+ name: el.querySelector('.hub-name span')?.textContent.trim() ?? '',
705
+ visibility: el.dataset.visibility ?? 'public',
706
+ description: el.dataset.description ?? null,
707
+ channels: Array.from(el.querySelectorAll('li.channel-item, li[data-key]')).map(li => {
708
+ const link = li.querySelector('a.channel-link, a[data-channel-id], a')
709
+ return {
710
+ channel_id: li.dataset.channelId ?? link?.dataset.channelId ?? li.dataset.key,
711
+ hub_id,
712
+ name: (link?.textContent.trim() ?? '').replace(/^#\s*/, ''),
713
+ url: link?.href ?? '',
714
+ topic: link?.dataset.channelTopic ?? null,
715
+ visibility: link?.dataset.channelVisibility ?? 'public',
716
+ selected: li.dataset.selected === 'true' || li.classList.contains('active'),
717
+ }
718
+ }),
719
+ }
720
+ })
721
+ }
722
+
723
+ function _populateDmsFromDom(root) {
724
+ return Array.from(root.querySelectorAll('.dm-item')).map(li => ({
725
+ channel_id: li.dataset.channelId,
726
+ with_user: { display_name: li.querySelector('.dm-name')?.textContent.trim() ?? '' },
727
+ }))
728
+ }
729
+
730
+ function _navigateAfterDeletion(remainingHubs) {
731
+ const first = remainingHubs.flatMap(h => h.channels ?? []).find(Boolean)
732
+ window.location.href = first ? `${BASE()}/channels/${first.channel_id}` : `${BASE()}/`
733
+ }
734
+
735
+ // ─────────────────────────────────────────────────────────────────────────────
736
+ // Admin form builders (module-private, called by button handlers)
737
+ // ─────────────────────────────────────────────────────────────────────────────
738
+
739
+ function _buildHubForm(container, { hubId, hubName, hubDescription, hubVisibility, ws, dismiss }) {
740
+ const currentVisibility = hubVisibility ?? 'public'
741
+ container.innerHTML = `
742
+ <div class="field">
743
+ <label for="hub-name-input">Hub name</label>
744
+ <input id="hub-name-input" type="text" value="${escHtml(hubName)}" maxlength="80" autocomplete="off">
745
+ </div>
746
+ <div class="field">
747
+ <label for="hub-desc-input">Description <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
748
+ <input id="hub-desc-input" type="text" value="${escHtml(hubDescription ?? '')}" maxlength="240" autocomplete="off">
749
+ </div>
750
+ <div class="field">
751
+ <label for="hub-visibility-input">Visibility</label>
752
+ <select id="hub-visibility-input">
753
+ <option value="public" ${currentVisibility === 'public' ? 'selected' : ''}>Public</option>
754
+ <option value="restricted" ${currentVisibility === 'restricted' ? 'selected' : ''}>Restricted</option>
755
+ </select>
756
+ </div>
757
+ <div class="modal-footer">
758
+ <button class="btn-ghost" id="hub-cancel-btn" type="button">Cancel</button>
759
+ <button class="btn-primary" id="hub-save-btn" type="button">Save</button>
760
+ </div>
761
+ <div class="modal-danger-zone">
762
+ <p>Deleting this hub removes it and all its channels permanently.</p>
763
+ <button class="btn-danger" id="hub-delete-btn" type="button">Delete hub</button>
764
+ </div>`
765
+
766
+ container.querySelector('#hub-cancel-btn').addEventListener('click', dismiss)
767
+ container.querySelector('#hub-save-btn').addEventListener('click', () => {
768
+ const name = container.querySelector('#hub-name-input').value.trim()
769
+ if (!name) return
770
+ ws.send({ t: 'hub.update', body: {
771
+ hub_id: hubId, name,
772
+ description: container.querySelector('#hub-desc-input').value.trim() || null,
773
+ visibility: container.querySelector('#hub-visibility-input').value,
774
+ } })
775
+ dismiss()
776
+ })
777
+ container.querySelector('#hub-delete-btn').addEventListener('click', () => {
778
+ ws.send({ t: 'hub.delete', body: { hub_id: hubId } })
779
+ dismiss()
780
+ })
781
+ requestAnimationFrame(() => container.querySelector('#hub-name-input')?.focus())
782
+ }
783
+
784
+ function _buildChannelForm(container, { channelId, channelName, channelTopic, channelVisibility, ws, dismiss }) {
785
+ container.innerHTML = `
786
+ <div class="field">
787
+ <label for="ch-name-input">Channel name</label>
788
+ <input id="ch-name-input" type="text" value="${escHtml(channelName)}" maxlength="80" autocomplete="off">
789
+ </div>
790
+ <div class="field">
791
+ <label for="ch-topic-input">Topic <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
792
+ <input id="ch-topic-input" type="text" value="${escHtml(channelTopic ?? '')}" maxlength="240" autocomplete="off">
793
+ </div>
794
+ <div class="modal-footer">
795
+ <button class="btn-ghost" id="ch-cancel-btn" type="button">Cancel</button>
796
+ <button class="btn-primary" id="ch-save-btn" type="button">Save</button>
797
+ </div>
798
+ <div class="modal-danger-zone">
799
+ <p>Deleting this channel removes all its messages permanently.</p>
800
+ <button class="btn-danger" id="ch-delete-btn" type="button">Delete channel</button>
801
+ </div>`
802
+
803
+ container.querySelector('#ch-cancel-btn').addEventListener('click', dismiss)
804
+ container.querySelector('#ch-save-btn').addEventListener('click', () => {
805
+ const name = container.querySelector('#ch-name-input').value.trim()
806
+ if (!name) return
807
+ ws.send({ t: 'channel.update', body: {
808
+ channel_id: channelId, name,
809
+ topic: container.querySelector('#ch-topic-input').value.trim() || null,
810
+ visibility: channelVisibility,
811
+ } })
812
+ dismiss()
813
+ })
814
+ container.querySelector('#ch-delete-btn').addEventListener('click', () => {
815
+ ws.send({ t: 'channel.delete', body: { channel_id: channelId } })
816
+ dismiss()
817
+ })
818
+ requestAnimationFrame(() => container.querySelector('#ch-name-input')?.focus())
819
+ }
820
+
821
+ function _buildCreateHubForm(container, { ws, dismiss }) {
822
+ container.innerHTML = `
823
+ <div class="field">
824
+ <label for="new-hub-name">Hub name</label>
825
+ <input id="new-hub-name" type="text" placeholder="e.g. Engineering" maxlength="80" autocomplete="off">
826
+ </div>
827
+ <div class="field">
828
+ <label for="new-hub-desc">Description <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
829
+ <input id="new-hub-desc" type="text" maxlength="240" autocomplete="off">
830
+ </div>
831
+ <div class="modal-footer">
832
+ <button class="btn-ghost" id="new-hub-cancel" type="button">Cancel</button>
833
+ <button class="btn-primary" id="new-hub-save" type="button">Create</button>
834
+ </div>`
835
+ container.querySelector('#new-hub-cancel').addEventListener('click', dismiss)
836
+ container.querySelector('#new-hub-save').addEventListener('click', () => {
837
+ const name = container.querySelector('#new-hub-name').value.trim()
838
+ if (!name) return
839
+ ws.send({ t: 'hub.create', body: {
840
+ name,
841
+ description: container.querySelector('#new-hub-desc').value.trim() || null,
842
+ visibility: 'public',
843
+ } })
844
+ dismiss()
845
+ })
846
+ requestAnimationFrame(() => container.querySelector('#new-hub-name')?.focus())
847
+ }
848
+
849
+ function _buildCreateChannelForm(container, { hubId, ws, dismiss }) {
850
+ container.innerHTML = `
851
+ <div class="field">
852
+ <label for="new-ch-name">Channel name</label>
853
+ <input id="new-ch-name" type="text" placeholder="e.g. general" maxlength="80" autocomplete="off">
854
+ </div>
855
+ <div class="field">
856
+ <label for="new-ch-topic">Topic <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
857
+ <input id="new-ch-topic" type="text" maxlength="240" autocomplete="off">
858
+ </div>
859
+ <div class="modal-footer">
860
+ <button class="btn-ghost" id="new-ch-cancel" type="button">Cancel</button>
861
+ <button class="btn-primary" id="new-ch-save" type="button">Create</button>
862
+ </div>`
863
+ container.querySelector('#new-ch-cancel').addEventListener('click', dismiss)
864
+ container.querySelector('#new-ch-save').addEventListener('click', () => {
865
+ const name = container.querySelector('#new-ch-name').value.trim()
866
+ if (!name) return
867
+ ws.send({ t: 'channel.create', body: {
868
+ hub_id: hubId, kind: 'text', name,
869
+ topic: container.querySelector('#new-ch-topic').value.trim() || null,
870
+ visibility: 'public',
871
+ } })
872
+ dismiss()
873
+ })
874
+ requestAnimationFrame(() => container.querySelector('#new-ch-name')?.focus())
875
+ }
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
+
928
+ // ─── Modal / sheet openers ────────────────────────────────────────────────────
929
+
930
+ function _openCreateHubModal(ws) {
931
+ showModal({ title: 'New hub', build: body => _buildCreateHubForm(body, { ws, dismiss: dismissModal }) })
932
+ }
933
+ function _openCreateHubSheet(ws) {
934
+ showActionSheet({ label: 'New hub', items: [] })
935
+ _buildCreateHubForm(getItemsContainer(), { ws, dismiss: dismissSheet })
936
+ }
937
+ function _openHubModal(hubId, hubName, hubDescription, hubVisibility, ws) {
938
+ showModal({ title: 'Hub settings', build: body => _buildHubForm(body, { hubId, hubName, hubDescription, hubVisibility, ws, dismiss: dismissModal }) })
939
+ }
940
+ function _openHubSheet(hubId, hubName, hubDescription, hubVisibility, ws) {
941
+ showActionSheet({ label: hubName, items: [
942
+ { label: 'Edit hub', action: () => {
943
+ showActionSheet({ label: 'Edit hub', items: [] })
944
+ _buildHubForm(getItemsContainer(), { hubId, hubName, hubDescription, hubVisibility, ws, dismiss: dismissSheet })
945
+ }},
946
+ { label: 'Create channel', action: () => {
947
+ showActionSheet({ label: `New channel in ${hubName}`, items: [] })
948
+ _buildCreateChannelForm(getItemsContainer(), { hubId, ws, dismiss: dismissSheet })
949
+ }},
950
+ { label: 'Delete hub', danger: true, action: () => {
951
+ showActionSheet({ label: `Delete "${hubName}"?`, items: [
952
+ { label: 'Cancel', action: () => {} },
953
+ { label: 'Delete hub', danger: true, action: () => { ws.send({ t: 'hub.delete', body: { hub_id: hubId } }); dismissSheet() } },
954
+ ]})
955
+ }},
956
+ ]})
957
+ }
958
+ function _openCreateChannelModal(hubId, hubName, ws) {
959
+ showModal({ title: `New channel in ${hubName}`, build: body => _buildCreateChannelForm(body, { hubId, ws, dismiss: dismissModal }) })
960
+ }
961
+ function _openChannelModal(channelId, channelName, channelTopic, channelVisibility, ws) {
962
+ showModal({ title: 'Channel settings', build: body => _buildChannelForm(body, { channelId, channelName, channelTopic, channelVisibility, ws, dismiss: dismissModal }) })
963
+ }
964
+ function _openChannelSheet(channelId, channelName, channelTopic, channelVisibility, ws) {
965
+ showActionSheet({ label: channelName, items: [
966
+ { label: 'Edit channel', action: () => {
967
+ showActionSheet({ label: 'Edit channel', items: [] })
968
+ _buildChannelForm(getItemsContainer(), { channelId, channelName, channelTopic, channelVisibility, ws, dismiss: dismissSheet })
969
+ }},
970
+ { label: 'Delete channel', danger: true, action: () => {
971
+ showActionSheet({ label: `Delete "#${channelName}"?`, items: [
972
+ { label: 'Cancel', action: () => {} },
973
+ { label: 'Delete channel', danger: true, action: () => { ws.send({ t: 'channel.delete', body: { channel_id: channelId } }); dismissSheet() } },
974
+ ]})
975
+ }},
976
+ ]})
977
+ }