@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,461 @@
1
+ /**
2
+ * MessageListView.js — renders the main channel message list.
3
+ *
4
+ * Listens to AppModel events and updates the DOM. All user-action events are
5
+ * delegated through MessageInteractions → dispatched to ChatController.
6
+ *
7
+ * Owned DOM: #messages (the scrollable message list)
8
+ * #load-more-sentinel (IntersectionObserver trigger)
9
+ *
10
+ * Model events handled:
11
+ * message-added → append message
12
+ * message-updated → update text + edited marker
13
+ * message-deleted → remove article
14
+ * reactions-updated → re-render reaction bar
15
+ * messages-prepended → prepend older messages, adjust scroll
16
+ * channel-selected → clear and re-render cached messages for new channel
17
+ * loading-more-changed → show/hide sentinel
18
+ */
19
+
20
+ import * as Ev from '../model/events.js'
21
+ import {
22
+ makeMessageEl, renderAttachment, escHtml,
23
+ utcDateKey, makeDateSeparator, applyInlineRenderingToTextNodes,
24
+ } from '../shared/messages.js'
25
+ import { attachMessageInteractions, cancelActiveEdit } from './shared/MessageInteractions.js'
26
+ import { renderQuickPicksSlot } from './shared/EmojiPickerSingleton.js'
27
+ import { dispatch } from '../controllers/ChatController.js'
28
+
29
+ export class MessageListView {
30
+ #model
31
+ #el // #messages container
32
+ #sentinelEl // #load-more-sentinel
33
+ #observer // IntersectionObserver
34
+ #channelId // currently rendered channel
35
+
36
+ /**
37
+ * @param {AppModel} model
38
+ * @param {HTMLElement} messagesEl — #messages
39
+ * @param {HTMLElement} sentinelEl — #load-more-sentinel
40
+ */
41
+ constructor(model, messagesEl, sentinelEl) {
42
+ this.#model = model
43
+ this.#el = messagesEl
44
+ this.#sentinelEl = sentinelEl
45
+ this.#channelId = model.currentChannelId
46
+
47
+ this.#bindModelEvents()
48
+ this.#setupPagination()
49
+ attachMessageInteractions(messagesEl, { model })
50
+ this.#hydrateExisting()
51
+ }
52
+
53
+ // ─────────────────────────────────────────────────────────────────────────
54
+ // Model event bindings
55
+ // ─────────────────────────────────────────────────────────────────────────
56
+
57
+ #bindModelEvents() {
58
+ const m = this.#model
59
+
60
+ m.addEventListener(Ev.CHANNEL_SELECTED, e => this.#onChannelSelected(e.detail))
61
+ m.addEventListener(Ev.MESSAGE_ADDED, e => this.#onMessageAdded(e.detail))
62
+ m.addEventListener(Ev.MESSAGE_UPDATED, e => this.#onMessageUpdated(e.detail))
63
+ m.addEventListener(Ev.MESSAGE_DELETED, e => this.#onMessageDeleted(e.detail))
64
+ m.addEventListener(Ev.REACTIONS_UPDATED, e => this.#onReactionsUpdated(e.detail))
65
+ m.addEventListener(Ev.MESSAGES_PREPENDED, e => this.#onMessagesPrepended(e.detail))
66
+ m.addEventListener(Ev.LOADING_MORE_CHANGED, e => this.#onLoadingMoreChanged(e.detail))
67
+ m.addEventListener(Ev.MEMBERS_UPDATED, () => this.#reapplyMentions())
68
+ }
69
+
70
+ // ─────────────────────────────────────────────────────────────────────────
71
+ // Hydrate seed messages already in the DOM (SSR)
72
+ // ─────────────────────────────────────────────────────────────────────────
73
+
74
+ #hydrateExisting() {
75
+ const model = this.#model
76
+ const userId = model.userId
77
+ let prevDateKey = null
78
+
79
+ for (const article of this.#el.querySelectorAll('article.message')) {
80
+ if (article.dataset.hydrated) continue
81
+ article.dataset.hydrated = '1'
82
+
83
+ // DM trigger on non-self handles
84
+ const handle = article.querySelector('.message-handle[data-user-id]')
85
+ if (handle && handle.dataset.userId !== userId) {
86
+ handle.classList.add('dm-trigger')
87
+ handle.title = 'Send a direct message'
88
+ }
89
+
90
+ // Hover toolbar if missing
91
+ if (!article.querySelector('.message-hover-actions')) {
92
+ _addHoverToolbar(article, userId)
93
+ }
94
+
95
+ // "View N replies" link
96
+ if (!article.querySelector('.thread-replies-link')) {
97
+ const replyCount = parseInt(article.dataset.replyCount ?? '0', 10)
98
+ if (replyCount > 0) _addThreadRepliesLink(article)
99
+ }
100
+
101
+ // Inline rendering (@mentions, URLs) on server-rendered text
102
+ const textEl = article.querySelector('.message-text')
103
+ if (textEl) applyInlineRenderingToTextNodes(textEl, { userHandle: model.userHandle, knownHandles: model.knownHandles })
104
+
105
+ // Attachments from data-attachments JSON
106
+ const raw = article.dataset.attachments
107
+ if (raw) {
108
+ let attachments
109
+ try { attachments = JSON.parse(raw) } catch { attachments = null }
110
+ if (Array.isArray(attachments) && attachments.length > 0) {
111
+ attachments.forEach(a => article.insertAdjacentHTML('beforeend', renderAttachment(a)))
112
+ }
113
+ }
114
+
115
+ // Reaction bar
116
+ const rawReactions = article.dataset.reactions
117
+ if (article.dataset.msgId) {
118
+ let reactions = []
119
+ if (rawReactions) {
120
+ try { reactions = JSON.parse(rawReactions) } catch { reactions = [] }
121
+ }
122
+ renderReactionBar(article, reactions, article.dataset.msgId)
123
+ }
124
+
125
+ _enableTaskCheckboxes(article)
126
+ _renderQuickPicks(article)
127
+
128
+ // Local-timezone time
129
+ const ts = parseInt(article.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
130
+ if (ts) {
131
+ const timeEl = article.querySelector('.message-time')
132
+ if (timeEl) {
133
+ const localTime = new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
134
+ const editedSpan = timeEl.querySelector('.message-edited')
135
+ timeEl.textContent = localTime
136
+ if (editedSpan) timeEl.appendChild(editedSpan)
137
+ }
138
+ const dateKey = utcDateKey(ts)
139
+ if (prevDateKey && dateKey !== prevDateKey) {
140
+ article.before(makeDateSeparator(dateKey))
141
+ }
142
+ prevDateKey = dateKey
143
+ }
144
+ }
145
+
146
+ // Scroll to bottom on initial load.
147
+ // Double-rAF: first rAF lets the browser compute flex heights; second rAF
148
+ // fires after those dimensions are stable so scrollHeight is accurate.
149
+ // Direct scrollTop assignment bypasses CSS scroll-behavior:smooth.
150
+ requestAnimationFrame(() => {
151
+ requestAnimationFrame(() => {
152
+ this.#el.scrollTop = this.#el.scrollHeight
153
+ })
154
+ })
155
+ }
156
+
157
+ // ─────────────────────────────────────────────────────────────────────────
158
+ // Pagination (IntersectionObserver on sentinel)
159
+ // ─────────────────────────────────────────────────────────────────────────
160
+
161
+ #setupPagination() {
162
+ if (!this.#sentinelEl) return
163
+
164
+ this.#observer = new IntersectionObserver(entries => {
165
+ if (!entries[0].isIntersecting) return
166
+ if (this.#model.loadingMore) return
167
+ const channelId = this.#model.currentChannelId
168
+ const beforeSeq = this.#model.oldestSeqFor(channelId)
169
+ if (beforeSeq <= 1) return
170
+ dispatch('load-more', { channelId, beforeSeq })
171
+ }, { root: this.#el, threshold: 0.1 })
172
+
173
+ // Only observe if the server said there are more messages
174
+ if (!this.#sentinelEl.hidden) {
175
+ this.#observer.observe(this.#sentinelEl)
176
+ }
177
+ }
178
+
179
+ // ─────────────────────────────────────────────────────────────────────────
180
+ // Event handlers
181
+ // ─────────────────────────────────────────────────────────────────────────
182
+
183
+ #onChannelSelected({ channelId, prev }) {
184
+ this.#channelId = channelId
185
+ cancelActiveEdit()
186
+
187
+ const msgs = this.#model.messagesFor(channelId)
188
+
189
+ if (msgs.length > 0) {
190
+ // Model has cached messages (previously visited channel) — re-render from cache
191
+ this.#el.innerHTML = ''
192
+ const fragment = _buildMessageFragment(msgs, this.#model)
193
+ if (this.#sentinelEl) {
194
+ this.#sentinelEl.after(fragment)
195
+ } else {
196
+ this.#el.appendChild(fragment)
197
+ }
198
+ requestAnimationFrame(() => {
199
+ requestAnimationFrame(() => {
200
+ this.#el.scrollTop = this.#el.scrollHeight
201
+ })
202
+ })
203
+ } else {
204
+ // No cache — router.js already morphed SSR content into the DOM; hydrate it.
205
+ this.#hydrateExisting()
206
+ }
207
+
208
+ // Reset pagination sentinel for the new channel
209
+ const hasMore = this.#model.hasMoreFor(channelId)
210
+ if (this.#sentinelEl) {
211
+ this.#sentinelEl.hidden = !hasMore
212
+ if (hasMore) this.#observer?.observe(this.#sentinelEl)
213
+ else this.#observer?.unobserve(this.#sentinelEl)
214
+ }
215
+ }
216
+
217
+ #onMessageAdded({ channelId, message }) {
218
+ if (channelId !== this.#channelId) return
219
+ if (this.#el.querySelector(`[data-msg-id="${message.msg_id}"]`)) return
220
+
221
+ // Date separator if day changed
222
+ const dateKey = utcDateKey(message.ts)
223
+ const lastMsg = this.#el.querySelector('article.message:last-of-type')
224
+ if (lastMsg) {
225
+ const lastTs = parseInt(lastMsg.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
226
+ if (lastTs && utcDateKey(lastTs) !== dateKey) {
227
+ this.#el.appendChild(makeDateSeparator(dateKey))
228
+ }
229
+ }
230
+
231
+ const article = makeMessageEl(message, {
232
+ userId: this.#model.userId,
233
+ userHandle: this.#model.userHandle,
234
+ knownHandles: this.#model.knownHandles,
235
+ })
236
+ _postProcess(article, message.reactions ?? [], message.msg_id)
237
+ this.#el.appendChild(article)
238
+ this.#el.scrollTop = this.#el.scrollHeight
239
+ }
240
+
241
+ #onMessageUpdated({ channelId, message }) {
242
+ if (channelId !== this.#channelId) return
243
+ const article = this.#el.querySelector(`[data-msg-id="${message.msg_id}"]`)
244
+ if (!article) return
245
+ _applyMessageUpdate(article, message)
246
+ }
247
+
248
+ #onMessageDeleted({ channelId, msgId }) {
249
+ if (channelId !== this.#channelId) return
250
+ this.#el.querySelector(`[data-msg-id="${msgId}"]`)?.remove()
251
+ }
252
+
253
+ #onReactionsUpdated({ msgId, channelId, reactions }) {
254
+ if (channelId !== this.#channelId) return
255
+ const article = this.#el.querySelector(`[data-msg-id="${msgId}"]`)
256
+ if (article) renderReactionBar(article, reactions, msgId)
257
+
258
+ // Also update reply-count link if reactions belong to a parent msg
259
+ // (No action needed here — reply-count updates come via model.updateMessage)
260
+ }
261
+
262
+ #onMessagesPrepended({ channelId, messages, hasMore }) {
263
+ if (channelId !== this.#channelId) return
264
+
265
+ const prevHeight = this.#el.scrollHeight
266
+ const fragment = _buildMessageFragment(messages, this.#model)
267
+
268
+ if (this.#sentinelEl) {
269
+ this.#sentinelEl.after(fragment)
270
+ } else {
271
+ this.#el.prepend(fragment)
272
+ }
273
+
274
+ // Maintain scroll position
275
+ this.#el.scrollTop += this.#el.scrollHeight - prevHeight
276
+
277
+ // Show/hide sentinel
278
+ if (this.#sentinelEl) {
279
+ this.#sentinelEl.hidden = !hasMore
280
+ if (!hasMore) this.#observer?.unobserve(this.#sentinelEl)
281
+ }
282
+ }
283
+
284
+ #onLoadingMoreChanged({ loading }) {
285
+ // The sentinel visibility is managed in #onMessagesPrepended.
286
+ // Nothing to do here unless we want a loading spinner.
287
+ }
288
+
289
+ #reapplyMentions() {
290
+ const { userHandle, knownHandles } = this.#model
291
+ for (const textEl of this.#el.querySelectorAll('.message-text')) {
292
+ applyInlineRenderingToTextNodes(textEl, { userHandle, knownHandles })
293
+ }
294
+ }
295
+ }
296
+
297
+ // ─────────────────────────────────────────────────────────────────────────────
298
+ // Shared helpers (module-private)
299
+ // ─────────────────────────────────────────────────────────────────────────────
300
+
301
+ /**
302
+ * Build a fragment from an array of message objects (already in the model).
303
+ */
304
+ function _buildMessageFragment(msgs, model) {
305
+ const fragment = document.createDocumentFragment()
306
+ let prevDate = null
307
+
308
+ for (const msg of msgs) {
309
+ const dateKey = utcDateKey(msg.ts)
310
+ if (prevDate && dateKey !== prevDate) {
311
+ fragment.appendChild(makeDateSeparator(dateKey))
312
+ }
313
+ const article = makeMessageEl(msg, {
314
+ userId: model.userId,
315
+ userHandle: model.userHandle,
316
+ knownHandles: model.knownHandles,
317
+ })
318
+ _postProcess(article, msg.reactions ?? [], msg.msg_id)
319
+ fragment.appendChild(article)
320
+ prevDate = dateKey
321
+ }
322
+
323
+ return fragment
324
+ }
325
+
326
+ function _postProcess(article, reactions, msgId) {
327
+ if (!article.querySelector('.reaction-bar')) {
328
+ const bar = document.createElement('div')
329
+ bar.className = 'reaction-bar'
330
+ article.appendChild(bar)
331
+ }
332
+ _enableTaskCheckboxes(article)
333
+ _renderQuickPicks(article)
334
+ renderReactionBar(article, reactions, msgId)
335
+ }
336
+
337
+ function _enableTaskCheckboxes(article) {
338
+ for (const cb of article.querySelectorAll('.task-list-item-checkbox[disabled]')) {
339
+ cb.removeAttribute('disabled')
340
+ }
341
+ }
342
+
343
+ function _renderQuickPicks(article) {
344
+ renderQuickPicksSlot(article.querySelector('.quick-picks'))
345
+ }
346
+
347
+ function _addHoverToolbar(article, userId) {
348
+ const toolbar = document.createElement('div')
349
+ toolbar.className = 'message-hover-actions'
350
+ const quickPicks = document.createElement('span')
351
+ quickPicks.className = 'quick-picks'
352
+ toolbar.appendChild(quickPicks)
353
+
354
+ const replyBtn = document.createElement('button')
355
+ replyBtn.className = 'btn-reply btn-icon'
356
+ replyBtn.type = 'button'
357
+ replyBtn.title = 'Reply in thread'
358
+ replyBtn.setAttribute('aria-label', 'Reply in thread')
359
+ replyBtn.innerHTML = '&#x21A9;'
360
+ toolbar.appendChild(replyBtn)
361
+
362
+ const reactBtn = document.createElement('button')
363
+ reactBtn.className = 'btn-react btn-icon'
364
+ reactBtn.type = 'button'
365
+ reactBtn.title = 'Add reaction'
366
+ reactBtn.setAttribute('aria-label', 'Add reaction')
367
+ reactBtn.textContent = '🙂'
368
+ toolbar.appendChild(reactBtn)
369
+
370
+ if (article.dataset.userId === userId) {
371
+ const actionsBtn = document.createElement('button')
372
+ actionsBtn.className = 'btn-msg-actions btn-icon'
373
+ actionsBtn.type = 'button'
374
+ actionsBtn.title = 'Message actions'
375
+ actionsBtn.textContent = '…'
376
+ toolbar.appendChild(actionsBtn)
377
+ }
378
+
379
+ article.appendChild(toolbar)
380
+ renderQuickPicksSlot(quickPicks)
381
+ }
382
+
383
+ function _addThreadRepliesLink(article) {
384
+ const replyCount = parseInt(article.dataset.replyCount ?? '0', 10)
385
+ if (replyCount <= 0) return
386
+ const msgId = article.dataset.msgId
387
+ const link = document.createElement('a')
388
+ link.className = 'thread-replies-link'
389
+ link.href = '#'
390
+ link.dataset.msgId = msgId
391
+ link.textContent = `View ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`
392
+ const reactionBar = article.querySelector('.reaction-bar')
393
+ if (reactionBar) article.insertBefore(link, reactionBar)
394
+ else article.appendChild(link)
395
+ }
396
+
397
+ /**
398
+ * Apply a message update (edit) to an article element.
399
+ */
400
+ function _applyMessageUpdate(article, message) {
401
+ if (message.text !== undefined) {
402
+ article.dataset.rawText = message.text
403
+ }
404
+ if (message.rendered_text !== undefined || message.text !== undefined) {
405
+ const textEl = article.querySelector('.message-text')
406
+ if (textEl) {
407
+ const html = message.rendered_text ?? escHtml(message.text ?? '')
408
+ textEl.innerHTML = _sanitize(html)
409
+ _enableTaskCheckboxes(article)
410
+ }
411
+ }
412
+ if (message.edited_at) {
413
+ article.dataset.editedAt = message.edited_at
414
+ const timeEl = article.querySelector('.message-time')
415
+ if (timeEl && !timeEl.querySelector('.message-edited')) {
416
+ const span = document.createElement('span')
417
+ span.className = 'message-edited'
418
+ span.textContent = '(edited)'
419
+ timeEl.appendChild(span)
420
+ }
421
+ }
422
+ if (message.reply_count !== undefined) {
423
+ article.dataset.replyCount = String(message.reply_count)
424
+ _updateReplyCountLink(article, message.reply_count)
425
+ }
426
+ }
427
+
428
+ function _updateReplyCountLink(article, count) {
429
+ let link = article.querySelector('.thread-replies-link')
430
+ if (count > 0) {
431
+ const label = `View ${count} ${count === 1 ? 'reply' : 'replies'}`
432
+ if (!link) {
433
+ link = document.createElement('a')
434
+ link.className = 'thread-replies-link'
435
+ link.href = '#'
436
+ link.dataset.msgId = article.dataset.msgId
437
+ const reactionBar = article.querySelector('.reaction-bar')
438
+ if (reactionBar) article.insertBefore(link, reactionBar)
439
+ else article.appendChild(link)
440
+ }
441
+ link.textContent = label
442
+ } else if (link) {
443
+ link.remove()
444
+ }
445
+ }
446
+
447
+ /** Re-render the reaction bar inside an article. */
448
+ export function renderReactionBar(article, reactions, msgId) {
449
+ const bar = article.querySelector('.reaction-bar')
450
+ if (!bar) return
451
+ bar.innerHTML = (reactions ?? []).map(r => `
452
+ <button class="reaction-pill${r.reacted ? ' reacted' : ''}"
453
+ data-emoji="${escHtml(r.emoji)}" data-msg-id="${escHtml(msgId)}"
454
+ type="button" title="${r.count} reaction${r.count !== 1 ? 's' : ''}">
455
+ ${r.emoji} <span class="reaction-count">${r.count}</span>
456
+ </button>`).join('')
457
+ }
458
+
459
+ function _sanitize(html) {
460
+ return String(html ?? '').replaceAll('<script>', '').replaceAll('</script>', '')
461
+ }