@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,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
|
+
}
|
|
@@ -439,6 +439,9 @@ details summary {
|
|
|
439
439
|
display: flex;
|
|
440
440
|
flex-direction: column;
|
|
441
441
|
gap: 10px;
|
|
442
|
+
position: sticky;
|
|
443
|
+
bottom: 0;
|
|
444
|
+
background: var(--bg-sidebar);
|
|
442
445
|
}
|
|
443
446
|
|
|
444
447
|
.sidebar-username {
|
|
@@ -585,6 +588,8 @@ details summary {
|
|
|
585
588
|
.result-handle { font-weight: 600; margin-right: 8px; }
|
|
586
589
|
|
|
587
590
|
.messages {
|
|
591
|
+
flex: 1;
|
|
592
|
+
min-height: 0;
|
|
588
593
|
overflow-y: auto;
|
|
589
594
|
overflow-x: clip;
|
|
590
595
|
touch-action: pan-y;
|
|
@@ -694,34 +699,6 @@ details summary {
|
|
|
694
699
|
.reaction-add:hover { background: var(--fill-tertiary); color: var(--accent); }
|
|
695
700
|
|
|
696
701
|
/* ── Message context menu (right-click) — Apple-style popover ────────────── */
|
|
697
|
-
.msg-context-menu {
|
|
698
|
-
position: absolute;
|
|
699
|
-
background: var(--bg-sidebar);
|
|
700
|
-
border: none;
|
|
701
|
-
border-radius: var(--radius-lg);
|
|
702
|
-
box-shadow: var(--shadow-md);
|
|
703
|
-
z-index: 300;
|
|
704
|
-
min-width: 160px;
|
|
705
|
-
overflow: hidden;
|
|
706
|
-
}
|
|
707
|
-
.msg-context-menu-item {
|
|
708
|
-
display: block;
|
|
709
|
-
width: 100%;
|
|
710
|
-
padding: 0 16px;
|
|
711
|
-
height: 40px;
|
|
712
|
-
background: none;
|
|
713
|
-
border: none;
|
|
714
|
-
border-top: 0.5px solid var(--border);
|
|
715
|
-
cursor: pointer;
|
|
716
|
-
text-align: left;
|
|
717
|
-
color: var(--text-primary);
|
|
718
|
-
font-size: 15px;
|
|
719
|
-
transition: background var(--transition);
|
|
720
|
-
}
|
|
721
|
-
.msg-context-menu-item:first-child { border-top: none; }
|
|
722
|
-
.msg-context-menu-item:hover { background: var(--bg-hover); }
|
|
723
|
-
.msg-context-menu-item--danger { color: var(--color-danger); }
|
|
724
|
-
.msg-context-menu-item--danger:hover { background: color-mix(in srgb, var(--color-danger) 8%, transparent); }
|
|
725
702
|
|
|
726
703
|
/* ── Emoji picker ────────────────────────────────────────────────────────── */
|
|
727
704
|
.emoji-picker {
|
|
@@ -1540,6 +1517,7 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
|
|
|
1540
1517
|
}
|
|
1541
1518
|
|
|
1542
1519
|
.thread-composer {
|
|
1520
|
+
position: relative;
|
|
1543
1521
|
display: flex;
|
|
1544
1522
|
flex-direction: column;
|
|
1545
1523
|
gap: 8px;
|
|
@@ -2309,7 +2287,7 @@ article.message:hover .message-hover-actions { opacity: 1; pointer-events: auto;
|
|
|
2309
2287
|
|
|
2310
2288
|
/* Context menu */
|
|
2311
2289
|
.msg-context-menu {
|
|
2312
|
-
position:
|
|
2290
|
+
position: fixed;
|
|
2313
2291
|
z-index: 300;
|
|
2314
2292
|
background: var(--bg-sidebar);
|
|
2315
2293
|
border: 1px solid var(--border);
|