@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.
- 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 -40
- package/pages/public/client/app.js +136 -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/shared/messages.js +21 -9
- package/pages/public/client/views/CallView.js +761 -0
- package/pages/public/client/views/ComposerView.js +488 -0
- package/pages/public/client/views/MessageListView.js +461 -0
- package/pages/public/client/views/SidebarView.js +902 -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 +7 -29
- package/pages/public/client/islands/call.js +0 -2282
- package/pages/public/client/islands/sidebar.js +0 -1198
|
@@ -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 = '↩'
|
|
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
|
+
}
|