@devchitchat/chat 4.5.0 → 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,353 @@
1
+ /**
2
+ * MessageInteractions.js — shared click delegation for message containers.
3
+ *
4
+ * Attach to any container that holds <article class="message"> elements:
5
+ * attachMessageInteractions(containerEl, { model, isThread })
6
+ *
7
+ * Both MessageListView and ThreadPanelView call this — zero duplication.
8
+ * Actions are dispatched as document CustomEvents so ChatController handles them.
9
+ *
10
+ * Dispatched events:
11
+ * 'react' { msgId, channelId, emoji }
12
+ * 'unreact' { msgId, channelId, emoji }
13
+ * 'open-thread' { msgId } — only when !isThread
14
+ * 'open-dm' { targetUserId }
15
+ * 'delete-message' { msgId, channelId }
16
+ * 'task-toggle' { msgId, channelId, text }
17
+ */
18
+
19
+ import {
20
+ openEmojiPicker,
21
+ saveRecentEmoji,
22
+ } from './EmojiPickerSingleton.js'
23
+ import { escHtml } from '../../shared/messages.js'
24
+ import { showActionSheet, dismiss as dismissActionSheet, getItemsContainer } from '../../action-sheet.js'
25
+ import { addLongPress } from '../../long-press.js'
26
+ import { dispatch } from '../../controllers/ChatController.js'
27
+
28
+ /**
29
+ * @param {HTMLElement} containerEl
30
+ * @param {{ model: AppModel, isThread?: boolean }} opts
31
+ */
32
+ export function attachMessageInteractions(containerEl, { model, isThread = false }) {
33
+ // ── Quick-react buttons (recent emoji, no picker) ──────────────────────────
34
+ containerEl.addEventListener('click', e => {
35
+ const btn = e.target.closest('.btn-quick-react')
36
+ if (!btn) return
37
+ e.stopPropagation()
38
+ const emoji = btn.dataset.emoji
39
+ const article = btn.closest('article.message')
40
+ const msgId = article?.dataset.msgId
41
+ const channelId = _channelId(model)
42
+ if (!emoji || !msgId) return
43
+ saveRecentEmoji(emoji)
44
+ dispatch('react', { msgId, channelId, emoji })
45
+ })
46
+
47
+ // ── Reaction pills — toggle react/unreact ──────────────────────────────────
48
+ containerEl.addEventListener('click', e => {
49
+ const pill = e.target.closest('.reaction-pill')
50
+ if (!pill) return
51
+ e.stopPropagation()
52
+ const emoji = pill.dataset.emoji
53
+ const msgId = pill.dataset.msgId
54
+ const channelId = _channelId(model)
55
+ if (!emoji || !msgId) return
56
+ if (pill.classList.contains('reacted')) {
57
+ dispatch('unreact', { msgId, channelId, emoji })
58
+ } else {
59
+ dispatch('react', { msgId, channelId, emoji })
60
+ }
61
+ })
62
+
63
+ // ── Emoji picker trigger (.btn-react / .reaction-add) ─────────────────────
64
+ containerEl.addEventListener('click', e => {
65
+ const addBtn = e.target.closest('.reaction-add')
66
+ const reactBtn = e.target.closest('.btn-react')
67
+ const btn = addBtn ?? reactBtn
68
+ if (!btn) return
69
+ e.stopPropagation()
70
+ const msgId = addBtn?.dataset.msgId ?? btn.closest('article.message')?.dataset.msgId
71
+ const channelId = _channelId(model)
72
+ if (!msgId) return
73
+ openEmojiPicker(btn, msgId, emoji => {
74
+ dispatch('react', { msgId, channelId, emoji })
75
+ })
76
+ })
77
+
78
+ // ── Reply button → open thread panel ──────────────────────────────────────
79
+ if (!isThread) {
80
+ containerEl.addEventListener('click', e => {
81
+ const btn = e.target.closest('.btn-reply')
82
+ if (!btn) return
83
+ e.stopPropagation()
84
+ const msgId = btn.closest('article.message')?.dataset.msgId
85
+ if (!msgId) return
86
+ dispatch('open-thread', { msgId })
87
+ })
88
+
89
+ // "View N replies" link → open thread panel
90
+ containerEl.addEventListener('click', e => {
91
+ const link = e.target.closest('.thread-replies-link')
92
+ if (!link) return
93
+ e.preventDefault()
94
+ e.stopPropagation()
95
+ const msgId = link.dataset.msgId ?? link.closest('article.message')?.dataset.msgId
96
+ if (!msgId) return
97
+ dispatch('open-thread', { msgId })
98
+ })
99
+ }
100
+
101
+ // ── DM trigger (sender handle → open DM) ──────────────────────────────────
102
+ containerEl.addEventListener('click', e => {
103
+ const handle = e.target.closest('.dm-trigger')
104
+ if (!handle) return
105
+ const targetUserId = handle.dataset.userId
106
+ if (!targetUserId || targetUserId === model.userId) return
107
+ dispatch('open-dm', { targetUserId })
108
+ })
109
+
110
+ // ── Task-list checkboxes ───────────────────────────────────────────────────
111
+ containerEl.addEventListener('click', e => {
112
+ const cb = e.target.closest('.task-list-item-checkbox')
113
+ if (!cb) return
114
+ e.preventDefault()
115
+ const article = cb.closest('article.message')
116
+ if (!article) return
117
+ const rawText = article.dataset.rawText
118
+ if (!rawText) return
119
+ const allCbs = Array.from(article.querySelectorAll('.task-list-item-checkbox'))
120
+ const idx = allCbs.indexOf(cb)
121
+ if (idx === -1) return
122
+ let count = 0
123
+ const newText = rawText.replace(/\[([ xX])\]/g, (match, state) => {
124
+ if (count++ !== idx) return match
125
+ return state.trim() === '' ? '[x]' : '[ ]'
126
+ })
127
+ if (newText === rawText) return
128
+ cb.checked = !cb.checked
129
+ article.dataset.rawText = newText
130
+ dispatch('task-toggle', {
131
+ msgId: article.dataset.msgId,
132
+ channelId: _channelId(model),
133
+ text: newText,
134
+ })
135
+ })
136
+
137
+ // ── … (message actions) button → context menu ─────────────────────────────
138
+ containerEl.addEventListener('click', e => {
139
+ const btn = e.target.closest('.btn-msg-actions')
140
+ if (!btn) return
141
+ e.stopPropagation()
142
+ const article = btn.closest('article.message')
143
+ if (article) _showContextMenu(article, btn, model)
144
+ })
145
+
146
+ // ── Mobile long-press → action sheet ─────────────────────────────────────
147
+ addLongPress(containerEl, e => {
148
+ const article = e.target.closest?.('article.message')
149
+ const msgId = article?.dataset.msgId
150
+ const channelId = _channelId(model)
151
+ if (!msgId) return
152
+
153
+ // Open sheet first (which clears the container), then populate it.
154
+ showActionSheet({ label: 'React to this message', items: [] })
155
+
156
+ const wrapper = document.createElement('div')
157
+ wrapper.className = 'action-sheet-emoji-picker-wrap'
158
+ getItemsContainer().appendChild(wrapper)
159
+
160
+ openEmojiPicker(wrapper, msgId, emoji => {
161
+ saveRecentEmoji(emoji)
162
+ dismissActionSheet()
163
+ dispatch('react', { msgId, channelId, emoji })
164
+ })
165
+ })
166
+ }
167
+
168
+ // ─────────────────────────────────────────────────────────────────────────────
169
+ // Inline edit (called from context menu or keyboard shortcut)
170
+ // ─────────────────────────────────────────────────────────────────────────────
171
+
172
+ let activeEditCancel = null
173
+
174
+ export function startInlineEdit(article, model) {
175
+ if (article.querySelector('.message-edit-wrap')) return
176
+ const textEl = article.querySelector('.message-text')
177
+ if (!textEl) return
178
+ const rawText = article.dataset.rawText ?? ''
179
+
180
+ const wrap = document.createElement('div')
181
+ wrap.className = 'message-edit-wrap'
182
+
183
+ const tabStrip = document.createElement('div')
184
+ tabStrip.className = 'message-edit-tabs'
185
+ tabStrip.setAttribute('role', 'tablist')
186
+ tabStrip.innerHTML = `
187
+ <button class="message-edit-tab message-edit-tab--active" data-tab="write"
188
+ role="tab" aria-selected="true" type="button">Write</button>
189
+ <button class="message-edit-tab" data-tab="preview"
190
+ role="tab" aria-selected="false" type="button">Preview</button>`
191
+
192
+ const textarea = document.createElement('textarea')
193
+ textarea.className = 'message-edit-input'
194
+ textarea.value = rawText
195
+
196
+ const preview = document.createElement('div')
197
+ preview.className = 'message-edit-preview message-text'
198
+ preview.hidden = true
199
+ preview.setAttribute('aria-live', 'polite')
200
+
201
+ const toolbar = document.createElement('div')
202
+ toolbar.className = 'message-edit-toolbar'
203
+ toolbar.innerHTML = `
204
+ <span class="message-edit-hint">Ctrl+Enter to save · Esc to cancel</span>
205
+ <button class="btn-ghost btn-edit-cancel" type="button">Cancel</button>
206
+ <button class="btn-primary btn-edit-save" type="button">Save</button>`
207
+
208
+ wrap.append(tabStrip, textarea, preview, toolbar)
209
+ textEl.replaceWith(wrap)
210
+ textarea.focus()
211
+ textarea.setSelectionRange(rawText.length, rawText.length)
212
+
213
+ const cancel = () => {
214
+ wrap.replaceWith(textEl)
215
+ activeEditCancel = null
216
+ }
217
+
218
+ const save = () => {
219
+ const text = textarea.value.trim()
220
+ if (!text) return
221
+ dispatch('edit-message', {
222
+ msgId: article.dataset.msgId,
223
+ channelId: _channelId(model),
224
+ text,
225
+ })
226
+ cancel()
227
+ }
228
+
229
+ activeEditCancel = cancel
230
+
231
+ // Tab switching
232
+ tabStrip.addEventListener('click', async e => {
233
+ const btn = e.target.closest('.message-edit-tab')
234
+ if (!btn) return
235
+ const tab = btn.dataset.tab
236
+ tabStrip.querySelectorAll('.message-edit-tab').forEach(b => {
237
+ b.classList.toggle('message-edit-tab--active', b === btn)
238
+ b.setAttribute('aria-selected', String(b === btn))
239
+ })
240
+ textarea.hidden = tab !== 'write'
241
+ preview.hidden = tab !== 'preview'
242
+ if (tab === 'preview') {
243
+ const text = textarea.value.trim()
244
+ if (!text) { preview.innerHTML = '<p style="color:var(--text-muted)">Nothing to preview yet.</p>'; return }
245
+ let html = null
246
+ await new Promise(resolve => {
247
+ dispatch('preview-text', { text, resolve: h => { html = h; resolve() } })
248
+ })
249
+ preview.innerHTML = html ? _sanitize(html) : escHtml(text)
250
+ }
251
+ })
252
+
253
+ toolbar.querySelector('.btn-edit-cancel').addEventListener('click', cancel)
254
+ toolbar.querySelector('.btn-edit-save').addEventListener('click', save)
255
+
256
+ textarea.addEventListener('keydown', e => {
257
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save() }
258
+ if (e.key === 'Escape') { e.preventDefault(); cancel() }
259
+ })
260
+ }
261
+
262
+ export function cancelActiveEdit() {
263
+ activeEditCancel?.()
264
+ }
265
+
266
+ // ─────────────────────────────────────────────────────────────────────────────
267
+ // Private helpers
268
+ // ─────────────────────────────────────────────────────────────────────────────
269
+
270
+ function _channelId(model) {
271
+ return model.currentChannelId
272
+ }
273
+
274
+ function _sanitize(html) {
275
+ return String(html ?? '').replaceAll('<script>', '').replaceAll('</script>', '')
276
+ }
277
+
278
+ function _showContextMenu(article, anchorEl, model) {
279
+ const msgId = article.dataset.msgId
280
+ const channelId = _channelId(model)
281
+ const items = [
282
+ { label: 'Edit', action: () => startInlineEdit(article, model) },
283
+ { label: 'Delete', danger: true, action: () => dispatch('delete-message', { msgId, channelId }) },
284
+ ]
285
+
286
+ // Touch devices → bottom sheet; pointer devices → floating popover
287
+ if (window.matchMedia('(pointer: coarse)').matches) {
288
+ showActionSheet({ label: 'Message actions', items })
289
+ } else {
290
+ _showPopover(anchorEl, items)
291
+ }
292
+ }
293
+
294
+ // ─────────────────────────────────────────────────────────────────────────────
295
+ // Floating popover for desktop (replaces action sheet on pointer devices)
296
+ // ─────────────────────────────────────────────────────────────────────────────
297
+
298
+ let _popoverEl = null
299
+ let _popoverCleanup = null
300
+
301
+ function _showPopover(anchorEl, items) {
302
+ _dismissPopover()
303
+
304
+ const el = document.createElement('div')
305
+ el.className = 'msg-context-menu'
306
+ el.setAttribute('role', 'menu')
307
+ for (const item of items) {
308
+ const btn = document.createElement('button')
309
+ btn.type = 'button'
310
+ btn.className = 'msg-context-menu-item' + (item.danger ? ' msg-context-menu-item--danger' : '')
311
+ btn.setAttribute('role', 'menuitem')
312
+ btn.textContent = item.label
313
+ btn.addEventListener('click', () => {
314
+ _dismissPopover()
315
+ item.action()
316
+ })
317
+ el.appendChild(btn)
318
+ }
319
+ document.body.appendChild(el)
320
+ _popoverEl = el
321
+
322
+ // Position: fixed, so coordinates are viewport-relative (no scroll offset needed)
323
+ const rect = anchorEl.getBoundingClientRect()
324
+ const gap = 4
325
+ let top = rect.bottom + gap
326
+ let left = rect.right - el.offsetWidth
327
+ if (left < 4) left = 4
328
+
329
+ el.style.left = `${left}px`
330
+ el.style.top = `${top}px`
331
+
332
+ // Flip up if the menu overflows the bottom of the viewport
333
+ const elRect = el.getBoundingClientRect()
334
+ if (elRect.bottom > window.innerHeight - 8) {
335
+ el.style.top = `${rect.top - el.offsetHeight - gap}px`
336
+ }
337
+
338
+ const onKey = e => { if (e.key === 'Escape') _dismissPopover() }
339
+ const onClick = e => { if (!el.contains(e.target)) _dismissPopover() }
340
+ document.addEventListener('keydown', onKey, { capture: true })
341
+ document.addEventListener('click', onClick, { capture: true })
342
+ _popoverCleanup = () => {
343
+ document.removeEventListener('keydown', onKey, { capture: true })
344
+ document.removeEventListener('click', onClick, { capture: true })
345
+ }
346
+ }
347
+
348
+ function _dismissPopover() {
349
+ _popoverEl?.remove()
350
+ _popoverEl = null
351
+ _popoverCleanup?.()
352
+ _popoverCleanup = null
353
+ }
@@ -588,6 +588,8 @@ details summary {
588
588
  .result-handle { font-weight: 600; margin-right: 8px; }
589
589
 
590
590
  .messages {
591
+ flex: 1;
592
+ min-height: 0;
591
593
  overflow-y: auto;
592
594
  overflow-x: clip;
593
595
  touch-action: pan-y;
@@ -697,34 +699,6 @@ details summary {
697
699
  .reaction-add:hover { background: var(--fill-tertiary); color: var(--accent); }
698
700
 
699
701
  /* ── Message context menu (right-click) — Apple-style popover ────────────── */
700
- .msg-context-menu {
701
- position: absolute;
702
- background: var(--bg-sidebar);
703
- border: none;
704
- border-radius: var(--radius-lg);
705
- box-shadow: var(--shadow-md);
706
- z-index: 300;
707
- min-width: 160px;
708
- overflow: hidden;
709
- }
710
- .msg-context-menu-item {
711
- display: block;
712
- width: 100%;
713
- padding: 0 16px;
714
- height: 40px;
715
- background: none;
716
- border: none;
717
- border-top: 0.5px solid var(--border);
718
- cursor: pointer;
719
- text-align: left;
720
- color: var(--text-primary);
721
- font-size: 15px;
722
- transition: background var(--transition);
723
- }
724
- .msg-context-menu-item:first-child { border-top: none; }
725
- .msg-context-menu-item:hover { background: var(--bg-hover); }
726
- .msg-context-menu-item--danger { color: var(--color-danger); }
727
- .msg-context-menu-item--danger:hover { background: color-mix(in srgb, var(--color-danger) 8%, transparent); }
728
702
 
729
703
  /* ── Emoji picker ────────────────────────────────────────────────────────── */
730
704
  .emoji-picker {
@@ -1543,6 +1517,7 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
1543
1517
  }
1544
1518
 
1545
1519
  .thread-composer {
1520
+ position: relative;
1546
1521
  display: flex;
1547
1522
  flex-direction: column;
1548
1523
  gap: 8px;
@@ -2312,7 +2287,7 @@ article.message:hover .message-hover-actions { opacity: 1; pointer-events: auto;
2312
2287
 
2313
2288
  /* Context menu */
2314
2289
  .msg-context-menu {
2315
- position: absolute;
2290
+ position: fixed;
2316
2291
  z-index: 300;
2317
2292
  background: var(--bg-sidebar);
2318
2293
  border: 1px solid var(--border);