@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,260 @@
1
+ /**
2
+ * ThreadPanelView.js — renders the thread panel.
3
+ *
4
+ * Uses the SAME makeMessageEl and MessageInteractions as MessageListView.
5
+ * There is no duplication of click handlers.
6
+ *
7
+ * Owned DOM:
8
+ * #thread-panel — the panel wrapper (toggled .active)
9
+ * #thread-anchor — parent message clone
10
+ * #thread-replies — reply list
11
+ * #thread-input — reply textarea
12
+ * #thread-send — send button
13
+ *
14
+ * Model events handled:
15
+ * thread-opened → show panel, clone parent, clear replies
16
+ * thread-closed → hide panel
17
+ * thread-loaded → render reply list
18
+ * thread-reply-added → append reply
19
+ * thread-reply-updated → update reply text/reactions
20
+ * thread-reply-deleted → remove reply
21
+ * reactions-updated → re-render reaction bar on reply if open
22
+ * message-updated → update anchor clone if it's the parent
23
+ */
24
+
25
+ import * as Ev from '../model/events.js'
26
+ import { makeMessageEl, escHtml, utcDateKey, makeDateSeparator } from '../shared/messages.js'
27
+ import { attachMessageInteractions } from './shared/MessageInteractions.js'
28
+ import { renderReactionBar } from './MessageListView.js'
29
+ import { renderQuickPicksSlot } from './shared/EmojiPickerSingleton.js'
30
+ import { dispatch } from '../controllers/ChatController.js'
31
+ import { MentionPicker } from './shared/MentionPicker.js'
32
+
33
+ export class ThreadPanelView {
34
+ #model
35
+ #panelEl
36
+ #anchorEl
37
+ #repliesEl
38
+ #bodyEl
39
+ #inputEl
40
+ #sendBtn
41
+ #mentionPicker
42
+
43
+ /**
44
+ * @param {AppModel} model
45
+ * @param {HTMLElement} panelEl — #thread-panel
46
+ */
47
+ constructor(model, panelEl) {
48
+ this.#model = model
49
+ this.#panelEl = panelEl
50
+
51
+ this.#anchorEl = panelEl.querySelector('#thread-anchor') ?? panelEl.querySelector('.thread-anchor')
52
+ this.#repliesEl = panelEl.querySelector('#thread-replies') ?? panelEl.querySelector('.thread-replies')
53
+ this.#bodyEl = panelEl.querySelector('.thread-body')
54
+ this.#inputEl = panelEl.querySelector('#thread-input')
55
+ this.#sendBtn = panelEl.querySelector('#thread-send')
56
+
57
+ this.#bindModelEvents()
58
+ this.#bindPanelEvents()
59
+
60
+ // @mention picker for the thread reply input
61
+ const composerEl = panelEl.querySelector('.thread-composer')
62
+ if (this.#inputEl && composerEl) {
63
+ this.#mentionPicker = new MentionPicker(
64
+ this.#inputEl, composerEl, () => [...model.members, ...model.bots],
65
+ )
66
+ }
67
+
68
+ // Attach message interactions to the replies container.
69
+ // isThread: true → no nested thread-open buttons.
70
+ if (this.#repliesEl) {
71
+ attachMessageInteractions(this.#repliesEl, { model, isThread: true })
72
+ }
73
+ }
74
+
75
+ // ─────────────────────────────────────────────────────────────────────────
76
+ // Model event bindings
77
+ // ─────────────────────────────────────────────────────────────────────────
78
+
79
+ #bindModelEvents() {
80
+ const m = this.#model
81
+
82
+ m.addEventListener(Ev.THREAD_OPENED, e => this.#onThreadOpened(e.detail))
83
+ m.addEventListener(Ev.THREAD_CLOSED, () => this.#onThreadClosed())
84
+ m.addEventListener(Ev.THREAD_LOADED, e => this.#onThreadLoaded(e.detail))
85
+ m.addEventListener(Ev.THREAD_REPLY_ADDED, e => this.#onReplyAdded(e.detail))
86
+ m.addEventListener(Ev.THREAD_REPLY_UPDATED, e => this.#onReplyUpdated(e.detail))
87
+ m.addEventListener(Ev.THREAD_REPLY_DELETED, e => this.#onReplyDeleted(e.detail))
88
+ m.addEventListener(Ev.REACTIONS_UPDATED, e => this.#onReactionsUpdated(e.detail))
89
+ m.addEventListener(Ev.MESSAGE_UPDATED, e => this.#onParentUpdated(e.detail))
90
+ m.addEventListener(Ev.CHANNEL_SELECTED, () => this.#onThreadClosed())
91
+ }
92
+
93
+ // ─────────────────────────────────────────────────────────────────────────
94
+ // Panel UI event bindings (close button, send button, textarea)
95
+ // ─────────────────────────────────────────────────────────────────────────
96
+
97
+ #bindPanelEvents() {
98
+ // Close button(s) — delegation so both header X and mobile footer button work
99
+ this.#panelEl.addEventListener('click', e => {
100
+ if (e.target.closest('.thread-panel-close')) dispatch('close-thread')
101
+ })
102
+
103
+ this.#sendBtn?.addEventListener('click', () => this.#sendReply())
104
+
105
+ this.#inputEl?.addEventListener('keydown', e => {
106
+ if (this.#mentionPicker?.isOpen) return
107
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.#sendReply() }
108
+ })
109
+ }
110
+
111
+ // ─────────────────────────────────────────────────────────────────────────
112
+ // Event handlers
113
+ // ─────────────────────────────────────────────────────────────────────────
114
+
115
+ #onThreadOpened({ parentMsg }) {
116
+ // Clone the parent message into the anchor
117
+ if (this.#anchorEl) {
118
+ this.#anchorEl.innerHTML = ''
119
+ if (parentMsg) {
120
+ const clone = makeMessageEl(parentMsg, {
121
+ userId: this.#model.userId,
122
+ userHandle: this.#model.userHandle,
123
+ knownHandles: this.#model.knownHandles,
124
+ })
125
+ // Strip interactive elements from the clone
126
+ clone.querySelector('.message-hover-actions')?.remove()
127
+ clone.querySelector('.thread-replies-link')?.remove()
128
+ this.#anchorEl.appendChild(clone)
129
+ }
130
+ }
131
+
132
+ if (this.#repliesEl) {
133
+ this.#repliesEl.innerHTML = '<p class="thread-loading">Loading…</p>'
134
+ }
135
+
136
+ this.#panelEl.classList.add('active')
137
+ setTimeout(() => this.#inputEl?.focus(), 50)
138
+ }
139
+
140
+ #onThreadClosed() {
141
+ this.#panelEl.classList.remove('active')
142
+ if (this.#anchorEl) this.#anchorEl.innerHTML = ''
143
+ if (this.#repliesEl) this.#repliesEl.innerHTML = ''
144
+ }
145
+
146
+ #onThreadLoaded({ parentMsgId, replies }) {
147
+ if (parentMsgId !== this.#model.threadParentId) return
148
+ if (!this.#repliesEl) return
149
+
150
+ this.#repliesEl.innerHTML = ''
151
+
152
+ if (!replies.length) {
153
+ this.#repliesEl.innerHTML = '<p class="thread-empty">No replies yet. Be the first!</p>'
154
+ return
155
+ }
156
+
157
+ let prevDate = null
158
+ for (const reply of replies) {
159
+ const dateKey = utcDateKey(reply.ts)
160
+ if (prevDate && dateKey !== prevDate) {
161
+ this.#repliesEl.appendChild(makeDateSeparator(dateKey))
162
+ }
163
+ this.#repliesEl.appendChild(this.#makeReplyEl(reply))
164
+ prevDate = dateKey
165
+ }
166
+
167
+ this.#scrollToBottom()
168
+ }
169
+
170
+ #onReplyAdded({ parentMsgId, reply }) {
171
+ if (parentMsgId !== this.#model.threadParentId) return
172
+ if (!this.#repliesEl) return
173
+
174
+ const emptyEl = this.#repliesEl.querySelector('.thread-empty')
175
+ if (emptyEl) emptyEl.remove()
176
+
177
+ this.#repliesEl.appendChild(this.#makeReplyEl(reply))
178
+ this.#scrollToBottom()
179
+ }
180
+
181
+ #onReplyUpdated({ reply }) {
182
+ if (!this.#repliesEl) return
183
+ const article = this.#repliesEl.querySelector(`[data-msg-id="${reply.msg_id}"]`)
184
+ if (!article) return
185
+ if (reply.text !== undefined) article.dataset.rawText = reply.text
186
+ if (reply.rendered_text !== undefined || reply.text !== undefined) {
187
+ const textEl = article.querySelector('.message-text')
188
+ if (textEl) textEl.innerHTML = _sanitize(reply.rendered_text ?? escHtml(reply.text ?? ''))
189
+ }
190
+ if (reply.edited_at) {
191
+ article.dataset.editedAt = reply.edited_at
192
+ const timeEl = article.querySelector('.message-time')
193
+ if (timeEl && !timeEl.querySelector('.message-edited')) {
194
+ const span = document.createElement('span')
195
+ span.className = 'message-edited'
196
+ span.textContent = '(edited)'
197
+ timeEl.appendChild(span)
198
+ }
199
+ }
200
+ }
201
+
202
+ #onReplyDeleted({ msgId }) {
203
+ if (!this.#repliesEl) return
204
+ this.#repliesEl.querySelector(`[data-msg-id="${msgId}"]`)?.remove()
205
+ }
206
+
207
+ #onReactionsUpdated({ msgId, reactions }) {
208
+ if (!this.#repliesEl) return
209
+ const article = this.#repliesEl.querySelector(`[data-msg-id="${msgId}"]`)
210
+ if (article) renderReactionBar(article, reactions, msgId)
211
+ }
212
+
213
+ #onParentUpdated({ message }) {
214
+ if (message.msg_id !== this.#model.threadParentId) return
215
+ if (!this.#anchorEl) return
216
+ const textEl = this.#anchorEl.querySelector('.message-text')
217
+ if (textEl && (message.rendered_text !== undefined || message.text !== undefined)) {
218
+ textEl.innerHTML = _sanitize(message.rendered_text ?? escHtml(message.text ?? ''))
219
+ }
220
+ }
221
+
222
+ // ─────────────────────────────────────────────────────────────────────────
223
+ // Send reply
224
+ // ─────────────────────────────────────────────────────────────────────────
225
+
226
+ #sendReply() {
227
+ const text = this.#inputEl?.value.trim()
228
+ const parentMsgId = this.#model.threadParentId
229
+ const channelId = this.#model.currentChannelId
230
+ if (!text || !parentMsgId) return
231
+ dispatch('send-thread-reply', { channelId, parentMsgId, text })
232
+ if (this.#inputEl) this.#inputEl.value = ''
233
+ }
234
+
235
+ // ─────────────────────────────────────────────────────────────────────────
236
+ // Private helpers
237
+ // ─────────────────────────────────────────────────────────────────────────
238
+
239
+ #makeReplyEl(reply) {
240
+ const article = makeMessageEl(reply, {
241
+ userId: this.#model.userId,
242
+ userHandle: this.#model.userHandle,
243
+ knownHandles: this.#model.knownHandles,
244
+ isThreadReply: true,
245
+ })
246
+ renderQuickPicksSlot(article.querySelector('.quick-picks'))
247
+ if (reply.reactions?.length) {
248
+ renderReactionBar(article, reply.reactions, reply.msg_id)
249
+ }
250
+ return article
251
+ }
252
+
253
+ #scrollToBottom() {
254
+ if (this.#bodyEl) this.#bodyEl.scrollTop = this.#bodyEl.scrollHeight
255
+ }
256
+ }
257
+
258
+ function _sanitize(html) {
259
+ return String(html ?? '').replaceAll('<script>', '').replaceAll('</script>', '')
260
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * EmojiPickerSingleton.js — one emoji picker shared across the whole app.
3
+ *
4
+ * Both MessageListView and ThreadPanelView use this. The picker is a singleton
5
+ * that floats in document.body and positions itself near the anchor element.
6
+ *
7
+ * Usage:
8
+ * import { openEmojiPicker, closeEmojiPicker, saveRecentEmoji,
9
+ * loadRecentEmoji, refreshAllQuickPicks } from './EmojiPickerSingleton.js'
10
+ *
11
+ * openEmojiPicker(anchorEl, msgId, channelId, onPick)
12
+ * closeEmojiPicker()
13
+ */
14
+
15
+ import { CATEGORIES, EMOJI_NAMES } from '../../emoji-data.js'
16
+ import { escHtml } from '../../shared/messages.js'
17
+
18
+ const RECENT_KEY = 'devchitchat_recent_emoji'
19
+ const RECENT_MAX = 24
20
+ const QUICK_PICKS_COUNT = 4
21
+
22
+ // ── Persistent state ──────────────────────────────────────────────────────────
23
+
24
+ let pickerEl = null
25
+ let currentCat = 'smileys'
26
+ let currentMsgId = null
27
+ let currentOnPick = null
28
+
29
+ // ── Public API ────────────────────────────────────────────────────────────────
30
+
31
+ export function loadRecentEmoji() {
32
+ try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') } catch { return [] }
33
+ }
34
+
35
+ export function saveRecentEmoji(emoji) {
36
+ let recents = loadRecentEmoji().filter(e => e !== emoji)
37
+ recents.unshift(emoji)
38
+ if (recents.length > RECENT_MAX) recents = recents.slice(0, RECENT_MAX)
39
+ localStorage.setItem(RECENT_KEY, JSON.stringify(recents))
40
+ refreshAllQuickPicks()
41
+ }
42
+
43
+ /** Refresh the quick-pick slots (recent emoji) in every visible message toolbar. */
44
+ export function refreshAllQuickPicks() {
45
+ for (const slot of document.querySelectorAll('.message-hover-actions .quick-picks')) {
46
+ renderQuickPicksSlot(slot)
47
+ }
48
+ }
49
+
50
+ /** Render quick-pick buttons into a `.quick-picks` slot. */
51
+ export function renderQuickPicksSlot(slot) {
52
+ if (!slot) return
53
+ const recents = loadRecentEmoji().slice(0, QUICK_PICKS_COUNT)
54
+ slot.innerHTML = recents.map(emoji =>
55
+ `<button class="btn-quick-react btn-icon" data-emoji="${escHtml(emoji)}" type="button" title="${escHtml(emoji)}">${emoji}</button>`
56
+ ).join('')
57
+ }
58
+
59
+ /** Open the floating emoji picker anchored to `anchorEl`. */
60
+ export function openEmojiPicker(anchorEl, msgId, onPick) {
61
+ // Toggle off if already open for the same message
62
+ if (pickerEl?.parentNode && currentMsgId === msgId) {
63
+ closeEmojiPicker()
64
+ return
65
+ }
66
+
67
+ currentMsgId = msgId
68
+ currentOnPick = onPick
69
+
70
+ pickerEl = pickerEl ?? _build()
71
+
72
+ if (currentCat === 'recent') _renderGrid(null)
73
+
74
+ document.body.appendChild(pickerEl)
75
+ _position(anchorEl)
76
+ }
77
+
78
+ /** Close and detach the picker. */
79
+ export function closeEmojiPicker() {
80
+ pickerEl?.parentNode?.removeChild(pickerEl)
81
+ currentMsgId = null
82
+ currentOnPick = null
83
+ }
84
+
85
+ /** True if the picker is open for the given msgId. */
86
+ export function isPickerOpenFor(msgId) {
87
+ return pickerEl?.parentNode != null && currentMsgId === msgId
88
+ }
89
+
90
+ // ── Private ───────────────────────────────────────────────────────────────────
91
+
92
+ function _build() {
93
+ const el = document.createElement('div')
94
+ el.className = 'emoji-picker'
95
+
96
+ const searchInput = document.createElement('input')
97
+ searchInput.type = 'search'
98
+ searchInput.className = 'emoji-picker-search'
99
+ searchInput.placeholder = 'Search emoji…'
100
+ searchInput.setAttribute('aria-label', 'Search emoji')
101
+ el.appendChild(searchInput)
102
+
103
+ const tabs = document.createElement('div')
104
+ tabs.className = 'emoji-picker-tabs'
105
+ for (const cat of CATEGORIES) {
106
+ const btn = document.createElement('button')
107
+ btn.type = 'button'
108
+ btn.className = 'emoji-picker-tab' + (cat.id === currentCat ? ' active' : '')
109
+ btn.dataset.catId = cat.id
110
+ btn.textContent = cat.label
111
+ btn.title = cat.id
112
+ tabs.appendChild(btn)
113
+ }
114
+ el.appendChild(tabs)
115
+
116
+ const grid = document.createElement('div')
117
+ grid.className = 'emoji-picker-grid'
118
+ el.appendChild(grid)
119
+
120
+ tabs.addEventListener('click', e => {
121
+ const btn = e.target.closest('.emoji-picker-tab')
122
+ if (!btn) return
123
+ currentCat = btn.dataset.catId
124
+ tabs.querySelectorAll('.emoji-picker-tab').forEach(b => {
125
+ b.classList.toggle('active', b.dataset.catId === currentCat)
126
+ })
127
+ searchInput.value = ''
128
+ _renderGrid(null)
129
+ })
130
+
131
+ searchInput.addEventListener('input', () => {
132
+ _renderGrid(searchInput.value.trim().toLowerCase() || null)
133
+ })
134
+
135
+ grid.addEventListener('click', e => {
136
+ const btn = e.target.closest('button[data-emoji]')
137
+ if (!btn) return
138
+ const emoji = btn.dataset.emoji
139
+ saveRecentEmoji(emoji)
140
+ closeEmojiPicker()
141
+ currentOnPick?.(emoji)
142
+ })
143
+
144
+ _renderGrid(null)
145
+ return el
146
+ }
147
+
148
+ function _renderGrid(query) {
149
+ if (!pickerEl) return
150
+ const grid = pickerEl.querySelector('.emoji-picker-grid')
151
+ if (!grid) return
152
+
153
+ let list
154
+ if (query) {
155
+ const all = CATEGORIES.flatMap(c => c.emoji)
156
+ const unique = [...new Set(all)]
157
+ list = unique.filter(e => {
158
+ const name = EMOJI_NAMES[e] ?? ''
159
+ return name.includes(query) || e.includes(query)
160
+ })
161
+ } else if (currentCat === 'recent') {
162
+ list = loadRecentEmoji()
163
+ } else {
164
+ const cat = CATEGORIES.find(c => c.id === currentCat)
165
+ list = cat?.emoji ?? []
166
+ }
167
+
168
+ grid.innerHTML = list.map(e =>
169
+ `<button type="button" data-emoji="${escHtml(e)}" title="${escHtml(EMOJI_NAMES[e] ?? e)}">${e}</button>`
170
+ ).join('')
171
+ }
172
+
173
+ function _position(anchorEl) {
174
+ pickerEl.style.position = 'fixed'
175
+ pickerEl.style.zIndex = '400'
176
+
177
+ const rect = anchorEl.getBoundingClientRect()
178
+ pickerEl.style.top = `${rect.bottom + 4}px`
179
+ pickerEl.style.left = `${rect.left}px`
180
+
181
+ requestAnimationFrame(() => {
182
+ if (!pickerEl) return
183
+ const pr = pickerEl.getBoundingClientRect()
184
+ let left = rect.left
185
+ if (left + pr.width > window.innerWidth - 8) left = window.innerWidth - 8 - pr.width
186
+ if (left < 8) left = 8
187
+ pickerEl.style.left = `${left}px`
188
+ if (rect.bottom + 4 + pr.height > window.innerHeight - 8) {
189
+ pickerEl.style.top = `${rect.top - 4 - pr.height}px`
190
+ }
191
+ })
192
+ }
193
+
194
+ // ── Global click-outside handler ──────────────────────────────────────────────
195
+
196
+ document.addEventListener('click', e => {
197
+ if (!pickerEl?.parentNode) return
198
+ if (pickerEl.contains(e.target)) return
199
+ if (e.target.closest('.btn-react') || e.target.closest('.reaction-add')) return
200
+ closeEmojiPicker()
201
+ }, { capture: true })
@@ -0,0 +1,139 @@
1
+ /**
2
+ * MentionPicker.js — reusable @mention autocomplete for any textarea.
3
+ *
4
+ * Usage:
5
+ * const picker = new MentionPicker(textarea, container, () => model.members)
6
+ * picker.destroy() // remove all listeners
7
+ *
8
+ * The picker element is injected as the first child of `container` so CSS can
9
+ * position it relative to the composer/panel element.
10
+ */
11
+
12
+ import { escHtml } from '../../shared/messages.js'
13
+
14
+ export class MentionPicker {
15
+ #textarea
16
+ #pickerEl
17
+ #getMembers
18
+
19
+ #filtered = []
20
+ #start = -1
21
+ #selIdx = 0
22
+
23
+ /**
24
+ * @param {HTMLTextAreaElement} textarea
25
+ * @param {HTMLElement} container parent element — picker is prepended here
26
+ * @param {() => Array} getMembers returns current member list
27
+ */
28
+ constructor(textarea, container, getMembers) {
29
+ this.#textarea = textarea
30
+ this.#getMembers = getMembers
31
+
32
+ const el = document.createElement('div')
33
+ el.id = 'mention-picker-' + Math.random().toString(36).slice(2)
34
+ el.className = 'mention-picker'
35
+ el.hidden = true
36
+ container.prepend(el)
37
+ this.#pickerEl = el
38
+
39
+ // Mouse click selects without firing the textarea blur
40
+ el.addEventListener('mousedown', e => {
41
+ e.preventDefault()
42
+ const btn = e.target.closest('.mention-option')
43
+ if (!btn) return
44
+ this.#select(this.#filtered[parseInt(btn.dataset.idx, 10)])
45
+ })
46
+
47
+ textarea.addEventListener('input', this.#onInput)
48
+ textarea.addEventListener('keydown', this.#onKeydown)
49
+ }
50
+
51
+ /** Returns true if the picker intercepted the keydown event */
52
+ get isOpen() { return !this.#pickerEl.hidden }
53
+
54
+ destroy() {
55
+ this.#textarea.removeEventListener('input', this.#onInput)
56
+ this.#textarea.removeEventListener('keydown', this.#onKeydown)
57
+ this.#pickerEl.remove()
58
+ }
59
+
60
+ // ─────────────────────────────────────────────────────────────────────────
61
+
62
+ #onInput = () => {
63
+ const ta = this.#textarea
64
+ const cursor = ta.selectionStart
65
+ const before = ta.value.substring(0, cursor)
66
+ const match = before.match(/@([a-zA-Z0-9_.-]*)$/)
67
+ if (!match) { this.#close(); return }
68
+
69
+ const query = match[1].toLowerCase()
70
+ const start = cursor - match[0].length
71
+ const filtered = this.#getMembers()
72
+ .filter(m =>
73
+ m.handle.toLowerCase().startsWith(query) ||
74
+ (m.display_name ?? '').toLowerCase().startsWith(query)
75
+ )
76
+ .slice(0, 8)
77
+
78
+ if (filtered.length === 0) { this.#close(); return }
79
+
80
+ this.#filtered = filtered
81
+ this.#start = start
82
+ this.#selIdx = 0
83
+ this.#render()
84
+ }
85
+
86
+ #onKeydown = e => {
87
+ if (this.#pickerEl.hidden) return
88
+
89
+ if (e.key === 'ArrowDown') {
90
+ e.preventDefault()
91
+ this.#selIdx = Math.min(this.#selIdx + 1, this.#filtered.length - 1)
92
+ this.#render()
93
+ return
94
+ }
95
+ if (e.key === 'ArrowUp') {
96
+ e.preventDefault()
97
+ this.#selIdx = Math.max(this.#selIdx - 1, 0)
98
+ this.#render()
99
+ return
100
+ }
101
+ if (e.key === 'Enter' || e.key === 'Tab') {
102
+ e.preventDefault()
103
+ this.#select(this.#filtered[this.#selIdx])
104
+ return
105
+ }
106
+ if (e.key === 'Escape') {
107
+ e.stopPropagation()
108
+ this.#close()
109
+ }
110
+ }
111
+
112
+ #render() {
113
+ this.#pickerEl.innerHTML = this.#filtered.map((m, i) => `
114
+ <button class="mention-option${i === this.#selIdx ? ' selected' : ''}"
115
+ data-idx="${i}" type="button">
116
+ <span class="mention-option-name">${escHtml(m.display_name || m.handle)}</span>
117
+ <span class="mention-option-handle">@${escHtml(m.handle)}</span>
118
+ </button>`).join('')
119
+ this.#pickerEl.hidden = false
120
+ }
121
+
122
+ #select(member) {
123
+ if (!member) return
124
+ const ta = this.#textarea
125
+ const cursor = ta.selectionStart
126
+ const insert = `@${member.handle} `
127
+ ta.value = ta.value.substring(0, this.#start) + insert + ta.value.substring(cursor)
128
+ const pos = this.#start + insert.length
129
+ ta.setSelectionRange(pos, pos)
130
+ this.#close()
131
+ ta.focus()
132
+ }
133
+
134
+ #close() {
135
+ this.#filtered = []
136
+ this.#start = -1
137
+ this.#pickerEl.hidden = true
138
+ }
139
+ }