@devchitchat/chat 0.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.
Files changed (131) hide show
  1. package/README.md +313 -0
  2. package/index.js +148 -0
  3. package/migrate/001-drop-channel-invites.js +3 -0
  4. package/migrate/002-invite-initial-roles.js +5 -0
  5. package/migrate/003-dm-channels.js +35 -0
  6. package/migrate/004-notifications.js +15 -0
  7. package/migrate/005-uploads.js +21 -0
  8. package/migrate/006-mention-priority.js +3 -0
  9. package/migrate/007-push-subscriptions.js +14 -0
  10. package/migrate/008-messages-channel-seq-index.js +3 -0
  11. package/migrate/009-message-reactions.js +15 -0
  12. package/migrate/010-edit-messages.js +7 -0
  13. package/package.json +51 -0
  14. package/pages/_error.html +12 -0
  15. package/pages/_layout.html +31 -0
  16. package/pages/_layout.js +13 -0
  17. package/pages/admin/_layout.html +52 -0
  18. package/pages/admin/_layout.js +8 -0
  19. package/pages/admin/bots/[userId].js +88 -0
  20. package/pages/admin/bots/[userId].phtml +89 -0
  21. package/pages/admin/bots/index.js +41 -0
  22. package/pages/admin/bots/index.phtml +58 -0
  23. package/pages/admin/index.js +8 -0
  24. package/pages/admin/invites/index.js +72 -0
  25. package/pages/admin/invites/index.phtml +88 -0
  26. package/pages/admin/users/[userId].js +60 -0
  27. package/pages/admin/users/[userId].phtml +57 -0
  28. package/pages/admin/users/index.js +20 -0
  29. package/pages/admin/users/index.phtml +37 -0
  30. package/pages/api/uploads/index.js +66 -0
  31. package/pages/api/user/settings.js +26 -0
  32. package/pages/auth/signout.js +14 -0
  33. package/pages/channels/[channelId].js +99 -0
  34. package/pages/channels/[channelId].phtml +173 -0
  35. package/pages/index.js +33 -0
  36. package/pages/invite/[token].js +10 -0
  37. package/pages/login/index.js +57 -0
  38. package/pages/login/index.phtml +29 -0
  39. package/pages/public/client/action-sheet.js +77 -0
  40. package/pages/public/client/app.js +38 -0
  41. package/pages/public/client/auth-tabs.js +13 -0
  42. package/pages/public/client/emoji-data.js +197 -0
  43. package/pages/public/client/islands/call.js +1770 -0
  44. package/pages/public/client/islands/sidebar.js +1197 -0
  45. package/pages/public/client/long-press.js +59 -0
  46. package/pages/public/client/modal.js +50 -0
  47. package/pages/public/client/router.js +87 -0
  48. package/pages/public/client/rtc-peer-manager.js +344 -0
  49. package/pages/public/client/settings-sync.js +76 -0
  50. package/pages/public/client/shared/messages.js +147 -0
  51. package/pages/public/client/swipe-nav.js +98 -0
  52. package/pages/public/client/theme.js +27 -0
  53. package/pages/public/client/ws.js +71 -0
  54. package/pages/public/favicon.ico +0 -0
  55. package/pages/public/favicon.png +0 -0
  56. package/pages/public/icon.png +0 -0
  57. package/pages/public/manifest.json +11 -0
  58. package/pages/public/sw.js +38 -0
  59. package/pages/public/themes/base.css +1786 -0
  60. package/pages/public/themes/dark.css +22 -0
  61. package/pages/public/themes/forest.css +22 -0
  62. package/pages/public/themes/light.css +23 -0
  63. package/pages/public/themes/ocean.css +22 -0
  64. package/pages/public/themes/rose.css +22 -0
  65. package/pages/registration/index.js +35 -0
  66. package/pages/registration/index.phtml +38 -0
  67. package/pages/uploads/[uploadId]/[filename].js +45 -0
  68. package/src/adapters/InMemoryAuthRepository.js +74 -0
  69. package/src/adapters/InMemoryChannelRepository.js +138 -0
  70. package/src/adapters/InMemoryDeliveryRepository.js +52 -0
  71. package/src/adapters/InMemoryFileStore.js +53 -0
  72. package/src/adapters/InMemoryHubRepository.js +85 -0
  73. package/src/adapters/InMemoryMessageRepository.js +35 -0
  74. package/src/adapters/InMemoryReactionRepository.js +45 -0
  75. package/src/adapters/InMemorySearchRepository.js +37 -0
  76. package/src/adapters/InMemorySignalingRepository.js +35 -0
  77. package/src/adapters/InMemoryUploadRepository.js +36 -0
  78. package/src/adapters/InMemoryUserSettingsRepository.js +15 -0
  79. package/src/adapters/LocalFileStore.js +40 -0
  80. package/src/adapters/SqliteAuthRepository.js +184 -0
  81. package/src/adapters/SqliteChannelRepository.js +149 -0
  82. package/src/adapters/SqliteDeliveryRepository.js +53 -0
  83. package/src/adapters/SqliteHubRepository.js +99 -0
  84. package/src/adapters/SqliteMessageRepository.js +90 -0
  85. package/src/adapters/SqlitePushRepository.js +39 -0
  86. package/src/adapters/SqliteReactionRepository.js +50 -0
  87. package/src/adapters/SqliteSearchRepository.js +42 -0
  88. package/src/adapters/SqliteSignalingRepository.js +50 -0
  89. package/src/adapters/SqliteUploadRepository.js +34 -0
  90. package/src/adapters/SqliteUserSettingsRepository.js +23 -0
  91. package/src/adminAuth.js +25 -0
  92. package/src/config.js +11 -0
  93. package/src/context.js +77 -0
  94. package/src/core/dm.js +10 -0
  95. package/src/core/mentions.js +27 -0
  96. package/src/core/messages.js +21 -0
  97. package/src/core/reactions.js +6 -0
  98. package/src/core/roles.js +5 -0
  99. package/src/core/uploads.js +107 -0
  100. package/src/db/initDb.js +225 -0
  101. package/src/db/openDb.js +18 -0
  102. package/src/db/runMigrations.js +45 -0
  103. package/src/db/transaction.js +11 -0
  104. package/src/ports/IFileStore.js +34 -0
  105. package/src/services/AuthService.js +208 -0
  106. package/src/services/BotService.js +148 -0
  107. package/src/services/ChannelService.js +176 -0
  108. package/src/services/DeliveryService.js +28 -0
  109. package/src/services/HubService.js +133 -0
  110. package/src/services/MessageService.js +122 -0
  111. package/src/services/NotificationService.js +45 -0
  112. package/src/services/PresenceService.js +55 -0
  113. package/src/services/ReactionService.js +57 -0
  114. package/src/services/SearchService.js +21 -0
  115. package/src/services/SignalingService.js +177 -0
  116. package/src/services/UploadService.js +111 -0
  117. package/src/services/UserSettingsService.js +30 -0
  118. package/src/services/WebPushService.js +217 -0
  119. package/src/util/crypto.js +21 -0
  120. package/src/util/errors.js +14 -0
  121. package/src/util/ids.js +3 -0
  122. package/src/util/logger.js +21 -0
  123. package/src/ws/ChatServer.js +478 -0
  124. package/src/ws/handlers/authHandlers.js +152 -0
  125. package/src/ws/handlers/channelHandlers.js +166 -0
  126. package/src/ws/handlers/hubHandlers.js +82 -0
  127. package/src/ws/handlers/messageHandlers.js +88 -0
  128. package/src/ws/handlers/pushHandlers.js +25 -0
  129. package/src/ws/handlers/reactionHandlers.js +27 -0
  130. package/src/ws/handlers/rtcHandlers.js +126 -0
  131. package/styles.css +22 -0
