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