@devchitchat/chat 4.4.4 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2282 +0,0 @@
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
- let activeMentionTextarea = null // which textarea triggered the picker
79
-
80
- // ── Call state ─────────────────────────────────────────────────────────────
81
- const inCall = signal(false)
82
- const callIdSig = signal(null) // active call_id in this channel (may exist before we join)
83
- let callChannelId = null // channel where the active call lives (may differ from channelId after navigation)
84
- const selfPeerId = signal(null)
85
- const micMuted = signal(false)
86
- const camOff = signal(false)
87
- const screenSharing = signal(false)
88
- let pinnedPeerId = null
89
-
90
- // ── Local media streams ────────────────────────────────────────────────────
91
- let audioStream = null // local mic
92
- let videoStream = null // local camera
93
- let screenStream = null // local screen share
94
- let iceServers = [{ urls: 'stun:stun.l.google.com:19302' }]
95
-
96
- // ── RTC peer manager ───────────────────────────────────────────────────────
97
- const rtcManager = new RtcPeerManager({
98
- iceServers,
99
- getLocalStreams: () => ({ audio: audioStream, video: videoStream, screen: screenStream }),
100
- handlers: {
101
- onOffer: (peerId, sdp) => ws.send({ t: 'rtc.offer', body: { call_id: callIdSig(), to_peer_id: peerId, sdp } }),
102
- onAnswer: (peerId, sdp) => ws.send({ t: 'rtc.answer', body: { call_id: callIdSig(), to_peer_id: peerId, sdp } }),
103
- onIceCandidate: (peerId, candidate) => ws.send({ t: 'rtc.ice', body: { call_id: callIdSig(), to_peer_id: peerId, candidate } }),
104
- onTrack: (peerId, tileId, stream, label) => { _renderTile(tileId, stream, false, label); _ensureRemoteAudio(stream, peerId) },
105
- onAudio: (peerId, stream) => _ensureRemoteAudio(stream, peerId),
106
- onPeerClosed: (peerId) => {
107
- tileGridEl?.querySelectorAll(`[data-peer^="${peerId}"]`).forEach(t => t.remove())
108
- document.querySelectorAll(`audio[data-peer-id="${peerId}"]`).forEach(a => { a.srcObject = null; a.remove() })
109
- _updateTileLayout()
110
- },
111
- },
112
- })
113
-
114
- // ── Device state ───────────────────────────────────────────────────────────
115
- const DEVICES_KEY = 'devchitchat_devices'
116
- let availableDevices = { cameras: [], mics: [] }
117
- let activeCameraId = null
118
- let activeMicId = null
119
-
120
- function loadSavedDevices() {
121
- try { return JSON.parse(localStorage.getItem(DEVICES_KEY) ?? '{}') } catch { return {} }
122
- }
123
- function saveDevices(patch) {
124
- localStorage.setItem(DEVICES_KEY, JSON.stringify({ ...loadSavedDevices(), ...patch }))
125
- }
126
- async function refreshDevices() {
127
- const devices = await navigator.mediaDevices.enumerateDevices()
128
- availableDevices = {
129
- cameras: devices.filter(d => d.kind === 'videoinput'),
130
- mics: devices.filter(d => d.kind === 'audioinput'),
131
- }
132
- return availableDevices
133
- }
134
-
135
- // ── Reaction bar ───────────────────────────────────────────────────────────
136
-
137
- function renderReactionBar(article, reactions, msgId) {
138
- const bar = article.querySelector('.reaction-bar')
139
- if (!bar) return
140
- bar.innerHTML = reactions.map(r => `
141
- <button class="reaction-pill${r.reacted ? ' reacted' : ''}"
142
- data-emoji="${escHtml(r.emoji)}" data-msg-id="${escHtml(msgId)}"
143
- type="button" title="${r.count} reaction${r.count !== 1 ? 's' : ''}">
144
- ${r.emoji} <span class="reaction-count">${r.count}</span>
145
- </button>`).join('')
146
- }
147
-
148
- // ── Hydrate seed message attachments ──────────────────────────────────────
149
- // Seed messages are SSR'd without attachment HTML. Process data-attachments now.
150
- // Called on initial mount and again after each SPA navigation morph.
151
- function hydrateSeedMessages() {
152
- const articles = Array.from(messages.querySelectorAll('article.message'))
153
- let prevDateKey = null
154
-
155
- for (const article of articles) {
156
- if (article.dataset.hydrated) continue
157
- article.dataset.hydrated = '1'
158
-
159
- // Add dm-trigger to non-self sender handles
160
- const handle = article.querySelector('.message-handle[data-user-id]')
161
- if (handle && handle.dataset.userId !== userId) {
162
- handle.classList.add('dm-trigger')
163
- handle.title = 'Send a direct message'
164
- }
165
- // Add hover action toolbar (react for all messages; … only for own)
166
- if (!article.querySelector('.message-hover-actions')) {
167
- const toolbar = document.createElement('div')
168
- toolbar.className = 'message-hover-actions'
169
- const quickPicks = document.createElement('span')
170
- quickPicks.className = 'quick-picks'
171
- toolbar.appendChild(quickPicks)
172
- const replyBtn = document.createElement('button')
173
- replyBtn.className = 'btn-reply btn-icon'
174
- replyBtn.type = 'button'
175
- replyBtn.title = 'Reply in thread'
176
- replyBtn.setAttribute('aria-label', 'Reply in thread')
177
- replyBtn.innerHTML = '&#x21A9;'
178
- toolbar.appendChild(replyBtn)
179
- const reactBtn = document.createElement('button')
180
- reactBtn.className = 'btn-react btn-icon'
181
- reactBtn.type = 'button'
182
- reactBtn.title = 'Add reaction'
183
- reactBtn.setAttribute('aria-label', 'Add reaction')
184
- reactBtn.textContent = '🙂'
185
- toolbar.appendChild(reactBtn)
186
- if (article.dataset.userId === userId) {
187
- const actionsBtn = document.createElement('button')
188
- actionsBtn.className = 'btn-msg-actions btn-icon'
189
- actionsBtn.type = 'button'
190
- actionsBtn.title = 'Message actions'
191
- actionsBtn.textContent = '…'
192
- toolbar.appendChild(actionsBtn)
193
- }
194
- article.appendChild(toolbar)
195
- renderQuickPicks(toolbar)
196
- }
197
-
198
- // Hydrate "view N replies" link for seed messages that have replies
199
- if (!article.querySelector('.thread-replies-link')) {
200
- const replyCount = parseInt(article.dataset.replyCount ?? '0', 10)
201
- if (replyCount > 0) {
202
- const msgId = article.dataset.msgId
203
- const link = document.createElement('a')
204
- link.className = 'thread-replies-link'
205
- link.href = '#'
206
- link.dataset.msgId = msgId
207
- link.textContent = `View ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`
208
- const reactionBar = article.querySelector('.reaction-bar')
209
- if (reactionBar) article.insertBefore(link, reactionBar)
210
- else article.appendChild(link)
211
- }
212
- }
213
-
214
- // Apply inline rendering (URLs, @mentions) to server-rendered message text.
215
- // Walk text nodes instead of replacing innerHTML so that <a> tags already
216
- // rendered server-side (e.g. from markdown link syntax) are preserved.
217
- const textEl = article.querySelector('.message-text')
218
- if (textEl) applyInlineRenderingToTextNodes(textEl, { userHandle })
219
-
220
- // Inject attachment HTML for seed messages that have attachments_json
221
- const raw = article.dataset.attachments
222
- if (raw) {
223
- let attachments
224
- try { attachments = JSON.parse(raw) } catch { attachments = null }
225
- if (Array.isArray(attachments) && attachments.length > 0) {
226
- attachments.forEach(a => article.insertAdjacentHTML('beforeend', renderAttachment(a)))
227
- }
228
- }
229
-
230
- // Hydrate reaction bar for seed messages
231
- const rawReactions = article.dataset.reactions
232
- const msgId = article.dataset.msgId
233
- if (msgId) {
234
- let reactions = []
235
- if (rawReactions) {
236
- try { reactions = JSON.parse(rawReactions) } catch { reactions = [] }
237
- }
238
- renderReactionBar(article, reactions, msgId)
239
- }
240
-
241
- enableTaskCheckboxes(article)
242
-
243
- // Date separator before this article if date changed
244
- const ts = parseInt(article.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
245
- if (ts) {
246
- // Re-format time in the browser's local timezone (SSR bakes UTC time)
247
- const timeEl = article.querySelector('.message-time')
248
- if (timeEl) {
249
- const localTime = new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
250
- const editedSpan = timeEl.querySelector('.message-edited')
251
- timeEl.textContent = localTime
252
- if (editedSpan) timeEl.appendChild(editedSpan)
253
- }
254
-
255
- const dateKey = utcDateKey(ts)
256
- if (prevDateKey && dateKey !== prevDateKey) {
257
- article.before(makeDateSeparator(dateKey))
258
- }
259
- prevDateKey = dateKey
260
- }
261
- }
262
- }
263
- hydrateSeedMessages()
264
- // Scroll to the bottom instantly on first load — requestAnimationFrame gives
265
- // the browser one layout cycle to settle flex heights before we measure
266
- // scrollHeight. behavior:'instant' bypasses scroll-behavior:smooth so there
267
- // is no visible animation from top to bottom on mount.
268
- requestAnimationFrame(() => messages.scrollTo({ top: messages.scrollHeight, behavior: 'instant' }))
269
-
270
- // ── Load-more sentinel + pagination ───────────────────────────────────────
271
-
272
- function showSentinel() { if (sentinelEl) sentinelEl.hidden = false }
273
- function hideSentinel() { if (sentinelEl) sentinelEl.hidden = true }
274
-
275
- if (root.dataset.seedHasMore === 'true') showSentinel()
276
-
277
- const loadMoreObserver = new IntersectionObserver(entries => {
278
- if (!entries[0].isIntersecting || loadingMore || oldestSeq <= 1) return
279
- loadingMore = true
280
- ws.send({ t: 'msg.list', body: { channel_id: channelId, before_seq: oldestSeq } })
281
- }, { root: messages, threshold: 0.1 })
282
-
283
- if (sentinelEl) loadMoreObserver.observe(sentinelEl)
284
-
285
- // ── Date separator helpers ─────────────────────────────────────────────────
286
- // utcDateKey, formatDateLabel, makeDateSeparator imported from shared/messages.js
287
-
288
- function prependMessages(msgs) {
289
- const prevHeight = messages.scrollHeight
290
- const fragment = document.createDocumentFragment()
291
- let prevDate = null
292
-
293
- const firstExisting = messages.querySelector('article.message')
294
- if (firstExisting) {
295
- const ts = parseInt(firstExisting.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
296
- if (ts) prevDate = utcDateKey(ts)
297
- }
298
-
299
- for (const m of msgs) {
300
- const dateKey = utcDateKey(m.ts)
301
- if (prevDate && dateKey !== prevDate) {
302
- fragment.appendChild(makeDateSeparator(prevDate))
303
- }
304
- fragment.appendChild(makeMessageEl(m, { userId, userHandle }))
305
- prevDate = dateKey
306
- }
307
-
308
- // If last prepended message is different day from first existing, insert separator before existing
309
- if (firstExisting && prevDate) {
310
- const existingTs = parseInt(firstExisting.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
311
- const existingDateKey = existingTs ? utcDateKey(existingTs) : null
312
- if (existingDateKey && prevDate !== existingDateKey) {
313
- messages.insertBefore(makeDateSeparator(existingDateKey), firstExisting)
314
- }
315
- }
316
-
317
- sentinelEl ? sentinelEl.after(fragment) : messages.prepend(fragment)
318
- messages.scrollTop += messages.scrollHeight - prevHeight
319
- }
320
-
321
- // ── @mention picker ────────────────────────────────────────────────────────
322
-
323
- const mentionPickerEl = document.createElement('div')
324
- mentionPickerEl.id = 'mention-picker'
325
- mentionPickerEl.className = 'mention-picker'
326
- mentionPickerEl.hidden = true
327
- root.querySelector('.composer')?.prepend(mentionPickerEl)
328
-
329
- function openPicker(filtered, start) {
330
- mentionFiltered = filtered
331
- mentionStart = start
332
- mentionSelIdx = 0
333
- renderPicker()
334
- }
335
-
336
- function closePicker() {
337
- mentionFiltered = []
338
- mentionStart = -1
339
- mentionPickerEl.hidden = true
340
- }
341
-
342
- function renderPicker() {
343
- if (mentionFiltered.length === 0) { closePicker(); return }
344
- mentionPickerEl.innerHTML = mentionFiltered.map((m, i) => `
345
- <button class="mention-option${i === mentionSelIdx ? ' selected' : ''}"
346
- data-idx="${i}" type="button">
347
- <span class="mention-option-name">${escHtml(m.display_name || m.handle)}</span>
348
- <span class="mention-option-handle">@${escHtml(m.handle)}</span>
349
- </button>`).join('')
350
- mentionPickerEl.hidden = false
351
- }
352
-
353
- function selectMention(member) {
354
- if (!member) return
355
- const textarea = activeMentionTextarea ?? root.querySelector('#message-input')
356
- if (!textarea) return
357
- const cursor = textarea.selectionStart
358
- const val = textarea.value
359
- const insert = `@${member.handle} `
360
- textarea.value = val.substring(0, mentionStart) + insert + val.substring(cursor)
361
- if (textarea.id === 'message-input') draft.set(textarea.value)
362
- const pos = mentionStart + insert.length
363
- textarea.setSelectionRange(pos, pos)
364
- closePicker()
365
- textarea.focus()
366
- }
367
-
368
- mentionPickerEl.addEventListener('mousedown', e => {
369
- // mousedown instead of click so the textarea doesn't lose focus first
370
- e.preventDefault()
371
- const btn = e.target.closest('.mention-option')
372
- if (!btn) return
373
- selectMention(mentionFiltered[parseInt(btn.dataset.idx, 10)])
374
- })
375
-
376
- function handleComposerInput(e) {
377
- const textarea = e.target
378
- const cursor = textarea.selectionStart
379
- const before = textarea.value.substring(0, cursor)
380
- // Match a bare @ or @partial-handle with no space, anchored to end of text-so-far
381
- const match = before.match(/@([a-zA-Z0-9_.-]*)$/)
382
- if (!match) { closePicker(); return }
383
- const query = match[1].toLowerCase()
384
- const start = cursor - match[0].length
385
- const filtered = [...channelMembers, ...channelBots]
386
- .filter(m =>
387
- m.handle.toLowerCase().startsWith(query) ||
388
- (m.display_name ?? '').toLowerCase().startsWith(query)
389
- )
390
- .slice(0, 8)
391
- if (filtered.length === 0) { closePicker(); return }
392
- openPicker(filtered, start)
393
- }
394
-
395
- const mainInputEl = root.querySelector('#message-input')
396
-
397
- mainInputEl?.addEventListener('input', handleComposerInput)
398
- mainInputEl?.addEventListener('focus', () => {
399
- activeMentionTextarea = mainInputEl
400
- root.querySelector('.composer')?.prepend(mentionPickerEl)
401
- })
402
-
403
- // ── Chat: connect + join channel ───────────────────────────────────────────
404
-
405
- ws.on('open', () => {
406
- ws.send({ t: 'hello', body: { client: 'devchitchat', resume: { session_token: null } } })
407
- })
408
-
409
- ws.on('hello_ack', () => {
410
- ws.send({ t: 'channel.join', body: { channel_id: channelId } })
411
- })
412
-
413
- ws.on('channel.joined', () => {
414
- if (afterSeq > 0) {
415
- ws.send({ t: 'msg.list', body: { channel_id: channelId, after_seq: afterSeq } })
416
- }
417
- if (channelMembers.length === 0) {
418
- ws.send({ t: 'user.list', body: {} })
419
- ws.send({ t: 'bot.list', body: {} })
420
- }
421
- })
422
-
423
- ws.on('user.list_result', ({ users }) => {
424
- channelMembers = (users ?? []).filter(m => m.handle)
425
- })
426
-
427
- ws.on('bot.list_result', ({ bots }) => {
428
- channelBots = (bots ?? []).filter(b => b.handle)
429
- })
430
-
431
- ws.on('msg.list_result', ({ messages: msgs, next_after_seq, has_more, direction }) => {
432
- if (direction === 'before') {
433
- if (msgs.length === 0) {
434
- hideSentinel()
435
- if (sentinelEl) loadMoreObserver.unobserve(sentinelEl)
436
- loadingMore = false
437
- return
438
- }
439
- prependMessages(msgs)
440
- if (msgs[0].seq < oldestSeq) oldestSeq = msgs[0].seq
441
- if (!has_more || oldestSeq <= 1) {
442
- hideSentinel()
443
- if (sentinelEl) loadMoreObserver.unobserve(sentinelEl)
444
- }
445
- loadingMore = false
446
- return
447
- }
448
- // after_seq catch-up path
449
- msgs.forEach(appendMessage)
450
- if (msgs.length) afterSeq = msgs[msgs.length - 1].seq
451
- else if (next_after_seq != null) afterSeq = next_after_seq
452
- })
453
-
454
- ws.on('msg.event', (body) => {
455
- if (body.channel_id !== channelId) return
456
- if (body.parent_msg_id) return // thread reply — handled via thread.reply_event
457
- appendMessage(body)
458
- afterSeq = body.seq
459
- })
460
-
461
- ws.on('msg.deleted', ({ msg_id }) => {
462
- const article = messages.querySelector(`[data-msg-id="${msg_id}"]`)
463
- ?? threadRepliesEl?.querySelector(`[data-msg-id="${msg_id}"]`)
464
- if (article) article.remove()
465
- })
466
-
467
- ws.on('msg.edited', ({ msg_id, text, edited_at, rendered_text }) => {
468
- const article = messages.querySelector(`[data-msg-id="${msg_id}"]`)
469
- ?? threadRepliesEl?.querySelector(`[data-msg-id="${msg_id}"]`)
470
- if (!article) return
471
- const textEl = article.querySelector('.message-text')
472
- if (textEl) { textEl.innerHTML = sanitizeHtml(rendered_text); enableTaskCheckboxes(article) }
473
- article.dataset.rawText = text
474
- article.dataset.editedAt = edited_at
475
- const timeEl = article.querySelector('.message-time')
476
- if (timeEl) {
477
- let editedSpan = timeEl.querySelector('.message-edited')
478
- if (!editedSpan) {
479
- editedSpan = document.createElement('span')
480
- editedSpan.className = 'message-edited'
481
- editedSpan.textContent = '(edited)'
482
- timeEl.appendChild(editedSpan)
483
- }
484
- }
485
- })
486
-
487
- ws.on('reaction.event', ({ msg_id, reactions }) => {
488
- // Check both the main message list and the open thread panel
489
- const article = messages.querySelector(`[data-msg-id="${msg_id}"]`)
490
- ?? threadRepliesEl?.querySelector(`[data-msg-id="${msg_id}"]`)
491
- if (article) renderReactionBar(article, reactions ?? [], msg_id)
492
- })
493
-
494
- ws.on('channel.updated', (body) => {
495
- if (body.channel?.channel_id !== channelId) return
496
- channelName.set(body.channel.name)
497
- channelTopic.set(body.channel.topic ?? '')
498
- document.title = `#${body.channel.name} — devchitchat`
499
- })
500
-
501
- // ── Thread panel ───────────────────────────────────────────────────────────
502
-
503
- const threadPanelEl = document.getElementById('thread-panel')
504
- const threadBodyEl = threadPanelEl?.querySelector('.thread-body')
505
- const threadAnchorEl = document.getElementById('thread-anchor')
506
- const threadRepliesEl = document.getElementById('thread-replies')
507
- const threadInputEl = document.getElementById('thread-input')
508
- const threadSendBtn = document.getElementById('thread-send')
509
-
510
- let activeThreadParentId = null
511
-
512
- function updateReplyCountLink(article, count) {
513
- let link = article.querySelector('.thread-replies-link')
514
- if (count > 0) {
515
- const label = `View ${count} ${count === 1 ? 'reply' : 'replies'}`
516
- if (!link) {
517
- link = document.createElement('a')
518
- link.className = 'thread-replies-link'
519
- link.href = '#'
520
- link.dataset.msgId = article.dataset.msgId
521
- const reactionBar = article.querySelector('.reaction-bar')
522
- if (reactionBar) article.insertBefore(link, reactionBar)
523
- else article.appendChild(link)
524
- }
525
- link.textContent = label
526
- }
527
- }
528
-
529
- function openThread(parentMsgId) {
530
- activeThreadParentId = parentMsgId
531
- const parentArticle = messages.querySelector(`[data-msg-id="${parentMsgId}"]`)
532
-
533
- // Render anchor (clone parent message, strip hover actions)
534
- if (threadAnchorEl) {
535
- threadAnchorEl.innerHTML = ''
536
- if (parentArticle) {
537
- const clone = parentArticle.cloneNode(true)
538
- clone.querySelector('.message-hover-actions')?.remove()
539
- clone.querySelector('.thread-replies-link')?.remove()
540
- threadAnchorEl.appendChild(clone)
541
- }
542
- }
543
-
544
- if (threadRepliesEl) threadRepliesEl.innerHTML = '<p class="thread-loading">Loading…</p>'
545
- threadPanelEl?.classList.add('active')
546
-
547
- ws.send({ t: 'thread.list', body: { parent_msg_id: parentMsgId, channel_id: channelId } })
548
-
549
- setTimeout(() => threadInputEl?.focus(), 50)
550
- }
551
-
552
- function closeThread() {
553
- threadPanelEl?.classList.remove('active')
554
- activeThreadParentId = null
555
- if (threadAnchorEl) threadAnchorEl.innerHTML = ''
556
- if (threadRepliesEl) threadRepliesEl.innerHTML = ''
557
- }
558
-
559
- // Use delegation so both the top ✕ and the mobile footer "Close thread" button work
560
- threadPanelEl?.addEventListener('click', e => {
561
- if (e.target.closest('.thread-panel-close')) closeThread()
562
- })
563
-
564
- function sendThreadReply() {
565
- const text = threadInputEl?.value.trim()
566
- if (!text || !activeThreadParentId) return
567
- ws.send({ t: 'msg.send', body: { channel_id: channelId, text, parent_msg_id: activeThreadParentId } })
568
- if (threadInputEl) threadInputEl.value = ''
569
- }
570
-
571
- threadSendBtn?.addEventListener('click', sendThreadReply)
572
- threadInputEl?.addEventListener('input', handleComposerInput)
573
- threadInputEl?.addEventListener('focus', () => {
574
- activeMentionTextarea = threadInputEl
575
- root.querySelector('.thread-composer')?.prepend(mentionPickerEl)
576
- })
577
- threadInputEl?.addEventListener('keydown', e => {
578
- if (!mentionPickerEl.hidden) {
579
- if (e.key === 'ArrowDown') { e.preventDefault(); mentionSelIdx = Math.min(mentionSelIdx + 1, mentionFiltered.length - 1); renderPicker(); return }
580
- if (e.key === 'ArrowUp') { e.preventDefault(); mentionSelIdx = Math.max(mentionSelIdx - 1, 0); renderPicker(); return }
581
- if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); selectMention(mentionFiltered[mentionSelIdx]); return }
582
- if (e.key === 'Escape') { closePicker(); return }
583
- }
584
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendThreadReply() }
585
- })
586
-
587
- ws.on('thread.list_result', ({ parent_msg_id, replies }) => {
588
- if (parent_msg_id !== activeThreadParentId) return
589
- if (!threadRepliesEl) return
590
- threadRepliesEl.innerHTML = ''
591
- if (!replies.length) {
592
- threadRepliesEl.innerHTML = '<p class="thread-empty">No replies yet. Be the first!</p>'
593
- return
594
- }
595
- for (const reply of replies) {
596
- const article = makeMessageEl(reply, { userId, userHandle, isThreadReply: true })
597
- renderQuickPicks(article.querySelector('.message-hover-actions'))
598
- if (reply.reactions?.length) renderReactionBar(article, reply.reactions, reply.msg_id)
599
- threadRepliesEl.appendChild(article)
600
- }
601
- if (threadBodyEl) threadBodyEl.scrollTop = threadBodyEl.scrollHeight
602
- })
603
-
604
- ws.on('thread.reply_event', ({ parent_msg_id, channel_id: evtChannelId, reply }) => {
605
- if (evtChannelId !== channelId) return
606
-
607
- // Update reply count on parent message in the channel list
608
- const parentArticle = messages.querySelector(`[data-msg-id="${parent_msg_id}"]`)
609
- if (parentArticle) {
610
- const current = parseInt(parentArticle.dataset.replyCount ?? '0', 10)
611
- const next = current + 1
612
- parentArticle.dataset.replyCount = String(next)
613
- updateReplyCountLink(parentArticle, next)
614
- }
615
-
616
- // Append reply to thread panel if it's open for this parent
617
- if (activeThreadParentId === parent_msg_id && threadRepliesEl) {
618
- const emptyEl = threadRepliesEl.querySelector('.thread-empty')
619
- if (emptyEl) emptyEl.remove()
620
- const article = makeMessageEl(reply, { userId, userHandle, isThreadReply: true })
621
- renderQuickPicks(article.querySelector('.message-hover-actions'))
622
- threadRepliesEl.appendChild(article)
623
- if (threadBodyEl) threadBodyEl.scrollTop = threadBodyEl.scrollHeight
624
- }
625
- })
626
-
627
- // Delegated click: reply button → open thread panel
628
- messages.addEventListener('click', e => {
629
- const btn = e.target.closest('.btn-reply')
630
- if (!btn) return
631
- e.stopPropagation()
632
- const article = btn.closest('article.message')
633
- const msgId = article?.dataset.msgId
634
- if (!msgId) return
635
- openThread(msgId)
636
- })
637
-
638
- // Delegated click: "view N replies" link → open thread panel
639
- messages.addEventListener('click', e => {
640
- const link = e.target.closest('.thread-replies-link')
641
- if (!link) return
642
- e.preventDefault()
643
- e.stopPropagation()
644
- const msgId = link.dataset.msgId
645
- if (!msgId) return
646
- openThread(msgId)
647
- })
648
-
649
- // ── Chat: composer ─────────────────────────────────────────────────────────
650
-
651
- // Pending attachments: [{ upload_id, url, original_name, mime_type, size_bytes }]
652
- let pendingAttachments = []
653
-
654
- const composerEl = root.querySelector('.composer')
655
- const textareaEl = root.querySelector('#message-input')
656
-
657
- // Attachment chips container — injected above the textarea
658
- const chipsEl = document.createElement('div')
659
- chipsEl.className = 'attachment-chips'
660
- composerEl?.insertBefore(chipsEl, textareaEl)
661
-
662
- // Hidden file input
663
- const fileInputEl = document.createElement('input')
664
- fileInputEl.type = 'file'
665
- fileInputEl.multiple = true
666
- fileInputEl.style.display = 'none'
667
- fileInputEl.setAttribute('aria-hidden', 'true')
668
- composerEl?.appendChild(fileInputEl)
669
-
670
- // Attach-file button (paperclip)
671
- const btnAttachEl = document.createElement('button')
672
- btnAttachEl.type = 'button'
673
- btnAttachEl.className = 'btn-attach btn-icon'
674
- btnAttachEl.title = 'Attach file'
675
- btnAttachEl.setAttribute('aria-label', 'Attach file')
676
- btnAttachEl.innerHTML = '📎'
677
- // Insert before the send button
678
- const btnSendEl = composerEl?.querySelector('.btn-send')
679
- if (btnSendEl && composerEl) composerEl.insertBefore(btnAttachEl, btnSendEl)
680
-
681
- btnAttachEl.addEventListener('click', () => fileInputEl.click())
682
- fileInputEl.addEventListener('change', () => {
683
- uploadFiles([...fileInputEl.files])
684
- fileInputEl.value = ''
685
- })
686
-
687
- // Drag-and-drop onto the textarea
688
- let dropOverlayEl = null
689
-
690
- function ensureDropOverlay() {
691
- if (dropOverlayEl) return dropOverlayEl
692
- dropOverlayEl = document.createElement('div')
693
- dropOverlayEl.className = 'drop-overlay'
694
- dropOverlayEl.textContent = 'Drop to attach'
695
- composerEl?.appendChild(dropOverlayEl)
696
- return dropOverlayEl
697
- }
698
-
699
- composerEl?.addEventListener('dragover', e => {
700
- if (!e.dataTransfer.types.includes('Files')) return
701
- e.preventDefault()
702
- ensureDropOverlay().hidden = false
703
- })
704
-
705
- composerEl?.addEventListener('dragleave', e => {
706
- if (composerEl.contains(e.relatedTarget)) return
707
- if (dropOverlayEl) dropOverlayEl.hidden = true
708
- })
709
-
710
- composerEl?.addEventListener('drop', e => {
711
- e.preventDefault()
712
- if (dropOverlayEl) dropOverlayEl.hidden = true
713
- const files = [...(e.dataTransfer.files ?? [])]
714
- if (files.length > 0) uploadFiles(files)
715
- })
716
-
717
- // Paste image from clipboard (screenshots, copied images)
718
- textareaEl?.addEventListener('paste', e => {
719
- const items = [...(e.clipboardData?.items ?? [])]
720
- const imageFiles = items
721
- .filter(item => item.kind === 'file' && item.type.startsWith('image/'))
722
- .map(item => {
723
- const file = item.getAsFile()
724
- if (!file) return null
725
- if (!file.name) {
726
- const ext = item.type.split('/')[1] ?? 'png'
727
- return new File([file], `paste-${Date.now()}.${ext}`, { type: item.type })
728
- }
729
- return file
730
- })
731
- .filter(Boolean)
732
- if (imageFiles.length === 0) return
733
- e.preventDefault()
734
- uploadFiles(imageFiles)
735
- })
736
-
737
- async function uploadFiles(files) {
738
- for (const file of files) {
739
- await uploadOneFile(file, channelId)
740
- }
741
- }
742
-
743
- async function uploadOneFile(file, targetChannelId) {
744
- const formData = new FormData()
745
- formData.append('file', file)
746
- formData.append('channel_id', targetChannelId)
747
-
748
- let res
749
- try {
750
- res = await fetch(`${window.__BASE_PATH__}/api/uploads`, { method: 'POST', body: formData })
751
- } catch {
752
- showComposerError(`Upload failed: network error`)
753
- return null
754
- }
755
-
756
- if (!res.ok) {
757
- const body = await res.json().catch(() => ({}))
758
- showComposerError(`Upload failed: ${body.error ?? res.statusText}`)
759
- return null
760
- }
761
-
762
- const attachment = await res.json()
763
- pendingAttachments.push(attachment)
764
- renderChips()
765
- return attachment
766
- }
767
-
768
- function renderChips() {
769
- const html = pendingAttachments.map((a, i) => `
770
- <span class="attachment-chip" data-index="${i}">
771
- <span class="attachment-chip-name">${escHtml(a.original_name)}</span>
772
- <button type="button" class="attachment-chip-remove" data-index="${i}" aria-label="Remove ${escHtml(a.original_name)}">×</button>
773
- </span>
774
- `).join('')
775
- chipsEl.innerHTML = html
776
- chipsEl.hidden = pendingAttachments.length === 0
777
- if (composeChipsEl) {
778
- composeChipsEl.innerHTML = html
779
- composeChipsEl.hidden = pendingAttachments.length === 0
780
- }
781
- }
782
-
783
- function _removeChipAt(idx) {
784
- pendingAttachments.splice(idx, 1)
785
- renderChips()
786
- }
787
-
788
- chipsEl.addEventListener('click', e => {
789
- const btn = e.target.closest('.attachment-chip-remove')
790
- if (!btn) return
791
- _removeChipAt(parseInt(btn.dataset.index, 10))
792
- })
793
-
794
- function showComposerError(msg) {
795
- const target = composeOpen ? composeChipsEl : chipsEl
796
- if (!target) return
797
- const chip = document.createElement('span')
798
- chip.className = 'attachment-chip attachment-chip-error'
799
- chip.textContent = msg
800
- target.appendChild(chip)
801
- target.hidden = false
802
- setTimeout(() => chip.remove(), 5000)
803
- }
804
-
805
- // ── Compose overlay ───────────────────────────────────────────────────────
806
- const composeOverlayEl = root.querySelector('#compose-overlay')
807
- const composeTaEl = document.getElementById('compose-textarea')
808
- const composePreviewEl = document.getElementById('compose-preview')
809
- const composeCollapseBtn = document.getElementById('btn-compose-collapse')
810
- const composeSendBtn = document.getElementById('compose-send')
811
- const composeUrgentBtn = document.getElementById('compose-urgent-toggle')
812
- const composeChipsEl = document.getElementById('compose-chips')
813
- const composeBtnAttach = document.getElementById('compose-attach')
814
-
815
- let composeOpen = false
816
-
817
- function openComposeMode() {
818
- if (composeOpen) return
819
- composeOpen = true
820
- if (composeTaEl) composeTaEl.value = draft()
821
- messages.hidden = true
822
- composerEl.hidden = true
823
- if (composeOverlayEl) composeOverlayEl.hidden = false
824
- _switchComposeTab('write')
825
- requestAnimationFrame(() => {
826
- if (!composeTaEl) return
827
- composeTaEl.focus()
828
- const len = composeTaEl.value.length
829
- composeTaEl.setSelectionRange(len, len)
830
- })
831
- }
832
-
833
- function closeComposeMode() {
834
- if (!composeOpen) return
835
- composeOpen = false
836
- messages.hidden = false
837
- composerEl.hidden = false
838
- if (composeOverlayEl) composeOverlayEl.hidden = true
839
- textareaEl?.focus()
840
- }
841
-
842
- function toggleComposeMode() {
843
- composeOpen ? closeComposeMode() : openComposeMode()
844
- }
845
-
846
- function _switchComposeTab(tab) {
847
- root.querySelectorAll('.compose-tab').forEach(btn => {
848
- const active = btn.dataset.tab === tab
849
- btn.classList.toggle('compose-tab--active', active)
850
- btn.setAttribute('aria-selected', String(active))
851
- })
852
- if (composeTaEl) composeTaEl.hidden = tab !== 'write'
853
- if (composePreviewEl) composePreviewEl.hidden = tab !== 'preview'
854
- if (tab === 'preview') _renderComposePreview()
855
- }
856
-
857
- async function _renderComposePreview() {
858
- const text = composeTaEl?.value ?? ''
859
- if (!text.trim()) {
860
- if (composePreviewEl) composePreviewEl.innerHTML = '<p style="color:var(--text-muted)">Nothing to preview yet.</p>'
861
- return
862
- }
863
- try {
864
- const res = await fetch(`${window.__BASE_PATH__}/api/preview`, {
865
- method: 'POST',
866
- headers: { 'Content-Type': 'application/json' },
867
- body: JSON.stringify({ text }),
868
- })
869
- if (res.ok) {
870
- const { html } = await res.json()
871
- if (composePreviewEl) composePreviewEl.innerHTML = sanitizeHtml(html)
872
- }
873
- } catch {
874
- if (composePreviewEl) composePreviewEl.textContent = text
875
- }
876
- }
877
-
878
- // Sync compose textarea → draft signal
879
- composeTaEl?.addEventListener('input', () => { draft.set(composeTaEl.value) })
880
-
881
- // Ctrl+Enter / Cmd+Enter in compose textarea → send and collapse
882
- composeTaEl?.addEventListener('keydown', e => {
883
- if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
884
- e.preventDefault()
885
- sendMessage()
886
- closeComposeMode()
887
- }
888
- })
889
-
890
- // Tab strip
891
- root.querySelectorAll('.compose-tab').forEach(btn => {
892
- btn.addEventListener('click', () => _switchComposeTab(btn.dataset.tab))
893
- })
894
-
895
- // Collapse, send, and attach buttons
896
- composeCollapseBtn?.addEventListener('click', closeComposeMode)
897
- composeSendBtn?.addEventListener('click', () => { sendMessage(); closeComposeMode() })
898
- composeUrgentBtn?.addEventListener('click', () => toggleUrgentMode())
899
- composeBtnAttach?.addEventListener('click', () => fileInputEl.click())
900
- composeChipsEl?.addEventListener('click', e => {
901
- const btn = e.target.closest('.attachment-chip-remove')
902
- if (!btn) return
903
- _removeChipAt(parseInt(btn.dataset.index, 10))
904
- })
905
-
906
- // Paste images into the compose textarea
907
- composeTaEl?.addEventListener('paste', e => {
908
- const items = [...(e.clipboardData?.items ?? [])]
909
- const imageFiles = items
910
- .filter(item => item.kind === 'file' && item.type.startsWith('image/'))
911
- .map(item => {
912
- const file = item.getAsFile()
913
- if (!file) return null
914
- if (!file.name) {
915
- const ext = item.type.split('/')[1] ?? 'png'
916
- return new File([file], `paste-${Date.now()}.${ext}`, { type: item.type })
917
- }
918
- return file
919
- })
920
- .filter(Boolean)
921
- if (imageFiles.length === 0) return
922
- e.preventDefault()
923
- uploadFiles(imageFiles)
924
- })
925
-
926
- // Drag-and-drop files onto the compose overlay
927
- let composeDropOverlayEl = null
928
- function ensureComposeDropOverlay() {
929
- if (composeDropOverlayEl) return composeDropOverlayEl
930
- composeDropOverlayEl = document.createElement('div')
931
- composeDropOverlayEl.className = 'drop-overlay'
932
- composeDropOverlayEl.textContent = 'Drop to attach'
933
- composeOverlayEl?.appendChild(composeDropOverlayEl)
934
- return composeDropOverlayEl
935
- }
936
- composeOverlayEl?.addEventListener('dragover', e => {
937
- if (!e.dataTransfer.types.includes('Files')) return
938
- e.preventDefault()
939
- ensureComposeDropOverlay().hidden = false
940
- })
941
- composeOverlayEl?.addEventListener('dragleave', e => {
942
- if (composeOverlayEl.contains(e.relatedTarget)) return
943
- if (composeDropOverlayEl) composeDropOverlayEl.hidden = true
944
- })
945
- composeOverlayEl?.addEventListener('drop', e => {
946
- e.preventDefault()
947
- if (composeDropOverlayEl) composeDropOverlayEl.hidden = true
948
- const files = [...(e.dataTransfer.files ?? [])]
949
- if (files.length > 0) uploadFiles(files)
950
- })
951
-
952
- // Ctrl+E / Cmd+E anywhere to toggle compose mode
953
- document.addEventListener('keydown', e => {
954
- if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
955
- e.preventDefault()
956
- toggleComposeMode()
957
- }
958
- })
959
-
960
- // ── Urgent send ───────────────────────────────────────────────────────────
961
- const urgentMode = signal(false)
962
- const composerFooter = root.querySelector('.composer')
963
-
964
- const urgentClass = computed(() => ({ 'is-urgent': urgentMode() }))
965
-
966
- function toggleUrgentMode() {
967
- urgentMode.set(!urgentMode())
968
- composerFooter?.classList.toggle('composer-urgent', urgentMode())
969
- composeOverlayEl?.classList.toggle('composer-urgent', urgentMode())
970
- composeUrgentBtn?.classList.toggle('is-urgent', urgentMode())
971
- }
972
-
973
- function sendMessage({ priority } = {}) {
974
- const text = draft().trim()
975
- if (!text && pendingAttachments.length === 0) return
976
- const resolvedPriority = priority ?? (urgentMode() ? 'now' : 'normal')
977
- ws.send({
978
- t: 'msg.send',
979
- body: {
980
- channel_id: channelId,
981
- text,
982
- client_msg_id: `local_${Date.now()}`,
983
- priority: resolvedPriority,
984
- attachments: pendingAttachments.map(a => ({
985
- upload_id: a.upload_id,
986
- url: a.url,
987
- filename: a.original_name,
988
- mime_type: a.mime_type,
989
- size_bytes: a.size_bytes,
990
- }))
991
- }
992
- })
993
- draft.set('')
994
- pendingAttachments = []
995
- renderChips()
996
- }
997
-
998
- function handleComposerKey(e) {
999
- if (!mentionPickerEl.hidden) {
1000
- if (e.key === 'ArrowDown') {
1001
- e.preventDefault()
1002
- mentionSelIdx = Math.min(mentionSelIdx + 1, mentionFiltered.length - 1)
1003
- renderPicker()
1004
- return
1005
- }
1006
- if (e.key === 'ArrowUp') {
1007
- e.preventDefault()
1008
- mentionSelIdx = Math.max(mentionSelIdx - 1, 0)
1009
- renderPicker()
1010
- return
1011
- }
1012
- if (e.key === 'Enter' || e.key === 'Tab') {
1013
- e.preventDefault()
1014
- selectMention(mentionFiltered[mentionSelIdx])
1015
- return
1016
- }
1017
- if (e.key === 'Escape') {
1018
- closePicker()
1019
- return
1020
- }
1021
- }
1022
- if (e.key === 'Enter' && !e.shiftKey) {
1023
- e.preventDefault()
1024
- const priority = e.ctrlKey || e.metaKey ? 'now' : undefined
1025
- sendMessage({ priority })
1026
- }
1027
- }
1028
-
1029
- function appendMessage({ msg_id, seq, user_id, user_display_name, ts, text, rendered_text, attachments, reactions }) {
1030
- if (messages.querySelector(`[data-msg-id="${msg_id}"]`)) return
1031
-
1032
- // Date separator if day changed
1033
- const dateKey = utcDateKey(ts)
1034
- const lastMsg = messages.querySelector('article.message:last-of-type')
1035
- if (lastMsg) {
1036
- const lastTs = parseInt(lastMsg.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
1037
- if (lastTs && utcDateKey(lastTs) !== dateKey) {
1038
- messages.appendChild(makeDateSeparator(dateKey))
1039
- }
1040
- }
1041
-
1042
- const article = makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, text, rendered_text, attachments }, { userId, userHandle })
1043
-
1044
- // Ensure reaction bar exists in dynamically created messages
1045
- if (!article.querySelector('.reaction-bar')) {
1046
- const bar = document.createElement('div')
1047
- bar.className = 'reaction-bar'
1048
- article.appendChild(bar)
1049
- }
1050
-
1051
- enableTaskCheckboxes(article)
1052
- const toolbar = article.querySelector('.message-hover-actions')
1053
- if (toolbar) renderQuickPicks(toolbar)
1054
- messages.appendChild(article)
1055
- renderReactionBar(article, reactions ?? [], msg_id)
1056
- messages.scrollTop = messages.scrollHeight
1057
- }
1058
-
1059
- // renderAttachment, formatBytes imported from shared/messages.js
1060
-
1061
- function sanitizeHtml(html) {
1062
- return String(html ?? '').replaceAll('<script>', '').replaceAll('</script>', '')
1063
- }
1064
-
1065
- // Enable task-list checkboxes so they're clickable (renderer marks them disabled)
1066
- function enableTaskCheckboxes(article) {
1067
- for (const cb of article.querySelectorAll('.task-list-item-checkbox[disabled]')) {
1068
- cb.removeAttribute('disabled')
1069
- }
1070
- }
1071
-
1072
- // Delegated click: message sender name → open DM
1073
- messages.addEventListener('click', e => {
1074
- const handle = e.target.closest('.dm-trigger')
1075
- if (!handle) return
1076
- const targetUserId = handle.dataset.userId
1077
- if (!targetUserId || targetUserId === userId) return
1078
- ws.send({ t: 'dm.open', body: { target_user_id: targetUserId } })
1079
- })
1080
-
1081
- // ── Emoji picker ──────────────────────────────────────────────────────────
1082
-
1083
- const RECENT_KEY = 'devchitchat_recent_emoji'
1084
- const RECENT_MAX = 24
1085
-
1086
- function loadRecentEmoji() {
1087
- try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') } catch { return [] }
1088
- }
1089
-
1090
- function saveRecentEmoji(emoji) {
1091
- let recents = loadRecentEmoji().filter(e => e !== emoji)
1092
- recents.unshift(emoji)
1093
- if (recents.length > RECENT_MAX) recents = recents.slice(0, RECENT_MAX)
1094
- localStorage.setItem(RECENT_KEY, JSON.stringify(recents))
1095
- refreshAllQuickPicks()
1096
- }
1097
-
1098
- function renderQuickPicks(toolbar) {
1099
- const slot = toolbar.querySelector('.quick-picks')
1100
- if (!slot) return
1101
- const recents = loadRecentEmoji().slice(0, 4)
1102
- slot.innerHTML = recents.map(emoji =>
1103
- `<button class="btn-quick-react btn-icon" data-emoji="${escHtml(emoji)}" type="button" title="${escHtml(emoji)}">${emoji}</button>`
1104
- ).join('')
1105
- }
1106
-
1107
- function refreshAllQuickPicks() {
1108
- for (const toolbar of messages.querySelectorAll('.message-hover-actions')) {
1109
- renderQuickPicks(toolbar)
1110
- }
1111
- if (threadRepliesEl) {
1112
- for (const toolbar of threadRepliesEl.querySelectorAll('.message-hover-actions')) {
1113
- renderQuickPicks(toolbar)
1114
- }
1115
- }
1116
- }
1117
-
1118
- let emojiPickerEl = null
1119
- let emojiPickerCurrentCat = 'smileys'
1120
- let emojiPickerTarget = null // msg_id the picker is for
1121
-
1122
- function buildEmojiPicker() {
1123
- emojiPickerEl = document.createElement('div')
1124
- emojiPickerEl.className = 'emoji-picker'
1125
-
1126
- const searchInput = document.createElement('input')
1127
- searchInput.type = 'search'
1128
- searchInput.className = 'emoji-picker-search'
1129
- searchInput.placeholder = 'Search emoji…'
1130
- searchInput.setAttribute('aria-label', 'Search emoji')
1131
- emojiPickerEl.appendChild(searchInput)
1132
-
1133
- const tabs = document.createElement('div')
1134
- tabs.className = 'emoji-picker-tabs'
1135
- for (const cat of CATEGORIES) {
1136
- const btn = document.createElement('button')
1137
- btn.type = 'button'
1138
- btn.className = 'emoji-picker-tab' + (cat.id === emojiPickerCurrentCat ? ' active' : '')
1139
- btn.dataset.catId = cat.id
1140
- btn.textContent = cat.label
1141
- btn.title = cat.id
1142
- tabs.appendChild(btn)
1143
- }
1144
- emojiPickerEl.appendChild(tabs)
1145
-
1146
- const grid = document.createElement('div')
1147
- grid.className = 'emoji-picker-grid'
1148
- emojiPickerEl.appendChild(grid)
1149
-
1150
- tabs.addEventListener('click', e => {
1151
- const btn = e.target.closest('.emoji-picker-tab')
1152
- if (!btn) return
1153
- emojiPickerCurrentCat = btn.dataset.catId
1154
- tabs.querySelectorAll('.emoji-picker-tab').forEach(b => b.classList.toggle('active', b.dataset.catId === emojiPickerCurrentCat))
1155
- searchInput.value = ''
1156
- renderEmojiGrid(null)
1157
- })
1158
-
1159
- searchInput.addEventListener('input', () => {
1160
- renderEmojiGrid(searchInput.value.trim().toLowerCase())
1161
- })
1162
-
1163
- grid.addEventListener('click', e => {
1164
- const btn = e.target.closest('button[data-emoji]')
1165
- if (!btn) return
1166
- const emoji = btn.dataset.emoji
1167
- saveRecentEmoji(emoji)
1168
- emojiPickerEl.dispatchEvent(new CustomEvent('emoji:pick', { bubbles: true, detail: { emoji } }))
1169
- })
1170
-
1171
- renderEmojiGrid(null)
1172
- return emojiPickerEl
1173
- }
1174
-
1175
- function renderEmojiGrid(query) {
1176
- if (!emojiPickerEl) return
1177
- const grid = emojiPickerEl.querySelector('.emoji-picker-grid')
1178
- if (!grid) return
1179
-
1180
- let emojiList
1181
- if (query) {
1182
- // Search across all categories
1183
- const allEmoji = CATEGORIES.flatMap(c => c.emoji)
1184
- const unique = [...new Set(allEmoji)]
1185
- emojiList = unique.filter(e => {
1186
- const name = EMOJI_NAMES[e] ?? ''
1187
- return name.includes(query) || e.includes(query)
1188
- })
1189
- } else {
1190
- if (emojiPickerCurrentCat === 'recent') {
1191
- emojiList = loadRecentEmoji()
1192
- } else {
1193
- const cat = CATEGORIES.find(c => c.id === emojiPickerCurrentCat)
1194
- emojiList = cat ? cat.emoji : []
1195
- }
1196
- }
1197
-
1198
- grid.innerHTML = emojiList.map(e =>
1199
- `<button type="button" data-emoji="${escHtml(e)}" title="${escHtml(EMOJI_NAMES[e] ?? e)}">${e}</button>`
1200
- ).join('')
1201
- }
1202
-
1203
- function getOrBuildEmojiPicker() {
1204
- if (!emojiPickerEl) buildEmojiPicker()
1205
- return emojiPickerEl
1206
- }
1207
-
1208
- function openEmojiPickerAt(anchorEl, msgId) {
1209
- emojiPickerTarget = msgId
1210
- const picker = getOrBuildEmojiPicker()
1211
-
1212
- // Refresh recent tab if active
1213
- if (emojiPickerCurrentCat === 'recent') renderEmojiGrid(null)
1214
-
1215
- // Attach handler once (use named function to avoid duplicates)
1216
- picker.onEmojiPickHandler = (e) => {
1217
- const { emoji } = e.detail
1218
- closeEmojiPicker()
1219
- ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
1220
- }
1221
- picker.removeEventListener('emoji:pick', picker._boundEmojiPick)
1222
- picker._boundEmojiPick = picker.onEmojiPickHandler
1223
- picker.addEventListener('emoji:pick', picker._boundEmojiPick)
1224
-
1225
- document.body.appendChild(picker)
1226
- picker.style.position = 'fixed'
1227
- picker.style.zIndex = '400'
1228
-
1229
- // Position below the anchor, viewport-aware
1230
- const rect = anchorEl.getBoundingClientRect()
1231
- picker.style.top = `${rect.bottom + 4}px`
1232
- picker.style.left = `${rect.left}px`
1233
-
1234
- // Force layout so getBoundingClientRect is accurate
1235
- requestAnimationFrame(() => {
1236
- const pickerRect = picker.getBoundingClientRect()
1237
- let left = rect.left
1238
- if (left + pickerRect.width > window.innerWidth - 8) {
1239
- left = window.innerWidth - 8 - pickerRect.width
1240
- }
1241
- if (left < 8) left = 8
1242
- picker.style.left = `${left}px`
1243
-
1244
- // Flip above if not enough room below
1245
- if (rect.bottom + 4 + pickerRect.height > window.innerHeight - 8) {
1246
- picker.style.top = `${rect.top - 4 - pickerRect.height}px`
1247
- }
1248
- })
1249
- }
1250
-
1251
- function closeEmojiPicker() {
1252
- if (emojiPickerEl && emojiPickerEl.parentNode) emojiPickerEl.parentNode.removeChild(emojiPickerEl)
1253
- emojiPickerTarget = null
1254
- }
1255
-
1256
- document.addEventListener('click', e => {
1257
- // Close emoji picker on click-outside
1258
- if (emojiPickerEl && emojiPickerEl.parentNode && !emojiPickerEl.contains(e.target)) {
1259
- const isReactionAddBtn = e.target.closest('.reaction-add') || e.target.closest('.btn-react')
1260
- if (!isReactionAddBtn) closeEmojiPicker()
1261
- }
1262
- }, { capture: true })
1263
-
1264
- document.addEventListener('keydown', e => {
1265
- if (e.key === 'Escape') {
1266
- if (activeEditCancel) { activeEditCancel(); return }
1267
- if (composeOpen) { closeComposeMode(); return }
1268
- closeEmojiPicker()
1269
- }
1270
- })
1271
-
1272
- // Delegated click on task-list checkboxes — toggle [ ] ↔ [x] and save via msg.edit
1273
- messages.addEventListener('click', e => {
1274
- const cb = e.target.closest('.task-list-item-checkbox')
1275
- if (!cb) return
1276
- e.preventDefault() // we control the toggle ourselves
1277
- const article = cb.closest('article.message')
1278
- if (!article) return
1279
- const rawText = article.dataset.rawText
1280
- if (!rawText) return
1281
- const allCbs = Array.from(article.querySelectorAll('.task-list-item-checkbox'))
1282
- const idx = allCbs.indexOf(cb)
1283
- if (idx === -1) return
1284
- let count = 0
1285
- const newText = rawText.replace(/\[([ xX])\]/g, (match, state) => {
1286
- if (count++ !== idx) return match
1287
- return state.trim() === '' ? '[x]' : '[ ]'
1288
- })
1289
- if (newText === rawText) return
1290
- cb.checked = !cb.checked // optimistic toggle
1291
- article.dataset.rawText = newText
1292
- ws.send({ t: 'msg.edit', body: { msg_id: article.dataset.msgId, channel_id: channelId, text: newText } })
1293
- })
1294
-
1295
- // Delegated click on .btn-quick-react — send reaction without opening picker
1296
- messages.addEventListener('click', e => {
1297
- const btn = e.target.closest('.btn-quick-react')
1298
- if (!btn) return
1299
- e.stopPropagation()
1300
- const emoji = btn.dataset.emoji
1301
- const msgId = btn.closest('article.message')?.dataset.msgId
1302
- if (!emoji || !msgId) return
1303
- saveRecentEmoji(emoji)
1304
- ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
1305
- })
1306
-
1307
- // Delegated click on .reaction-pill — toggle reaction
1308
- messages.addEventListener('click', e => {
1309
- const pill = e.target.closest('.reaction-pill')
1310
- if (!pill) return
1311
- e.stopPropagation()
1312
- const emoji = pill.dataset.emoji
1313
- const msgId = pill.dataset.msgId
1314
- if (!emoji || !msgId) return
1315
- if (pill.classList.contains('reacted')) {
1316
- ws.send({ t: 'reaction.remove', body: { msg_id: msgId, channel_id: channelId, emoji } })
1317
- } else {
1318
- ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
1319
- }
1320
- })
1321
-
1322
- // Delegated click on .reaction-add or .btn-react — open emoji picker
1323
- messages.addEventListener('click', e => {
1324
- const addBtn = e.target.closest('.reaction-add')
1325
- const reactBtn = e.target.closest('.btn-react')
1326
- const btn = addBtn ?? reactBtn
1327
- if (!btn) return
1328
- e.stopPropagation()
1329
- const msgId = addBtn?.dataset.msgId ?? btn.closest('article.message')?.dataset.msgId
1330
- if (!msgId) return
1331
- // Toggle: close if already open for this message
1332
- if (emojiPickerEl && emojiPickerEl.parentNode && emojiPickerTarget === msgId) {
1333
- closeEmojiPicker()
1334
- return
1335
- }
1336
- openEmojiPickerAt(btn, msgId)
1337
- })
1338
-
1339
- // ── Thread panel: mirror all message-level interaction handlers ───────────
1340
- // threadRepliesEl is a sibling outside the island root, so handlers on
1341
- // `messages` don't reach it — duplicate the relevant delegated clicks here.
1342
-
1343
- threadRepliesEl?.addEventListener('click', e => {
1344
- const btn = e.target.closest('.btn-quick-react')
1345
- if (!btn) return
1346
- e.stopPropagation()
1347
- const emoji = btn.dataset.emoji
1348
- const msgId = btn.closest('article.message')?.dataset.msgId
1349
- if (!emoji || !msgId) return
1350
- saveRecentEmoji(emoji)
1351
- ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
1352
- })
1353
-
1354
- threadRepliesEl?.addEventListener('click', e => {
1355
- const pill = e.target.closest('.reaction-pill')
1356
- if (!pill) return
1357
- e.stopPropagation()
1358
- const emoji = pill.dataset.emoji
1359
- const msgId = pill.dataset.msgId
1360
- if (!emoji || !msgId) return
1361
- if (pill.classList.contains('reacted')) {
1362
- ws.send({ t: 'reaction.remove', body: { msg_id: msgId, channel_id: channelId, emoji } })
1363
- } else {
1364
- ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
1365
- }
1366
- })
1367
-
1368
- threadRepliesEl?.addEventListener('click', e => {
1369
- const addBtn = e.target.closest('.reaction-add')
1370
- const reactBtn = e.target.closest('.btn-react')
1371
- const btn = addBtn ?? reactBtn
1372
- if (!btn) return
1373
- e.stopPropagation()
1374
- const msgId = addBtn?.dataset.msgId ?? btn.closest('article.message')?.dataset.msgId
1375
- if (!msgId) return
1376
- if (emojiPickerEl && emojiPickerEl.parentNode && emojiPickerTarget === msgId) {
1377
- closeEmojiPicker()
1378
- return
1379
- }
1380
- openEmojiPickerAt(btn, msgId)
1381
- })
1382
-
1383
- threadRepliesEl?.addEventListener('click', e => {
1384
- const btn = e.target.closest('.btn-msg-actions')
1385
- if (!btn) return
1386
- e.stopPropagation()
1387
- const article = btn.closest('article.message')
1388
- if (article) showContextMenu(article, btn)
1389
- })
1390
-
1391
- threadRepliesEl?.addEventListener('click', e => {
1392
- const handle = e.target.closest('.dm-trigger')
1393
- if (!handle) return
1394
- const targetUserId = handle.dataset.userId
1395
- if (!targetUserId || targetUserId === userId) return
1396
- ws.send({ t: 'dm.open', body: { target_user_id: targetUserId } })
1397
- })
1398
-
1399
- // ── Mobile long-press → action sheet with emoji picker ────────────────────
1400
-
1401
- addLongPress(messages, (e) => {
1402
- const article = e.target.closest?.('article.message')
1403
- if (!article) return
1404
- const msgId = article.dataset.msgId
1405
- if (!msgId) return
1406
-
1407
- const itemsContainer = getItemsContainer()
1408
- itemsContainer.innerHTML = ''
1409
-
1410
- const pickerWrapper = document.createElement('div')
1411
- pickerWrapper.className = 'action-sheet-emoji-picker-wrap'
1412
-
1413
- const picker = getOrBuildEmojiPicker()
1414
- pickerWrapper.appendChild(picker)
1415
- itemsContainer.appendChild(pickerWrapper)
1416
-
1417
- picker.removeEventListener('emoji:pick', picker._boundEmojiPick)
1418
- picker._boundEmojiPick = (ev) => {
1419
- const { emoji } = ev.detail
1420
- saveRecentEmoji(emoji)
1421
- dismissActionSheet()
1422
- ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
1423
- }
1424
- picker.addEventListener('emoji:pick', picker._boundEmojiPick)
1425
- emojiPickerTarget = msgId
1426
-
1427
- showActionSheet({ label: 'React to this message', items: [] })
1428
- })
1429
-
1430
- // ── Inline edit ────────────────────────────────────────────────────────────
1431
-
1432
- let activeEditCancel = null
1433
-
1434
- function startInlineEdit(article) {
1435
- if (article.querySelector('.message-edit-wrap')) return
1436
- const textEl = article.querySelector('.message-text')
1437
- if (!textEl) return
1438
- const rawText = article.dataset.rawText ?? ''
1439
-
1440
- // Build the edit widget
1441
- const wrap = document.createElement('div')
1442
- wrap.className = 'message-edit-wrap'
1443
-
1444
- const tabStrip = document.createElement('div')
1445
- tabStrip.className = 'message-edit-tabs'
1446
- tabStrip.setAttribute('role', 'tablist')
1447
- tabStrip.innerHTML = `
1448
- <button class="message-edit-tab message-edit-tab--active" data-tab="write" role="tab" aria-selected="true" type="button">Write</button>
1449
- <button class="message-edit-tab" data-tab="preview" role="tab" aria-selected="false" type="button">Preview</button>`
1450
-
1451
- const textarea = document.createElement('textarea')
1452
- textarea.className = 'message-edit-input'
1453
- textarea.value = rawText
1454
-
1455
- const preview = document.createElement('div')
1456
- preview.className = 'message-edit-preview message-text'
1457
- preview.hidden = true
1458
- preview.setAttribute('aria-live', 'polite')
1459
-
1460
- const toolbar = document.createElement('div')
1461
- toolbar.className = 'message-edit-toolbar'
1462
- toolbar.innerHTML = `
1463
- <span class="message-edit-hint">Ctrl+Enter to save · Esc to cancel</span>
1464
- <button class="btn-ghost btn-edit-cancel" type="button">Cancel</button>
1465
- <button class="btn-primary btn-edit-save" type="button">Save</button>`
1466
-
1467
- wrap.append(tabStrip, textarea, preview, toolbar)
1468
- textEl.replaceWith(wrap)
1469
- textarea.focus()
1470
- textarea.setSelectionRange(rawText.length, rawText.length)
1471
-
1472
- // Tab switching
1473
- tabStrip.addEventListener('click', async e => {
1474
- const btn = e.target.closest('.message-edit-tab')
1475
- if (!btn) return
1476
- const tab = btn.dataset.tab
1477
- tabStrip.querySelectorAll('.message-edit-tab').forEach(b => {
1478
- b.classList.toggle('message-edit-tab--active', b === btn)
1479
- b.setAttribute('aria-selected', String(b === btn))
1480
- })
1481
- textarea.hidden = tab !== 'write'
1482
- preview.hidden = tab !== 'preview'
1483
- if (tab === 'preview') {
1484
- const text = textarea.value.trim()
1485
- if (!text) { preview.innerHTML = '<p style="color:var(--text-muted)">Nothing to preview yet.</p>'; return }
1486
- try {
1487
- const res = await fetch(`${window.__BASE_PATH__}/api/preview`, {
1488
- method: 'POST',
1489
- headers: { 'Content-Type': 'application/json' },
1490
- body: JSON.stringify({ text }),
1491
- })
1492
- if (res.ok) { const { html } = await res.json(); preview.innerHTML = sanitizeHtml(html) }
1493
- } catch { preview.textContent = text }
1494
- }
1495
- })
1496
-
1497
- function cancel() {
1498
- if (!wrap.parentNode) return
1499
- wrap.replaceWith(textEl)
1500
- activeEditCancel = null
1501
- }
1502
-
1503
- activeEditCancel = cancel
1504
-
1505
- function save() {
1506
- const newText = textarea.value.trim()
1507
- if (!newText || newText === rawText) { cancel(); return }
1508
- cancel()
1509
- article.dataset.rawText = newText
1510
- ws.send({ t: 'msg.edit', body: { msg_id: article.dataset.msgId, channel_id: channelId, text: newText } })
1511
- }
1512
-
1513
- toolbar.querySelector('.btn-edit-save').addEventListener('click', save)
1514
- toolbar.querySelector('.btn-edit-cancel').addEventListener('click', cancel)
1515
- textarea.addEventListener('keydown', e => {
1516
- if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save() }
1517
- if (e.key === 'Escape') cancel()
1518
- })
1519
- }
1520
-
1521
- // ── Context menu (desktop hover → … button) ────────────────────────────────
1522
-
1523
- let activeContextMenu = null
1524
-
1525
- function closeContextMenu() {
1526
- if (activeContextMenu) { activeContextMenu.remove(); activeContextMenu = null }
1527
- }
1528
-
1529
- function showContextMenu(article, anchorEl) {
1530
- closeContextMenu()
1531
- const isAuthor = article.dataset.userId === userId
1532
- if (!isAuthor) return
1533
-
1534
- const menu = document.createElement('div')
1535
- menu.className = 'msg-context-menu'
1536
- const editBtn = document.createElement('button')
1537
- editBtn.className = 'msg-context-menu-item'
1538
- editBtn.type = 'button'
1539
- editBtn.textContent = 'Edit'
1540
- editBtn.addEventListener('click', () => { closeContextMenu(); startInlineEdit(article) })
1541
- menu.appendChild(editBtn)
1542
-
1543
- const deleteBtn = document.createElement('button')
1544
- deleteBtn.className = 'msg-context-menu-item msg-context-menu-item--danger'
1545
- deleteBtn.type = 'button'
1546
- deleteBtn.textContent = 'Delete'
1547
- deleteBtn.addEventListener('click', () => {
1548
- closeContextMenu()
1549
- ws.send({ t: 'msg.delete', body: { msg_id: article.dataset.msgId, channel_id: channelId } })
1550
- })
1551
- menu.appendChild(deleteBtn)
1552
-
1553
- document.body.appendChild(menu)
1554
- activeContextMenu = menu
1555
-
1556
- const rect = anchorEl.getBoundingClientRect()
1557
- const menuRect = menu.getBoundingClientRect()
1558
- let top = rect.bottom + window.scrollY + 4
1559
- let left = rect.right + window.scrollX - menuRect.width
1560
- if (left < 8) left = 8
1561
- if (left + menuRect.width > window.innerWidth - 8) left = window.innerWidth - 8 - menuRect.width
1562
- menu.style.top = `${top}px`
1563
- menu.style.left = `${left}px`
1564
- }
1565
-
1566
- document.addEventListener('click', e => {
1567
- if (activeContextMenu && !activeContextMenu.contains(e.target)) closeContextMenu()
1568
- }, { capture: true })
1569
-
1570
- document.addEventListener('keydown', e => {
1571
- if (e.key === 'Escape') closeContextMenu()
1572
- })
1573
-
1574
- // Delegated click: … button → open context menu
1575
- messages.addEventListener('click', e => {
1576
- const btn = e.target.closest('.btn-msg-actions')
1577
- if (!btn) return
1578
- e.stopPropagation()
1579
- const article = btn.closest('article.message')
1580
- if (article) showContextMenu(article, btn)
1581
- })
1582
-
1583
-
1584
- ws.on('dm.opened', ({ channel_id, notify_only }) => {
1585
- if (notify_only) return // target user — sidebar handles the notification
1586
- window.location.href = `${window.__BASE_PATH__}/channels/${channel_id}`
1587
- })
1588
-
1589
- // ── Call: rtc.call_state — drives "N in call" row + sidebar badge ──────────
1590
-
1591
- ws.on('rtc.call_state', (body) => {
1592
- if (body.channel_id !== channelId) return
1593
- // Don't overwrite the active call's ID when browsing a different channel —
1594
- // callIdSig is what miniBarLeave uses to leave the call.
1595
- if (!inCall() || body.channel_id === callChannelId) {
1596
- callIdSig.set(body.call_id)
1597
- }
1598
- _updateCallStatusRow(body.call_id, body.count, body.users ?? [])
1599
- _updateChannelBadge(body.count)
1600
- })
1601
-
1602
- function _updateCallStatusRow(activeCallId, count, users) {
1603
- if (!callStatusEl) return
1604
- if (inCall()) {
1605
- // Already in the call — just update peer count
1606
- if (peerCountEl) peerCountEl.textContent = count > 1 ? `${count} in call` : ''
1607
- callStatusEl.hidden = true
1608
- return
1609
- }
1610
- if (!activeCallId || count === 0) {
1611
- callStatusEl.hidden = true
1612
- return
1613
- }
1614
- callStatusEl.hidden = false
1615
- if (callStatusInfo) callStatusInfo.textContent = `${count} in call`
1616
- if (callStatusAvatars) {
1617
- callStatusAvatars.innerHTML = users.slice(0, 5).map(u =>
1618
- `<span class="call-status-avatar" title="${escHtml(u.user_id)}">${escHtml(u.user_id.slice(0, 2).toUpperCase())}</span>`
1619
- ).join('')
1620
- }
1621
- }
1622
-
1623
- function _updateChannelBadge(count) {
1624
- const li = document.querySelector(`.channel-link[data-channel-id="${channelId}"]`)?.closest('li')
1625
- if (!li) return
1626
- li.classList.toggle('call-active', count > 0)
1627
- const badge = li.querySelector('.call-badge')
1628
- if (badge) badge.textContent = count > 0 ? String(count) : ''
1629
- }
1630
-
1631
- // ── Call: start / join / leave ─────────────────────────────────────────────
1632
-
1633
- btnStartCall?.addEventListener('click', () => {
1634
- ws.send({ t: 'rtc.call_create', body: { channel_id: channelId, kind: 'mesh' } })
1635
- })
1636
-
1637
- btnJoinCall?.addEventListener('click', () => {
1638
- const id = callIdSig()
1639
- if (id) ws.send({ t: 'rtc.join', body: { call_id: id } })
1640
- })
1641
-
1642
- btnLeaveCall?.addEventListener('click', leaveCall)
1643
-
1644
- function leaveCall() {
1645
- const id = callIdSig()
1646
- if (!inCall() || !id) return
1647
- ws.send({ t: 'rtc.leave', body: { call_id: id } })
1648
- _teardownCall()
1649
- }
1650
-
1651
- // ── Call: WS message handlers ──────────────────────────────────────────────
1652
-
1653
- ws.on('rtc.call', (body) => {
1654
- // Server confirmed call creation / found existing call — now join it
1655
- if (body.ice_servers?.length) { iceServers = body.ice_servers; rtcManager.setIceServers(iceServers) }
1656
- callIdSig.set(body.call_id)
1657
- ws.send({ t: 'rtc.join', body: { call_id: body.call_id } })
1658
- })
1659
-
1660
- ws.on('rtc.joined', async (body) => {
1661
- const { call_id, peer_id, peers } = body
1662
- if (body.ice_servers?.length) { iceServers = body.ice_servers; rtcManager.setIceServers(iceServers) }
1663
- selfPeerId.set(peer_id)
1664
- callIdSig.set(call_id)
1665
- callChannelId = channelId
1666
- inCall.set(true)
1667
- _showCallControls()
1668
- _showTilePanel()
1669
- _attachDeviceChangeListener()
1670
- patchSettings({ last_channel_id: channelId })
1671
-
1672
- // Start audio immediately; video is opt-in
1673
- await _startAudio()
1674
-
1675
- // Cache display names for existing peers, then connect as offerer
1676
- for (const peer of peers) {
1677
- if (peer.peer_id !== peer_id) {
1678
- rtcManager.setDisplayName(peer.peer_id, peer.display_name)
1679
- rtcManager.ensurePeer(peer.peer_id)
1680
- rtcManager.negotiate(peer.peer_id)
1681
- }
1682
- }
1683
- })
1684
-
1685
- ws.on('rtc.peer_event', ({ call_id, kind, peer }) => {
1686
- if (kind === 'join' && peer.peer_id !== selfPeerId()) {
1687
- rtcManager.setDisplayName(peer.peer_id, peer.display_name)
1688
- // Existing peer receives new joiner's event — create answerer connection
1689
- // (new joiner will send us an offer)
1690
- rtcManager.ensurePeer(peer.peer_id)
1691
- }
1692
- if (kind === 'leave') {
1693
- rtcManager.closePeer(peer.peer_id)
1694
- }
1695
- })
1696
-
1697
- ws.on('rtc.offer_event', async ({ call_id, from_peer_id, sdp }) => {
1698
- await rtcManager.handleRemoteOffer(from_peer_id, call_id, sdp)
1699
- })
1700
-
1701
- ws.on('rtc.answer_event', async ({ call_id, from_peer_id, sdp }) => {
1702
- await rtcManager.handleRemoteAnswer(from_peer_id, sdp)
1703
- })
1704
-
1705
- ws.on('rtc.ice_event', async ({ from_peer_id, candidate }) => {
1706
- await rtcManager.handleIceCandidate(from_peer_id, candidate)
1707
- })
1708
-
1709
- ws.on('rtc.stream_event', () => {
1710
- // No pre-tile creation here — tile IDs must match between this handler
1711
- // (which uses kind: 'cam'/'screen') and ontrack (which uses transceiver.mid,
1712
- // a number like '1' or '2'). The mismatch left orphaned empty tiles.
1713
- // Tiles are created in ontrack once the actual stream track arrives.
1714
- })
1715
-
1716
- ws.on('rtc.call_end', ({ call_id }) => {
1717
- if (call_id === callIdSig()) _teardownCall()
1718
- })
1719
-
1720
- ws.on('rtc.left', () => {
1721
- // Server confirmed our leave
1722
- })
1723
-
1724
- // ── Local media ────────────────────────────────────────────────────────────
1725
- // Peer negotiation, transceiver slots, ICE queuing → RtcPeerManager
1726
-
1727
- async function _startAudio() {
1728
- if (audioStream) return
1729
- try {
1730
- const saved = loadSavedDevices()
1731
- audioStream = await navigator.mediaDevices.getUserMedia({
1732
- audio: saved.micId ? { deviceId: { ideal: saved.micId } } : true,
1733
- video: false,
1734
- })
1735
- activeMicId = audioStream.getAudioTracks()[0]?.getSettings().deviceId ?? null
1736
- audioStream.getAudioTracks().forEach(t => { t.enabled = !micMuted() })
1737
- await refreshDevices() // labels now available after permission granted
1738
- for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
1739
- } catch {
1740
- micMuted.set(true)
1741
- }
1742
- }
1743
-
1744
- async function toggleMic() {
1745
- micMuted.set(!micMuted())
1746
- audioStream?.getAudioTracks().forEach(t => { t.enabled = !micMuted() })
1747
- if (ctrlMic) ctrlMic.textContent = micMuted() ? '🔇' : '🎙'
1748
- if (miniBarMic) miniBarMic.textContent = micMuted() ? '🔇' : '🎙'
1749
- }
1750
-
1751
- async function toggleCamera() {
1752
- if (videoStream) {
1753
- videoStream.getTracks().forEach(t => t.stop())
1754
- _removeTile('local-cam')
1755
- videoStream = null
1756
- camOff.set(true)
1757
- for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
1758
- if (ctrlCam) ctrlCam.textContent = '📷'
1759
- return
1760
- }
1761
- try {
1762
- const saved = loadSavedDevices()
1763
- const videoConstraint = saved.cameraId
1764
- ? { deviceId: { ideal: saved.cameraId }, width: 640, height: 360 }
1765
- : { width: 640, height: 360 }
1766
- videoStream = await navigator.mediaDevices.getUserMedia({ video: videoConstraint, audio: false })
1767
- activeCameraId = videoStream.getVideoTracks()[0]?.getSettings().deviceId ?? null
1768
- camOff.set(false)
1769
- _renderTile('local-cam', videoStream, true, `${userHandle ?? 'You'} (cam)`)
1770
- ws.send({ t: 'rtc.stream_publish', body: { call_id: callIdSig(), stream: { kind: 'camera' } } })
1771
- for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
1772
- if (ctrlCam) ctrlCam.textContent = '📷✓'
1773
- } catch {
1774
- // Camera denied
1775
- }
1776
- }
1777
-
1778
- async function toggleScreen() {
1779
- if (screenStream) {
1780
- screenStream.getTracks().forEach(t => t.stop())
1781
- _removeTile('local-screen')
1782
- screenStream = null
1783
- screenSharing.set(false)
1784
- for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
1785
- if (ctrlScreen) ctrlScreen.textContent = '🖥'
1786
- return
1787
- }
1788
- if (!navigator.mediaDevices?.getDisplayMedia) return
1789
- try {
1790
- screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
1791
- screenSharing.set(true)
1792
- _renderTile('local-screen', screenStream, true, `${userHandle ?? 'You'} (screen)`)
1793
- ws.send({ t: 'rtc.stream_publish', body: { call_id: callIdSig(), stream: { kind: 'screen' } } })
1794
- screenStream.getVideoTracks()[0].addEventListener('ended', () => toggleScreen())
1795
- for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
1796
- if (ctrlScreen) ctrlScreen.textContent = '🖥✓'
1797
- } catch { /* user cancelled */ }
1798
- }
1799
-
1800
- // ── Audio element for remote peers ─────────────────────────────────────────
1801
-
1802
- function _ensureRemoteAudio(stream, peerId) {
1803
- if (document.querySelector(`audio[data-peer-id="${peerId}"]`)) return
1804
- const audio = document.createElement('audio')
1805
- audio.autoplay = true
1806
- audio.dataset.peerId = peerId
1807
- audio.srcObject = stream
1808
- document.body.appendChild(audio)
1809
- }
1810
-
1811
- // ── Tile grid ──────────────────────────────────────────────────────────────
1812
-
1813
- function _captureFrame(tile, label) {
1814
- const video = tile.querySelector('video')
1815
- if (!video || !video.videoWidth) return
1816
- const canvas = document.createElement('canvas')
1817
- canvas.width = video.videoWidth
1818
- canvas.height = video.videoHeight
1819
- canvas.getContext('2d').drawImage(video, 0, 0)
1820
- const a = document.createElement('a')
1821
- a.href = canvas.toDataURL('image/png')
1822
- a.download = `capture-${label.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}.png`
1823
- a.click()
1824
- }
1825
-
1826
- function _startCapture(tile, label, delay) {
1827
- const countdown = tile.querySelector('.tile-countdown')
1828
- const captureBtn = tile.querySelector('.tile-capture')
1829
- if (tile._captureTimer) {
1830
- clearInterval(tile._captureTimer)
1831
- tile._captureTimer = null
1832
- countdown.hidden = true
1833
- captureBtn.textContent = '📸'
1834
- return
1835
- }
1836
- if (delay === 0) { _captureFrame(tile, label); return }
1837
- let remaining = delay
1838
- countdown.textContent = remaining
1839
- countdown.hidden = false
1840
- captureBtn.textContent = '✕'
1841
- tile._captureTimer = setInterval(() => {
1842
- remaining--
1843
- if (remaining <= 0) {
1844
- clearInterval(tile._captureTimer)
1845
- tile._captureTimer = null
1846
- countdown.hidden = true
1847
- captureBtn.textContent = '📸'
1848
- _captureFrame(tile, label)
1849
- } else {
1850
- countdown.textContent = remaining
1851
- }
1852
- }, 1000)
1853
- }
1854
-
1855
- function _renderTile(tileId, stream, muted, label) {
1856
- if (!tileGridEl) return
1857
- let tile = tileGridEl.querySelector(`[data-peer="${tileId}"]`)
1858
- if (!tile) {
1859
- tile = document.createElement('div')
1860
- tile.className = 'stream-tile'
1861
- tile.dataset.peer = tileId
1862
- 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>`
1863
- tile.querySelector('video').addEventListener('click', e => e.stopPropagation())
1864
- tile.querySelector('.tile-pin').addEventListener('click', e => { e.stopPropagation(); _pinTile(tileId) })
1865
- const menu = tile.querySelector('.tile-capture-menu')
1866
- tile.querySelector('.tile-capture').addEventListener('click', e => {
1867
- e.stopPropagation()
1868
- if (tile._captureTimer) { _startCapture(tile, label, 0); return }
1869
- menu.hidden = !menu.hidden
1870
- })
1871
- menu.querySelectorAll('.tile-capture-opt').forEach(btn => {
1872
- btn.addEventListener('click', e => {
1873
- e.stopPropagation()
1874
- menu.hidden = true
1875
- _startCapture(tile, label, parseInt(btn.dataset.delay))
1876
- })
1877
- })
1878
- tileGridEl.appendChild(tile)
1879
- _updateTileLayout()
1880
- }
1881
- if (stream) tile.querySelector('video').srcObject = stream
1882
- return tile
1883
- }
1884
-
1885
- function _removeTile(tileId) {
1886
- tileGridEl?.querySelector(`[data-peer="${tileId}"]`)?.remove()
1887
- _updateTileLayout()
1888
- }
1889
-
1890
- function _updateTileLayout() {
1891
- if (!tileGridEl) return
1892
- const count = tileGridEl.querySelectorAll('.stream-tile').length
1893
- tileGridEl.classList.toggle('avatars-only', count >= 5)
1894
- }
1895
-
1896
- function _pinTile(tileId) {
1897
- if (pinnedPeerId === tileId) {
1898
- tileGridEl?.classList.remove('pinned')
1899
- tileGridEl?.querySelectorAll('.stream-tile').forEach(t => t.classList.remove('pinned-tile'))
1900
- pinnedPeerId = null
1901
- } else {
1902
- tileGridEl?.classList.add('pinned')
1903
- tileGridEl?.querySelectorAll('.stream-tile').forEach(t => t.classList.remove('pinned-tile'))
1904
- tileGridEl?.querySelector(`[data-peer="${tileId}"]`)?.classList.add('pinned-tile')
1905
- pinnedPeerId = tileId
1906
- }
1907
- }
1908
-
1909
- // ── Device switching ───────────────────────────────────────────────────────
1910
-
1911
- async function switchCamera(deviceId) {
1912
- const newStream = await navigator.mediaDevices.getUserMedia({
1913
- video: { deviceId: { exact: deviceId } },
1914
- })
1915
- const newTrack = newStream.getVideoTracks()[0]
1916
- await rtcManager.replaceTrack('camera', newTrack)
1917
-
1918
- videoStream?.getTracks().forEach(t => t.stop())
1919
- videoStream = newStream
1920
- activeCameraId = deviceId
1921
- saveDevices({ cameraId: deviceId })
1922
-
1923
- const localTile = tileGridEl?.querySelector('[data-peer="local-cam"]')
1924
- if (localTile) localTile.querySelector('video').srcObject = newStream
1925
- }
1926
-
1927
- async function switchMic(deviceId) {
1928
- const newStream = await navigator.mediaDevices.getUserMedia({
1929
- audio: { deviceId: { exact: deviceId } },
1930
- })
1931
- const newTrack = newStream.getAudioTracks()[0]
1932
- newTrack.enabled = !micMuted()
1933
- await rtcManager.replaceTrack('audio', newTrack)
1934
-
1935
- audioStream?.getTracks().forEach(t => t.stop())
1936
- audioStream = newStream
1937
- activeMicId = deviceId
1938
- saveDevices({ micId: deviceId })
1939
- }
1940
-
1941
- // ── Device change detection ────────────────────────────────────────────────
1942
-
1943
- function _onDeviceChange() {
1944
- refreshDevices().then(({ cameras, mics }) => {
1945
- const cameraGone = activeCameraId && !cameras.find(d => d.deviceId === activeCameraId)
1946
- const micGone = activeMicId && !mics.find(d => d.deviceId === activeMicId)
1947
- if (cameraGone || micGone) _showDeviceWarning(cameraGone ? 'camera' : 'mic')
1948
- if (devicePickerEl?.classList.contains('open')) _populatePicker()
1949
- })
1950
- }
1951
-
1952
- function _attachDeviceChangeListener() {
1953
- navigator.mediaDevices.addEventListener('devicechange', _onDeviceChange)
1954
- }
1955
- function _detachDeviceChangeListener() {
1956
- navigator.mediaDevices.removeEventListener('devicechange', _onDeviceChange)
1957
- }
1958
-
1959
- // ── Device picker ──────────────────────────────────────────────────────────
1960
-
1961
- let devicePickerEl = null
1962
-
1963
- function _buildPicker() {
1964
- devicePickerEl = document.createElement('div')
1965
- devicePickerEl.className = 'device-picker'
1966
- devicePickerEl.innerHTML = `
1967
- <div class="device-picker-row">
1968
- <label>Camera</label>
1969
- <select id="dp-camera"></select>
1970
- <video id="dp-preview" autoplay playsinline muted></video>
1971
- </div>
1972
- <div class="device-picker-row">
1973
- <label>Microphone</label>
1974
- <select id="dp-mic"></select>
1975
- <canvas id="dp-level" width="80" height="12"></canvas>
1976
- </div>
1977
- <div class="device-picker-footer">
1978
- <button id="dp-cancel" class="btn-ghost" type="button">Cancel</button>
1979
- <button id="dp-apply" class="btn-primary" type="button">Switch</button>
1980
- </div>
1981
- `
1982
- callControlsEl?.after(devicePickerEl)
1983
-
1984
- devicePickerEl.querySelector('#dp-cancel').addEventListener('click', _closePicker)
1985
- devicePickerEl.querySelector('#dp-apply').addEventListener('click', _applyPicker)
1986
-
1987
- const cameraSelect = devicePickerEl.querySelector('#dp-camera')
1988
- const previewVideo = devicePickerEl.querySelector('#dp-preview')
1989
-
1990
- cameraSelect.addEventListener('change', async () => {
1991
- devicePickerEl._previewStream?.getTracks().forEach(t => t.stop())
1992
- devicePickerEl._previewStream = null
1993
- if (!cameraSelect.value) return
1994
- try {
1995
- const stream = await navigator.mediaDevices.getUserMedia({
1996
- video: { deviceId: { exact: cameraSelect.value } },
1997
- })
1998
- previewVideo.srcObject = stream
1999
- devicePickerEl._previewStream = stream
2000
- } catch { /* camera unavailable */ }
2001
- })
2002
- }
2003
-
2004
- function _populatePicker() {
2005
- const { cameras, mics } = availableDevices
2006
- const cameraSelect = devicePickerEl?.querySelector('#dp-camera')
2007
- const micSelect = devicePickerEl?.querySelector('#dp-mic')
2008
- if (cameraSelect) {
2009
- cameraSelect.innerHTML = cameras
2010
- .map(d => `<option value="${escHtml(d.deviceId)}"${d.deviceId === activeCameraId ? ' selected' : ''}>${escHtml(d.label || 'Camera')}</option>`)
2011
- .join('')
2012
- }
2013
- if (micSelect) {
2014
- micSelect.innerHTML = mics
2015
- .map(d => `<option value="${escHtml(d.deviceId)}"${d.deviceId === activeMicId ? ' selected' : ''}>${escHtml(d.label || 'Microphone')}</option>`)
2016
- .join('')
2017
- }
2018
- }
2019
-
2020
- async function _openPicker() {
2021
- if (!devicePickerEl) _buildPicker()
2022
- await refreshDevices()
2023
- _populatePicker()
2024
- devicePickerEl.classList.add('open')
2025
- }
2026
-
2027
- function _closePicker() {
2028
- devicePickerEl?._previewStream?.getTracks().forEach(t => t.stop())
2029
- if (devicePickerEl) devicePickerEl._previewStream = null
2030
- devicePickerEl?.classList.remove('open')
2031
- }
2032
-
2033
- async function _applyPicker() {
2034
- const cameraId = devicePickerEl?.querySelector('#dp-camera')?.value
2035
- const micId = devicePickerEl?.querySelector('#dp-mic')?.value
2036
- try {
2037
- if (cameraId && cameraId !== activeCameraId && videoStream) await switchCamera(cameraId)
2038
- if (micId && micId !== activeMicId) await switchMic(micId)
2039
- ctrlDevices?.classList.remove('device-warning')
2040
- } catch { /* device unavailable — leave current stream in place */ }
2041
- _closePicker()
2042
- }
2043
-
2044
- ctrlDevices?.addEventListener('click', () => {
2045
- devicePickerEl?.classList.contains('open') ? _closePicker() : _openPicker()
2046
- })
2047
-
2048
- // ── Device warning toast ───────────────────────────────────────────────────
2049
-
2050
- function _showDeviceWarning(kind) {
2051
- const label = kind === 'camera' ? 'Camera' : 'Microphone'
2052
- const toast = document.createElement('div')
2053
- toast.className = 'device-warning-toast'
2054
- toast.textContent = `${label} disconnected — click ⚙ to switch`
2055
- document.body.appendChild(toast)
2056
- setTimeout(() => toast.remove(), 6000)
2057
- ctrlDevices?.classList.add('device-warning')
2058
- }
2059
-
2060
- // ── Controls visibility ────────────────────────────────────────────────────
2061
-
2062
- function _showCallControls() {
2063
- callStatusEl && (callStatusEl.hidden = true)
2064
- callControlsEl?.classList.add('active')
2065
- if (btnStartCall) btnStartCall.hidden = true
2066
- }
2067
-
2068
- function _hideCallControls() {
2069
- callControlsEl?.classList.remove('active')
2070
- if (btnStartCall) btnStartCall.hidden = false
2071
- }
2072
-
2073
- // ── Tile panel show / hide ─────────────────────────────────────────────────
2074
-
2075
- const LAYOUT_KEY = 'devchitchat_tile_layout'
2076
-
2077
- function _showTilePanel() {
2078
- document.querySelector('.main-content')?.classList.add('has-call')
2079
- tilePanelEl?.classList.add('active')
2080
- }
2081
-
2082
- function _hideTilePanel() {
2083
- document.querySelector('.main-content')?.classList.remove('has-call')
2084
- tilePanelEl?.classList.remove('active')
2085
- tilePanelEl?.classList.remove('collapsed')
2086
- }
2087
-
2088
- // Restore collapse state from localStorage
2089
- try {
2090
- const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
2091
- if (saved.collapsed) tilePanelEl?.classList.add('collapsed')
2092
- if (saved.overlayRight && saved.overlayTop && tilePanelEl) {
2093
- tilePanelEl.style.right = saved.overlayRight
2094
- tilePanelEl.style.top = saved.overlayTop
2095
- }
2096
- } catch { /* ignore */ }
2097
-
2098
- // Collapse toggle
2099
- document.getElementById('tile-panel-collapse')?.addEventListener('click', () => {
2100
- const collapsed = tilePanelEl?.classList.toggle('collapsed')
2101
- try {
2102
- const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
2103
- localStorage.setItem(LAYOUT_KEY, JSON.stringify({ ...saved, collapsed: !!collapsed }))
2104
- } catch { /* ignore */ }
2105
- })
2106
-
2107
- // Overlay drag (mobile only)
2108
- ;(function _attachOverlayDrag(panel) {
2109
- if (!panel) return
2110
- if (window.matchMedia('(min-width: 1025px)').matches) return
2111
-
2112
- const header = panel.querySelector('.tile-panel-header')
2113
- if (!header) return
2114
-
2115
- let startX, startY, startRight, startTop
2116
-
2117
- function onMove(e) {
2118
- e.preventDefault() // stop page scroll while dragging the tile panel
2119
- const clientX = e.touches ? e.touches[0].clientX : e.clientX
2120
- const clientY = e.touches ? e.touches[0].clientY : e.clientY
2121
- const dx = startX - clientX
2122
- const dy = clientY - startY
2123
- const newRight = Math.max(0, Math.min(startRight + dx, window.innerWidth - 60))
2124
- const newTop = Math.max(0, Math.min(startTop + dy, window.innerHeight - 60))
2125
- panel.style.right = newRight + 'px'
2126
- panel.style.top = newTop + 'px'
2127
- }
2128
-
2129
- function onEnd() {
2130
- document.removeEventListener('mousemove', onMove)
2131
- document.removeEventListener('mouseup', onEnd)
2132
- document.removeEventListener('touchmove', onMove)
2133
- document.removeEventListener('touchend', onEnd)
2134
- try {
2135
- const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
2136
- localStorage.setItem(LAYOUT_KEY, JSON.stringify({
2137
- ...saved,
2138
- overlayRight: panel.style.right,
2139
- overlayTop: panel.style.top,
2140
- }))
2141
- } catch { /* ignore */ }
2142
- }
2143
-
2144
- header.addEventListener('mousedown', e => {
2145
- startX = e.clientX; startY = e.clientY
2146
- startRight = parseInt(panel.style.right) || 0
2147
- startTop = parseInt(panel.style.top) || 0
2148
- document.addEventListener('mousemove', onMove)
2149
- document.addEventListener('mouseup', onEnd)
2150
- })
2151
-
2152
- header.addEventListener('touchstart', e => {
2153
- e.preventDefault() // prevent scroll from starting on the drag handle
2154
- startX = e.touches[0].clientX; startY = e.touches[0].clientY
2155
- startRight = parseInt(panel.style.right) || 0
2156
- startTop = parseInt(panel.style.top) || 0
2157
- document.addEventListener('touchmove', onMove, { passive: false })
2158
- document.addEventListener('touchend', onEnd)
2159
- }, { passive: false })
2160
- })(tilePanelEl)
2161
-
2162
- // ── Mini-bar (persists while navigating away during a call) ───────────────
2163
-
2164
- function _showMiniBar() {
2165
- if (!miniBarEl) return
2166
- if (miniBarName) miniBarName.textContent = channelName()
2167
- miniBarEl.classList.add('active')
2168
- }
2169
-
2170
- function _hideMiniBar() {
2171
- miniBarEl?.classList.remove('active')
2172
- }
2173
-
2174
- ctrlMic?.addEventListener('click', toggleMic)
2175
- ctrlCam?.addEventListener('click', toggleCamera)
2176
- ctrlScreen?.addEventListener('click', toggleScreen)
2177
- miniBarMic?.addEventListener('click', toggleMic)
2178
-
2179
- miniBarReturn?.addEventListener('click', () => {
2180
- if (callChannelId) navigateTo(`${window.__BASE_PATH__}/channels/${callChannelId}`, false)
2181
- })
2182
-
2183
- miniBarLeave?.addEventListener('click', () => {
2184
- leaveCall()
2185
- })
2186
-
2187
- // Show mini-bar when user navigates to a different channel while in a call
2188
- document.addEventListener('channelnavigated', (e) => {
2189
- if (inCall() && e.detail?.channelId !== channelId) {
2190
- _showMiniBar()
2191
- }
2192
- })
2193
-
2194
- // SPA navigation: router morphed .chat-panel and dispatched this event.
2195
- // Re-initialise chat state for the new channel without touching RTC.
2196
- document.addEventListener('chatpanel:navigated', (e) => {
2197
- const { channelId: newId, name, topic, kind, seedSeq: newSeedSeq, seedFirstSeq: newFirstSeq, seedHasMore: newHasMore } = e.detail
2198
-
2199
- if (composeOpen && newId !== channelId) closeComposeMode()
2200
-
2201
- // Morph strips dynamically-added content (reactions, attachments, timestamps, etc.)
2202
- // but may preserve data-hydrated="1". Always clear and re-hydrate.
2203
- for (const a of messages.querySelectorAll('article.message[data-hydrated]')) {
2204
- delete a.dataset.hydrated
2205
- }
2206
- hydrateSeedMessages()
2207
-
2208
- if (newId === channelId) return // same channel — re-hydrate only, no WS channel change
2209
-
2210
- // Leave old channel subscription on the server
2211
- ws.send({ t: 'channel.leave', body: { channel_id: channelId } })
2212
-
2213
- // Update local identity
2214
- channelId = newId
2215
- channelKind = kind
2216
- channelName.set(name)
2217
- channelTopic.set(topic)
2218
- afterSeq = newSeedSeq
2219
-
2220
- // Reset pagination state for new channel
2221
- oldestSeq = newFirstSeq ?? 0
2222
- loadingMore = false
2223
- if (sentinelEl) {
2224
- sentinelEl.hidden = !newHasMore
2225
- if (newHasMore) loadMoreObserver.observe(sentinelEl)
2226
- else loadMoreObserver.unobserve(sentinelEl)
2227
- }
2228
-
2229
- // Update browser chrome
2230
- document.title = `#${name} — devchitchat`
2231
- const textarea = root.querySelector('#message-input')
2232
- if (textarea) textarea.placeholder = `Message in ${name}`
2233
-
2234
- closePicker()
2235
-
2236
- // Join new channel — server responds with channel.joined + rtc.call_state.
2237
- // channel.joined handler sends msg.list if afterSeq > 0, which will append
2238
- // any messages that arrived after the seed snapshot.
2239
- ws.send({ t: 'channel.join', body: { channel_id: channelId } })
2240
- })
2241
-
2242
- // ── Teardown ───────────────────────────────────────────────────────────────
2243
-
2244
- function _teardownCall() {
2245
- rtcManager.teardown()
2246
-
2247
- audioStream?.getTracks().forEach(t => t.stop()); audioStream = null
2248
- videoStream?.getTracks().forEach(t => t.stop()); videoStream = null
2249
- screenStream?.getTracks().forEach(t => t.stop()); screenStream = null
2250
-
2251
- document.querySelectorAll('audio[data-peer-id]').forEach(a => { a.srcObject = null; a.remove() })
2252
- if (tileGridEl) tileGridEl.innerHTML = ''
2253
- _updateTileLayout()
2254
- _hideCallControls()
2255
- _hideTilePanel()
2256
- _hideMiniBar()
2257
- _closePicker()
2258
- _detachDeviceChangeListener()
2259
- ctrlDevices?.classList.remove('device-warning')
2260
-
2261
- micMuted.set(false)
2262
- camOff.set(false)
2263
- screenSharing.set(false)
2264
- inCall.set(false)
2265
- selfPeerId.set(null)
2266
- callChannelId = null
2267
- pinnedPeerId = null
2268
- activeCameraId = null
2269
- activeMicId = null
2270
- }
2271
-
2272
- // ── Mobile back button ─────────────────────────────────────────────────────
2273
-
2274
- root.querySelector('.btn-back-mobile')?.addEventListener('click', () => {
2275
- document.body.classList.add('sidebar-open')
2276
- patchSettings({ mobile_chat_open: false })
2277
- })
2278
-
2279
- // ── Exports (rdbljs bindings) ──────────────────────────────────────────────
2280
-
2281
- return { draft, channelName, channelTopic, urgentMode, urgentClass, sendMessage, handleComposerKey, toggleUrgentMode, toggleComposeMode }
2282
- }