@devchitchat/chat 4.4.4 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,488 @@
1
+ /**
2
+ * ComposerView.js — the message composer (textarea, attachments, @mention picker).
3
+ *
4
+ * Owned DOM (all within the .composer element or #compose-overlay):
5
+ * #message-input — main textarea
6
+ * .attachment-chips — injected chip strip
7
+ * .btn-attach — injected paperclip button
8
+ * #compose-overlay — full-screen compose mode
9
+ *
10
+ * When the user submits, dispatches 'send-message' on document so ChatController
11
+ * picks it up — the view never calls ws.send() directly.
12
+ *
13
+ * Urgent mode and compose-overlay are view-local (no model state needed).
14
+ */
15
+
16
+ import { escHtml } from '../shared/messages.js'
17
+ import { dispatch } from '../controllers/ChatController.js'
18
+ import { MentionPicker } from './shared/MentionPicker.js'
19
+ import * as Ev from '../model/events.js'
20
+
21
+ export class ComposerView {
22
+ #model
23
+ #composerEl // .composer wrapper
24
+ #textareaEl // #message-input
25
+ #mentionPicker // MentionPicker for main textarea
26
+ #chipsEl
27
+ #fileInputEl
28
+ #btnAttachEl
29
+
30
+ // Compose overlay
31
+ #overlayEl
32
+ #overlayTaEl
33
+ #overlayPreviewEl
34
+ #overlayMentionPicker // MentionPicker for overlay textarea
35
+ #composeOpen = false
36
+ #composeChipsEl
37
+
38
+ // member list — shared closure passed to both MentionPicker instances
39
+ #members = []
40
+
41
+ // Attachments
42
+ #pendingAttachments = []
43
+
44
+ // Urgent mode
45
+ #urgentMode = false
46
+
47
+ /**
48
+ * @param {AppModel} model
49
+ * @param {HTMLElement} composerEl — the .composer wrapper element
50
+ */
51
+ constructor(model, composerEl) {
52
+ this.#model = model
53
+ this.#composerEl = composerEl
54
+ this.#textareaEl = composerEl.querySelector('#message-input')
55
+
56
+ this.#buildAttachmentChips()
57
+ this.#buildFileInput()
58
+ this.#buildAttachButton()
59
+ this.#buildDropOverlay()
60
+ this.#bindComposerEvents()
61
+ this.#bindOverlay()
62
+ this.#bindGlobalKeys()
63
+ this.#bindModelEvents()
64
+
65
+ // Mention picker for the main textarea — injected into the composer element
66
+ if (this.#textareaEl) {
67
+ this.#mentionPicker = new MentionPicker(
68
+ this.#textareaEl,
69
+ this.#composerEl,
70
+ () => this.#members,
71
+ )
72
+ }
73
+ }
74
+
75
+ // ─────────────────────────────────────────────────────────────────────────
76
+ // Model events
77
+ // ─────────────────────────────────────────────────────────────────────────
78
+
79
+ #bindModelEvents() {
80
+ this.#model.addEventListener(Ev.MEMBERS_UPDATED, e => {
81
+ this.#members = [...(e.detail.members ?? []), ...(e.detail.bots ?? [])]
82
+ .filter(m => m.handle)
83
+ })
84
+
85
+ this.#model.addEventListener(Ev.CHANNEL_SELECTED, () => {
86
+ // Clear pending state on navigation
87
+ this.#pendingAttachments = []
88
+ this.#renderChips()
89
+ this.#textareaEl && (this.#textareaEl.value = '')
90
+ })
91
+ }
92
+
93
+ // ─────────────────────────────────────────────────────────────────────────
94
+ // Build injected elements
95
+ // ─────────────────────────────────────────────────────────────────────────
96
+
97
+ #buildAttachmentChips() {
98
+ const el = document.createElement('div')
99
+ el.className = 'attachment-chips'
100
+ el.hidden = true
101
+ this.#composerEl.insertBefore(el, this.#textareaEl)
102
+ this.#chipsEl = el
103
+
104
+ el.addEventListener('click', e => {
105
+ const btn = e.target.closest('.attachment-chip-remove')
106
+ if (!btn) return
107
+ this.#removeChipAt(parseInt(btn.dataset.index, 10))
108
+ })
109
+ }
110
+
111
+ #buildFileInput() {
112
+ const el = document.createElement('input')
113
+ el.type = 'file'
114
+ el.multiple = true
115
+ el.style.display = 'none'
116
+ el.setAttribute('aria-hidden', 'true')
117
+ this.#composerEl.appendChild(el)
118
+ this.#fileInputEl = el
119
+ el.addEventListener('change', () => {
120
+ this.#uploadFiles([...el.files])
121
+ el.value = ''
122
+ })
123
+ }
124
+
125
+ #buildAttachButton() {
126
+ const btn = document.createElement('button')
127
+ btn.type = 'button'
128
+ btn.className = 'btn-attach btn-icon'
129
+ btn.title = 'Attach file'
130
+ btn.setAttribute('aria-label', 'Attach file')
131
+ btn.innerHTML = '📎'
132
+ const sendBtn = this.#composerEl.querySelector('.btn-send')
133
+ if (sendBtn) this.#composerEl.insertBefore(btn, sendBtn)
134
+ else this.#composerEl.appendChild(btn)
135
+ this.#btnAttachEl = btn
136
+ btn.addEventListener('click', () => this.#fileInputEl.click())
137
+ }
138
+
139
+ #buildDropOverlay() {
140
+ let dropOverlayEl = null
141
+
142
+ const ensure = () => {
143
+ if (dropOverlayEl) return dropOverlayEl
144
+ dropOverlayEl = document.createElement('div')
145
+ dropOverlayEl.className = 'drop-overlay'
146
+ dropOverlayEl.textContent = 'Drop to attach'
147
+ this.#composerEl.appendChild(dropOverlayEl)
148
+ return dropOverlayEl
149
+ }
150
+
151
+ this.#composerEl.addEventListener('dragover', e => {
152
+ if (!e.dataTransfer.types.includes('Files')) return
153
+ e.preventDefault()
154
+ ensure().hidden = false
155
+ })
156
+ this.#composerEl.addEventListener('dragleave', e => {
157
+ if (this.#composerEl.contains(e.relatedTarget)) return
158
+ if (dropOverlayEl) dropOverlayEl.hidden = true
159
+ })
160
+ this.#composerEl.addEventListener('drop', e => {
161
+ e.preventDefault()
162
+ if (dropOverlayEl) dropOverlayEl.hidden = true
163
+ const files = [...(e.dataTransfer.files ?? [])]
164
+ if (files.length) this.#uploadFiles(files)
165
+ })
166
+ }
167
+
168
+ // ─────────────────────────────────────────────────────────────────────────
169
+ // Bind composer textarea events
170
+ // ─────────────────────────────────────────────────────────────────────────
171
+
172
+ #bindComposerEvents() {
173
+ const ta = this.#textareaEl
174
+ if (!ta) return
175
+
176
+ ta.addEventListener('keydown', e => {
177
+ // Let MentionPicker handle its keys first; only handle Enter for send
178
+ if (this.#mentionPicker?.isOpen) return
179
+ if (e.key === 'Enter' && !e.shiftKey) {
180
+ e.preventDefault()
181
+ const priority = (e.ctrlKey || e.metaKey) ? 'now' : undefined
182
+ this.#submitMain({ priority })
183
+ }
184
+ })
185
+ ta.addEventListener('paste', e => this.#handlePaste(e))
186
+
187
+ // Send button
188
+ this.#composerEl.querySelector('.btn-send')?.addEventListener('click', () => {
189
+ this.#submitMain()
190
+ })
191
+
192
+ // Urgent mode toggle button
193
+ this.#composerEl.querySelector('.btn-urgent-toggle')?.addEventListener('click', () => {
194
+ this.#toggleUrgent()
195
+ })
196
+
197
+ // Compose-expand button
198
+ this.#composerEl.querySelector('.btn-compose-expand')?.addEventListener('click', () => {
199
+ this.#composeOpen ? this.#closeOverlay() : this.#openOverlay()
200
+ })
201
+ }
202
+
203
+ // ─────────────────────────────────────────────────────────────────────────
204
+ // Compose overlay (Ctrl+E full-screen editor)
205
+ // ─────────────────────────────────────────────────────────────────────────
206
+
207
+ #bindOverlay() {
208
+ this.#overlayEl = document.getElementById('compose-overlay')
209
+ this.#overlayTaEl = document.getElementById('compose-textarea')
210
+ this.#overlayPreviewEl = document.getElementById('compose-preview')
211
+ this.#composeChipsEl = document.getElementById('compose-chips')
212
+ const collapseBtn = document.getElementById('btn-compose-collapse')
213
+ const sendBtn = document.getElementById('compose-send')
214
+ const urgentBtn = document.getElementById('compose-urgent-toggle')
215
+ const attachBtn = document.getElementById('compose-attach')
216
+
217
+ if (!this.#overlayEl) return
218
+
219
+ collapseBtn?.addEventListener('click', () => this.#closeOverlay())
220
+ sendBtn?.addEventListener('click', () => { this.#submitMain(); this.#closeOverlay() })
221
+ urgentBtn?.addEventListener('click', () => this.#toggleUrgent())
222
+ attachBtn?.addEventListener('click', () => this.#fileInputEl.click())
223
+
224
+ this.#overlayTaEl?.addEventListener('keydown', e => {
225
+ if (this.#overlayMentionPicker?.isOpen) return
226
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
227
+ e.preventDefault()
228
+ this.#submitMain()
229
+ this.#closeOverlay()
230
+ }
231
+ })
232
+
233
+ this.#overlayTaEl?.addEventListener('paste', e => this.#handlePaste(e))
234
+
235
+ // Mention picker for the overlay textarea
236
+ if (this.#overlayTaEl) {
237
+ this.#overlayMentionPicker = new MentionPicker(
238
+ this.#overlayTaEl,
239
+ this.#overlayEl,
240
+ () => this.#members,
241
+ )
242
+ }
243
+
244
+ // Tab strip in overlay
245
+ this.#overlayEl.querySelectorAll('.compose-tab').forEach(btn => {
246
+ btn.addEventListener('click', () => this.#switchComposeTab(btn.dataset.tab))
247
+ })
248
+
249
+ // Chips remove in overlay
250
+ this.#composeChipsEl?.addEventListener('click', e => {
251
+ const btn = e.target.closest('.attachment-chip-remove')
252
+ if (!btn) return
253
+ this.#removeChipAt(parseInt(btn.dataset.index, 10))
254
+ })
255
+
256
+ // Drag-drop on overlay
257
+ let composeDropEl = null
258
+ const ensureDropOverlay = () => {
259
+ if (composeDropEl) return composeDropEl
260
+ composeDropEl = document.createElement('div')
261
+ composeDropEl.className = 'drop-overlay'
262
+ composeDropEl.textContent = 'Drop to attach'
263
+ this.#overlayEl.appendChild(composeDropEl)
264
+ return composeDropEl
265
+ }
266
+ this.#overlayEl.addEventListener('dragover', e => {
267
+ if (!e.dataTransfer.types.includes('Files')) return
268
+ e.preventDefault()
269
+ ensureDropOverlay().hidden = false
270
+ })
271
+ this.#overlayEl.addEventListener('dragleave', e => {
272
+ if (this.#overlayEl.contains(e.relatedTarget)) return
273
+ if (composeDropEl) composeDropEl.hidden = true
274
+ })
275
+ this.#overlayEl.addEventListener('drop', e => {
276
+ e.preventDefault()
277
+ if (composeDropEl) composeDropEl.hidden = true
278
+ const files = [...(e.dataTransfer.files ?? [])]
279
+ if (files.length) this.#uploadFiles(files)
280
+ })
281
+ }
282
+
283
+ #bindGlobalKeys() {
284
+ document.addEventListener('keydown', e => {
285
+ if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
286
+ e.preventDefault()
287
+ this.#composeOpen ? this.#closeOverlay() : this.#openOverlay()
288
+ }
289
+ })
290
+ }
291
+
292
+ // ─────────────────────────────────────────────────────────────────────────
293
+ // Submit / send
294
+ // ─────────────────────────────────────────────────────────────────────────
295
+
296
+ #submitMain({ priority } = {}) {
297
+ const text = (this.#composeOpen
298
+ ? this.#overlayTaEl?.value
299
+ : this.#textareaEl?.value
300
+ )?.trim() ?? ''
301
+
302
+ if (!text && this.#pendingAttachments.length === 0) return
303
+
304
+ dispatch('send-message', {
305
+ channelId: this.#model.currentChannelId,
306
+ text,
307
+ attachments: [...this.#pendingAttachments],
308
+ priority: priority ?? (this.#urgentMode ? 'now' : 'normal'),
309
+ })
310
+
311
+ if (this.#textareaEl) this.#textareaEl.value = ''
312
+ if (this.#overlayTaEl) this.#overlayTaEl.value = ''
313
+ this.#pendingAttachments = []
314
+ this.#renderChips()
315
+ }
316
+
317
+ // ─────────────────────────────────────────────────────────────────────────
318
+ // Paste images
319
+ // ─────────────────────────────────────────────────────────────────────────
320
+
321
+ #handlePaste(e) {
322
+ const items = [...(e.clipboardData?.items ?? [])]
323
+ const imageFiles = items
324
+ .filter(item => item.kind === 'file' && item.type.startsWith('image/'))
325
+ .map(item => {
326
+ const file = item.getAsFile()
327
+ if (!file) return null
328
+ if (!file.name) {
329
+ const ext = item.type.split('/')[1] ?? 'png'
330
+ return new File([file], `paste-${Date.now()}.${ext}`, { type: item.type })
331
+ }
332
+ return file
333
+ })
334
+ .filter(Boolean)
335
+ if (imageFiles.length === 0) return
336
+ e.preventDefault()
337
+ this.#uploadFiles(imageFiles)
338
+ }
339
+
340
+ // ─────────────────────────────────────────────────────────────────────────
341
+ // File upload
342
+ // ─────────────────────────────────────────────────────────────────────────
343
+
344
+ async #uploadFiles(files) {
345
+ for (const file of files) await this.#uploadOne(file)
346
+ }
347
+
348
+ async #uploadOne(file) {
349
+ const formData = new FormData()
350
+ formData.append('file', file)
351
+ formData.append('channel_id', this.#model.currentChannelId)
352
+
353
+ let res
354
+ try {
355
+ res = await fetch(`${window.__BASE_PATH__ ?? ''}/api/uploads`, {
356
+ method: 'POST',
357
+ body: formData,
358
+ })
359
+ } catch {
360
+ this.#showError('Upload failed: network error')
361
+ return
362
+ }
363
+
364
+ if (!res.ok) {
365
+ const body = await res.json().catch(() => ({}))
366
+ this.#showError(`Upload failed: ${body.error ?? res.statusText}`)
367
+ return
368
+ }
369
+
370
+ const attachment = await res.json()
371
+ this.#pendingAttachments.push(attachment)
372
+ this.#renderChips()
373
+ }
374
+
375
+ // ─────────────────────────────────────────────────────────────────────────
376
+ // Attachment chips
377
+ // ─────────────────────────────────────────────────────────────────────────
378
+
379
+ #renderChips() {
380
+ const html = this.#pendingAttachments.map((a, i) => `
381
+ <span class="attachment-chip" data-index="${i}">
382
+ <span class="attachment-chip-name">${escHtml(a.original_name)}</span>
383
+ <button type="button" class="attachment-chip-remove" data-index="${i}"
384
+ aria-label="Remove ${escHtml(a.original_name)}">×</button>
385
+ </span>`).join('')
386
+
387
+ this.#chipsEl.innerHTML = html
388
+ this.#chipsEl.hidden = this.#pendingAttachments.length === 0
389
+
390
+ if (this.#composeChipsEl) {
391
+ this.#composeChipsEl.innerHTML = html
392
+ this.#composeChipsEl.hidden = this.#pendingAttachments.length === 0
393
+ }
394
+ }
395
+
396
+ #removeChipAt(idx) {
397
+ this.#pendingAttachments.splice(idx, 1)
398
+ this.#renderChips()
399
+ }
400
+
401
+ #showError(msg) {
402
+ const target = this.#composeOpen ? this.#composeChipsEl : this.#chipsEl
403
+ if (!target) return
404
+ const chip = document.createElement('span')
405
+ chip.className = 'attachment-chip attachment-chip-error'
406
+ chip.textContent = msg
407
+ target.appendChild(chip)
408
+ target.hidden = false
409
+ setTimeout(() => chip.remove(), 5000)
410
+ }
411
+
412
+ // ─────────────────────────────────────────────────────────────────────────
413
+ // Urgent mode
414
+ // ─────────────────────────────────────────────────────────────────────────
415
+
416
+ #toggleUrgent() {
417
+ this.#urgentMode = !this.#urgentMode
418
+ this.#composerEl.classList.toggle('composer-urgent', this.#urgentMode)
419
+ this.#overlayEl?.classList.toggle('composer-urgent', this.#urgentMode)
420
+ document.getElementById('compose-urgent-toggle')?.classList.toggle('is-urgent', this.#urgentMode)
421
+ }
422
+
423
+ // ─────────────────────────────────────────────────────────────────────────
424
+ // Compose overlay
425
+ // ─────────────────────────────────────────────────────────────────────────
426
+
427
+ #openOverlay() {
428
+ if (this.#composeOpen || !this.#overlayEl) return
429
+ this.#composeOpen = true
430
+ if (this.#overlayTaEl) this.#overlayTaEl.value = this.#textareaEl?.value ?? ''
431
+
432
+ const messagesEl = document.getElementById('messages')
433
+ if (messagesEl) messagesEl.hidden = true
434
+ this.#composerEl.hidden = true
435
+ this.#overlayEl.hidden = false
436
+ this.#switchComposeTab('write')
437
+
438
+ requestAnimationFrame(() => {
439
+ if (!this.#overlayTaEl) return
440
+ this.#overlayTaEl.focus()
441
+ const len = this.#overlayTaEl.value.length
442
+ this.#overlayTaEl.setSelectionRange(len, len)
443
+ })
444
+ }
445
+
446
+ #closeOverlay() {
447
+ if (!this.#composeOpen || !this.#overlayEl) return
448
+ this.#composeOpen = false
449
+
450
+ const messagesEl = document.getElementById('messages')
451
+ if (messagesEl) messagesEl.hidden = false
452
+ this.#composerEl.hidden = false
453
+ this.#overlayEl.hidden = true
454
+ this.#textareaEl?.focus()
455
+ }
456
+
457
+ async #switchComposeTab(tab) {
458
+ if (!this.#overlayEl) return
459
+ this.#overlayEl.querySelectorAll('.compose-tab').forEach(btn => {
460
+ const active = btn.dataset.tab === tab
461
+ btn.classList.toggle('compose-tab--active', active)
462
+ btn.setAttribute('aria-selected', String(active))
463
+ })
464
+ if (this.#overlayTaEl) this.#overlayTaEl.hidden = tab !== 'write'
465
+ if (this.#overlayPreviewEl) this.#overlayPreviewEl.hidden = tab !== 'preview'
466
+
467
+ if (tab === 'preview') {
468
+ const text = this.#overlayTaEl?.value ?? ''
469
+ if (!text.trim()) {
470
+ if (this.#overlayPreviewEl) {
471
+ this.#overlayPreviewEl.innerHTML = '<p style="color:var(--text-muted)">Nothing to preview yet.</p>'
472
+ }
473
+ return
474
+ }
475
+ let html = null
476
+ await new Promise(resolve => {
477
+ dispatch('preview-text', { text, resolve: h => { html = h; resolve() } })
478
+ })
479
+ if (this.#overlayPreviewEl) {
480
+ this.#overlayPreviewEl.innerHTML = html ? _sanitize(html) : escHtml(text)
481
+ }
482
+ }
483
+ }
484
+ }
485
+
486
+ function _sanitize(html) {
487
+ return String(html ?? '').replaceAll('<script>', '').replaceAll('</script>', '')
488
+ }