@devchitchat/chat 4.4.4 → 5.0.0

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