@@ -0,0 +1,1197 @@
1
+ /**
2
+ * sidebar.js — rdbljs island for hub/channel navigation and online presence.
3
+ *
4
+ * Mounted on: <aside island="/client/islands/sidebar.js" ...>
5
+ */
6
+ import { signal, getItemContext, effect, computed, Context } from '@devchitchat/rdbljs'
7
+ import { WsClient } from '../ws.js'
8
+ import { escHtml } from '../shared/messages.js'
9
+ import { addLongPress } from '../long-press.js'
10
+ import { showActionSheet, dismiss as dismissSheet, getItemsContainer } from '../action-sheet.js'
11
+ import { showModal, dismiss as dismissModal } from '../modal.js'
12
+
13
+ // ── Helpers ──────────────────────────────────────────────────────────────────
14
+
15
+ const isTouch = () => window.matchMedia('(pointer: coarse)').matches
16
+
17
+ function populateFromDom(root) {
18
+ return Array.from(root.querySelectorAll('details')).map(el => {
19
+ const hub_id = el.dataset.key
20
+ return {
21
+ hub_id,
22
+ name: el.querySelector('.hub-name span').textContent.trim(),
23
+ visibility: el.dataset.visibility ?? 'public',
24
+ description: el.dataset.description ?? null,
25
+ channels: Array.from(el.querySelectorAll('li')).map(li => {
26
+ const link = li.querySelector('a')
27
+ return {
28
+ channel_id: li.dataset.key,
29
+ hub_id,
30
+ name: link.textContent.trim(),
31
+ url: link.href,
32
+ topic: link.dataset.channelTopic ?? null,
33
+ visibility: link.dataset.channelVisibility ?? 'public',
34
+ selected: li.dataset.selected === 'true',
35
+ className: li.className.trim()
36
+ }
37
+ })
38
+ }
39
+ })
40
+ }
41
+
42
+ // ── Form builders ─────────────────────────────────────────────────────────────
43
+
44
+ function buildHubForm(container, { hubId, hubName, hubDescription, hubVisibility, ws, dismiss }) {
45
+ const currentVisibility = hubVisibility ?? 'public'
46
+ container.innerHTML = `
47
+ <div class="field">
48
+ <label for="hub-name-input">Hub name</label>
49
+ <input id="hub-name-input" type="text" value="${escHtml(hubName)}" maxlength="80" autocomplete="off">
50
+ </div>
51
+ <div class="field">
52
+ <label for="hub-desc-input">Description <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
53
+ <input id="hub-desc-input" type="text" value="${escHtml(hubDescription ?? '')}" maxlength="240" autocomplete="off">
54
+ </div>
55
+ <div class="field">
56
+ <label for="hub-visibility-input">Visibility</label>
57
+ <select id="hub-visibility-input">
58
+ <option value="public" ${currentVisibility === 'public' ? 'selected' : ''}>Public — visible to everyone on this instance</option>
59
+ <option value="restricted" ${currentVisibility === 'restricted' ? 'selected' : ''}>Restricted — only added members can see it</option>
60
+ </select>
61
+ </div>
62
+ <div id="hub-members-section" style="display:${currentVisibility === 'restricted' ? 'block' : 'none'}">
63
+ <div class="field">
64
+ <label>Members</label>
65
+ <div id="hub-members-list" class="members-list"><em style="color:var(--text-muted);font-size:13px">Loading…</em></div>
66
+ </div>
67
+ <div class="field">
68
+ <label for="hub-add-member-select">Add member</label>
69
+ <div style="display:flex;gap:8px;align-items:center">
70
+ <select id="hub-add-member-select" style="flex:1"><option value="">— select a user —</option></select>
71
+ <button id="hub-add-member-btn" type="button" class="btn-primary" style="white-space:nowrap">Add</button>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ <div class="modal-footer">
76
+ <button class="btn-ghost" id="hub-cancel-btn" type="button">Cancel</button>
77
+ <button class="btn-primary" id="hub-save-btn" type="button">Save</button>
78
+ </div>
79
+ <div class="modal-danger-zone">
80
+ <p>Deleting this hub removes it and all its channels permanently.</p>
81
+ <button class="btn-danger" id="hub-delete-btn" type="button">Delete hub</button>
82
+ </div>
83
+ `
84
+
85
+ const visibilitySelect = container.querySelector('#hub-visibility-input')
86
+ const membersSection = container.querySelector('#hub-members-section')
87
+
88
+ // Show/hide members section when visibility changes
89
+ visibilitySelect.addEventListener('change', () => {
90
+ const isRestricted = visibilitySelect.value === 'restricted'
91
+ membersSection.style.display = isRestricted ? 'block' : 'none'
92
+ if (isRestricted) loadMembers()
93
+ })
94
+
95
+ let membersLoaded = false
96
+ function loadMembers() {
97
+ if (membersLoaded) return
98
+ membersLoaded = true
99
+
100
+ let members = []
101
+ let allUsers = []
102
+
103
+ function render() {
104
+ const memberIds = new Set(members.map(m => m.user_id))
105
+
106
+ const listEl = container.querySelector('#hub-members-list')
107
+ if (members.length === 0) {
108
+ listEl.innerHTML = '<em style="color:var(--text-muted);font-size:13px">No members yet.</em>'
109
+ } else {
110
+ listEl.innerHTML = members.map(m => `
111
+ <div class="member-row" data-user-id="${escHtml(m.user_id)}" style="display:flex;align-items:center;justify-content:space-between;padding:4px 0">
112
+ <span>${escHtml(m.display_name ?? m.handle ?? m.user_id)}</span>
113
+ <button type="button" class="btn-ghost btn-sm hub-remove-member" data-user-id="${escHtml(m.user_id)}" style="font-size:12px">Remove</button>
114
+ </div>
115
+ `).join('')
116
+ listEl.querySelectorAll('.hub-remove-member').forEach(btn => {
117
+ btn.addEventListener('click', () => {
118
+ const uid = btn.dataset.userId
119
+ ws.send({ t: 'hub.remove_member', body: { hub_id: hubId, user_id: uid } })
120
+ members = members.filter(m => m.user_id !== uid)
121
+ render()
122
+ })
123
+ })
124
+ }
125
+
126
+ const sel = container.querySelector('#hub-add-member-select')
127
+ const available = allUsers.filter(u => !memberIds.has(u.user_id))
128
+ sel.innerHTML = '<option value="">— select a user —</option>' +
129
+ available.map(u => `<option value="${escHtml(u.user_id)}">${escHtml(u.display_name ?? u.handle)}</option>`).join('')
130
+ }
131
+
132
+ ws.once('hub.list_members_result', ({ hub_id, members: m }) => {
133
+ if (hub_id !== hubId) return
134
+ members = m
135
+ render()
136
+ })
137
+ ws.once('user.list_result', ({ users }) => {
138
+ allUsers = users
139
+ render()
140
+ })
141
+
142
+ ws.send({ t: 'hub.list_members', body: { hub_id: hubId } })
143
+ ws.send({ t: 'user.list', body: {} })
144
+ }
145
+
146
+ // Load immediately if already restricted
147
+ if (currentVisibility === 'restricted') loadMembers()
148
+
149
+ container.querySelector('#hub-add-member-btn').addEventListener('click', () => {
150
+ const sel = container.querySelector('#hub-add-member-select')
151
+ const userId = sel.value
152
+ if (!userId) return
153
+ ws.send({ t: 'hub.add_member', body: { hub_id: hubId, user_id: userId } })
154
+ membersLoaded = false
155
+ loadMembers()
156
+ })
157
+
158
+ container.querySelector('#hub-cancel-btn').addEventListener('click', dismiss)
159
+ container.querySelector('#hub-save-btn').addEventListener('click', () => {
160
+ const name = container.querySelector('#hub-name-input').value.trim()
161
+ if (!name) return
162
+ ws.send({ t: 'hub.update', body: {
163
+ hub_id: hubId,
164
+ name,
165
+ description: container.querySelector('#hub-desc-input').value.trim() || null,
166
+ visibility: visibilitySelect.value,
167
+ } })
168
+ dismiss()
169
+ })
170
+ container.querySelector('#hub-delete-btn').addEventListener('click', () => {
171
+ ws.send({ t: 'hub.delete', body: { hub_id: hubId } })
172
+ dismiss()
173
+ })
174
+ requestAnimationFrame(() => container.querySelector('#hub-name-input')?.focus())
175
+ }
176
+
177
+ function buildChannelForm(container, { channelId, channelName, channelTopic, channelVisibility, ws, dismiss }) {
178
+ const currentVisibility = channelVisibility ?? 'public'
179
+ container.innerHTML = `
180
+ <div class="field">
181
+ <label for="ch-name-input">Channel name</label>
182
+ <input id="ch-name-input" type="text" value="${escHtml(channelName)}" maxlength="80" autocomplete="off">
183
+ </div>
184
+ <div class="field">
185
+ <label for="ch-topic-input">Topic <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
186
+ <input id="ch-topic-input" type="text" value="${escHtml(channelTopic ?? '')}" maxlength="240" autocomplete="off">
187
+ </div>
188
+ <div class="field">
189
+ <label for="ch-visibility-input">Visibility</label>
190
+ <select id="ch-visibility-input">
191
+ <option value="public" ${currentVisibility === 'public' ? 'selected' : ''}>Public — visible to everyone in this hub</option>
192
+ <option value="private" ${currentVisibility === 'private' ? 'selected' : ''}>Private — only added members can see it</option>
193
+ </select>
194
+ </div>
195
+ <div id="ch-members-section" style="display:${currentVisibility === 'private' ? 'block' : 'none'}">
196
+ <div class="field">
197
+ <label>Members</label>
198
+ <div id="ch-members-list" class="members-list"><em style="color:var(--text-muted);font-size:13px">Loading…</em></div>
199
+ </div>
200
+ <div class="field">
201
+ <label for="ch-add-member-select">Add member</label>
202
+ <div style="display:flex;gap:8px;align-items:center">
203
+ <select id="ch-add-member-select" style="flex:1"><option value="">— select a user —</option></select>
204
+ <button id="ch-add-member-btn" type="button" class="btn-primary" style="white-space:nowrap">Add</button>
205
+ </div>
206
+ </div>
207
+ </div>
208
+ <div class="modal-footer">
209
+ <button class="btn-ghost" id="ch-cancel-btn" type="button">Cancel</button>
210
+ <button class="btn-primary" id="ch-save-btn" type="button">Save</button>
211
+ </div>
212
+ <div class="modal-danger-zone">
213
+ <p>Deleting this channel removes all its messages permanently.</p>
214
+ <button class="btn-danger" id="ch-delete-btn" type="button">Delete channel</button>
215
+ </div>
216
+ `
217
+
218
+ const visibilitySelect = container.querySelector('#ch-visibility-input')
219
+ const membersSection = container.querySelector('#ch-members-section')
220
+
221
+ // Show/hide members section when visibility changes
222
+ visibilitySelect.addEventListener('change', () => {
223
+ const isPrivate = visibilitySelect.value === 'private'
224
+ membersSection.style.display = isPrivate ? 'block' : 'none'
225
+ if (isPrivate) loadMembers()
226
+ })
227
+
228
+ // Load members and user list for the picker
229
+ let membersLoaded = false
230
+ function loadMembers() {
231
+ if (membersLoaded) return
232
+ membersLoaded = true
233
+
234
+ let members = []
235
+ let allUsers = []
236
+
237
+ function render() {
238
+ const memberIds = new Set(members.map(m => m.user_id))
239
+
240
+ // Render current members list
241
+ const listEl = container.querySelector('#ch-members-list')
242
+ if (members.length === 0) {
243
+ listEl.innerHTML = '<em style="color:var(--text-muted);font-size:13px">No members yet.</em>'
244
+ } else {
245
+ listEl.innerHTML = members.map(m => `
246
+ <div class="member-row" data-user-id="${escHtml(m.user_id)}" style="display:flex;align-items:center;justify-content:space-between;padding:4px 0">
247
+ <span>${escHtml(m.display_name ?? m.handle)} <span style="color:var(--text-muted);font-size:12px">${escHtml(m.role)}</span></span>
248
+ ${m.role !== 'owner' ? `<button type="button" class="btn-ghost btn-sm ch-remove-member" data-user-id="${escHtml(m.user_id)}" style="font-size:12px">Remove</button>` : ''}
249
+ </div>
250
+ `).join('')
251
+ listEl.querySelectorAll('.ch-remove-member').forEach(btn => {
252
+ btn.addEventListener('click', () => {
253
+ const uid = btn.dataset.userId
254
+ ws.send({ t: 'channel.remove_member', body: { channel_id: channelId, user_id: uid } })
255
+ members = members.filter(m => m.user_id !== uid)
256
+ render()
257
+ })
258
+ })
259
+ }
260
+
261
+ // Render add-member picker (exclude existing members)
262
+ const sel = container.querySelector('#ch-add-member-select')
263
+ const available = allUsers.filter(u => !memberIds.has(u.user_id))
264
+ sel.innerHTML = '<option value="">— select a user —</option>' +
265
+ available.map(u => `<option value="${escHtml(u.user_id)}">${escHtml(u.display_name ?? u.handle)}</option>`).join('')
266
+ }
267
+
268
+ ws.once('channel.list_members_result', ({ channel_id, members: m }) => {
269
+ if (channel_id !== channelId) return
270
+ members = m
271
+ render()
272
+ })
273
+ ws.once('user.list_result', ({ users }) => {
274
+ allUsers = users
275
+ render()
276
+ })
277
+
278
+ ws.send({ t: 'channel.list_members', body: { channel_id: channelId } })
279
+ ws.send({ t: 'user.list', body: {} })
280
+ }
281
+
282
+ // Load immediately if already private
283
+ if (currentVisibility === 'private') loadMembers()
284
+
285
+ container.querySelector('#ch-add-member-btn').addEventListener('click', () => {
286
+ const sel = container.querySelector('#ch-add-member-select')
287
+ const userId = sel.value
288
+ if (!userId) return
289
+ ws.send({ t: 'channel.add_member', body: { channel_id: channelId, user_id: userId } })
290
+ // Optimistically reload
291
+ membersLoaded = false
292
+ loadMembers()
293
+ })
294
+
295
+ container.querySelector('#ch-cancel-btn').addEventListener('click', dismiss)
296
+ container.querySelector('#ch-save-btn').addEventListener('click', () => {
297
+ const name = container.querySelector('#ch-name-input').value.trim()
298
+ if (!name) return
299
+ ws.send({ t: 'channel.update', body: {
300
+ channel_id: channelId,
301
+ name,
302
+ topic: container.querySelector('#ch-topic-input').value.trim() || null,
303
+ visibility: visibilitySelect.value,
304
+ } })
305
+ dismiss()
306
+ })
307
+ container.querySelector('#ch-delete-btn').addEventListener('click', () => {
308
+ ws.send({ t: 'channel.delete', body: { channel_id: channelId } })
309
+ dismiss()
310
+ })
311
+ requestAnimationFrame(() => container.querySelector('#ch-name-input')?.focus())
312
+ }
313
+
314
+ function buildCreateHubForm(container, { ws, dismiss }) {
315
+ container.innerHTML = `
316
+ <div class="field">
317
+ <label for="new-hub-name">Hub name</label>
318
+ <input id="new-hub-name" type="text" placeholder="e.g. Engineering" maxlength="80" autocomplete="off">
319
+ </div>
320
+ <div class="field">
321
+ <label for="new-hub-desc">Description <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
322
+ <input id="new-hub-desc" type="text" maxlength="240" autocomplete="off">
323
+ </div>
324
+ <div class="field">
325
+ <label for="new-hub-visibility">Visibility</label>
326
+ <select id="new-hub-visibility">
327
+ <option value="public">Public — visible to everyone on this instance</option>
328
+ <option value="restricted">Restricted — only added members can see it</option>
329
+ </select>
330
+ </div>
331
+ <div class="modal-footer">
332
+ <button class="btn-ghost" id="new-hub-cancel" type="button">Cancel</button>
333
+ <button class="btn-primary" id="new-hub-save" type="button">Create</button>
334
+ </div>
335
+ `
336
+ container.querySelector('#new-hub-cancel').addEventListener('click', dismiss)
337
+ container.querySelector('#new-hub-save').addEventListener('click', () => {
338
+ const name = container.querySelector('#new-hub-name').value.trim()
339
+ if (!name) return
340
+ ws.send({ t: 'hub.create', body: {
341
+ name,
342
+ description: container.querySelector('#new-hub-desc').value.trim() || null,
343
+ visibility: container.querySelector('#new-hub-visibility').value,
344
+ } })
345
+ dismiss()
346
+ })
347
+ requestAnimationFrame(() => container.querySelector('#new-hub-name')?.focus())
348
+ }
349
+
350
+ function buildCreateChannelForm(container, { hubId, ws, dismiss }) {
351
+ container.innerHTML = `
352
+ <div class="field">
353
+ <label for="new-ch-name">Channel name</label>
354
+ <input id="new-ch-name" type="text" placeholder="e.g. general" maxlength="80" autocomplete="off">
355
+ </div>
356
+ <div class="field">
357
+ <label for="new-ch-topic">Topic <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
358
+ <input id="new-ch-topic" type="text" maxlength="240" autocomplete="off">
359
+ </div>
360
+ <div class="field">
361
+ <label for="new-ch-visibility">Visibility</label>
362
+ <select id="new-ch-visibility">
363
+ <option value="public">Public — visible to everyone in this hub</option>
364
+ <option value="private">Private — only added members can see it</option>
365
+ </select>
366
+ </div>
367
+ <div class="modal-footer">
368
+ <button class="btn-ghost" id="new-ch-cancel" type="button">Cancel</button>
369
+ <button class="btn-primary" id="new-ch-save" type="button">Create</button>
370
+ </div>
371
+ `
372
+ container.querySelector('#new-ch-cancel').addEventListener('click', dismiss)
373
+ container.querySelector('#new-ch-save').addEventListener('click', () => {
374
+ const name = container.querySelector('#new-ch-name').value.trim()
375
+ if (!name) return
376
+ ws.send({ t: 'channel.create', body: {
377
+ hub_id: hubId,
378
+ kind: 'text',
379
+ name,
380
+ topic: container.querySelector('#new-ch-topic').value.trim() || null,
381
+ visibility: container.querySelector('#new-ch-visibility').value,
382
+ } })
383
+ dismiss()
384
+ })
385
+ requestAnimationFrame(() => container.querySelector('#new-ch-name')?.focus())
386
+ }
387
+
388
+ // ── Sheet/modal openers ───────────────────────────────────────────────────────
389
+
390
+ function openCreateHubModal(ws) {
391
+ showModal({
392
+ title: 'New hub',
393
+ build: body => buildCreateHubForm(body, { ws, dismiss: dismissModal })
394
+ })
395
+ }
396
+
397
+ function openCreateHubSheet(ws) {
398
+ showActionSheet({ label: 'New hub', items: [] })
399
+ buildCreateHubForm(getItemsContainer(), { ws, dismiss: dismissSheet })
400
+ }
401
+
402
+ function openHubSheet(hubId, hubName, hubDescription, hubVisibility, ws) {
403
+ showActionSheet({
404
+ label: hubName,
405
+ items: [
406
+ { label: 'Edit hub', action: () => {
407
+ showActionSheet({ label: 'Edit hub', items: [] })
408
+ buildHubForm(getItemsContainer(), { hubId, hubName, hubDescription, hubVisibility, ws, dismiss: dismissSheet })
409
+ }
410
+ },
411
+ { label: 'Create channel', action: () => {
412
+ showActionSheet({ label: `New channel in ${hubName}`, items: [] })
413
+ buildCreateChannelForm(getItemsContainer(), { hubId, ws, dismiss: dismissSheet })
414
+ }
415
+ },
416
+ { label: 'Delete hub', danger: true, action: () => {
417
+ showActionSheet({
418
+ label: `Delete "${hubName}"?`,
419
+ items: [
420
+ { label: 'Cancel', action: () => {} },
421
+ { label: 'Delete hub', danger: true, action: () => {
422
+ ws.send({ t: 'hub.delete', body: { hub_id: hubId } })
423
+ dismissSheet()
424
+ }
425
+ }
426
+ ]
427
+ })
428
+ }
429
+ }
430
+ ]
431
+ })
432
+ }
433
+
434
+ function openHubModal(hubId, hubName, hubDescription, hubVisibility, ws) {
435
+ showModal({
436
+ title: 'Hub settings',
437
+ build: body => buildHubForm(body, { hubId, hubName, hubDescription, hubVisibility, ws, dismiss: dismissModal })
438
+ })
439
+ }
440
+
441
+ function openCreateChannelModal(hubId, hubName, ws) {
442
+ showModal({
443
+ title: `New channel in ${hubName}`,
444
+ build: body => buildCreateChannelForm(body, { hubId, ws, dismiss: dismissModal })
445
+ })
446
+ }
447
+
448
+ function openChannelSheet(channelId, channelName, channelTopic, channelVisibility, ws) {
449
+ showActionSheet({
450
+ label: channelName,
451
+ items: [
452
+ { label: 'Edit channel', action: () => {
453
+ showActionSheet({ label: 'Edit channel', items: [] })
454
+ buildChannelForm(getItemsContainer(), { channelId, channelName, channelTopic, channelVisibility, ws, dismiss: dismissSheet })
455
+ }
456
+ },
457
+ { label: 'Delete channel', danger: true, action: () => {
458
+ showActionSheet({
459
+ label: `Delete "#${channelName}"?`,
460
+ items: [
461
+ { label: 'Cancel', action: () => {} },
462
+ { label: 'Delete channel', danger: true, action: () => {
463
+ ws.send({ t: 'channel.delete', body: { channel_id: channelId } })
464
+ dismissSheet()
465
+ }
466
+ }
467
+ ]
468
+ })
469
+ }
470
+ }
471
+ ]
472
+ })
473
+ }
474
+
475
+ function openChannelModal(channelId, channelName, channelTopic, channelVisibility, ws) {
476
+ showModal({
477
+ title: 'Channel settings',
478
+ build: body => buildChannelForm(body, { channelId, channelName, channelTopic, channelVisibility, ws, dismiss: dismissModal })
479
+ })
480
+ }
481
+
482
+ // ── Drag-and-drop channel reordering (desktop only) ──────────────────────────
483
+
484
+ function attachDragHandlers(sidebarEl, { ws, hubs }) {
485
+ // data-key is stripped from template nodes by rdbljs (clearNodeForTemplate removes it),
486
+ // so dataset.key is undefined on re-rendered items. Use getItemContext instead —
487
+ // rdbljs stores { item, key } in a WeakMap on every entry node and it survives re-renders.
488
+ let dragSrcId = null
489
+ let dragHubId = null
490
+
491
+ sidebarEl.addEventListener('dragstart', e => {
492
+ const li = e.target.closest('.channel-item')
493
+ if (!li) return
494
+ const ctx = getItemContext(li)
495
+ dragSrcId = ctx?.key ?? null
496
+ dragHubId = ctx?.item?.hub_id ?? null
497
+ if (!dragSrcId) return
498
+ li.classList.add('dragging')
499
+ e.dataTransfer.effectAllowed = 'move'
500
+ })
501
+
502
+ function clearDropIndicators() {
503
+ sidebarEl.querySelectorAll('.drop-before, .drop-after').forEach(el => {
504
+ el.classList.remove('drop-before', 'drop-after')
505
+ })
506
+ }
507
+
508
+ function insertBefore(e, li) {
509
+ return e.clientY < li.getBoundingClientRect().top + li.offsetHeight / 2
510
+ }
511
+
512
+ sidebarEl.addEventListener('dragend', e => {
513
+ const li = e.target.closest('.channel-item')
514
+ if (li) li.classList.remove('dragging')
515
+ clearDropIndicators()
516
+ dragSrcId = null
517
+ dragHubId = null
518
+ })
519
+
520
+ sidebarEl.addEventListener('dragover', e => {
521
+ const li = e.target.closest('.channel-item')
522
+ if (!li || !dragSrcId) return
523
+ const ctx = getItemContext(li)
524
+ if (!ctx || ctx.key === dragSrcId || ctx.item?.hub_id !== dragHubId) return
525
+ e.preventDefault()
526
+ e.dataTransfer.dropEffect = 'move'
527
+ clearDropIndicators()
528
+ li.classList.add(insertBefore(e, li) ? 'drop-before' : 'drop-after')
529
+ })
530
+
531
+ sidebarEl.addEventListener('dragleave', e => {
532
+ const li = e.target.closest('.channel-item')
533
+ if (li) { li.classList.remove('drop-before', 'drop-after') }
534
+ })
535
+
536
+ sidebarEl.addEventListener('drop', e => {
537
+ const targetLi = e.target.closest('.channel-item')
538
+ if (!targetLi || !dragSrcId || !dragHubId) return
539
+ const targetCtx = getItemContext(targetLi)
540
+ const targetChannelId = targetCtx?.key
541
+ if (!targetChannelId || targetCtx.item?.hub_id !== dragHubId) return
542
+ e.preventDefault()
543
+ clearDropIndicators()
544
+
545
+ const hub = hubs().find(h => h.hub_id === dragHubId)
546
+ if (!hub) return
547
+
548
+ const ids = (hub.channels ?? []).map(c => c.channel_id)
549
+ const fromIdx = ids.indexOf(dragSrcId)
550
+ const toIdx = ids.indexOf(targetChannelId)
551
+ if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
552
+
553
+ const before = insertBefore(e, targetLi)
554
+ ids.splice(fromIdx, 1)
555
+ // After removing the source, find target's new index and insert accordingly
556
+ const newToIdx = ids.indexOf(targetChannelId)
557
+ ids.splice(before ? newToIdx : newToIdx + 1, 0, dragSrcId)
558
+
559
+ ws.send({ t: 'channel.reorder', body: { hub_id: dragHubId, channel_ids: ids } })
560
+ })
561
+ }
562
+
563
+ // ── Drag-and-drop hub reordering (desktop only) ───────────────────────────────
564
+
565
+ function attachHubDragHandlers(sidebarEl, { ws, hubs }) {
566
+ // Uses the same WeakMap-based getItemContext pattern as channel drag handlers.
567
+ // Hub drag targets are details.hub-header elements; draggable="true" is set in the template.
568
+ let dragSrcHubId = null
569
+
570
+ function clearDropIndicators() {
571
+ sidebarEl.querySelectorAll('.hub-header.drop-before, .hub-header.drop-after').forEach(el => {
572
+ el.classList.remove('drop-before', 'drop-after')
573
+ })
574
+ }
575
+
576
+ function insertBefore(e, details) {
577
+ return e.clientY < details.getBoundingClientRect().top + details.offsetHeight / 2
578
+ }
579
+
580
+ sidebarEl.addEventListener('dragstart', e => {
581
+ const details = e.target.closest('.hub-header')
582
+ if (!details) return
583
+ // Ignore if the drag actually started on a channel item inside the hub
584
+ if (e.target.closest('.channel-item')) return
585
+ const ctx = getItemContext(details)
586
+ dragSrcHubId = ctx?.key ?? null
587
+ if (!dragSrcHubId) return
588
+ details.classList.add('dragging')
589
+ e.dataTransfer.effectAllowed = 'move'
590
+ e.stopPropagation()
591
+ })
592
+
593
+ sidebarEl.addEventListener('dragend', e => {
594
+ const details = e.target.closest('.hub-header')
595
+ if (details) details.classList.remove('dragging')
596
+ clearDropIndicators()
597
+ dragSrcHubId = null
598
+ })
599
+
600
+ sidebarEl.addEventListener('dragover', e => {
601
+ if (!dragSrcHubId) return
602
+ // Ignore drags over channel items — those belong to the channel drag handler
603
+ if (e.target.closest('.channel-item')) return
604
+ const details = e.target.closest('.hub-header')
605
+ if (!details) return
606
+ const ctx = getItemContext(details)
607
+ if (!ctx || ctx.key === dragSrcHubId) return
608
+ e.preventDefault()
609
+ e.dataTransfer.dropEffect = 'move'
610
+ clearDropIndicators()
611
+ details.classList.add(insertBefore(e, details) ? 'drop-before' : 'drop-after')
612
+ })
613
+
614
+ sidebarEl.addEventListener('dragleave', e => {
615
+ if (e.target.closest('.channel-item')) return
616
+ const details = e.target.closest('.hub-header')
617
+ if (details) details.classList.remove('drop-before', 'drop-after')
618
+ })
619
+
620
+ sidebarEl.addEventListener('drop', e => {
621
+ if (!dragSrcHubId) return
622
+ if (e.target.closest('.channel-item')) return
623
+ const targetDetails = e.target.closest('.hub-header')
624
+ if (!targetDetails) return
625
+ const targetCtx = getItemContext(targetDetails)
626
+ const targetHubId = targetCtx?.key
627
+ if (!targetHubId || targetHubId === dragSrcHubId) return
628
+ e.preventDefault()
629
+ clearDropIndicators()
630
+
631
+ const ids = hubs().map(h => h.hub_id)
632
+ const fromIdx = ids.indexOf(dragSrcHubId)
633
+ const toIdx = ids.indexOf(targetHubId)
634
+ if (fromIdx === -1 || toIdx === -1) return
635
+
636
+ const before = insertBefore(e, targetDetails)
637
+ ids.splice(fromIdx, 1)
638
+ const newToIdx = ids.indexOf(targetHubId)
639
+ ids.splice(before ? newToIdx : newToIdx + 1, 0, dragSrcHubId)
640
+
641
+ ws.send({ t: 'hub.reorder', body: { hub_ids: ids } })
642
+ })
643
+ }
644
+
645
+ // ── File-drop onto channel links ─────────────────────────────────────────────
646
+
647
+ function attachFileDropHandlers(sidebarEl, { ws }) {
648
+ let hoverTimer = null
649
+ let hoverTarget = null
650
+
651
+ function clearHover() {
652
+ clearTimeout(hoverTimer)
653
+ hoverTimer = null
654
+ if (hoverTarget) {
655
+ hoverTarget.classList.remove('file-drop-hover')
656
+ hoverTarget = null
657
+ }
658
+ }
659
+
660
+ function showToast(text) {
661
+ const toast = document.createElement('div')
662
+ toast.className = 'sidebar-toast'
663
+ toast.textContent = text
664
+ sidebarEl.appendChild(toast)
665
+ setTimeout(() => toast.remove(), 3000)
666
+ }
667
+
668
+ sidebarEl.addEventListener('dragover', e => {
669
+ const link = e.target.closest('.channel-link')
670
+ if (!link) { clearHover(); return }
671
+ // Only act on file drags (not channel-reordering drags)
672
+ if (!e.dataTransfer.types.includes('Files')) return
673
+ e.preventDefault()
674
+ e.dataTransfer.dropEffect = 'copy'
675
+
676
+ if (link !== hoverTarget) {
677
+ clearHover()
678
+ hoverTarget = link
679
+ hoverTimer = setTimeout(() => link.classList.add('file-drop-hover'), 600)
680
+ }
681
+ })
682
+
683
+ sidebarEl.addEventListener('dragleave', e => {
684
+ if (hoverTarget && !hoverTarget.contains(e.relatedTarget)) clearHover()
685
+ })
686
+
687
+ sidebarEl.addEventListener('drop', async e => {
688
+ const link = e.target.closest('.channel-link')
689
+ clearHover()
690
+ if (!link) return
691
+ if (!e.dataTransfer.types.includes('Files')) return
692
+ e.preventDefault()
693
+ e.stopPropagation()
694
+
695
+ const targetChannelId = link.dataset.channelId
696
+ const targetChannelName = link.dataset.channelName ?? targetChannelId
697
+ if (!targetChannelId) return
698
+
699
+ const files = [...e.dataTransfer.files]
700
+ if (files.length === 0) return
701
+
702
+ // Join the channel first (needed for delivery cursor)
703
+ ws.send({ t: 'channel.join', body: { channel_id: targetChannelId } })
704
+
705
+ const uploaded = []
706
+ for (const file of files) {
707
+ const formData = new FormData()
708
+ formData.append('file', file)
709
+ formData.append('channel_id', targetChannelId)
710
+ try {
711
+ const res = await fetch(`${window.__BASE_PATH__}/api/uploads`, { method: 'POST', body: formData })
712
+ if (!res.ok) {
713
+ const body = await res.json().catch(() => ({}))
714
+ showToast(`Upload failed: ${body.error ?? res.statusText}`)
715
+ continue
716
+ }
717
+ const a = await res.json()
718
+ uploaded.push({
719
+ upload_id: a.upload_id,
720
+ url: a.url,
721
+ filename: a.original_name,
722
+ mime_type: a.mime_type,
723
+ size_bytes: a.size_bytes,
724
+ })
725
+ } catch {
726
+ showToast('Upload failed: network error')
727
+ }
728
+ }
729
+
730
+ if (uploaded.length === 0) return
731
+
732
+ ws.send({
733
+ t: 'msg.send',
734
+ body: {
735
+ channel_id: targetChannelId,
736
+ text: '',
737
+ client_msg_id: `drop_${Date.now()}`,
738
+ attachments: uploaded,
739
+ }
740
+ })
741
+
742
+ showToast(`Sent to #${targetChannelName}`)
743
+ })
744
+ }
745
+
746
+ // ── Attach management handlers (event delegation — safe across re-renders) ───
747
+
748
+ function attachManagementHandlers(sidebarEl, { ws, hubs }) {
749
+ // Gear buttons and add button: single delegated click listener
750
+ sidebarEl.addEventListener('click', e => {
751
+ // Hub gear
752
+ if (e.target.closest('.btn-hub-gear')) {
753
+ e.stopPropagation()
754
+ const summary = e.target.closest('.hub-name')
755
+ const hubId = summary?.dataset.hubId
756
+ if (!hubId) return
757
+ const hub = hubs().find(h => h.hub_id === hubId)
758
+ openHubModal(hubId, hub?.name ?? '', hub?.description ?? null, hub?.visibility ?? 'public', ws)
759
+ return
760
+ }
761
+
762
+ // Hub add-channel button
763
+ if (e.target.closest('.btn-hub-add')) {
764
+ e.stopPropagation()
765
+ const summary = e.target.closest('.hub-name')
766
+ const hubId = summary?.dataset.hubId
767
+ if (!hubId) return
768
+ const hub = hubs().find(h => h.hub_id === hubId)
769
+ openCreateChannelModal(hubId, hub?.name ?? '', ws)
770
+ return
771
+ }
772
+
773
+ // Channel gear
774
+ if (e.target.closest('.btn-channel-gear')) {
775
+ e.preventDefault()
776
+ const li = e.target.closest('.channel-item')
777
+ const link = li?.querySelector('.channel-link')
778
+ const channelId = link?.dataset.channelId
779
+ if (!channelId) return
780
+ let ch = null
781
+ for (const hub of hubs()) {
782
+ ch = (hub.channels ?? []).find(c => c.channel_id === channelId)
783
+ if (ch) break
784
+ }
785
+ openChannelModal(channelId, ch?.name ?? '', ch?.topic ?? null, ch?.visibility ?? 'public', ws)
786
+ return
787
+ }
788
+ })
789
+
790
+ // Touch: long-press delegation on the sidebar
791
+ if (isTouch()) {
792
+ addLongPress(sidebarEl, (e) => {
793
+ const target = e.target ?? e.touches?.[0]?.target
794
+
795
+ // Long-press on hub summary
796
+ const summary = target?.closest?.('.hub-name')
797
+ if (summary) {
798
+ const hubId = summary.dataset.hubId
799
+ if (!hubId) return
800
+ const hub = hubs().find(h => h.hub_id === hubId)
801
+ openHubSheet(hubId, hub?.name ?? '', hub?.description ?? null, hub?.visibility ?? 'public', ws)
802
+ return
803
+ }
804
+
805
+ // Long-press on channel link
806
+ const link = target?.closest?.('.channel-link')
807
+ if (link) {
808
+ const channelId = link.dataset.channelId
809
+ if (!channelId) return
810
+ let ch = null
811
+ for (const hub of hubs()) {
812
+ ch = (hub.channels ?? []).find(c => c.channel_id === channelId)
813
+ if (ch) break
814
+ }
815
+ openChannelSheet(channelId, ch?.name ?? '', ch?.topic ?? null, ch?.visibility ?? 'public', ws)
816
+ }
817
+ })
818
+ }
819
+ }
820
+
821
+ // ── Navigation after deletion ─────────────────────────────────────────────────
822
+
823
+ function navigateAfterDeletion(remainingHubs) {
824
+ const first = remainingHubs.flatMap(h => h.channels ?? []).find(Boolean)
825
+ window.location.href = first ? `${window.__BASE_PATH__}/channels/${first.channel_id}` : `${window.__BASE_PATH__}/`
826
+ }
827
+
828
+ // ── Island ────────────────────────────────────────────────────────────────────
829
+
830
+ function populateDmsFromDom(root) {
831
+ return Array.from(root.querySelectorAll('.dm-item')).map(li => ({
832
+ channel_id: li.dataset.channelId,
833
+ with_user: { display_name: li.querySelector('.dm-name')?.textContent.trim() ?? '' }
834
+ }))
835
+ }
836
+
837
+ export default function SidebarIsland(root) {
838
+ let currentChannelId = root.dataset.currentchannel
839
+ const currentUserId = root.dataset.userid ?? null
840
+ const hubs = signal(populateFromDom(root))
841
+ const dms = signal(populateDmsFromDom(root))
842
+ // channelId → true if this channel has an unread @mention
843
+ const mentionedChannels = signal(new Set())
844
+ // channelId → true if this channel has an unread urgent (@mention + now priority)
845
+ const urgentChannels = signal(new Set())
846
+ const dmUnread = signal(new Set())
847
+ const ws = new WsClient(`${window.__BASE_PATH__}/ws`)
848
+
849
+ ws.on('hub.created', ({ hub }) => {
850
+ hubs.set([...hubs(), { ...hub, channels: [] }])
851
+ })
852
+
853
+ ws.on('hub.updated', ({ hub }) => {
854
+ hubs.set(hubs().map(h => h.hub_id === hub.hub_id ? { ...h, ...hub } : h))
855
+ })
856
+
857
+ ws.on('notification.mention', ({ channel_id, priority }) => {
858
+ if (channel_id === currentChannelId) return // already viewing — no dot needed
859
+ if (priority === 'now') {
860
+ const next = new Set(urgentChannels())
861
+ next.add(channel_id)
862
+ urgentChannels.set(next)
863
+ } else {
864
+ const next = new Set(mentionedChannels())
865
+ next.add(channel_id)
866
+ mentionedChannels.set(next)
867
+ }
868
+ })
869
+
870
+ ws.on('notification.digest', ({ channels }) => {
871
+ if (!channels?.length) return
872
+ const nextMentioned = new Set(mentionedChannels())
873
+ const nextUrgent = new Set(urgentChannels())
874
+ for (const c of channels) {
875
+ if (c.urgent) nextUrgent.add(c.channel_id)
876
+ else if (c.mentions > 0) nextMentioned.add(c.channel_id)
877
+ }
878
+ mentionedChannels.set(nextMentioned)
879
+ urgentChannels.set(nextUrgent)
880
+ })
881
+
882
+ ws.on('hub.member_added', ({ hub_id, user_id }) => {
883
+ if (user_id !== currentUserId) return
884
+ // Current user was added to a hub — fetch the hub list and merge new hubs in
885
+ ws.once('hub.list_result', ({ hubs: serverHubs }) => {
886
+ const existing = new Set(hubs().map(h => h.hub_id))
887
+ const newHubs = serverHubs.filter(h => !existing.has(h.hub_id))
888
+ if (newHubs.length > 0) {
889
+ hubs.set([...hubs(), ...newHubs.map(h => ({ ...h, channels: [] }))])
890
+ }
891
+ })
892
+ ws.send({ t: 'hub.list', body: {} })
893
+ })
894
+
895
+ ws.on('hub.member_removed', ({ hub_id, user_id }) => {
896
+ if (user_id !== currentUserId) return
897
+ // Current user was removed from a hub — drop it from the signal
898
+ const removedHub = hubs().find(h => h.hub_id === hub_id)
899
+ const affectsCurrentChannel = (removedHub?.channels ?? []).some(c => c.channel_id === currentChannelId)
900
+ hubs.set(hubs().filter(h => h.hub_id !== hub_id))
901
+ if (affectsCurrentChannel) navigateAfterDeletion(hubs())
902
+ })
903
+
904
+ ws.on('dm.list_result', ({ dms: list }) => {
905
+ dms.set(list)
906
+ })
907
+
908
+ ws.on('dm.opened', ({ channel_id, with_user, notify_only }) => {
909
+ // Add to DM list if not already present
910
+ if (!dms().some(d => d.channel_id === channel_id)) {
911
+ dms.set([{ channel_id, with_user }, ...dms()])
912
+ }
913
+ if (notify_only) {
914
+ // Target user — show unread dot, don't navigate
915
+ const next = new Set(dmUnread()); next.add(channel_id); dmUnread.set(next)
916
+ } else {
917
+ // Initiating user — navigate to the DM channel
918
+ window.location.href = `${window.__BASE_PATH__}/channels/${channel_id}`
919
+ }
920
+ })
921
+
922
+ // Highlight DM list item when a message arrives in a DM channel not currently open
923
+ ws.on('msg.event', ({ channel_id }) => {
924
+ if (channel_id === currentChannelId) return
925
+ if (!dms().some(d => d.channel_id === channel_id)) return
926
+ const next = new Set(dmUnread()); next.add(channel_id); dmUnread.set(next)
927
+ })
928
+
929
+ // Clear dot when user clicks a DM link
930
+ root.addEventListener('click', e => {
931
+ const link = e.target.closest('.dm-link')
932
+ if (!link) return
933
+ const id = link.dataset.channelId
934
+ if (id) { const next = new Set(dmUnread()); next.delete(id); dmUnread.set(next) }
935
+ })
936
+
937
+ // Track current channel across SPA navigations and clear DM dot when landing on a DM
938
+ document.addEventListener('chatpanel:navigated', e => {
939
+ const { channelId: newId } = e.detail
940
+ const prevId = currentChannelId
941
+ currentChannelId = newId
942
+ // Only create new objects for hubs/channels that actually changed (prev active → new
943
+ // active). Returning the same object reference for unchanged hubs lets rdbljs skip
944
+ // destroying and recreating those DOM rows, which prevents a brief empty-sidebar flash
945
+ // on iOS during the CSS transition animation.
946
+ if (prevId !== newId) {
947
+ hubs.set(hubs().map(h => {
948
+ const channels = h.channels ?? []
949
+ const affected = channels.some(c => c.channel_id === newId || c.channel_id === prevId)
950
+ if (!affected) return h // same reference — rdbljs skips this hub entirely
951
+ return {
952
+ ...h,
953
+ channels: channels.map(c => {
954
+ if (c.channel_id !== newId && c.channel_id !== prevId) return c
955
+ const base = (c.className ?? 'channel-item').replace(/\bactive\b/g, '').trim()
956
+ const next = c.channel_id === newId ? `${base} active` : base
957
+ return next === c.className ? c : { ...c, className: next }
958
+ })
959
+ }
960
+ }))
961
+ }
962
+ if (dmUnread().has(newId)) {
963
+ const next = new Set(dmUnread()); next.delete(newId); dmUnread.set(next)
964
+ }
965
+ })
966
+
967
+ ws.on('hub.reordered', ({ hubs: updated }) => {
968
+ // Merge server-authoritative order into local state, preserving loaded channel arrays
969
+ const channelMap = new Map(hubs().map(h => [h.hub_id, h.channels]))
970
+ hubs.set(updated.map(h => ({ ...h, channels: channelMap.get(h.hub_id) ?? [] })))
971
+ })
972
+
973
+ ws.on('hub.deleted', ({ hub_id }) => {
974
+ const deletedHub = hubs().find(h => h.hub_id === hub_id)
975
+ const affectsCurrentChannel = (deletedHub?.channels ?? []).some(c => c.channel_id === currentChannelId)
976
+ hubs.set(hubs().filter(h => h.hub_id !== hub_id))
977
+ if (affectsCurrentChannel) navigateAfterDeletion(hubs())
978
+ })
979
+
980
+ ws.on('channel.created', ({ channel }) => {
981
+ hubs.set(hubs().map(h => {
982
+ if (h.hub_id !== channel.hub_id) return h
983
+ const channels = [...(h.channels ?? []), {
984
+ ...channel,
985
+ url: `${window.__BASE_PATH__}/channels/${channel.channel_id}`,
986
+ label: `# ${channel.name}`,
987
+ className: 'channel-item'
988
+ }]
989
+ return { ...h, channels }
990
+ }))
991
+ })
992
+
993
+ ws.on('channel.updated', ({ channel }) => {
994
+ hubs.set(hubs().map(h => {
995
+ if (h.hub_id !== channel.hub_id) return h
996
+ return {
997
+ ...h,
998
+ channels: (h.channels ?? []).map(c =>
999
+ c.channel_id === channel.channel_id
1000
+ ? { ...c, ...channel, label: `# ${channel.name}` }
1001
+ : c
1002
+ )
1003
+ }
1004
+ }))
1005
+ })
1006
+
1007
+ ws.on('channel.reordered', ({ hub_id, channels }) => {
1008
+ hubs.set(hubs().map(h => {
1009
+ if (h.hub_id !== hub_id) return h
1010
+ const channelMap = new Map((h.channels ?? []).map(c => [c.channel_id, c]))
1011
+ const reordered = channels.map(c => ({ ...channelMap.get(c.channel_id), ...c, url: `${window.__BASE_PATH__}/channels/${c.channel_id}` }))
1012
+ return { ...h, channels: reordered }
1013
+ }))
1014
+ })
1015
+
1016
+ ws.on('channel.deleted', ({ channel_id }) => {
1017
+ const wasCurrentChannel = channel_id === currentChannelId
1018
+ hubs.set(hubs().map(h => ({
1019
+ ...h,
1020
+ channels: (h.channels ?? []).filter(c => c.channel_id !== channel_id)
1021
+ })))
1022
+ if (wasCurrentChannel) navigateAfterDeletion(hubs())
1023
+ })
1024
+
1025
+ // Channel link clicks: dispatch channelnavigated + mobile sidebar hide + clear mention dot
1026
+ root.addEventListener('click', e => {
1027
+ const link = e.target.closest('.channel-link')
1028
+ if (!link) return
1029
+ const clickedChannelId = link.dataset.channelId
1030
+ document.dispatchEvent(new CustomEvent('channelnavigated', {
1031
+ detail: { channelId: clickedChannelId }
1032
+ }))
1033
+ // Clear mention/urgent dot for this channel
1034
+ if (clickedChannelId && (mentionedChannels().has(clickedChannelId) || urgentChannels().has(clickedChannelId))) {
1035
+ const nextMentioned = new Set(mentionedChannels())
1036
+ const nextUrgent = new Set(urgentChannels())
1037
+ nextMentioned.delete(clickedChannelId)
1038
+ nextUrgent.delete(clickedChannelId)
1039
+ mentionedChannels.set(nextMentioned)
1040
+ urgentChannels.set(nextUrgent)
1041
+ }
1042
+ if (window.matchMedia('(max-width: 700px)').matches) {
1043
+ document.body.classList.remove('sidebar-open')
1044
+ }
1045
+ })
1046
+
1047
+ // Mention dot management.
1048
+ // Uses data-mention / data-urgent attributes instead of CSS classes so that
1049
+ // rdbljs className re-renders (el.className = ...) never wipe the dot state.
1050
+ function updateMentionDots() {
1051
+ const mentioned = mentionedChannels()
1052
+ const urgent = urgentChannels()
1053
+ root.querySelectorAll('.channel-item').forEach(li => {
1054
+ const link = li.querySelector('.channel-link')
1055
+ const channelId = link?.dataset.channelId
1056
+ if (!channelId) return
1057
+ if (urgent.has(channelId)) {
1058
+ li.dataset.urgent = ''
1059
+ delete li.dataset.mention
1060
+ } else if (mentioned.has(channelId)) {
1061
+ li.dataset.mention = ''
1062
+ delete li.dataset.urgent
1063
+ } else {
1064
+ delete li.dataset.mention
1065
+ delete li.dataset.urgent
1066
+ }
1067
+ })
1068
+ }
1069
+ effect(() => updateMentionDots())
1070
+
1071
+ // DMs list rendering — driven by dms + dmUnread signals via effect below
1072
+ const dmListEl = root.querySelector('#dm-list')
1073
+ function renderDms() {
1074
+ if (!dmListEl) return
1075
+ const list = dms()
1076
+ const unread = dmUnread()
1077
+ if (list.length === 0) {
1078
+ dmListEl.innerHTML = '<li class="dm-empty" style="padding:4px 8px;color:var(--text-muted);font-size:13px">No messages yet.</li>'
1079
+ return
1080
+ }
1081
+ dmListEl.innerHTML = list.map(d => {
1082
+ const name = escHtml(d.with_user?.display_name ?? d.channel_id)
1083
+ const selected = d.channel_id === currentChannelId ? ' dm-selected' : ''
1084
+ const mentionAttr = unread.has(d.channel_id) ? ' data-mention=""' : ''
1085
+ return `<li class="dm-item${selected}"${mentionAttr} data-channel-id="${escHtml(d.channel_id)}">
1086
+ <a class="dm-link channel-link" href="${window.__BASE_PATH__}/channels/${escHtml(d.channel_id)}" data-channel-id="${escHtml(d.channel_id)}">
1087
+ <span class="dm-name">${name}</span>
1088
+ </a>
1089
+ </li>`
1090
+ }).join('')
1091
+ }
1092
+ effect(() => renderDms())
1093
+
1094
+ // Fetch DM list as soon as the socket opens — the connection is already
1095
+ // authenticated via the session cookie at upgrade time, so no hello needed.
1096
+ ws.on('open', () => ws.send({ t: 'dm.list', body: {} }))
1097
+
1098
+ // New hub button
1099
+ root.querySelector('#btn-new-hub')?.addEventListener('click', () => {
1100
+ isTouch() ? openCreateHubSheet(ws) : openCreateHubModal(ws)
1101
+ })
1102
+
1103
+ // Wire management handlers (event delegation — attached once, survives re-renders)
1104
+ attachManagementHandlers(root, { ws, hubs })
1105
+
1106
+ // Wire drag-and-drop reordering (desktop only — touch uses action sheet)
1107
+ attachDragHandlers(root, { ws, hubs })
1108
+ attachHubDragHandlers(root, { ws, hubs })
1109
+
1110
+ // Wire file-drop onto channel links
1111
+ attachFileDropHandlers(root, { ws })
1112
+
1113
+ // ── Web Push subscription ─────────────────────────────────────────────────
1114
+ // Browsers require a user gesture to call Notification.requestPermission().
1115
+ // Strategy: show a small "Enable notifications" button in the sidebar footer.
1116
+ // It appears when VAPID is configured + browser supports push + permission is
1117
+ // 'default'. Clicking it (user gesture) requests permission then subscribes.
1118
+ const vapidKey = root.dataset.vapidKey ?? ''
1119
+ if (vapidKey && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window) {
1120
+ function vapidKeyToUint8Array(b64url) {
1121
+ const padded = b64url + '==='.slice((b64url.length + 3) % 4)
1122
+ const b64 = padded.replace(/-/g, '+').replace(/_/g, '/')
1123
+ return Uint8Array.from(atob(b64), c => c.charCodeAt(0))
1124
+ }
1125
+
1126
+ async function subscribeToPush(swReg) {
1127
+ try {
1128
+ const sub = await swReg.pushManager.subscribe({
1129
+ userVisibleOnly: true,
1130
+ applicationServerKey: vapidKeyToUint8Array(vapidKey),
1131
+ })
1132
+ ws.send({ t: 'push.subscribe', body: { subscription: sub.toJSON() } })
1133
+ } catch { /* subscribe failed or user blocked — ignore */ }
1134
+ }
1135
+
1136
+ // Register SW once and hold a reference for later use.
1137
+ // After registration, probe pushManager to confirm push actually works in
1138
+ // this browser context — Safari Private windows register a SW fine but
1139
+ // silently refuse push subscriptions, so we skip the button there.
1140
+ let swReg = null
1141
+ navigator.serviceWorker.register(`${window.__BASE_PATH__}/sw.js`, { scope: `${window.__BASE_PATH__}/` })
1142
+ .then(async reg => {
1143
+ swReg = reg
1144
+ // Confirm push is functional by checking the subscription API
1145
+ try { await reg.pushManager.getSubscription() } catch {
1146
+ return // push not available in this context (e.g. Safari Private)
1147
+ }
1148
+ if (Notification.permission === 'granted') subscribeToPush(reg)
1149
+ if (Notification.permission !== 'granted') showEnableButton()
1150
+ })
1151
+ .catch(() => { /* http in dev, or SW blocked entirely */ })
1152
+
1153
+ // Inject a small "Enable notifications" button into the sidebar footer.
1154
+ // This is the only place we can legally call requestPermission() — inside
1155
+ // a synchronous click handler (user gesture).
1156
+ //
1157
+ // Three states:
1158
+ // 'default' → clickable, opens browser permission dialog
1159
+ // 'denied' → non-clickable, tells user to update browser settings
1160
+ // 'granted' → button is removed entirely
1161
+ let enableBtn = null
1162
+
1163
+ function updateEnableButton() {
1164
+ if (!enableBtn) return
1165
+ const perm = Notification.permission
1166
+ if (perm === 'granted') {
1167
+ enableBtn.remove()
1168
+ enableBtn = null
1169
+ return
1170
+ }
1171
+ if (perm === 'denied') {
1172
+ enableBtn.textContent = '🔕 Notifications blocked in browser settings'
1173
+ enableBtn.dataset.blocked = 'true'
1174
+ } else {
1175
+ enableBtn.textContent = '🔔 Enable notifications'
1176
+ delete enableBtn.dataset.blocked
1177
+ }
1178
+ }
1179
+
1180
+ function showEnableButton() {
1181
+ if (enableBtn) return
1182
+ enableBtn = document.createElement('button')
1183
+ enableBtn.className = 'btn-enable-push'
1184
+ enableBtn.type = 'button'
1185
+ enableBtn.addEventListener('click', async () => {
1186
+ if (enableBtn.dataset.blocked) return // denied — browser won't show dialog
1187
+ const permission = await Notification.requestPermission()
1188
+ updateEnableButton()
1189
+ if (permission === 'granted' && swReg) subscribeToPush(swReg)
1190
+ })
1191
+ root.querySelector('.sidebar-footer')?.prepend(enableBtn)
1192
+ updateEnableButton()
1193
+ }
1194
+ }
1195
+
1196
+ return { hubs }
1197
+ }