@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.
- 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 +4 -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
|
+
}
|
|
@@ -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:
|
|
2290
|
+
position: fixed;
|
|
2316
2291
|
z-index: 300;
|
|
2317
2292
|
background: var(--bg-sidebar);
|
|
2318
2293
|
border: 1px solid var(--border);
|