@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,1770 @@
1
+ /**
2
+ * call.js — combined chat + WebRTC island.
3
+ *
4
+ * Mounted on: <section class="chat-panel" island="/client/islands/call.js" …>
5
+ *
6
+ * Handles:
7
+ * - Chat (messages, composer) — same as the old chat.js island
8
+ * - WebRTC calls: start, join, leave, tile grid, mini-bar, sidebar badge
9
+ *
10
+ * WebRTC patterns ported from v1 RtcCallService:
11
+ * - negotiationInFlight / negotiationQueued per-peer serialisation
12
+ * - Pre-allocated transceiver slots (1 audio + 2 video: camera + screen)
13
+ * - replaceTrack() + direction toggle rather than addTrack() for renegotiation
14
+ * - waitForStableSignaling() before every offer
15
+ * - ICE candidate queue (pendingIceByPeer) until remote description is set
16
+ * - New joiner is offerer toward all existing peers; existing peers are answerers
17
+ */
18
+ import { signal, effect, computed, Context } from '@devchitchat/rdbljs'
19
+ import { WsClient } from '../ws.js'
20
+ import { patchSettings } from '../settings-sync.js'
21
+ import { navigateTo } from '../router.js'
22
+ import { escHtml, utcDateKey, formatDateLabel, makeDateSeparator, applyInlineRenderingToTextNodes, renderAttachment, makeMessageEl } from '../shared/messages.js'
23
+ import { RtcPeerManager } from '../rtc-peer-manager.js'
24
+ import { CATEGORIES, EMOJI_NAMES } from '../emoji-data.js'
25
+ import { showActionSheet, dismiss as dismissActionSheet, getItemsContainer } from '../action-sheet.js'
26
+ import { addLongPress } from '../long-press.js'
27
+
28
+ export default function CallIsland(root) {
29
+ // ── Data from HTML ─────────────────────────────────────────────────────────
30
+ let channelId = root.dataset.id
31
+ let channelKind = root.dataset.kind ?? 'text'
32
+ const userId = root.dataset.userId
33
+ const userHandle = root.dataset.userHandle
34
+ const seedSeq = parseInt(root.dataset.seedSeq ?? '0', 10)
35
+ let oldestSeq = parseInt(root.dataset.seedFirstSeq ?? '0', 10)
36
+ let loadingMore = false
37
+
38
+ // ── DOM refs ───────────────────────────────────────────────────────────────
39
+ const messages = document.getElementById('messages')
40
+ const sentinelEl = document.getElementById('load-more-sentinel')
41
+ const tilePanelEl = document.getElementById('tile-panel')
42
+ const tileGridEl = document.getElementById('tile-grid')
43
+ const callStatusEl = document.getElementById('call-status')
44
+ const callStatusInfo = document.getElementById('call-status-info')
45
+ const callStatusAvatars = document.getElementById('call-status-avatars')
46
+ const callControlsEl = document.getElementById('call-controls-bar')
47
+ const peerCountEl = document.getElementById('call-peer-count')
48
+ const btnStartCall = document.getElementById('btn-start-call')
49
+ const btnJoinCall = document.getElementById('btn-join-call')
50
+ const btnLeaveCall = document.getElementById('btn-leave-call')
51
+ const ctrlMic = document.getElementById('ctrl-mic')
52
+ const ctrlCam = document.getElementById('ctrl-cam')
53
+ const ctrlScreen = document.getElementById('ctrl-screen')
54
+ const ctrlDevices = document.getElementById('ctrl-devices')
55
+
56
+ // Mini-bar (lives in sidebar footer — shared across channel navigations)
57
+ const miniBarEl = document.getElementById('call-mini-bar')
58
+ const miniBarName = document.getElementById('mini-bar-channel-name')
59
+ const miniBarMic = document.getElementById('mini-bar-mic')
60
+ const miniBarReturn = document.getElementById('mini-bar-return')
61
+ const miniBarLeave = document.getElementById('mini-bar-leave')
62
+
63
+ // ── WebSocket ──────────────────────────────────────────────────────────────
64
+ const ws = new WsClient(`${window.__BASE_PATH__}/ws`)
65
+
66
+ // ── Chat signals ───────────────────────────────────────────────────────────
67
+ const draft = signal('')
68
+ const channelName = signal(root.dataset.name ?? '')
69
+ const channelTopic = signal(root.dataset.topic ?? '')
70
+ let afterSeq = seedSeq
71
+
72
+ // ── @mention picker state ──────────────────────────────────────────────────
73
+ let channelMembers = [] // [{ user_id, handle, display_name }] — non-bot users
74
+ let channelBots = [] // [{ user_id, handle, display_name }] — bot users
75
+ let mentionFiltered = [] // current filtered subset
76
+ let mentionStart = -1 // index of '@' in textarea.value
77
+ let mentionSelIdx = 0 // keyboard-selected row
78
+
79
+ // ── Call state ─────────────────────────────────────────────────────────────
80
+ const inCall = signal(false)
81
+ const callIdSig = signal(null) // active call_id in this channel (may exist before we join)
82
+ let callChannelId = null // channel where the active call lives (may differ from channelId after navigation)
83
+ const selfPeerId = signal(null)
84
+ const micMuted = signal(false)
85
+ const camOff = signal(false)
86
+ const screenSharing = signal(false)
87
+ let pinnedPeerId = null
88
+
89
+ // ── Local media streams ────────────────────────────────────────────────────
90
+ let audioStream = null // local mic
91
+ let videoStream = null // local camera
92
+ let screenStream = null // local screen share
93
+ let iceServers = [{ urls: 'stun:stun.l.google.com:19302' }]
94
+
95
+ // ── RTC peer manager ───────────────────────────────────────────────────────
96
+ const rtcManager = new RtcPeerManager({
97
+ iceServers,
98
+ getLocalStreams: () => ({ audio: audioStream, video: videoStream, screen: screenStream }),
99
+ handlers: {
100
+ onOffer: (peerId, sdp) => ws.send({ t: 'rtc.offer', body: { call_id: callIdSig(), to_peer_id: peerId, sdp } }),
101
+ onAnswer: (peerId, sdp) => ws.send({ t: 'rtc.answer', body: { call_id: callIdSig(), to_peer_id: peerId, sdp } }),
102
+ onIceCandidate: (peerId, candidate) => ws.send({ t: 'rtc.ice', body: { call_id: callIdSig(), to_peer_id: peerId, candidate } }),
103
+ onTrack: (peerId, tileId, stream, label) => { _renderTile(tileId, stream, false, label); _ensureRemoteAudio(stream, peerId) },
104
+ onAudio: (peerId, stream) => _ensureRemoteAudio(stream, peerId),
105
+ onPeerClosed: (peerId) => {
106
+ tileGridEl?.querySelectorAll(`[data-peer^="${peerId}"]`).forEach(t => t.remove())
107
+ document.querySelectorAll(`audio[data-peer-id="${peerId}"]`).forEach(a => { a.srcObject = null; a.remove() })
108
+ _updateTileLayout()
109
+ },
110
+ },
111
+ })
112
+
113
+ // ── Device state ───────────────────────────────────────────────────────────
114
+ const DEVICES_KEY = 'devchitchat_devices'
115
+ let availableDevices = { cameras: [], mics: [] }
116
+ let activeCameraId = null
117
+ let activeMicId = null
118
+
119
+ function loadSavedDevices() {
120
+ try { return JSON.parse(localStorage.getItem(DEVICES_KEY) ?? '{}') } catch { return {} }
121
+ }
122
+ function saveDevices(patch) {
123
+ localStorage.setItem(DEVICES_KEY, JSON.stringify({ ...loadSavedDevices(), ...patch }))
124
+ }
125
+ async function refreshDevices() {
126
+ const devices = await navigator.mediaDevices.enumerateDevices()
127
+ availableDevices = {
128
+ cameras: devices.filter(d => d.kind === 'videoinput'),
129
+ mics: devices.filter(d => d.kind === 'audioinput'),
130
+ }
131
+ return availableDevices
132
+ }
133
+
134
+ // ── Reaction bar ───────────────────────────────────────────────────────────
135
+
136
+ function renderReactionBar(article, reactions, msgId) {
137
+ const bar = article.querySelector('.reaction-bar')
138
+ if (!bar) return
139
+ bar.innerHTML = reactions.map(r => `
140
+ <button class="reaction-pill${r.reacted ? ' reacted' : ''}"
141
+ data-emoji="${escHtml(r.emoji)}" data-msg-id="${escHtml(msgId)}"
142
+ type="button" title="${r.count} reaction${r.count !== 1 ? 's' : ''}">
143
+ ${r.emoji} <span class="reaction-count">${r.count}</span>
144
+ </button>`).join('') +
145
+ `<button class="reaction-add" data-msg-id="${escHtml(msgId)}" type="button"
146
+ title="Add reaction" aria-label="Add reaction">+</button>`
147
+ }
148
+
149
+ // ── Hydrate seed message attachments ──────────────────────────────────────
150
+ // Seed messages are SSR'd without attachment HTML. Process data-attachments now.
151
+ // Called on initial mount and again after each SPA navigation morph.
152
+ function hydrateSeedMessages() {
153
+ const articles = Array.from(messages.querySelectorAll('article.message'))
154
+ let prevDateKey = null
155
+
156
+ for (const article of articles) {
157
+ if (article.dataset.hydrated) continue
158
+ article.dataset.hydrated = '1'
159
+
160
+ // Add dm-trigger to non-self sender handles; add … actions button to own messages
161
+ const handle = article.querySelector('.message-handle[data-user-id]')
162
+ if (handle && handle.dataset.userId !== userId) {
163
+ handle.classList.add('dm-trigger')
164
+ handle.title = 'Send a direct message'
165
+ } else if (article.dataset.userId === userId && !article.querySelector('.btn-msg-actions')) {
166
+ const btn = document.createElement('button')
167
+ btn.className = 'btn-msg-actions btn-icon'
168
+ btn.type = 'button'
169
+ btn.title = 'Message actions'
170
+ btn.textContent = '…'
171
+ article.appendChild(btn)
172
+ }
173
+
174
+ // Apply inline rendering (URLs, @mentions) to server-rendered message text.
175
+ // Walk text nodes instead of replacing innerHTML so that <a> tags already
176
+ // rendered server-side (e.g. from markdown link syntax) are preserved.
177
+ const textEl = article.querySelector('.message-text')
178
+ if (textEl) applyInlineRenderingToTextNodes(textEl, { userHandle })
179
+
180
+ // Inject attachment HTML for seed messages that have attachments_json
181
+ const raw = article.dataset.attachments
182
+ if (raw) {
183
+ let attachments
184
+ try { attachments = JSON.parse(raw) } catch { attachments = null }
185
+ if (Array.isArray(attachments) && attachments.length > 0) {
186
+ attachments.forEach(a => article.insertAdjacentHTML('beforeend', renderAttachment(a)))
187
+ }
188
+ }
189
+
190
+ // Hydrate reaction bar for seed messages
191
+ const rawReactions = article.dataset.reactions
192
+ const msgId = article.dataset.msgId
193
+ if (msgId) {
194
+ let reactions = []
195
+ if (rawReactions) {
196
+ try { reactions = JSON.parse(rawReactions) } catch { reactions = [] }
197
+ }
198
+ renderReactionBar(article, reactions, msgId)
199
+ }
200
+
201
+ // Date separator before this article if date changed
202
+ const ts = parseInt(article.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
203
+ if (ts) {
204
+ const dateKey = utcDateKey(ts)
205
+ if (prevDateKey && dateKey !== prevDateKey) {
206
+ article.before(makeDateSeparator(dateKey))
207
+ }
208
+ prevDateKey = dateKey
209
+ }
210
+ }
211
+ }
212
+ hydrateSeedMessages()
213
+ // Scroll to the bottom instantly on first load — requestAnimationFrame gives
214
+ // the browser one layout cycle to settle flex heights before we measure
215
+ // scrollHeight. behavior:'instant' bypasses scroll-behavior:smooth so there
216
+ // is no visible animation from top to bottom on mount.
217
+ requestAnimationFrame(() => messages.scrollTo({ top: messages.scrollHeight, behavior: 'instant' }))
218
+
219
+ // ── Load-more sentinel + pagination ───────────────────────────────────────
220
+
221
+ function showSentinel() { if (sentinelEl) sentinelEl.hidden = false }
222
+ function hideSentinel() { if (sentinelEl) sentinelEl.hidden = true }
223
+
224
+ if (root.dataset.seedHasMore === 'true') showSentinel()
225
+
226
+ const loadMoreObserver = new IntersectionObserver(entries => {
227
+ if (!entries[0].isIntersecting || loadingMore || oldestSeq <= 1) return
228
+ loadingMore = true
229
+ ws.send({ t: 'msg.list', body: { channel_id: channelId, before_seq: oldestSeq } })
230
+ }, { root: messages, threshold: 0.1 })
231
+
232
+ if (sentinelEl) loadMoreObserver.observe(sentinelEl)
233
+
234
+ // ── Date separator helpers ─────────────────────────────────────────────────
235
+ // utcDateKey, formatDateLabel, makeDateSeparator imported from shared/messages.js
236
+
237
+ function prependMessages(msgs) {
238
+ const prevHeight = messages.scrollHeight
239
+ const fragment = document.createDocumentFragment()
240
+ let prevDate = null
241
+
242
+ const firstExisting = messages.querySelector('article.message')
243
+ if (firstExisting) {
244
+ const ts = parseInt(firstExisting.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
245
+ if (ts) prevDate = utcDateKey(ts)
246
+ }
247
+
248
+ for (const m of msgs) {
249
+ const dateKey = utcDateKey(m.ts)
250
+ if (prevDate && dateKey !== prevDate) {
251
+ fragment.appendChild(makeDateSeparator(prevDate))
252
+ }
253
+ fragment.appendChild(makeMessageEl(m, { userId, userHandle }))
254
+ prevDate = dateKey
255
+ }
256
+
257
+ // If last prepended message is different day from first existing, insert separator before existing
258
+ if (firstExisting && prevDate) {
259
+ const existingTs = parseInt(firstExisting.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
260
+ const existingDateKey = existingTs ? utcDateKey(existingTs) : null
261
+ if (existingDateKey && prevDate !== existingDateKey) {
262
+ messages.insertBefore(makeDateSeparator(existingDateKey), firstExisting)
263
+ }
264
+ }
265
+
266
+ sentinelEl ? sentinelEl.after(fragment) : messages.prepend(fragment)
267
+ messages.scrollTop += messages.scrollHeight - prevHeight
268
+ }
269
+
270
+ // ── @mention picker ────────────────────────────────────────────────────────
271
+
272
+ const mentionPickerEl = document.createElement('div')
273
+ mentionPickerEl.id = 'mention-picker'
274
+ mentionPickerEl.className = 'mention-picker'
275
+ mentionPickerEl.hidden = true
276
+ root.querySelector('.composer')?.prepend(mentionPickerEl)
277
+
278
+ function openPicker(filtered, start) {
279
+ mentionFiltered = filtered
280
+ mentionStart = start
281
+ mentionSelIdx = 0
282
+ renderPicker()
283
+ }
284
+
285
+ function closePicker() {
286
+ mentionFiltered = []
287
+ mentionStart = -1
288
+ mentionPickerEl.hidden = true
289
+ }
290
+
291
+ function renderPicker() {
292
+ if (mentionFiltered.length === 0) { closePicker(); return }
293
+ mentionPickerEl.innerHTML = mentionFiltered.map((m, i) => `
294
+ <button class="mention-option${i === mentionSelIdx ? ' selected' : ''}"
295
+ data-idx="${i}" type="button">
296
+ <span class="mention-option-name">${escHtml(m.display_name || m.handle)}</span>
297
+ <span class="mention-option-handle">@${escHtml(m.handle)}</span>
298
+ </button>`).join('')
299
+ mentionPickerEl.hidden = false
300
+ }
301
+
302
+ function selectMention(member) {
303
+ if (!member) return
304
+ const textarea = root.querySelector('#message-input')
305
+ if (!textarea) return
306
+ const cursor = textarea.selectionStart
307
+ const val = textarea.value
308
+ const insert = `@${member.handle} `
309
+ textarea.value = val.substring(0, mentionStart) + insert + val.substring(cursor)
310
+ draft.set(textarea.value)
311
+ const pos = mentionStart + insert.length
312
+ textarea.setSelectionRange(pos, pos)
313
+ closePicker()
314
+ textarea.focus()
315
+ }
316
+
317
+ mentionPickerEl.addEventListener('mousedown', e => {
318
+ // mousedown instead of click so the textarea doesn't lose focus first
319
+ e.preventDefault()
320
+ const btn = e.target.closest('.mention-option')
321
+ if (!btn) return
322
+ selectMention(mentionFiltered[parseInt(btn.dataset.idx, 10)])
323
+ })
324
+
325
+ function handleComposerInput(e) {
326
+ const textarea = e.target
327
+ const cursor = textarea.selectionStart
328
+ const before = textarea.value.substring(0, cursor)
329
+ // Match a bare @ or @partial-handle with no space, anchored to end of text-so-far
330
+ const match = before.match(/@([a-zA-Z0-9_.-]*)$/)
331
+ if (!match) { closePicker(); return }
332
+ const query = match[1].toLowerCase()
333
+ const start = cursor - match[0].length
334
+ const filtered = [...channelMembers, ...channelBots]
335
+ .filter(m =>
336
+ m.handle.toLowerCase().startsWith(query) ||
337
+ (m.display_name ?? '').toLowerCase().startsWith(query)
338
+ )
339
+ .slice(0, 8)
340
+ if (filtered.length === 0) { closePicker(); return }
341
+ openPicker(filtered, start)
342
+ }
343
+
344
+ root.querySelector('#message-input')?.addEventListener('input', handleComposerInput)
345
+
346
+ // ── Chat: connect + join channel ───────────────────────────────────────────
347
+
348
+ ws.on('open', () => {
349
+ ws.send({ t: 'hello', body: { client: 'devchitchat', resume: { session_token: null } } })
350
+ })
351
+
352
+ ws.on('hello_ack', () => {
353
+ ws.send({ t: 'channel.join', body: { channel_id: channelId } })
354
+ })
355
+
356
+ ws.on('channel.joined', () => {
357
+ if (afterSeq > 0) {
358
+ ws.send({ t: 'msg.list', body: { channel_id: channelId, after_seq: afterSeq } })
359
+ }
360
+ if (channelMembers.length === 0) {
361
+ ws.send({ t: 'user.list', body: {} })
362
+ ws.send({ t: 'bot.list', body: {} })
363
+ }
364
+ })
365
+
366
+ ws.on('user.list_result', ({ users }) => {
367
+ channelMembers = (users ?? []).filter(m => m.handle)
368
+ })
369
+
370
+ ws.on('bot.list_result', ({ bots }) => {
371
+ channelBots = (bots ?? []).filter(b => b.handle)
372
+ })
373
+
374
+ ws.on('msg.list_result', ({ messages: msgs, next_after_seq, has_more, direction }) => {
375
+ if (direction === 'before') {
376
+ if (msgs.length === 0) {
377
+ hideSentinel()
378
+ if (sentinelEl) loadMoreObserver.unobserve(sentinelEl)
379
+ loadingMore = false
380
+ return
381
+ }
382
+ prependMessages(msgs)
383
+ if (msgs[0].seq < oldestSeq) oldestSeq = msgs[0].seq
384
+ if (!has_more || oldestSeq <= 1) {
385
+ hideSentinel()
386
+ if (sentinelEl) loadMoreObserver.unobserve(sentinelEl)
387
+ }
388
+ loadingMore = false
389
+ return
390
+ }
391
+ // after_seq catch-up path
392
+ msgs.forEach(appendMessage)
393
+ if (msgs.length) afterSeq = msgs[msgs.length - 1].seq
394
+ else if (next_after_seq != null) afterSeq = next_after_seq
395
+ })
396
+
397
+ ws.on('msg.event', (body) => {
398
+ if (body.channel_id !== channelId) return
399
+ appendMessage(body)
400
+ afterSeq = body.seq
401
+ })
402
+
403
+ ws.on('msg.deleted', ({ msg_id }) => {
404
+ const article = messages.querySelector(`[data-msg-id="${msg_id}"]`)
405
+ if (article) article.remove()
406
+ })
407
+
408
+ ws.on('msg.edited', ({ msg_id, text, edited_at, rendered_text }) => {
409
+ const article = messages.querySelector(`[data-msg-id="${msg_id}"]`)
410
+ if (!article) return
411
+ const textEl = article.querySelector('.message-text')
412
+ if (textEl) textEl.innerHTML = sanitizeHtml(rendered_text)
413
+ article.dataset.rawText = text
414
+ article.dataset.editedAt = edited_at
415
+ const timeEl = article.querySelector('.message-time')
416
+ if (timeEl) {
417
+ let editedSpan = timeEl.querySelector('.message-edited')
418
+ if (!editedSpan) {
419
+ editedSpan = document.createElement('span')
420
+ editedSpan.className = 'message-edited'
421
+ editedSpan.textContent = '(edited)'
422
+ timeEl.appendChild(editedSpan)
423
+ }
424
+ }
425
+ })
426
+
427
+ ws.on('reaction.event', ({ msg_id, reactions }) => {
428
+ const article = messages.querySelector(`[data-msg-id="${msg_id}"]`)
429
+ if (article) renderReactionBar(article, reactions ?? [], msg_id)
430
+ })
431
+
432
+ ws.on('channel.updated', (body) => {
433
+ if (body.channel?.channel_id !== channelId) return
434
+ channelName.set(body.channel.name)
435
+ channelTopic.set(body.channel.topic ?? '')
436
+ document.title = `#${body.channel.name} — devchitchat`
437
+ })
438
+
439
+ // ── Chat: composer ─────────────────────────────────────────────────────────
440
+
441
+ // Pending attachments: [{ upload_id, url, original_name, mime_type, size_bytes }]
442
+ let pendingAttachments = []
443
+
444
+ const composerEl = root.querySelector('.composer')
445
+ const textareaEl = root.querySelector('#message-input')
446
+
447
+ // Attachment chips container — injected above the textarea
448
+ const chipsEl = document.createElement('div')
449
+ chipsEl.className = 'attachment-chips'
450
+ composerEl?.insertBefore(chipsEl, textareaEl)
451
+
452
+ // Hidden file input
453
+ const fileInputEl = document.createElement('input')
454
+ fileInputEl.type = 'file'
455
+ fileInputEl.multiple = true
456
+ fileInputEl.style.display = 'none'
457
+ fileInputEl.setAttribute('aria-hidden', 'true')
458
+ composerEl?.appendChild(fileInputEl)
459
+
460
+ // Attach-file button (paperclip)
461
+ const btnAttachEl = document.createElement('button')
462
+ btnAttachEl.type = 'button'
463
+ btnAttachEl.className = 'btn-attach btn-icon'
464
+ btnAttachEl.title = 'Attach file'
465
+ btnAttachEl.setAttribute('aria-label', 'Attach file')
466
+ btnAttachEl.innerHTML = '📎'
467
+ // Insert before the send button
468
+ const btnSendEl = composerEl?.querySelector('.btn-send')
469
+ if (btnSendEl && composerEl) composerEl.insertBefore(btnAttachEl, btnSendEl)
470
+
471
+ btnAttachEl.addEventListener('click', () => fileInputEl.click())
472
+ fileInputEl.addEventListener('change', () => {
473
+ uploadFiles([...fileInputEl.files])
474
+ fileInputEl.value = ''
475
+ })
476
+
477
+ // Drag-and-drop onto the textarea
478
+ let dropOverlayEl = null
479
+
480
+ function ensureDropOverlay() {
481
+ if (dropOverlayEl) return dropOverlayEl
482
+ dropOverlayEl = document.createElement('div')
483
+ dropOverlayEl.className = 'drop-overlay'
484
+ dropOverlayEl.textContent = 'Drop to attach'
485
+ composerEl?.appendChild(dropOverlayEl)
486
+ return dropOverlayEl
487
+ }
488
+
489
+ composerEl?.addEventListener('dragover', e => {
490
+ if (!e.dataTransfer.types.includes('Files')) return
491
+ e.preventDefault()
492
+ ensureDropOverlay().hidden = false
493
+ })
494
+
495
+ composerEl?.addEventListener('dragleave', e => {
496
+ if (composerEl.contains(e.relatedTarget)) return
497
+ if (dropOverlayEl) dropOverlayEl.hidden = true
498
+ })
499
+
500
+ composerEl?.addEventListener('drop', e => {
501
+ e.preventDefault()
502
+ if (dropOverlayEl) dropOverlayEl.hidden = true
503
+ const files = [...(e.dataTransfer.files ?? [])]
504
+ if (files.length > 0) uploadFiles(files)
505
+ })
506
+
507
+ // Paste image from clipboard (screenshots, copied images)
508
+ textareaEl?.addEventListener('paste', e => {
509
+ const items = [...(e.clipboardData?.items ?? [])]
510
+ const imageFiles = items
511
+ .filter(item => item.kind === 'file' && item.type.startsWith('image/'))
512
+ .map(item => {
513
+ const file = item.getAsFile()
514
+ if (!file) return null
515
+ if (!file.name) {
516
+ const ext = item.type.split('/')[1] ?? 'png'
517
+ return new File([file], `paste-${Date.now()}.${ext}`, { type: item.type })
518
+ }
519
+ return file
520
+ })
521
+ .filter(Boolean)
522
+ if (imageFiles.length === 0) return
523
+ e.preventDefault()
524
+ uploadFiles(imageFiles)
525
+ })
526
+
527
+ async function uploadFiles(files) {
528
+ for (const file of files) {
529
+ await uploadOneFile(file, channelId)
530
+ }
531
+ }
532
+
533
+ async function uploadOneFile(file, targetChannelId) {
534
+ const formData = new FormData()
535
+ formData.append('file', file)
536
+ formData.append('channel_id', targetChannelId)
537
+
538
+ let res
539
+ try {
540
+ res = await fetch(`${window.__BASE_PATH__}/api/uploads`, { method: 'POST', body: formData })
541
+ } catch {
542
+ showComposerError(`Upload failed: network error`)
543
+ return null
544
+ }
545
+
546
+ if (!res.ok) {
547
+ const body = await res.json().catch(() => ({}))
548
+ showComposerError(`Upload failed: ${body.error ?? res.statusText}`)
549
+ return null
550
+ }
551
+
552
+ const attachment = await res.json()
553
+ pendingAttachments.push(attachment)
554
+ renderChips()
555
+ return attachment
556
+ }
557
+
558
+ function renderChips() {
559
+ chipsEl.innerHTML = pendingAttachments.map((a, i) => `
560
+ <span class="attachment-chip" data-index="${i}">
561
+ <span class="attachment-chip-name">${escHtml(a.original_name)}</span>
562
+ <button type="button" class="attachment-chip-remove" data-index="${i}" aria-label="Remove ${escHtml(a.original_name)}">×</button>
563
+ </span>
564
+ `).join('')
565
+ chipsEl.hidden = pendingAttachments.length === 0
566
+ }
567
+
568
+ chipsEl.addEventListener('click', e => {
569
+ const btn = e.target.closest('.attachment-chip-remove')
570
+ if (!btn) return
571
+ const idx = parseInt(btn.dataset.index, 10)
572
+ pendingAttachments.splice(idx, 1)
573
+ renderChips()
574
+ })
575
+
576
+ function showComposerError(msg) {
577
+ const chip = document.createElement('span')
578
+ chip.className = 'attachment-chip attachment-chip-error'
579
+ chip.textContent = msg
580
+ chipsEl.appendChild(chip)
581
+ chipsEl.hidden = false
582
+ setTimeout(() => chip.remove(), 5000)
583
+ }
584
+
585
+ // ── Urgent send ───────────────────────────────────────────────────────────
586
+ const urgentMode = signal(false)
587
+ const composerFooter = root.querySelector('.composer')
588
+
589
+ const urgentClass = computed(() => ({ 'is-urgent': urgentMode() }))
590
+
591
+ function toggleUrgentMode() {
592
+ urgentMode.set(!urgentMode())
593
+ composerFooter?.classList.toggle('composer-urgent', urgentMode())
594
+ }
595
+
596
+ function sendMessage({ priority } = {}) {
597
+ const text = draft().trim()
598
+ if (!text && pendingAttachments.length === 0) return
599
+ const resolvedPriority = priority ?? (urgentMode() ? 'now' : 'normal')
600
+ ws.send({
601
+ t: 'msg.send',
602
+ body: {
603
+ channel_id: channelId,
604
+ text,
605
+ client_msg_id: `local_${Date.now()}`,
606
+ priority: resolvedPriority,
607
+ attachments: pendingAttachments.map(a => ({
608
+ upload_id: a.upload_id,
609
+ url: a.url,
610
+ filename: a.original_name,
611
+ mime_type: a.mime_type,
612
+ size_bytes: a.size_bytes,
613
+ }))
614
+ }
615
+ })
616
+ draft.set('')
617
+ pendingAttachments = []
618
+ renderChips()
619
+ }
620
+
621
+ function handleComposerKey(e) {
622
+ if (!mentionPickerEl.hidden) {
623
+ if (e.key === 'ArrowDown') {
624
+ e.preventDefault()
625
+ mentionSelIdx = Math.min(mentionSelIdx + 1, mentionFiltered.length - 1)
626
+ renderPicker()
627
+ return
628
+ }
629
+ if (e.key === 'ArrowUp') {
630
+ e.preventDefault()
631
+ mentionSelIdx = Math.max(mentionSelIdx - 1, 0)
632
+ renderPicker()
633
+ return
634
+ }
635
+ if (e.key === 'Enter' || e.key === 'Tab') {
636
+ e.preventDefault()
637
+ selectMention(mentionFiltered[mentionSelIdx])
638
+ return
639
+ }
640
+ if (e.key === 'Escape') {
641
+ closePicker()
642
+ return
643
+ }
644
+ }
645
+ if (e.key === 'Enter' && !e.shiftKey) {
646
+ e.preventDefault()
647
+ const priority = e.ctrlKey || e.metaKey ? 'now' : undefined
648
+ sendMessage({ priority })
649
+ }
650
+ }
651
+
652
+ function appendMessage({ msg_id, seq, user_id, user_display_name, ts, text, rendered_text, attachments, reactions }) {
653
+ if (messages.querySelector(`[data-msg-id="${msg_id}"]`)) return
654
+
655
+ // Date separator if day changed
656
+ const dateKey = utcDateKey(ts)
657
+ const lastMsg = messages.querySelector('article.message:last-of-type')
658
+ if (lastMsg) {
659
+ const lastTs = parseInt(lastMsg.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
660
+ if (lastTs && utcDateKey(lastTs) !== dateKey) {
661
+ messages.appendChild(makeDateSeparator(dateKey))
662
+ }
663
+ }
664
+
665
+ const article = makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, text, rendered_text, attachments }, { userId, userHandle })
666
+
667
+ // Ensure reaction bar exists in dynamically created messages
668
+ if (!article.querySelector('.reaction-bar')) {
669
+ const bar = document.createElement('div')
670
+ bar.className = 'reaction-bar'
671
+ article.appendChild(bar)
672
+ }
673
+
674
+ messages.appendChild(article)
675
+ renderReactionBar(article, reactions ?? [], msg_id)
676
+ messages.scrollTop = messages.scrollHeight
677
+ }
678
+
679
+ // renderAttachment, formatBytes imported from shared/messages.js
680
+
681
+ function sanitizeHtml(html) {
682
+ return String(html ?? '').replaceAll('<script>', '').replaceAll('</script>', '')
683
+ }
684
+
685
+ // Delegated click: message sender name → open DM
686
+ messages.addEventListener('click', e => {
687
+ const handle = e.target.closest('.dm-trigger')
688
+ if (!handle) return
689
+ const targetUserId = handle.dataset.userId
690
+ if (!targetUserId || targetUserId === userId) return
691
+ ws.send({ t: 'dm.open', body: { target_user_id: targetUserId } })
692
+ })
693
+
694
+ // ── Emoji picker ──────────────────────────────────────────────────────────
695
+
696
+ const RECENT_KEY = 'devchitchat_recent_emoji'
697
+ const RECENT_MAX = 24
698
+
699
+ function loadRecentEmoji() {
700
+ try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') } catch { return [] }
701
+ }
702
+
703
+ function saveRecentEmoji(emoji) {
704
+ let recents = loadRecentEmoji().filter(e => e !== emoji)
705
+ recents.unshift(emoji)
706
+ if (recents.length > RECENT_MAX) recents = recents.slice(0, RECENT_MAX)
707
+ localStorage.setItem(RECENT_KEY, JSON.stringify(recents))
708
+ }
709
+
710
+ let emojiPickerEl = null
711
+ let emojiPickerCurrentCat = 'smileys'
712
+ let emojiPickerTarget = null // msg_id the picker is for
713
+
714
+ function buildEmojiPicker() {
715
+ emojiPickerEl = document.createElement('div')
716
+ emojiPickerEl.className = 'emoji-picker'
717
+
718
+ const searchInput = document.createElement('input')
719
+ searchInput.type = 'search'
720
+ searchInput.className = 'emoji-picker-search'
721
+ searchInput.placeholder = 'Search emoji…'
722
+ searchInput.setAttribute('aria-label', 'Search emoji')
723
+ emojiPickerEl.appendChild(searchInput)
724
+
725
+ const tabs = document.createElement('div')
726
+ tabs.className = 'emoji-picker-tabs'
727
+ for (const cat of CATEGORIES) {
728
+ const btn = document.createElement('button')
729
+ btn.type = 'button'
730
+ btn.className = 'emoji-picker-tab' + (cat.id === emojiPickerCurrentCat ? ' active' : '')
731
+ btn.dataset.catId = cat.id
732
+ btn.textContent = cat.label
733
+ btn.title = cat.id
734
+ tabs.appendChild(btn)
735
+ }
736
+ emojiPickerEl.appendChild(tabs)
737
+
738
+ const grid = document.createElement('div')
739
+ grid.className = 'emoji-picker-grid'
740
+ emojiPickerEl.appendChild(grid)
741
+
742
+ tabs.addEventListener('click', e => {
743
+ const btn = e.target.closest('.emoji-picker-tab')
744
+ if (!btn) return
745
+ emojiPickerCurrentCat = btn.dataset.catId
746
+ tabs.querySelectorAll('.emoji-picker-tab').forEach(b => b.classList.toggle('active', b.dataset.catId === emojiPickerCurrentCat))
747
+ searchInput.value = ''
748
+ renderEmojiGrid(null)
749
+ })
750
+
751
+ searchInput.addEventListener('input', () => {
752
+ renderEmojiGrid(searchInput.value.trim().toLowerCase())
753
+ })
754
+
755
+ grid.addEventListener('click', e => {
756
+ const btn = e.target.closest('button[data-emoji]')
757
+ if (!btn) return
758
+ const emoji = btn.dataset.emoji
759
+ saveRecentEmoji(emoji)
760
+ emojiPickerEl.dispatchEvent(new CustomEvent('emoji:pick', { bubbles: true, detail: { emoji } }))
761
+ })
762
+
763
+ renderEmojiGrid(null)
764
+ return emojiPickerEl
765
+ }
766
+
767
+ function renderEmojiGrid(query) {
768
+ if (!emojiPickerEl) return
769
+ const grid = emojiPickerEl.querySelector('.emoji-picker-grid')
770
+ if (!grid) return
771
+
772
+ let emojiList
773
+ if (query) {
774
+ // Search across all categories
775
+ const allEmoji = CATEGORIES.flatMap(c => c.emoji)
776
+ const unique = [...new Set(allEmoji)]
777
+ emojiList = unique.filter(e => {
778
+ const name = EMOJI_NAMES[e] ?? ''
779
+ return name.includes(query) || e.includes(query)
780
+ })
781
+ } else {
782
+ if (emojiPickerCurrentCat === 'recent') {
783
+ emojiList = loadRecentEmoji()
784
+ } else {
785
+ const cat = CATEGORIES.find(c => c.id === emojiPickerCurrentCat)
786
+ emojiList = cat ? cat.emoji : []
787
+ }
788
+ }
789
+
790
+ grid.innerHTML = emojiList.map(e =>
791
+ `<button type="button" data-emoji="${escHtml(e)}" title="${escHtml(EMOJI_NAMES[e] ?? e)}">${e}</button>`
792
+ ).join('')
793
+ }
794
+
795
+ function getOrBuildEmojiPicker() {
796
+ if (!emojiPickerEl) buildEmojiPicker()
797
+ return emojiPickerEl
798
+ }
799
+
800
+ function openEmojiPickerAt(anchorEl, msgId) {
801
+ emojiPickerTarget = msgId
802
+ const picker = getOrBuildEmojiPicker()
803
+
804
+ // Refresh recent tab if active
805
+ if (emojiPickerCurrentCat === 'recent') renderEmojiGrid(null)
806
+
807
+ // Attach handler once (use named function to avoid duplicates)
808
+ picker.onEmojiPickHandler = (e) => {
809
+ const { emoji } = e.detail
810
+ closeEmojiPicker()
811
+ closeReactionContextMenu()
812
+ ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
813
+ }
814
+ picker.removeEventListener('emoji:pick', picker._boundEmojiPick)
815
+ picker._boundEmojiPick = picker.onEmojiPickHandler
816
+ picker.addEventListener('emoji:pick', picker._boundEmojiPick)
817
+
818
+ document.body.appendChild(picker)
819
+ picker.style.position = 'fixed'
820
+ picker.style.zIndex = '400'
821
+
822
+ // Position below the anchor, viewport-aware
823
+ const rect = anchorEl.getBoundingClientRect()
824
+ picker.style.top = `${rect.bottom + 4}px`
825
+ picker.style.left = `${rect.left}px`
826
+
827
+ // Force layout so getBoundingClientRect is accurate
828
+ requestAnimationFrame(() => {
829
+ const pickerRect = picker.getBoundingClientRect()
830
+ let left = rect.left
831
+ if (left + pickerRect.width > window.innerWidth - 8) {
832
+ left = window.innerWidth - 8 - pickerRect.width
833
+ }
834
+ if (left < 8) left = 8
835
+ picker.style.left = `${left}px`
836
+
837
+ // Flip above if not enough room below
838
+ if (rect.bottom + 4 + pickerRect.height > window.innerHeight - 8) {
839
+ picker.style.top = `${rect.top - 4 - pickerRect.height}px`
840
+ }
841
+ })
842
+ }
843
+
844
+ function closeEmojiPicker() {
845
+ if (emojiPickerEl && emojiPickerEl.parentNode) emojiPickerEl.parentNode.removeChild(emojiPickerEl)
846
+ emojiPickerTarget = null
847
+ }
848
+
849
+ // ── Reaction context menu (desktop right-click) ────────────────────────────
850
+
851
+ let activeReactionContextMenu = null
852
+
853
+ function closeReactionContextMenu() {
854
+ if (activeReactionContextMenu) { activeReactionContextMenu.remove(); activeReactionContextMenu = null }
855
+ }
856
+
857
+ function showReactionContextMenu(article, x, y) {
858
+ closeReactionContextMenu()
859
+ const msgId = article.dataset.msgId
860
+ if (!msgId) return
861
+
862
+ const menu = document.createElement('div')
863
+ menu.className = 'msg-context-menu msg-context-menu--reaction'
864
+
865
+ const reactBtn = document.createElement('button')
866
+ reactBtn.className = 'msg-context-menu-item'
867
+ reactBtn.type = 'button'
868
+ reactBtn.textContent = 'React'
869
+ reactBtn.addEventListener('click', (e) => {
870
+ e.stopPropagation()
871
+ openEmojiPickerAt(reactBtn, msgId)
872
+ })
873
+ menu.appendChild(reactBtn)
874
+
875
+ document.body.appendChild(menu)
876
+ activeReactionContextMenu = menu
877
+
878
+ // Position near cursor, viewport-aware
879
+ const menuRect = menu.getBoundingClientRect()
880
+ let left = x + window.scrollX
881
+ let top = y + window.scrollY
882
+ if (left + menuRect.width > window.innerWidth - 8) left = window.innerWidth - 8 - menuRect.width
883
+ if (left < 8) left = 8
884
+ menu.style.top = `${top}px`
885
+ menu.style.left = `${left}px`
886
+ }
887
+
888
+ // Right-click on a message article
889
+ messages.addEventListener('contextmenu', e => {
890
+ const article = e.target.closest('article.message')
891
+ if (!article) return
892
+ e.preventDefault()
893
+ showReactionContextMenu(article, e.clientX, e.clientY)
894
+ })
895
+
896
+ document.addEventListener('click', e => {
897
+ if (activeReactionContextMenu && !activeReactionContextMenu.contains(e.target)) closeReactionContextMenu()
898
+ // Close emoji picker on click-outside
899
+ if (emojiPickerEl && emojiPickerEl.parentNode && !emojiPickerEl.contains(e.target)) {
900
+ const isReactionAddBtn = e.target.closest('.reaction-add')
901
+ if (!isReactionAddBtn) closeEmojiPicker()
902
+ }
903
+ }, { capture: true })
904
+
905
+ document.addEventListener('keydown', e => {
906
+ if (e.key === 'Escape') {
907
+ closeReactionContextMenu()
908
+ closeEmojiPicker()
909
+ }
910
+ })
911
+
912
+ // Delegated click on .reaction-pill — toggle reaction
913
+ messages.addEventListener('click', e => {
914
+ const pill = e.target.closest('.reaction-pill')
915
+ if (!pill) return
916
+ e.stopPropagation()
917
+ const emoji = pill.dataset.emoji
918
+ const msgId = pill.dataset.msgId
919
+ if (!emoji || !msgId) return
920
+ if (pill.classList.contains('reacted')) {
921
+ ws.send({ t: 'reaction.remove', body: { msg_id: msgId, channel_id: channelId, emoji } })
922
+ } else {
923
+ ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
924
+ }
925
+ })
926
+
927
+ // Delegated click on .reaction-add — open emoji picker
928
+ messages.addEventListener('click', e => {
929
+ const btn = e.target.closest('.reaction-add')
930
+ if (!btn) return
931
+ e.stopPropagation()
932
+ const msgId = btn.dataset.msgId
933
+ if (!msgId) return
934
+ // Toggle: close if already open for this message
935
+ if (emojiPickerEl && emojiPickerEl.parentNode && emojiPickerTarget === msgId) {
936
+ closeEmojiPicker()
937
+ return
938
+ }
939
+ openEmojiPickerAt(btn, msgId)
940
+ })
941
+
942
+ // ── Mobile long-press → action sheet with emoji picker ────────────────────
943
+
944
+ addLongPress(messages, (e) => {
945
+ const article = e.target.closest?.('article.message')
946
+ if (!article) return
947
+ const msgId = article.dataset.msgId
948
+ if (!msgId) return
949
+
950
+ const itemsContainer = getItemsContainer()
951
+ itemsContainer.innerHTML = ''
952
+
953
+ const pickerWrapper = document.createElement('div')
954
+ pickerWrapper.className = 'action-sheet-emoji-picker-wrap'
955
+
956
+ const picker = getOrBuildEmojiPicker()
957
+ pickerWrapper.appendChild(picker)
958
+ itemsContainer.appendChild(pickerWrapper)
959
+
960
+ picker.removeEventListener('emoji:pick', picker._boundEmojiPick)
961
+ picker._boundEmojiPick = (ev) => {
962
+ const { emoji } = ev.detail
963
+ saveRecentEmoji(emoji)
964
+ dismissActionSheet()
965
+ ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
966
+ }
967
+ picker.addEventListener('emoji:pick', picker._boundEmojiPick)
968
+ emojiPickerTarget = msgId
969
+
970
+ showActionSheet({ label: 'React to this message', items: [] })
971
+ })
972
+
973
+ // ── Inline edit ────────────────────────────────────────────────────────────
974
+
975
+ function startInlineEdit(article) {
976
+ if (article.querySelector('.message-edit-input')) return // already editing
977
+ const textEl = article.querySelector('.message-text')
978
+ if (!textEl) return
979
+ const rawText = article.dataset.rawText ?? ''
980
+
981
+ const textarea = document.createElement('textarea')
982
+ textarea.className = 'message-edit-input'
983
+ textarea.value = rawText
984
+ textEl.replaceWith(textarea)
985
+ textarea.focus()
986
+ textarea.setSelectionRange(rawText.length, rawText.length)
987
+
988
+ const toolbar = document.createElement('div')
989
+ toolbar.className = 'message-edit-toolbar'
990
+ toolbar.innerHTML = '<button class="btn-edit-save btn-primary" type="button">Save</button><button class="btn-edit-cancel btn-ghost" type="button">Cancel</button>'
991
+ textarea.after(toolbar)
992
+
993
+ function cancel() {
994
+ textarea.replaceWith(textEl)
995
+ toolbar.remove()
996
+ }
997
+
998
+ function save() {
999
+ const newText = textarea.value.trim()
1000
+ if (!newText) { cancel(); return }
1001
+ if (newText === rawText) { cancel(); return }
1002
+ // Optimistic: show new text immediately (unrendered), server will push rendered version via msg.edited
1003
+ textEl.textContent = newText
1004
+ cancel()
1005
+ ws.send({ t: 'msg.edit', body: { msg_id: article.dataset.msgId, channel_id: channelId, text: newText } })
1006
+ }
1007
+
1008
+ toolbar.querySelector('.btn-edit-save').addEventListener('click', save)
1009
+ toolbar.querySelector('.btn-edit-cancel').addEventListener('click', cancel)
1010
+ textarea.addEventListener('keydown', e => {
1011
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); save() }
1012
+ if (e.key === 'Escape') cancel()
1013
+ })
1014
+ }
1015
+
1016
+ // ── Context menu (desktop hover → … button) ────────────────────────────────
1017
+
1018
+ let activeContextMenu = null
1019
+
1020
+ function closeContextMenu() {
1021
+ if (activeContextMenu) { activeContextMenu.remove(); activeContextMenu = null }
1022
+ }
1023
+
1024
+ function showContextMenu(article, anchorEl) {
1025
+ closeContextMenu()
1026
+ const isAuthor = article.dataset.userId === userId
1027
+ if (!isAuthor) return
1028
+
1029
+ const menu = document.createElement('div')
1030
+ menu.className = 'msg-context-menu'
1031
+ const editBtn = document.createElement('button')
1032
+ editBtn.className = 'msg-context-menu-item'
1033
+ editBtn.type = 'button'
1034
+ editBtn.textContent = 'Edit'
1035
+ editBtn.addEventListener('click', () => { closeContextMenu(); startInlineEdit(article) })
1036
+ menu.appendChild(editBtn)
1037
+
1038
+ const deleteBtn = document.createElement('button')
1039
+ deleteBtn.className = 'msg-context-menu-item msg-context-menu-item--danger'
1040
+ deleteBtn.type = 'button'
1041
+ deleteBtn.textContent = 'Delete'
1042
+ deleteBtn.addEventListener('click', () => {
1043
+ closeContextMenu()
1044
+ ws.send({ t: 'msg.delete', body: { msg_id: article.dataset.msgId, channel_id: channelId } })
1045
+ })
1046
+ menu.appendChild(deleteBtn)
1047
+
1048
+ document.body.appendChild(menu)
1049
+ activeContextMenu = menu
1050
+
1051
+ const rect = anchorEl.getBoundingClientRect()
1052
+ const menuRect = menu.getBoundingClientRect()
1053
+ let top = rect.bottom + window.scrollY + 4
1054
+ let left = rect.right + window.scrollX - menuRect.width
1055
+ if (left < 8) left = 8
1056
+ if (left + menuRect.width > window.innerWidth - 8) left = window.innerWidth - 8 - menuRect.width
1057
+ menu.style.top = `${top}px`
1058
+ menu.style.left = `${left}px`
1059
+ }
1060
+
1061
+ document.addEventListener('click', e => {
1062
+ if (activeContextMenu && !activeContextMenu.contains(e.target)) closeContextMenu()
1063
+ }, { capture: true })
1064
+
1065
+ document.addEventListener('keydown', e => {
1066
+ if (e.key === 'Escape') closeContextMenu()
1067
+ })
1068
+
1069
+ // Delegated click: … button → open context menu
1070
+ messages.addEventListener('click', e => {
1071
+ const btn = e.target.closest('.btn-msg-actions')
1072
+ if (!btn) return
1073
+ e.stopPropagation()
1074
+ const article = btn.closest('article.message')
1075
+ if (article) showContextMenu(article, btn)
1076
+ })
1077
+
1078
+
1079
+ ws.on('dm.opened', ({ channel_id, notify_only }) => {
1080
+ if (notify_only) return // target user — sidebar handles the notification
1081
+ window.location.href = `${window.__BASE_PATH__}/channels/${channel_id}`
1082
+ })
1083
+
1084
+ // ── Call: rtc.call_state — drives "N in call" row + sidebar badge ──────────
1085
+
1086
+ ws.on('rtc.call_state', (body) => {
1087
+ if (body.channel_id !== channelId) return
1088
+ // Don't overwrite the active call's ID when browsing a different channel —
1089
+ // callIdSig is what miniBarLeave uses to leave the call.
1090
+ if (!inCall() || body.channel_id === callChannelId) {
1091
+ callIdSig.set(body.call_id)
1092
+ }
1093
+ _updateCallStatusRow(body.call_id, body.count, body.users ?? [])
1094
+ _updateChannelBadge(body.count)
1095
+ })
1096
+
1097
+ function _updateCallStatusRow(activeCallId, count, users) {
1098
+ if (!callStatusEl) return
1099
+ if (inCall()) {
1100
+ // Already in the call — just update peer count
1101
+ if (peerCountEl) peerCountEl.textContent = count > 1 ? `${count} in call` : ''
1102
+ callStatusEl.hidden = true
1103
+ return
1104
+ }
1105
+ if (!activeCallId || count === 0) {
1106
+ callStatusEl.hidden = true
1107
+ return
1108
+ }
1109
+ callStatusEl.hidden = false
1110
+ if (callStatusInfo) callStatusInfo.textContent = `${count} in call`
1111
+ if (callStatusAvatars) {
1112
+ callStatusAvatars.innerHTML = users.slice(0, 5).map(u =>
1113
+ `<span class="call-status-avatar" title="${escHtml(u.user_id)}">${escHtml(u.user_id.slice(0, 2).toUpperCase())}</span>`
1114
+ ).join('')
1115
+ }
1116
+ }
1117
+
1118
+ function _updateChannelBadge(count) {
1119
+ const li = document.querySelector(`.channel-link[data-channel-id="${channelId}"]`)?.closest('li')
1120
+ if (!li) return
1121
+ li.classList.toggle('call-active', count > 0)
1122
+ const badge = li.querySelector('.call-badge')
1123
+ if (badge) badge.textContent = count > 0 ? String(count) : ''
1124
+ }
1125
+
1126
+ // ── Call: start / join / leave ─────────────────────────────────────────────
1127
+
1128
+ btnStartCall?.addEventListener('click', () => {
1129
+ ws.send({ t: 'rtc.call_create', body: { channel_id: channelId, kind: 'mesh' } })
1130
+ })
1131
+
1132
+ btnJoinCall?.addEventListener('click', () => {
1133
+ const id = callIdSig()
1134
+ if (id) ws.send({ t: 'rtc.join', body: { call_id: id } })
1135
+ })
1136
+
1137
+ btnLeaveCall?.addEventListener('click', leaveCall)
1138
+
1139
+ function leaveCall() {
1140
+ const id = callIdSig()
1141
+ if (!inCall() || !id) return
1142
+ ws.send({ t: 'rtc.leave', body: { call_id: id } })
1143
+ _teardownCall()
1144
+ }
1145
+
1146
+ // ── Call: WS message handlers ──────────────────────────────────────────────
1147
+
1148
+ ws.on('rtc.call', (body) => {
1149
+ // Server confirmed call creation / found existing call — now join it
1150
+ if (body.ice_servers?.length) { iceServers = body.ice_servers; rtcManager.setIceServers(iceServers) }
1151
+ callIdSig.set(body.call_id)
1152
+ ws.send({ t: 'rtc.join', body: { call_id: body.call_id } })
1153
+ })
1154
+
1155
+ ws.on('rtc.joined', async (body) => {
1156
+ const { call_id, peer_id, peers } = body
1157
+ if (body.ice_servers?.length) { iceServers = body.ice_servers; rtcManager.setIceServers(iceServers) }
1158
+ selfPeerId.set(peer_id)
1159
+ callIdSig.set(call_id)
1160
+ callChannelId = channelId
1161
+ inCall.set(true)
1162
+ _showCallControls()
1163
+ _showTilePanel()
1164
+ _attachDeviceChangeListener()
1165
+ patchSettings({ last_channel_id: channelId })
1166
+
1167
+ // Start audio immediately; video is opt-in
1168
+ await _startAudio()
1169
+
1170
+ // Cache display names for existing peers, then connect as offerer
1171
+ for (const peer of peers) {
1172
+ if (peer.peer_id !== peer_id) {
1173
+ rtcManager.setDisplayName(peer.peer_id, peer.display_name)
1174
+ rtcManager.ensurePeer(peer.peer_id)
1175
+ rtcManager.negotiate(peer.peer_id)
1176
+ }
1177
+ }
1178
+ })
1179
+
1180
+ ws.on('rtc.peer_event', ({ call_id, kind, peer }) => {
1181
+ if (kind === 'join' && peer.peer_id !== selfPeerId()) {
1182
+ rtcManager.setDisplayName(peer.peer_id, peer.display_name)
1183
+ // Existing peer receives new joiner's event — create answerer connection
1184
+ // (new joiner will send us an offer)
1185
+ rtcManager.ensurePeer(peer.peer_id)
1186
+ }
1187
+ if (kind === 'leave') {
1188
+ rtcManager.closePeer(peer.peer_id)
1189
+ }
1190
+ })
1191
+
1192
+ ws.on('rtc.offer_event', async ({ call_id, from_peer_id, sdp }) => {
1193
+ await rtcManager.handleRemoteOffer(from_peer_id, call_id, sdp)
1194
+ })
1195
+
1196
+ ws.on('rtc.answer_event', async ({ call_id, from_peer_id, sdp }) => {
1197
+ await rtcManager.handleRemoteAnswer(from_peer_id, sdp)
1198
+ })
1199
+
1200
+ ws.on('rtc.ice_event', async ({ from_peer_id, candidate }) => {
1201
+ await rtcManager.handleIceCandidate(from_peer_id, candidate)
1202
+ })
1203
+
1204
+ ws.on('rtc.stream_event', () => {
1205
+ // No pre-tile creation here — tile IDs must match between this handler
1206
+ // (which uses kind: 'cam'/'screen') and ontrack (which uses transceiver.mid,
1207
+ // a number like '1' or '2'). The mismatch left orphaned empty tiles.
1208
+ // Tiles are created in ontrack once the actual stream track arrives.
1209
+ })
1210
+
1211
+ ws.on('rtc.call_end', ({ call_id }) => {
1212
+ if (call_id === callIdSig()) _teardownCall()
1213
+ })
1214
+
1215
+ ws.on('rtc.left', () => {
1216
+ // Server confirmed our leave
1217
+ })
1218
+
1219
+ // ── Local media ────────────────────────────────────────────────────────────
1220
+ // Peer negotiation, transceiver slots, ICE queuing → RtcPeerManager
1221
+
1222
+ async function _startAudio() {
1223
+ if (audioStream) return
1224
+ try {
1225
+ const saved = loadSavedDevices()
1226
+ audioStream = await navigator.mediaDevices.getUserMedia({
1227
+ audio: saved.micId ? { deviceId: { ideal: saved.micId } } : true,
1228
+ video: false,
1229
+ })
1230
+ activeMicId = audioStream.getAudioTracks()[0]?.getSettings().deviceId ?? null
1231
+ audioStream.getAudioTracks().forEach(t => { t.enabled = !micMuted() })
1232
+ await refreshDevices() // labels now available after permission granted
1233
+ for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
1234
+ } catch {
1235
+ micMuted.set(true)
1236
+ }
1237
+ }
1238
+
1239
+ async function toggleMic() {
1240
+ micMuted.set(!micMuted())
1241
+ audioStream?.getAudioTracks().forEach(t => { t.enabled = !micMuted() })
1242
+ if (ctrlMic) ctrlMic.textContent = micMuted() ? '🔇' : '🎙'
1243
+ if (miniBarMic) miniBarMic.textContent = micMuted() ? '🔇' : '🎙'
1244
+ }
1245
+
1246
+ async function toggleCamera() {
1247
+ if (videoStream) {
1248
+ videoStream.getTracks().forEach(t => t.stop())
1249
+ _removeTile('local-cam')
1250
+ videoStream = null
1251
+ camOff.set(true)
1252
+ for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
1253
+ if (ctrlCam) ctrlCam.textContent = '📷'
1254
+ return
1255
+ }
1256
+ try {
1257
+ const saved = loadSavedDevices()
1258
+ const videoConstraint = saved.cameraId
1259
+ ? { deviceId: { ideal: saved.cameraId }, width: 640, height: 360 }
1260
+ : { width: 640, height: 360 }
1261
+ videoStream = await navigator.mediaDevices.getUserMedia({ video: videoConstraint, audio: false })
1262
+ activeCameraId = videoStream.getVideoTracks()[0]?.getSettings().deviceId ?? null
1263
+ camOff.set(false)
1264
+ _renderTile('local-cam', videoStream, true, `${userHandle ?? 'You'} (cam)`)
1265
+ ws.send({ t: 'rtc.stream_publish', body: { call_id: callIdSig(), stream: { kind: 'camera' } } })
1266
+ for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
1267
+ if (ctrlCam) ctrlCam.textContent = '📷✓'
1268
+ } catch {
1269
+ // Camera denied
1270
+ }
1271
+ }
1272
+
1273
+ async function toggleScreen() {
1274
+ if (screenStream) {
1275
+ screenStream.getTracks().forEach(t => t.stop())
1276
+ _removeTile('local-screen')
1277
+ screenStream = null
1278
+ screenSharing.set(false)
1279
+ for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
1280
+ if (ctrlScreen) ctrlScreen.textContent = '🖥'
1281
+ return
1282
+ }
1283
+ if (!navigator.mediaDevices?.getDisplayMedia) return
1284
+ try {
1285
+ screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
1286
+ screenSharing.set(true)
1287
+ _renderTile('local-screen', screenStream, true, `${userHandle ?? 'You'} (screen)`)
1288
+ ws.send({ t: 'rtc.stream_publish', body: { call_id: callIdSig(), stream: { kind: 'screen' } } })
1289
+ screenStream.getVideoTracks()[0].addEventListener('ended', () => toggleScreen())
1290
+ for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
1291
+ if (ctrlScreen) ctrlScreen.textContent = '🖥✓'
1292
+ } catch { /* user cancelled */ }
1293
+ }
1294
+
1295
+ // ── Audio element for remote peers ─────────────────────────────────────────
1296
+
1297
+ function _ensureRemoteAudio(stream, peerId) {
1298
+ if (document.querySelector(`audio[data-peer-id="${peerId}"]`)) return
1299
+ const audio = document.createElement('audio')
1300
+ audio.autoplay = true
1301
+ audio.dataset.peerId = peerId
1302
+ audio.srcObject = stream
1303
+ document.body.appendChild(audio)
1304
+ }
1305
+
1306
+ // ── Tile grid ──────────────────────────────────────────────────────────────
1307
+
1308
+ function _captureFrame(tile, label) {
1309
+ const video = tile.querySelector('video')
1310
+ if (!video || !video.videoWidth) return
1311
+ const canvas = document.createElement('canvas')
1312
+ canvas.width = video.videoWidth
1313
+ canvas.height = video.videoHeight
1314
+ canvas.getContext('2d').drawImage(video, 0, 0)
1315
+ const a = document.createElement('a')
1316
+ a.href = canvas.toDataURL('image/png')
1317
+ a.download = `capture-${label.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}.png`
1318
+ a.click()
1319
+ }
1320
+
1321
+ function _startCapture(tile, label, delay) {
1322
+ const countdown = tile.querySelector('.tile-countdown')
1323
+ const captureBtn = tile.querySelector('.tile-capture')
1324
+ if (tile._captureTimer) {
1325
+ clearInterval(tile._captureTimer)
1326
+ tile._captureTimer = null
1327
+ countdown.hidden = true
1328
+ captureBtn.textContent = '📸'
1329
+ return
1330
+ }
1331
+ if (delay === 0) { _captureFrame(tile, label); return }
1332
+ let remaining = delay
1333
+ countdown.textContent = remaining
1334
+ countdown.hidden = false
1335
+ captureBtn.textContent = '✕'
1336
+ tile._captureTimer = setInterval(() => {
1337
+ remaining--
1338
+ if (remaining <= 0) {
1339
+ clearInterval(tile._captureTimer)
1340
+ tile._captureTimer = null
1341
+ countdown.hidden = true
1342
+ captureBtn.textContent = '📸'
1343
+ _captureFrame(tile, label)
1344
+ } else {
1345
+ countdown.textContent = remaining
1346
+ }
1347
+ }, 1000)
1348
+ }
1349
+
1350
+ function _renderTile(tileId, stream, muted, label) {
1351
+ if (!tileGridEl) return
1352
+ let tile = tileGridEl.querySelector(`[data-peer="${tileId}"]`)
1353
+ if (!tile) {
1354
+ tile = document.createElement('div')
1355
+ tile.className = 'stream-tile'
1356
+ tile.dataset.peer = tileId
1357
+ tile.innerHTML = `<video autoplay playsinline controls ${muted ? 'muted' : ''}></video><span class="tile-label">${escHtml(label)}</span><div class="tile-capture-wrap"><button class="tile-pin" title="Move to top">⬆</button><button class="tile-capture" title="Capture photo">📸</button><div class="tile-capture-menu" hidden><button class="tile-capture-opt" data-delay="0">0s</button><button class="tile-capture-opt" data-delay="1">1s</button><button class="tile-capture-opt" data-delay="3">3s</button><button class="tile-capture-opt" data-delay="5">5s</button></div></div><div class="tile-countdown" hidden></div>`
1358
+ tile.querySelector('video').addEventListener('click', e => e.stopPropagation())
1359
+ tile.querySelector('.tile-pin').addEventListener('click', e => { e.stopPropagation(); _pinTile(tileId) })
1360
+ const menu = tile.querySelector('.tile-capture-menu')
1361
+ tile.querySelector('.tile-capture').addEventListener('click', e => {
1362
+ e.stopPropagation()
1363
+ if (tile._captureTimer) { _startCapture(tile, label, 0); return }
1364
+ menu.hidden = !menu.hidden
1365
+ })
1366
+ menu.querySelectorAll('.tile-capture-opt').forEach(btn => {
1367
+ btn.addEventListener('click', e => {
1368
+ e.stopPropagation()
1369
+ menu.hidden = true
1370
+ _startCapture(tile, label, parseInt(btn.dataset.delay))
1371
+ })
1372
+ })
1373
+ tileGridEl.appendChild(tile)
1374
+ _updateTileLayout()
1375
+ }
1376
+ if (stream) tile.querySelector('video').srcObject = stream
1377
+ return tile
1378
+ }
1379
+
1380
+ function _removeTile(tileId) {
1381
+ tileGridEl?.querySelector(`[data-peer="${tileId}"]`)?.remove()
1382
+ _updateTileLayout()
1383
+ }
1384
+
1385
+ function _updateTileLayout() {
1386
+ if (!tileGridEl) return
1387
+ const count = tileGridEl.querySelectorAll('.stream-tile').length
1388
+ tileGridEl.classList.toggle('avatars-only', count >= 5)
1389
+ }
1390
+
1391
+ function _pinTile(tileId) {
1392
+ if (pinnedPeerId === tileId) {
1393
+ tileGridEl?.classList.remove('pinned')
1394
+ tileGridEl?.querySelectorAll('.stream-tile').forEach(t => t.classList.remove('pinned-tile'))
1395
+ pinnedPeerId = null
1396
+ } else {
1397
+ tileGridEl?.classList.add('pinned')
1398
+ tileGridEl?.querySelectorAll('.stream-tile').forEach(t => t.classList.remove('pinned-tile'))
1399
+ tileGridEl?.querySelector(`[data-peer="${tileId}"]`)?.classList.add('pinned-tile')
1400
+ pinnedPeerId = tileId
1401
+ }
1402
+ }
1403
+
1404
+ // ── Device switching ───────────────────────────────────────────────────────
1405
+
1406
+ async function switchCamera(deviceId) {
1407
+ const newStream = await navigator.mediaDevices.getUserMedia({
1408
+ video: { deviceId: { exact: deviceId } },
1409
+ })
1410
+ const newTrack = newStream.getVideoTracks()[0]
1411
+ await rtcManager.replaceTrack('camera', newTrack)
1412
+
1413
+ videoStream?.getTracks().forEach(t => t.stop())
1414
+ videoStream = newStream
1415
+ activeCameraId = deviceId
1416
+ saveDevices({ cameraId: deviceId })
1417
+
1418
+ const localTile = tileGridEl?.querySelector('[data-peer="local-cam"]')
1419
+ if (localTile) localTile.querySelector('video').srcObject = newStream
1420
+ }
1421
+
1422
+ async function switchMic(deviceId) {
1423
+ const newStream = await navigator.mediaDevices.getUserMedia({
1424
+ audio: { deviceId: { exact: deviceId } },
1425
+ })
1426
+ const newTrack = newStream.getAudioTracks()[0]
1427
+ newTrack.enabled = !micMuted()
1428
+ await rtcManager.replaceTrack('audio', newTrack)
1429
+
1430
+ audioStream?.getTracks().forEach(t => t.stop())
1431
+ audioStream = newStream
1432
+ activeMicId = deviceId
1433
+ saveDevices({ micId: deviceId })
1434
+ }
1435
+
1436
+ // ── Device change detection ────────────────────────────────────────────────
1437
+
1438
+ function _onDeviceChange() {
1439
+ refreshDevices().then(({ cameras, mics }) => {
1440
+ const cameraGone = activeCameraId && !cameras.find(d => d.deviceId === activeCameraId)
1441
+ const micGone = activeMicId && !mics.find(d => d.deviceId === activeMicId)
1442
+ if (cameraGone || micGone) _showDeviceWarning(cameraGone ? 'camera' : 'mic')
1443
+ if (devicePickerEl?.classList.contains('open')) _populatePicker()
1444
+ })
1445
+ }
1446
+
1447
+ function _attachDeviceChangeListener() {
1448
+ navigator.mediaDevices.addEventListener('devicechange', _onDeviceChange)
1449
+ }
1450
+ function _detachDeviceChangeListener() {
1451
+ navigator.mediaDevices.removeEventListener('devicechange', _onDeviceChange)
1452
+ }
1453
+
1454
+ // ── Device picker ──────────────────────────────────────────────────────────
1455
+
1456
+ let devicePickerEl = null
1457
+
1458
+ function _buildPicker() {
1459
+ devicePickerEl = document.createElement('div')
1460
+ devicePickerEl.className = 'device-picker'
1461
+ devicePickerEl.innerHTML = `
1462
+ <div class="device-picker-row">
1463
+ <label>Camera</label>
1464
+ <select id="dp-camera"></select>
1465
+ <video id="dp-preview" autoplay playsinline muted></video>
1466
+ </div>
1467
+ <div class="device-picker-row">
1468
+ <label>Microphone</label>
1469
+ <select id="dp-mic"></select>
1470
+ <canvas id="dp-level" width="80" height="12"></canvas>
1471
+ </div>
1472
+ <div class="device-picker-footer">
1473
+ <button id="dp-cancel" class="btn-ghost" type="button">Cancel</button>
1474
+ <button id="dp-apply" class="btn-primary" type="button">Switch</button>
1475
+ </div>
1476
+ `
1477
+ callControlsEl?.after(devicePickerEl)
1478
+
1479
+ devicePickerEl.querySelector('#dp-cancel').addEventListener('click', _closePicker)
1480
+ devicePickerEl.querySelector('#dp-apply').addEventListener('click', _applyPicker)
1481
+
1482
+ const cameraSelect = devicePickerEl.querySelector('#dp-camera')
1483
+ const previewVideo = devicePickerEl.querySelector('#dp-preview')
1484
+
1485
+ cameraSelect.addEventListener('change', async () => {
1486
+ devicePickerEl._previewStream?.getTracks().forEach(t => t.stop())
1487
+ devicePickerEl._previewStream = null
1488
+ if (!cameraSelect.value) return
1489
+ try {
1490
+ const stream = await navigator.mediaDevices.getUserMedia({
1491
+ video: { deviceId: { exact: cameraSelect.value } },
1492
+ })
1493
+ previewVideo.srcObject = stream
1494
+ devicePickerEl._previewStream = stream
1495
+ } catch { /* camera unavailable */ }
1496
+ })
1497
+ }
1498
+
1499
+ function _populatePicker() {
1500
+ const { cameras, mics } = availableDevices
1501
+ const cameraSelect = devicePickerEl?.querySelector('#dp-camera')
1502
+ const micSelect = devicePickerEl?.querySelector('#dp-mic')
1503
+ if (cameraSelect) {
1504
+ cameraSelect.innerHTML = cameras
1505
+ .map(d => `<option value="${escHtml(d.deviceId)}"${d.deviceId === activeCameraId ? ' selected' : ''}>${escHtml(d.label || 'Camera')}</option>`)
1506
+ .join('')
1507
+ }
1508
+ if (micSelect) {
1509
+ micSelect.innerHTML = mics
1510
+ .map(d => `<option value="${escHtml(d.deviceId)}"${d.deviceId === activeMicId ? ' selected' : ''}>${escHtml(d.label || 'Microphone')}</option>`)
1511
+ .join('')
1512
+ }
1513
+ }
1514
+
1515
+ async function _openPicker() {
1516
+ if (!devicePickerEl) _buildPicker()
1517
+ await refreshDevices()
1518
+ _populatePicker()
1519
+ devicePickerEl.classList.add('open')
1520
+ }
1521
+
1522
+ function _closePicker() {
1523
+ devicePickerEl?._previewStream?.getTracks().forEach(t => t.stop())
1524
+ if (devicePickerEl) devicePickerEl._previewStream = null
1525
+ devicePickerEl?.classList.remove('open')
1526
+ }
1527
+
1528
+ async function _applyPicker() {
1529
+ const cameraId = devicePickerEl?.querySelector('#dp-camera')?.value
1530
+ const micId = devicePickerEl?.querySelector('#dp-mic')?.value
1531
+ try {
1532
+ if (cameraId && cameraId !== activeCameraId && videoStream) await switchCamera(cameraId)
1533
+ if (micId && micId !== activeMicId) await switchMic(micId)
1534
+ ctrlDevices?.classList.remove('device-warning')
1535
+ } catch { /* device unavailable — leave current stream in place */ }
1536
+ _closePicker()
1537
+ }
1538
+
1539
+ ctrlDevices?.addEventListener('click', () => {
1540
+ devicePickerEl?.classList.contains('open') ? _closePicker() : _openPicker()
1541
+ })
1542
+
1543
+ // ── Device warning toast ───────────────────────────────────────────────────
1544
+
1545
+ function _showDeviceWarning(kind) {
1546
+ const label = kind === 'camera' ? 'Camera' : 'Microphone'
1547
+ const toast = document.createElement('div')
1548
+ toast.className = 'device-warning-toast'
1549
+ toast.textContent = `${label} disconnected — click ⚙ to switch`
1550
+ document.body.appendChild(toast)
1551
+ setTimeout(() => toast.remove(), 6000)
1552
+ ctrlDevices?.classList.add('device-warning')
1553
+ }
1554
+
1555
+ // ── Controls visibility ────────────────────────────────────────────────────
1556
+
1557
+ function _showCallControls() {
1558
+ callStatusEl && (callStatusEl.hidden = true)
1559
+ callControlsEl?.classList.add('active')
1560
+ if (btnStartCall) btnStartCall.hidden = true
1561
+ }
1562
+
1563
+ function _hideCallControls() {
1564
+ callControlsEl?.classList.remove('active')
1565
+ if (btnStartCall) btnStartCall.hidden = false
1566
+ }
1567
+
1568
+ // ── Tile panel show / hide ─────────────────────────────────────────────────
1569
+
1570
+ const LAYOUT_KEY = 'devchitchat_tile_layout'
1571
+
1572
+ function _showTilePanel() {
1573
+ document.querySelector('.main-content')?.classList.add('has-call')
1574
+ tilePanelEl?.classList.add('active')
1575
+ }
1576
+
1577
+ function _hideTilePanel() {
1578
+ document.querySelector('.main-content')?.classList.remove('has-call')
1579
+ tilePanelEl?.classList.remove('active')
1580
+ tilePanelEl?.classList.remove('collapsed')
1581
+ }
1582
+
1583
+ // Restore collapse state from localStorage
1584
+ try {
1585
+ const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
1586
+ if (saved.collapsed) tilePanelEl?.classList.add('collapsed')
1587
+ if (saved.overlayRight && saved.overlayTop && tilePanelEl) {
1588
+ tilePanelEl.style.right = saved.overlayRight
1589
+ tilePanelEl.style.top = saved.overlayTop
1590
+ }
1591
+ } catch { /* ignore */ }
1592
+
1593
+ // Collapse toggle
1594
+ document.getElementById('tile-panel-collapse')?.addEventListener('click', () => {
1595
+ const collapsed = tilePanelEl?.classList.toggle('collapsed')
1596
+ try {
1597
+ const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
1598
+ localStorage.setItem(LAYOUT_KEY, JSON.stringify({ ...saved, collapsed: !!collapsed }))
1599
+ } catch { /* ignore */ }
1600
+ })
1601
+
1602
+ // Overlay drag (mobile only)
1603
+ ;(function _attachOverlayDrag(panel) {
1604
+ if (!panel) return
1605
+ if (window.matchMedia('(min-width: 1025px)').matches) return
1606
+
1607
+ const header = panel.querySelector('.tile-panel-header')
1608
+ if (!header) return
1609
+
1610
+ let startX, startY, startRight, startTop
1611
+
1612
+ function onMove(e) {
1613
+ e.preventDefault() // stop page scroll while dragging the tile panel
1614
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX
1615
+ const clientY = e.touches ? e.touches[0].clientY : e.clientY
1616
+ const dx = startX - clientX
1617
+ const dy = clientY - startY
1618
+ const newRight = Math.max(0, Math.min(startRight + dx, window.innerWidth - 60))
1619
+ const newTop = Math.max(0, Math.min(startTop + dy, window.innerHeight - 60))
1620
+ panel.style.right = newRight + 'px'
1621
+ panel.style.top = newTop + 'px'
1622
+ }
1623
+
1624
+ function onEnd() {
1625
+ document.removeEventListener('mousemove', onMove)
1626
+ document.removeEventListener('mouseup', onEnd)
1627
+ document.removeEventListener('touchmove', onMove)
1628
+ document.removeEventListener('touchend', onEnd)
1629
+ try {
1630
+ const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
1631
+ localStorage.setItem(LAYOUT_KEY, JSON.stringify({
1632
+ ...saved,
1633
+ overlayRight: panel.style.right,
1634
+ overlayTop: panel.style.top,
1635
+ }))
1636
+ } catch { /* ignore */ }
1637
+ }
1638
+
1639
+ header.addEventListener('mousedown', e => {
1640
+ startX = e.clientX; startY = e.clientY
1641
+ startRight = parseInt(panel.style.right) || 0
1642
+ startTop = parseInt(panel.style.top) || 0
1643
+ document.addEventListener('mousemove', onMove)
1644
+ document.addEventListener('mouseup', onEnd)
1645
+ })
1646
+
1647
+ header.addEventListener('touchstart', e => {
1648
+ e.preventDefault() // prevent scroll from starting on the drag handle
1649
+ startX = e.touches[0].clientX; startY = e.touches[0].clientY
1650
+ startRight = parseInt(panel.style.right) || 0
1651
+ startTop = parseInt(panel.style.top) || 0
1652
+ document.addEventListener('touchmove', onMove, { passive: false })
1653
+ document.addEventListener('touchend', onEnd)
1654
+ }, { passive: false })
1655
+ })(tilePanelEl)
1656
+
1657
+ // ── Mini-bar (persists while navigating away during a call) ───────────────
1658
+
1659
+ function _showMiniBar() {
1660
+ if (!miniBarEl) return
1661
+ if (miniBarName) miniBarName.textContent = channelName()
1662
+ miniBarEl.classList.add('active')
1663
+ }
1664
+
1665
+ function _hideMiniBar() {
1666
+ miniBarEl?.classList.remove('active')
1667
+ }
1668
+
1669
+ ctrlMic?.addEventListener('click', toggleMic)
1670
+ ctrlCam?.addEventListener('click', toggleCamera)
1671
+ ctrlScreen?.addEventListener('click', toggleScreen)
1672
+ miniBarMic?.addEventListener('click', toggleMic)
1673
+
1674
+ miniBarReturn?.addEventListener('click', () => {
1675
+ if (callChannelId) navigateTo(`${window.__BASE_PATH__}/channels/${callChannelId}`, false)
1676
+ })
1677
+
1678
+ miniBarLeave?.addEventListener('click', () => {
1679
+ leaveCall()
1680
+ })
1681
+
1682
+ // Show mini-bar when user navigates to a different channel while in a call
1683
+ document.addEventListener('channelnavigated', (e) => {
1684
+ if (inCall() && e.detail?.channelId !== channelId) {
1685
+ _showMiniBar()
1686
+ }
1687
+ })
1688
+
1689
+ // SPA navigation: router morphed .chat-panel and dispatched this event.
1690
+ // Re-initialise chat state for the new channel without touching RTC.
1691
+ document.addEventListener('chatpanel:navigated', (e) => {
1692
+ const { channelId: newId, name, topic, kind, seedSeq: newSeedSeq, seedFirstSeq: newFirstSeq, seedHasMore: newHasMore } = e.detail
1693
+ if (newId === channelId) return // same channel — nothing to do
1694
+
1695
+ // Leave old channel subscription on the server
1696
+ ws.send({ t: 'channel.leave', body: { channel_id: channelId } })
1697
+
1698
+ // Update local identity
1699
+ channelId = newId
1700
+ channelKind = kind
1701
+ channelName.set(name)
1702
+ channelTopic.set(topic)
1703
+ afterSeq = newSeedSeq
1704
+
1705
+ // Reset pagination state for new channel
1706
+ oldestSeq = newFirstSeq ?? 0
1707
+ loadingMore = false
1708
+ if (sentinelEl) {
1709
+ sentinelEl.hidden = !newHasMore
1710
+ if (newHasMore) loadMoreObserver.observe(sentinelEl)
1711
+ else loadMoreObserver.unobserve(sentinelEl)
1712
+ }
1713
+
1714
+ // Update browser chrome
1715
+ document.title = `#${name} — devchitchat`
1716
+ const textarea = root.querySelector('#message-input')
1717
+ if (textarea) textarea.placeholder = `Message in ${name}`
1718
+
1719
+ // Hydrate attachments + dm-trigger on the freshly morphed seed articles
1720
+ hydrateSeedMessages()
1721
+
1722
+ closePicker()
1723
+
1724
+ // Join new channel — server responds with channel.joined + rtc.call_state.
1725
+ // channel.joined handler sends msg.list if afterSeq > 0, which will append
1726
+ // any messages that arrived after the seed snapshot.
1727
+ ws.send({ t: 'channel.join', body: { channel_id: channelId } })
1728
+ })
1729
+
1730
+ // ── Teardown ───────────────────────────────────────────────────────────────
1731
+
1732
+ function _teardownCall() {
1733
+ rtcManager.teardown()
1734
+
1735
+ audioStream?.getTracks().forEach(t => t.stop()); audioStream = null
1736
+ videoStream?.getTracks().forEach(t => t.stop()); videoStream = null
1737
+ screenStream?.getTracks().forEach(t => t.stop()); screenStream = null
1738
+
1739
+ document.querySelectorAll('audio[data-peer-id]').forEach(a => { a.srcObject = null; a.remove() })
1740
+ if (tileGridEl) tileGridEl.innerHTML = ''
1741
+ _updateTileLayout()
1742
+ _hideCallControls()
1743
+ _hideTilePanel()
1744
+ _hideMiniBar()
1745
+ _closePicker()
1746
+ _detachDeviceChangeListener()
1747
+ ctrlDevices?.classList.remove('device-warning')
1748
+
1749
+ micMuted.set(false)
1750
+ camOff.set(false)
1751
+ screenSharing.set(false)
1752
+ inCall.set(false)
1753
+ selfPeerId.set(null)
1754
+ callChannelId = null
1755
+ pinnedPeerId = null
1756
+ activeCameraId = null
1757
+ activeMicId = null
1758
+ }
1759
+
1760
+ // ── Mobile back button ─────────────────────────────────────────────────────
1761
+
1762
+ root.querySelector('.btn-back-mobile')?.addEventListener('click', () => {
1763
+ document.body.classList.add('sidebar-open')
1764
+ patchSettings({ mobile_chat_open: false })
1765
+ })
1766
+
1767
+ // ── Exports (rdbljs bindings) ──────────────────────────────────────────────
1768
+
1769
+ return { draft, channelName, channelTopic, urgentMode, urgentClass, sendMessage, handleComposerKey, toggleUrgentMode }
1770
+ }