@devchitchat/chat 4.5.0 → 5.0.1
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 -42
- package/pages/design/_layout.html +349 -0
- package/pages/design/_layout.js +13 -0
- package/pages/design/components/index.js +3 -0
- package/pages/design/components/index.phtml +380 -0
- package/pages/design/index.js +3 -0
- package/pages/design/index.phtml +78 -0
- package/pages/design/principles/index.js +3 -0
- package/pages/design/principles/index.phtml +147 -0
- package/pages/design/tokens/index.js +3 -0
- package/pages/design/tokens/index.phtml +236 -0
- package/pages/public/client/app.js +171 -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/resizable.js +74 -0
- package/pages/public/client/rtc-peer-manager.js +5 -2
- package/pages/public/client/settings-sync.js +45 -7
- package/pages/public/client/shared/messages.js +21 -9
- package/pages/public/client/theme.js +6 -4
- package/pages/public/client/views/CallView.js +754 -0
- package/pages/public/client/views/ChatHeaderView.js +67 -0
- package/pages/public/client/views/ComposerView.js +491 -0
- package/pages/public/client/views/MessageListView.js +461 -0
- package/pages/public/client/views/SidebarView.js +977 -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 +29 -31
- package/src/ws/ChatServer.js +2 -1
- package/src/ws/handlers/rtcHandlers.js +7 -0
- package/pages/public/client/islands/call.js +0 -2282
- package/pages/public/client/islands/sidebar.js +0 -1198
|
@@ -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
|
+
import { getPref, setPref } from '../../settings-sync.js'
|
|
18
|
+
|
|
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
|
+
return getPref('recent_emoji', [])
|
|
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
|
+
setPref('recent_emoji', 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
|
+
}
|