@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
|
@@ -1,2282 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* call.js — combined chat + WebRTC island.
|
|
3
|
-
*
|
|
4
|
-
* Mounted on: <section class="chat-panel" island="/client/islands/call.js" …>
|
|
5
|
-
*
|
|
6
|
-
* Handles:
|
|
7
|
-
* - Chat (messages, composer) — same as the old chat.js island
|
|
8
|
-
* - WebRTC calls: start, join, leave, tile grid, mini-bar, sidebar badge
|
|
9
|
-
*
|
|
10
|
-
* WebRTC patterns ported from v1 RtcCallService:
|
|
11
|
-
* - negotiationInFlight / negotiationQueued per-peer serialisation
|
|
12
|
-
* - Pre-allocated transceiver slots (1 audio + 2 video: camera + screen)
|
|
13
|
-
* - replaceTrack() + direction toggle rather than addTrack() for renegotiation
|
|
14
|
-
* - waitForStableSignaling() before every offer
|
|
15
|
-
* - ICE candidate queue (pendingIceByPeer) until remote description is set
|
|
16
|
-
* - New joiner is offerer toward all existing peers; existing peers are answerers
|
|
17
|
-
*/
|
|
18
|
-
import { signal, effect, computed, Context } from '@devchitchat/rdbljs'
|
|
19
|
-
import { WsClient } from '../ws.js'
|
|
20
|
-
import { patchSettings } from '../settings-sync.js'
|
|
21
|
-
import { navigateTo } from '../router.js'
|
|
22
|
-
import { escHtml, utcDateKey, formatDateLabel, makeDateSeparator, applyInlineRenderingToTextNodes, renderAttachment, makeMessageEl } from '../shared/messages.js'
|
|
23
|
-
import { RtcPeerManager } from '../rtc-peer-manager.js'
|
|
24
|
-
import { CATEGORIES, EMOJI_NAMES } from '../emoji-data.js'
|
|
25
|
-
import { showActionSheet, dismiss as dismissActionSheet, getItemsContainer } from '../action-sheet.js'
|
|
26
|
-
import { addLongPress } from '../long-press.js'
|
|
27
|
-
|
|
28
|
-
export default function CallIsland(root) {
|
|
29
|
-
// ── Data from HTML ─────────────────────────────────────────────────────────
|
|
30
|
-
let channelId = root.dataset.id
|
|
31
|
-
let channelKind = root.dataset.kind ?? 'text'
|
|
32
|
-
const userId = root.dataset.userId
|
|
33
|
-
const userHandle = root.dataset.userHandle
|
|
34
|
-
const seedSeq = parseInt(root.dataset.seedSeq ?? '0', 10)
|
|
35
|
-
let oldestSeq = parseInt(root.dataset.seedFirstSeq ?? '0', 10)
|
|
36
|
-
let loadingMore = false
|
|
37
|
-
|
|
38
|
-
// ── DOM refs ───────────────────────────────────────────────────────────────
|
|
39
|
-
const messages = document.getElementById('messages')
|
|
40
|
-
const sentinelEl = document.getElementById('load-more-sentinel')
|
|
41
|
-
const tilePanelEl = document.getElementById('tile-panel')
|
|
42
|
-
const tileGridEl = document.getElementById('tile-grid')
|
|
43
|
-
const callStatusEl = document.getElementById('call-status')
|
|
44
|
-
const callStatusInfo = document.getElementById('call-status-info')
|
|
45
|
-
const callStatusAvatars = document.getElementById('call-status-avatars')
|
|
46
|
-
const callControlsEl = document.getElementById('call-controls-bar')
|
|
47
|
-
const peerCountEl = document.getElementById('call-peer-count')
|
|
48
|
-
const btnStartCall = document.getElementById('btn-start-call')
|
|
49
|
-
const btnJoinCall = document.getElementById('btn-join-call')
|
|
50
|
-
const btnLeaveCall = document.getElementById('btn-leave-call')
|
|
51
|
-
const ctrlMic = document.getElementById('ctrl-mic')
|
|
52
|
-
const ctrlCam = document.getElementById('ctrl-cam')
|
|
53
|
-
const ctrlScreen = document.getElementById('ctrl-screen')
|
|
54
|
-
const ctrlDevices = document.getElementById('ctrl-devices')
|
|
55
|
-
|
|
56
|
-
// Mini-bar (lives in sidebar footer — shared across channel navigations)
|
|
57
|
-
const miniBarEl = document.getElementById('call-mini-bar')
|
|
58
|
-
const miniBarName = document.getElementById('mini-bar-channel-name')
|
|
59
|
-
const miniBarMic = document.getElementById('mini-bar-mic')
|
|
60
|
-
const miniBarReturn = document.getElementById('mini-bar-return')
|
|
61
|
-
const miniBarLeave = document.getElementById('mini-bar-leave')
|
|
62
|
-
|
|
63
|
-
// ── WebSocket ──────────────────────────────────────────────────────────────
|
|
64
|
-
const ws = new WsClient(`${window.__BASE_PATH__}/ws`)
|
|
65
|
-
|
|
66
|
-
// ── Chat signals ───────────────────────────────────────────────────────────
|
|
67
|
-
const draft = signal('')
|
|
68
|
-
const channelName = signal(root.dataset.name ?? '')
|
|
69
|
-
const channelTopic = signal(root.dataset.topic ?? '')
|
|
70
|
-
let afterSeq = seedSeq
|
|
71
|
-
|
|
72
|
-
// ── @mention picker state ──────────────────────────────────────────────────
|
|
73
|
-
let channelMembers = [] // [{ user_id, handle, display_name }] — non-bot users
|
|
74
|
-
let channelBots = [] // [{ user_id, handle, display_name }] — bot users
|
|
75
|
-
let mentionFiltered = [] // current filtered subset
|
|
76
|
-
let mentionStart = -1 // index of '@' in textarea.value
|
|
77
|
-
let mentionSelIdx = 0 // keyboard-selected row
|
|
78
|
-
let activeMentionTextarea = null // which textarea triggered the picker
|
|
79
|
-
|
|
80
|
-
// ── Call state ─────────────────────────────────────────────────────────────
|
|
81
|
-
const inCall = signal(false)
|
|
82
|
-
const callIdSig = signal(null) // active call_id in this channel (may exist before we join)
|
|
83
|
-
let callChannelId = null // channel where the active call lives (may differ from channelId after navigation)
|
|
84
|
-
const selfPeerId = signal(null)
|
|
85
|
-
const micMuted = signal(false)
|
|
86
|
-
const camOff = signal(false)
|
|
87
|
-
const screenSharing = signal(false)
|
|
88
|
-
let pinnedPeerId = null
|
|
89
|
-
|
|
90
|
-
// ── Local media streams ────────────────────────────────────────────────────
|
|
91
|
-
let audioStream = null // local mic
|
|
92
|
-
let videoStream = null // local camera
|
|
93
|
-
let screenStream = null // local screen share
|
|
94
|
-
let iceServers = [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
95
|
-
|
|
96
|
-
// ── RTC peer manager ───────────────────────────────────────────────────────
|
|
97
|
-
const rtcManager = new RtcPeerManager({
|
|
98
|
-
iceServers,
|
|
99
|
-
getLocalStreams: () => ({ audio: audioStream, video: videoStream, screen: screenStream }),
|
|
100
|
-
handlers: {
|
|
101
|
-
onOffer: (peerId, sdp) => ws.send({ t: 'rtc.offer', body: { call_id: callIdSig(), to_peer_id: peerId, sdp } }),
|
|
102
|
-
onAnswer: (peerId, sdp) => ws.send({ t: 'rtc.answer', body: { call_id: callIdSig(), to_peer_id: peerId, sdp } }),
|
|
103
|
-
onIceCandidate: (peerId, candidate) => ws.send({ t: 'rtc.ice', body: { call_id: callIdSig(), to_peer_id: peerId, candidate } }),
|
|
104
|
-
onTrack: (peerId, tileId, stream, label) => { _renderTile(tileId, stream, false, label); _ensureRemoteAudio(stream, peerId) },
|
|
105
|
-
onAudio: (peerId, stream) => _ensureRemoteAudio(stream, peerId),
|
|
106
|
-
onPeerClosed: (peerId) => {
|
|
107
|
-
tileGridEl?.querySelectorAll(`[data-peer^="${peerId}"]`).forEach(t => t.remove())
|
|
108
|
-
document.querySelectorAll(`audio[data-peer-id="${peerId}"]`).forEach(a => { a.srcObject = null; a.remove() })
|
|
109
|
-
_updateTileLayout()
|
|
110
|
-
},
|
|
111
|
-
},
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
// ── Device state ───────────────────────────────────────────────────────────
|
|
115
|
-
const DEVICES_KEY = 'devchitchat_devices'
|
|
116
|
-
let availableDevices = { cameras: [], mics: [] }
|
|
117
|
-
let activeCameraId = null
|
|
118
|
-
let activeMicId = null
|
|
119
|
-
|
|
120
|
-
function loadSavedDevices() {
|
|
121
|
-
try { return JSON.parse(localStorage.getItem(DEVICES_KEY) ?? '{}') } catch { return {} }
|
|
122
|
-
}
|
|
123
|
-
function saveDevices(patch) {
|
|
124
|
-
localStorage.setItem(DEVICES_KEY, JSON.stringify({ ...loadSavedDevices(), ...patch }))
|
|
125
|
-
}
|
|
126
|
-
async function refreshDevices() {
|
|
127
|
-
const devices = await navigator.mediaDevices.enumerateDevices()
|
|
128
|
-
availableDevices = {
|
|
129
|
-
cameras: devices.filter(d => d.kind === 'videoinput'),
|
|
130
|
-
mics: devices.filter(d => d.kind === 'audioinput'),
|
|
131
|
-
}
|
|
132
|
-
return availableDevices
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ── Reaction bar ───────────────────────────────────────────────────────────
|
|
136
|
-
|
|
137
|
-
function renderReactionBar(article, reactions, msgId) {
|
|
138
|
-
const bar = article.querySelector('.reaction-bar')
|
|
139
|
-
if (!bar) return
|
|
140
|
-
bar.innerHTML = reactions.map(r => `
|
|
141
|
-
<button class="reaction-pill${r.reacted ? ' reacted' : ''}"
|
|
142
|
-
data-emoji="${escHtml(r.emoji)}" data-msg-id="${escHtml(msgId)}"
|
|
143
|
-
type="button" title="${r.count} reaction${r.count !== 1 ? 's' : ''}">
|
|
144
|
-
${r.emoji} <span class="reaction-count">${r.count}</span>
|
|
145
|
-
</button>`).join('')
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ── Hydrate seed message attachments ──────────────────────────────────────
|
|
149
|
-
// Seed messages are SSR'd without attachment HTML. Process data-attachments now.
|
|
150
|
-
// Called on initial mount and again after each SPA navigation morph.
|
|
151
|
-
function hydrateSeedMessages() {
|
|
152
|
-
const articles = Array.from(messages.querySelectorAll('article.message'))
|
|
153
|
-
let prevDateKey = null
|
|
154
|
-
|
|
155
|
-
for (const article of articles) {
|
|
156
|
-
if (article.dataset.hydrated) continue
|
|
157
|
-
article.dataset.hydrated = '1'
|
|
158
|
-
|
|
159
|
-
// Add dm-trigger to non-self sender handles
|
|
160
|
-
const handle = article.querySelector('.message-handle[data-user-id]')
|
|
161
|
-
if (handle && handle.dataset.userId !== userId) {
|
|
162
|
-
handle.classList.add('dm-trigger')
|
|
163
|
-
handle.title = 'Send a direct message'
|
|
164
|
-
}
|
|
165
|
-
// Add hover action toolbar (react for all messages; … only for own)
|
|
166
|
-
if (!article.querySelector('.message-hover-actions')) {
|
|
167
|
-
const toolbar = document.createElement('div')
|
|
168
|
-
toolbar.className = 'message-hover-actions'
|
|
169
|
-
const quickPicks = document.createElement('span')
|
|
170
|
-
quickPicks.className = 'quick-picks'
|
|
171
|
-
toolbar.appendChild(quickPicks)
|
|
172
|
-
const replyBtn = document.createElement('button')
|
|
173
|
-
replyBtn.className = 'btn-reply btn-icon'
|
|
174
|
-
replyBtn.type = 'button'
|
|
175
|
-
replyBtn.title = 'Reply in thread'
|
|
176
|
-
replyBtn.setAttribute('aria-label', 'Reply in thread')
|
|
177
|
-
replyBtn.innerHTML = '↩'
|
|
178
|
-
toolbar.appendChild(replyBtn)
|
|
179
|
-
const reactBtn = document.createElement('button')
|
|
180
|
-
reactBtn.className = 'btn-react btn-icon'
|
|
181
|
-
reactBtn.type = 'button'
|
|
182
|
-
reactBtn.title = 'Add reaction'
|
|
183
|
-
reactBtn.setAttribute('aria-label', 'Add reaction')
|
|
184
|
-
reactBtn.textContent = '🙂'
|
|
185
|
-
toolbar.appendChild(reactBtn)
|
|
186
|
-
if (article.dataset.userId === userId) {
|
|
187
|
-
const actionsBtn = document.createElement('button')
|
|
188
|
-
actionsBtn.className = 'btn-msg-actions btn-icon'
|
|
189
|
-
actionsBtn.type = 'button'
|
|
190
|
-
actionsBtn.title = 'Message actions'
|
|
191
|
-
actionsBtn.textContent = '…'
|
|
192
|
-
toolbar.appendChild(actionsBtn)
|
|
193
|
-
}
|
|
194
|
-
article.appendChild(toolbar)
|
|
195
|
-
renderQuickPicks(toolbar)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Hydrate "view N replies" link for seed messages that have replies
|
|
199
|
-
if (!article.querySelector('.thread-replies-link')) {
|
|
200
|
-
const replyCount = parseInt(article.dataset.replyCount ?? '0', 10)
|
|
201
|
-
if (replyCount > 0) {
|
|
202
|
-
const msgId = article.dataset.msgId
|
|
203
|
-
const link = document.createElement('a')
|
|
204
|
-
link.className = 'thread-replies-link'
|
|
205
|
-
link.href = '#'
|
|
206
|
-
link.dataset.msgId = msgId
|
|
207
|
-
link.textContent = `View ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`
|
|
208
|
-
const reactionBar = article.querySelector('.reaction-bar')
|
|
209
|
-
if (reactionBar) article.insertBefore(link, reactionBar)
|
|
210
|
-
else article.appendChild(link)
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Apply inline rendering (URLs, @mentions) to server-rendered message text.
|
|
215
|
-
// Walk text nodes instead of replacing innerHTML so that <a> tags already
|
|
216
|
-
// rendered server-side (e.g. from markdown link syntax) are preserved.
|
|
217
|
-
const textEl = article.querySelector('.message-text')
|
|
218
|
-
if (textEl) applyInlineRenderingToTextNodes(textEl, { userHandle })
|
|
219
|
-
|
|
220
|
-
// Inject attachment HTML for seed messages that have attachments_json
|
|
221
|
-
const raw = article.dataset.attachments
|
|
222
|
-
if (raw) {
|
|
223
|
-
let attachments
|
|
224
|
-
try { attachments = JSON.parse(raw) } catch { attachments = null }
|
|
225
|
-
if (Array.isArray(attachments) && attachments.length > 0) {
|
|
226
|
-
attachments.forEach(a => article.insertAdjacentHTML('beforeend', renderAttachment(a)))
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Hydrate reaction bar for seed messages
|
|
231
|
-
const rawReactions = article.dataset.reactions
|
|
232
|
-
const msgId = article.dataset.msgId
|
|
233
|
-
if (msgId) {
|
|
234
|
-
let reactions = []
|
|
235
|
-
if (rawReactions) {
|
|
236
|
-
try { reactions = JSON.parse(rawReactions) } catch { reactions = [] }
|
|
237
|
-
}
|
|
238
|
-
renderReactionBar(article, reactions, msgId)
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
enableTaskCheckboxes(article)
|
|
242
|
-
|
|
243
|
-
// Date separator before this article if date changed
|
|
244
|
-
const ts = parseInt(article.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
|
|
245
|
-
if (ts) {
|
|
246
|
-
// Re-format time in the browser's local timezone (SSR bakes UTC time)
|
|
247
|
-
const timeEl = article.querySelector('.message-time')
|
|
248
|
-
if (timeEl) {
|
|
249
|
-
const localTime = new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
250
|
-
const editedSpan = timeEl.querySelector('.message-edited')
|
|
251
|
-
timeEl.textContent = localTime
|
|
252
|
-
if (editedSpan) timeEl.appendChild(editedSpan)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const dateKey = utcDateKey(ts)
|
|
256
|
-
if (prevDateKey && dateKey !== prevDateKey) {
|
|
257
|
-
article.before(makeDateSeparator(dateKey))
|
|
258
|
-
}
|
|
259
|
-
prevDateKey = dateKey
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
hydrateSeedMessages()
|
|
264
|
-
// Scroll to the bottom instantly on first load — requestAnimationFrame gives
|
|
265
|
-
// the browser one layout cycle to settle flex heights before we measure
|
|
266
|
-
// scrollHeight. behavior:'instant' bypasses scroll-behavior:smooth so there
|
|
267
|
-
// is no visible animation from top to bottom on mount.
|
|
268
|
-
requestAnimationFrame(() => messages.scrollTo({ top: messages.scrollHeight, behavior: 'instant' }))
|
|
269
|
-
|
|
270
|
-
// ── Load-more sentinel + pagination ───────────────────────────────────────
|
|
271
|
-
|
|
272
|
-
function showSentinel() { if (sentinelEl) sentinelEl.hidden = false }
|
|
273
|
-
function hideSentinel() { if (sentinelEl) sentinelEl.hidden = true }
|
|
274
|
-
|
|
275
|
-
if (root.dataset.seedHasMore === 'true') showSentinel()
|
|
276
|
-
|
|
277
|
-
const loadMoreObserver = new IntersectionObserver(entries => {
|
|
278
|
-
if (!entries[0].isIntersecting || loadingMore || oldestSeq <= 1) return
|
|
279
|
-
loadingMore = true
|
|
280
|
-
ws.send({ t: 'msg.list', body: { channel_id: channelId, before_seq: oldestSeq } })
|
|
281
|
-
}, { root: messages, threshold: 0.1 })
|
|
282
|
-
|
|
283
|
-
if (sentinelEl) loadMoreObserver.observe(sentinelEl)
|
|
284
|
-
|
|
285
|
-
// ── Date separator helpers ─────────────────────────────────────────────────
|
|
286
|
-
// utcDateKey, formatDateLabel, makeDateSeparator imported from shared/messages.js
|
|
287
|
-
|
|
288
|
-
function prependMessages(msgs) {
|
|
289
|
-
const prevHeight = messages.scrollHeight
|
|
290
|
-
const fragment = document.createDocumentFragment()
|
|
291
|
-
let prevDate = null
|
|
292
|
-
|
|
293
|
-
const firstExisting = messages.querySelector('article.message')
|
|
294
|
-
if (firstExisting) {
|
|
295
|
-
const ts = parseInt(firstExisting.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
|
|
296
|
-
if (ts) prevDate = utcDateKey(ts)
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
for (const m of msgs) {
|
|
300
|
-
const dateKey = utcDateKey(m.ts)
|
|
301
|
-
if (prevDate && dateKey !== prevDate) {
|
|
302
|
-
fragment.appendChild(makeDateSeparator(prevDate))
|
|
303
|
-
}
|
|
304
|
-
fragment.appendChild(makeMessageEl(m, { userId, userHandle }))
|
|
305
|
-
prevDate = dateKey
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// If last prepended message is different day from first existing, insert separator before existing
|
|
309
|
-
if (firstExisting && prevDate) {
|
|
310
|
-
const existingTs = parseInt(firstExisting.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
|
|
311
|
-
const existingDateKey = existingTs ? utcDateKey(existingTs) : null
|
|
312
|
-
if (existingDateKey && prevDate !== existingDateKey) {
|
|
313
|
-
messages.insertBefore(makeDateSeparator(existingDateKey), firstExisting)
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
sentinelEl ? sentinelEl.after(fragment) : messages.prepend(fragment)
|
|
318
|
-
messages.scrollTop += messages.scrollHeight - prevHeight
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// ── @mention picker ────────────────────────────────────────────────────────
|
|
322
|
-
|
|
323
|
-
const mentionPickerEl = document.createElement('div')
|
|
324
|
-
mentionPickerEl.id = 'mention-picker'
|
|
325
|
-
mentionPickerEl.className = 'mention-picker'
|
|
326
|
-
mentionPickerEl.hidden = true
|
|
327
|
-
root.querySelector('.composer')?.prepend(mentionPickerEl)
|
|
328
|
-
|
|
329
|
-
function openPicker(filtered, start) {
|
|
330
|
-
mentionFiltered = filtered
|
|
331
|
-
mentionStart = start
|
|
332
|
-
mentionSelIdx = 0
|
|
333
|
-
renderPicker()
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function closePicker() {
|
|
337
|
-
mentionFiltered = []
|
|
338
|
-
mentionStart = -1
|
|
339
|
-
mentionPickerEl.hidden = true
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function renderPicker() {
|
|
343
|
-
if (mentionFiltered.length === 0) { closePicker(); return }
|
|
344
|
-
mentionPickerEl.innerHTML = mentionFiltered.map((m, i) => `
|
|
345
|
-
<button class="mention-option${i === mentionSelIdx ? ' selected' : ''}"
|
|
346
|
-
data-idx="${i}" type="button">
|
|
347
|
-
<span class="mention-option-name">${escHtml(m.display_name || m.handle)}</span>
|
|
348
|
-
<span class="mention-option-handle">@${escHtml(m.handle)}</span>
|
|
349
|
-
</button>`).join('')
|
|
350
|
-
mentionPickerEl.hidden = false
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function selectMention(member) {
|
|
354
|
-
if (!member) return
|
|
355
|
-
const textarea = activeMentionTextarea ?? root.querySelector('#message-input')
|
|
356
|
-
if (!textarea) return
|
|
357
|
-
const cursor = textarea.selectionStart
|
|
358
|
-
const val = textarea.value
|
|
359
|
-
const insert = `@${member.handle} `
|
|
360
|
-
textarea.value = val.substring(0, mentionStart) + insert + val.substring(cursor)
|
|
361
|
-
if (textarea.id === 'message-input') draft.set(textarea.value)
|
|
362
|
-
const pos = mentionStart + insert.length
|
|
363
|
-
textarea.setSelectionRange(pos, pos)
|
|
364
|
-
closePicker()
|
|
365
|
-
textarea.focus()
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
mentionPickerEl.addEventListener('mousedown', e => {
|
|
369
|
-
// mousedown instead of click so the textarea doesn't lose focus first
|
|
370
|
-
e.preventDefault()
|
|
371
|
-
const btn = e.target.closest('.mention-option')
|
|
372
|
-
if (!btn) return
|
|
373
|
-
selectMention(mentionFiltered[parseInt(btn.dataset.idx, 10)])
|
|
374
|
-
})
|
|
375
|
-
|
|
376
|
-
function handleComposerInput(e) {
|
|
377
|
-
const textarea = e.target
|
|
378
|
-
const cursor = textarea.selectionStart
|
|
379
|
-
const before = textarea.value.substring(0, cursor)
|
|
380
|
-
// Match a bare @ or @partial-handle with no space, anchored to end of text-so-far
|
|
381
|
-
const match = before.match(/@([a-zA-Z0-9_.-]*)$/)
|
|
382
|
-
if (!match) { closePicker(); return }
|
|
383
|
-
const query = match[1].toLowerCase()
|
|
384
|
-
const start = cursor - match[0].length
|
|
385
|
-
const filtered = [...channelMembers, ...channelBots]
|
|
386
|
-
.filter(m =>
|
|
387
|
-
m.handle.toLowerCase().startsWith(query) ||
|
|
388
|
-
(m.display_name ?? '').toLowerCase().startsWith(query)
|
|
389
|
-
)
|
|
390
|
-
.slice(0, 8)
|
|
391
|
-
if (filtered.length === 0) { closePicker(); return }
|
|
392
|
-
openPicker(filtered, start)
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
const mainInputEl = root.querySelector('#message-input')
|
|
396
|
-
|
|
397
|
-
mainInputEl?.addEventListener('input', handleComposerInput)
|
|
398
|
-
mainInputEl?.addEventListener('focus', () => {
|
|
399
|
-
activeMentionTextarea = mainInputEl
|
|
400
|
-
root.querySelector('.composer')?.prepend(mentionPickerEl)
|
|
401
|
-
})
|
|
402
|
-
|
|
403
|
-
// ── Chat: connect + join channel ───────────────────────────────────────────
|
|
404
|
-
|
|
405
|
-
ws.on('open', () => {
|
|
406
|
-
ws.send({ t: 'hello', body: { client: 'devchitchat', resume: { session_token: null } } })
|
|
407
|
-
})
|
|
408
|
-
|
|
409
|
-
ws.on('hello_ack', () => {
|
|
410
|
-
ws.send({ t: 'channel.join', body: { channel_id: channelId } })
|
|
411
|
-
})
|
|
412
|
-
|
|
413
|
-
ws.on('channel.joined', () => {
|
|
414
|
-
if (afterSeq > 0) {
|
|
415
|
-
ws.send({ t: 'msg.list', body: { channel_id: channelId, after_seq: afterSeq } })
|
|
416
|
-
}
|
|
417
|
-
if (channelMembers.length === 0) {
|
|
418
|
-
ws.send({ t: 'user.list', body: {} })
|
|
419
|
-
ws.send({ t: 'bot.list', body: {} })
|
|
420
|
-
}
|
|
421
|
-
})
|
|
422
|
-
|
|
423
|
-
ws.on('user.list_result', ({ users }) => {
|
|
424
|
-
channelMembers = (users ?? []).filter(m => m.handle)
|
|
425
|
-
})
|
|
426
|
-
|
|
427
|
-
ws.on('bot.list_result', ({ bots }) => {
|
|
428
|
-
channelBots = (bots ?? []).filter(b => b.handle)
|
|
429
|
-
})
|
|
430
|
-
|
|
431
|
-
ws.on('msg.list_result', ({ messages: msgs, next_after_seq, has_more, direction }) => {
|
|
432
|
-
if (direction === 'before') {
|
|
433
|
-
if (msgs.length === 0) {
|
|
434
|
-
hideSentinel()
|
|
435
|
-
if (sentinelEl) loadMoreObserver.unobserve(sentinelEl)
|
|
436
|
-
loadingMore = false
|
|
437
|
-
return
|
|
438
|
-
}
|
|
439
|
-
prependMessages(msgs)
|
|
440
|
-
if (msgs[0].seq < oldestSeq) oldestSeq = msgs[0].seq
|
|
441
|
-
if (!has_more || oldestSeq <= 1) {
|
|
442
|
-
hideSentinel()
|
|
443
|
-
if (sentinelEl) loadMoreObserver.unobserve(sentinelEl)
|
|
444
|
-
}
|
|
445
|
-
loadingMore = false
|
|
446
|
-
return
|
|
447
|
-
}
|
|
448
|
-
// after_seq catch-up path
|
|
449
|
-
msgs.forEach(appendMessage)
|
|
450
|
-
if (msgs.length) afterSeq = msgs[msgs.length - 1].seq
|
|
451
|
-
else if (next_after_seq != null) afterSeq = next_after_seq
|
|
452
|
-
})
|
|
453
|
-
|
|
454
|
-
ws.on('msg.event', (body) => {
|
|
455
|
-
if (body.channel_id !== channelId) return
|
|
456
|
-
if (body.parent_msg_id) return // thread reply — handled via thread.reply_event
|
|
457
|
-
appendMessage(body)
|
|
458
|
-
afterSeq = body.seq
|
|
459
|
-
})
|
|
460
|
-
|
|
461
|
-
ws.on('msg.deleted', ({ msg_id }) => {
|
|
462
|
-
const article = messages.querySelector(`[data-msg-id="${msg_id}"]`)
|
|
463
|
-
?? threadRepliesEl?.querySelector(`[data-msg-id="${msg_id}"]`)
|
|
464
|
-
if (article) article.remove()
|
|
465
|
-
})
|
|
466
|
-
|
|
467
|
-
ws.on('msg.edited', ({ msg_id, text, edited_at, rendered_text }) => {
|
|
468
|
-
const article = messages.querySelector(`[data-msg-id="${msg_id}"]`)
|
|
469
|
-
?? threadRepliesEl?.querySelector(`[data-msg-id="${msg_id}"]`)
|
|
470
|
-
if (!article) return
|
|
471
|
-
const textEl = article.querySelector('.message-text')
|
|
472
|
-
if (textEl) { textEl.innerHTML = sanitizeHtml(rendered_text); enableTaskCheckboxes(article) }
|
|
473
|
-
article.dataset.rawText = text
|
|
474
|
-
article.dataset.editedAt = edited_at
|
|
475
|
-
const timeEl = article.querySelector('.message-time')
|
|
476
|
-
if (timeEl) {
|
|
477
|
-
let editedSpan = timeEl.querySelector('.message-edited')
|
|
478
|
-
if (!editedSpan) {
|
|
479
|
-
editedSpan = document.createElement('span')
|
|
480
|
-
editedSpan.className = 'message-edited'
|
|
481
|
-
editedSpan.textContent = '(edited)'
|
|
482
|
-
timeEl.appendChild(editedSpan)
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
})
|
|
486
|
-
|
|
487
|
-
ws.on('reaction.event', ({ msg_id, reactions }) => {
|
|
488
|
-
// Check both the main message list and the open thread panel
|
|
489
|
-
const article = messages.querySelector(`[data-msg-id="${msg_id}"]`)
|
|
490
|
-
?? threadRepliesEl?.querySelector(`[data-msg-id="${msg_id}"]`)
|
|
491
|
-
if (article) renderReactionBar(article, reactions ?? [], msg_id)
|
|
492
|
-
})
|
|
493
|
-
|
|
494
|
-
ws.on('channel.updated', (body) => {
|
|
495
|
-
if (body.channel?.channel_id !== channelId) return
|
|
496
|
-
channelName.set(body.channel.name)
|
|
497
|
-
channelTopic.set(body.channel.topic ?? '')
|
|
498
|
-
document.title = `#${body.channel.name} — devchitchat`
|
|
499
|
-
})
|
|
500
|
-
|
|
501
|
-
// ── Thread panel ───────────────────────────────────────────────────────────
|
|
502
|
-
|
|
503
|
-
const threadPanelEl = document.getElementById('thread-panel')
|
|
504
|
-
const threadBodyEl = threadPanelEl?.querySelector('.thread-body')
|
|
505
|
-
const threadAnchorEl = document.getElementById('thread-anchor')
|
|
506
|
-
const threadRepliesEl = document.getElementById('thread-replies')
|
|
507
|
-
const threadInputEl = document.getElementById('thread-input')
|
|
508
|
-
const threadSendBtn = document.getElementById('thread-send')
|
|
509
|
-
|
|
510
|
-
let activeThreadParentId = null
|
|
511
|
-
|
|
512
|
-
function updateReplyCountLink(article, count) {
|
|
513
|
-
let link = article.querySelector('.thread-replies-link')
|
|
514
|
-
if (count > 0) {
|
|
515
|
-
const label = `View ${count} ${count === 1 ? 'reply' : 'replies'}`
|
|
516
|
-
if (!link) {
|
|
517
|
-
link = document.createElement('a')
|
|
518
|
-
link.className = 'thread-replies-link'
|
|
519
|
-
link.href = '#'
|
|
520
|
-
link.dataset.msgId = article.dataset.msgId
|
|
521
|
-
const reactionBar = article.querySelector('.reaction-bar')
|
|
522
|
-
if (reactionBar) article.insertBefore(link, reactionBar)
|
|
523
|
-
else article.appendChild(link)
|
|
524
|
-
}
|
|
525
|
-
link.textContent = label
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function openThread(parentMsgId) {
|
|
530
|
-
activeThreadParentId = parentMsgId
|
|
531
|
-
const parentArticle = messages.querySelector(`[data-msg-id="${parentMsgId}"]`)
|
|
532
|
-
|
|
533
|
-
// Render anchor (clone parent message, strip hover actions)
|
|
534
|
-
if (threadAnchorEl) {
|
|
535
|
-
threadAnchorEl.innerHTML = ''
|
|
536
|
-
if (parentArticle) {
|
|
537
|
-
const clone = parentArticle.cloneNode(true)
|
|
538
|
-
clone.querySelector('.message-hover-actions')?.remove()
|
|
539
|
-
clone.querySelector('.thread-replies-link')?.remove()
|
|
540
|
-
threadAnchorEl.appendChild(clone)
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
if (threadRepliesEl) threadRepliesEl.innerHTML = '<p class="thread-loading">Loading…</p>'
|
|
545
|
-
threadPanelEl?.classList.add('active')
|
|
546
|
-
|
|
547
|
-
ws.send({ t: 'thread.list', body: { parent_msg_id: parentMsgId, channel_id: channelId } })
|
|
548
|
-
|
|
549
|
-
setTimeout(() => threadInputEl?.focus(), 50)
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
function closeThread() {
|
|
553
|
-
threadPanelEl?.classList.remove('active')
|
|
554
|
-
activeThreadParentId = null
|
|
555
|
-
if (threadAnchorEl) threadAnchorEl.innerHTML = ''
|
|
556
|
-
if (threadRepliesEl) threadRepliesEl.innerHTML = ''
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// Use delegation so both the top ✕ and the mobile footer "Close thread" button work
|
|
560
|
-
threadPanelEl?.addEventListener('click', e => {
|
|
561
|
-
if (e.target.closest('.thread-panel-close')) closeThread()
|
|
562
|
-
})
|
|
563
|
-
|
|
564
|
-
function sendThreadReply() {
|
|
565
|
-
const text = threadInputEl?.value.trim()
|
|
566
|
-
if (!text || !activeThreadParentId) return
|
|
567
|
-
ws.send({ t: 'msg.send', body: { channel_id: channelId, text, parent_msg_id: activeThreadParentId } })
|
|
568
|
-
if (threadInputEl) threadInputEl.value = ''
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
threadSendBtn?.addEventListener('click', sendThreadReply)
|
|
572
|
-
threadInputEl?.addEventListener('input', handleComposerInput)
|
|
573
|
-
threadInputEl?.addEventListener('focus', () => {
|
|
574
|
-
activeMentionTextarea = threadInputEl
|
|
575
|
-
root.querySelector('.thread-composer')?.prepend(mentionPickerEl)
|
|
576
|
-
})
|
|
577
|
-
threadInputEl?.addEventListener('keydown', e => {
|
|
578
|
-
if (!mentionPickerEl.hidden) {
|
|
579
|
-
if (e.key === 'ArrowDown') { e.preventDefault(); mentionSelIdx = Math.min(mentionSelIdx + 1, mentionFiltered.length - 1); renderPicker(); return }
|
|
580
|
-
if (e.key === 'ArrowUp') { e.preventDefault(); mentionSelIdx = Math.max(mentionSelIdx - 1, 0); renderPicker(); return }
|
|
581
|
-
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); selectMention(mentionFiltered[mentionSelIdx]); return }
|
|
582
|
-
if (e.key === 'Escape') { closePicker(); return }
|
|
583
|
-
}
|
|
584
|
-
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendThreadReply() }
|
|
585
|
-
})
|
|
586
|
-
|
|
587
|
-
ws.on('thread.list_result', ({ parent_msg_id, replies }) => {
|
|
588
|
-
if (parent_msg_id !== activeThreadParentId) return
|
|
589
|
-
if (!threadRepliesEl) return
|
|
590
|
-
threadRepliesEl.innerHTML = ''
|
|
591
|
-
if (!replies.length) {
|
|
592
|
-
threadRepliesEl.innerHTML = '<p class="thread-empty">No replies yet. Be the first!</p>'
|
|
593
|
-
return
|
|
594
|
-
}
|
|
595
|
-
for (const reply of replies) {
|
|
596
|
-
const article = makeMessageEl(reply, { userId, userHandle, isThreadReply: true })
|
|
597
|
-
renderQuickPicks(article.querySelector('.message-hover-actions'))
|
|
598
|
-
if (reply.reactions?.length) renderReactionBar(article, reply.reactions, reply.msg_id)
|
|
599
|
-
threadRepliesEl.appendChild(article)
|
|
600
|
-
}
|
|
601
|
-
if (threadBodyEl) threadBodyEl.scrollTop = threadBodyEl.scrollHeight
|
|
602
|
-
})
|
|
603
|
-
|
|
604
|
-
ws.on('thread.reply_event', ({ parent_msg_id, channel_id: evtChannelId, reply }) => {
|
|
605
|
-
if (evtChannelId !== channelId) return
|
|
606
|
-
|
|
607
|
-
// Update reply count on parent message in the channel list
|
|
608
|
-
const parentArticle = messages.querySelector(`[data-msg-id="${parent_msg_id}"]`)
|
|
609
|
-
if (parentArticle) {
|
|
610
|
-
const current = parseInt(parentArticle.dataset.replyCount ?? '0', 10)
|
|
611
|
-
const next = current + 1
|
|
612
|
-
parentArticle.dataset.replyCount = String(next)
|
|
613
|
-
updateReplyCountLink(parentArticle, next)
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// Append reply to thread panel if it's open for this parent
|
|
617
|
-
if (activeThreadParentId === parent_msg_id && threadRepliesEl) {
|
|
618
|
-
const emptyEl = threadRepliesEl.querySelector('.thread-empty')
|
|
619
|
-
if (emptyEl) emptyEl.remove()
|
|
620
|
-
const article = makeMessageEl(reply, { userId, userHandle, isThreadReply: true })
|
|
621
|
-
renderQuickPicks(article.querySelector('.message-hover-actions'))
|
|
622
|
-
threadRepliesEl.appendChild(article)
|
|
623
|
-
if (threadBodyEl) threadBodyEl.scrollTop = threadBodyEl.scrollHeight
|
|
624
|
-
}
|
|
625
|
-
})
|
|
626
|
-
|
|
627
|
-
// Delegated click: reply button → open thread panel
|
|
628
|
-
messages.addEventListener('click', e => {
|
|
629
|
-
const btn = e.target.closest('.btn-reply')
|
|
630
|
-
if (!btn) return
|
|
631
|
-
e.stopPropagation()
|
|
632
|
-
const article = btn.closest('article.message')
|
|
633
|
-
const msgId = article?.dataset.msgId
|
|
634
|
-
if (!msgId) return
|
|
635
|
-
openThread(msgId)
|
|
636
|
-
})
|
|
637
|
-
|
|
638
|
-
// Delegated click: "view N replies" link → open thread panel
|
|
639
|
-
messages.addEventListener('click', e => {
|
|
640
|
-
const link = e.target.closest('.thread-replies-link')
|
|
641
|
-
if (!link) return
|
|
642
|
-
e.preventDefault()
|
|
643
|
-
e.stopPropagation()
|
|
644
|
-
const msgId = link.dataset.msgId
|
|
645
|
-
if (!msgId) return
|
|
646
|
-
openThread(msgId)
|
|
647
|
-
})
|
|
648
|
-
|
|
649
|
-
// ── Chat: composer ─────────────────────────────────────────────────────────
|
|
650
|
-
|
|
651
|
-
// Pending attachments: [{ upload_id, url, original_name, mime_type, size_bytes }]
|
|
652
|
-
let pendingAttachments = []
|
|
653
|
-
|
|
654
|
-
const composerEl = root.querySelector('.composer')
|
|
655
|
-
const textareaEl = root.querySelector('#message-input')
|
|
656
|
-
|
|
657
|
-
// Attachment chips container — injected above the textarea
|
|
658
|
-
const chipsEl = document.createElement('div')
|
|
659
|
-
chipsEl.className = 'attachment-chips'
|
|
660
|
-
composerEl?.insertBefore(chipsEl, textareaEl)
|
|
661
|
-
|
|
662
|
-
// Hidden file input
|
|
663
|
-
const fileInputEl = document.createElement('input')
|
|
664
|
-
fileInputEl.type = 'file'
|
|
665
|
-
fileInputEl.multiple = true
|
|
666
|
-
fileInputEl.style.display = 'none'
|
|
667
|
-
fileInputEl.setAttribute('aria-hidden', 'true')
|
|
668
|
-
composerEl?.appendChild(fileInputEl)
|
|
669
|
-
|
|
670
|
-
// Attach-file button (paperclip)
|
|
671
|
-
const btnAttachEl = document.createElement('button')
|
|
672
|
-
btnAttachEl.type = 'button'
|
|
673
|
-
btnAttachEl.className = 'btn-attach btn-icon'
|
|
674
|
-
btnAttachEl.title = 'Attach file'
|
|
675
|
-
btnAttachEl.setAttribute('aria-label', 'Attach file')
|
|
676
|
-
btnAttachEl.innerHTML = '📎'
|
|
677
|
-
// Insert before the send button
|
|
678
|
-
const btnSendEl = composerEl?.querySelector('.btn-send')
|
|
679
|
-
if (btnSendEl && composerEl) composerEl.insertBefore(btnAttachEl, btnSendEl)
|
|
680
|
-
|
|
681
|
-
btnAttachEl.addEventListener('click', () => fileInputEl.click())
|
|
682
|
-
fileInputEl.addEventListener('change', () => {
|
|
683
|
-
uploadFiles([...fileInputEl.files])
|
|
684
|
-
fileInputEl.value = ''
|
|
685
|
-
})
|
|
686
|
-
|
|
687
|
-
// Drag-and-drop onto the textarea
|
|
688
|
-
let dropOverlayEl = null
|
|
689
|
-
|
|
690
|
-
function ensureDropOverlay() {
|
|
691
|
-
if (dropOverlayEl) return dropOverlayEl
|
|
692
|
-
dropOverlayEl = document.createElement('div')
|
|
693
|
-
dropOverlayEl.className = 'drop-overlay'
|
|
694
|
-
dropOverlayEl.textContent = 'Drop to attach'
|
|
695
|
-
composerEl?.appendChild(dropOverlayEl)
|
|
696
|
-
return dropOverlayEl
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
composerEl?.addEventListener('dragover', e => {
|
|
700
|
-
if (!e.dataTransfer.types.includes('Files')) return
|
|
701
|
-
e.preventDefault()
|
|
702
|
-
ensureDropOverlay().hidden = false
|
|
703
|
-
})
|
|
704
|
-
|
|
705
|
-
composerEl?.addEventListener('dragleave', e => {
|
|
706
|
-
if (composerEl.contains(e.relatedTarget)) return
|
|
707
|
-
if (dropOverlayEl) dropOverlayEl.hidden = true
|
|
708
|
-
})
|
|
709
|
-
|
|
710
|
-
composerEl?.addEventListener('drop', e => {
|
|
711
|
-
e.preventDefault()
|
|
712
|
-
if (dropOverlayEl) dropOverlayEl.hidden = true
|
|
713
|
-
const files = [...(e.dataTransfer.files ?? [])]
|
|
714
|
-
if (files.length > 0) uploadFiles(files)
|
|
715
|
-
})
|
|
716
|
-
|
|
717
|
-
// Paste image from clipboard (screenshots, copied images)
|
|
718
|
-
textareaEl?.addEventListener('paste', e => {
|
|
719
|
-
const items = [...(e.clipboardData?.items ?? [])]
|
|
720
|
-
const imageFiles = items
|
|
721
|
-
.filter(item => item.kind === 'file' && item.type.startsWith('image/'))
|
|
722
|
-
.map(item => {
|
|
723
|
-
const file = item.getAsFile()
|
|
724
|
-
if (!file) return null
|
|
725
|
-
if (!file.name) {
|
|
726
|
-
const ext = item.type.split('/')[1] ?? 'png'
|
|
727
|
-
return new File([file], `paste-${Date.now()}.${ext}`, { type: item.type })
|
|
728
|
-
}
|
|
729
|
-
return file
|
|
730
|
-
})
|
|
731
|
-
.filter(Boolean)
|
|
732
|
-
if (imageFiles.length === 0) return
|
|
733
|
-
e.preventDefault()
|
|
734
|
-
uploadFiles(imageFiles)
|
|
735
|
-
})
|
|
736
|
-
|
|
737
|
-
async function uploadFiles(files) {
|
|
738
|
-
for (const file of files) {
|
|
739
|
-
await uploadOneFile(file, channelId)
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
async function uploadOneFile(file, targetChannelId) {
|
|
744
|
-
const formData = new FormData()
|
|
745
|
-
formData.append('file', file)
|
|
746
|
-
formData.append('channel_id', targetChannelId)
|
|
747
|
-
|
|
748
|
-
let res
|
|
749
|
-
try {
|
|
750
|
-
res = await fetch(`${window.__BASE_PATH__}/api/uploads`, { method: 'POST', body: formData })
|
|
751
|
-
} catch {
|
|
752
|
-
showComposerError(`Upload failed: network error`)
|
|
753
|
-
return null
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
if (!res.ok) {
|
|
757
|
-
const body = await res.json().catch(() => ({}))
|
|
758
|
-
showComposerError(`Upload failed: ${body.error ?? res.statusText}`)
|
|
759
|
-
return null
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
const attachment = await res.json()
|
|
763
|
-
pendingAttachments.push(attachment)
|
|
764
|
-
renderChips()
|
|
765
|
-
return attachment
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
function renderChips() {
|
|
769
|
-
const html = pendingAttachments.map((a, i) => `
|
|
770
|
-
<span class="attachment-chip" data-index="${i}">
|
|
771
|
-
<span class="attachment-chip-name">${escHtml(a.original_name)}</span>
|
|
772
|
-
<button type="button" class="attachment-chip-remove" data-index="${i}" aria-label="Remove ${escHtml(a.original_name)}">×</button>
|
|
773
|
-
</span>
|
|
774
|
-
`).join('')
|
|
775
|
-
chipsEl.innerHTML = html
|
|
776
|
-
chipsEl.hidden = pendingAttachments.length === 0
|
|
777
|
-
if (composeChipsEl) {
|
|
778
|
-
composeChipsEl.innerHTML = html
|
|
779
|
-
composeChipsEl.hidden = pendingAttachments.length === 0
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
function _removeChipAt(idx) {
|
|
784
|
-
pendingAttachments.splice(idx, 1)
|
|
785
|
-
renderChips()
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
chipsEl.addEventListener('click', e => {
|
|
789
|
-
const btn = e.target.closest('.attachment-chip-remove')
|
|
790
|
-
if (!btn) return
|
|
791
|
-
_removeChipAt(parseInt(btn.dataset.index, 10))
|
|
792
|
-
})
|
|
793
|
-
|
|
794
|
-
function showComposerError(msg) {
|
|
795
|
-
const target = composeOpen ? composeChipsEl : chipsEl
|
|
796
|
-
if (!target) return
|
|
797
|
-
const chip = document.createElement('span')
|
|
798
|
-
chip.className = 'attachment-chip attachment-chip-error'
|
|
799
|
-
chip.textContent = msg
|
|
800
|
-
target.appendChild(chip)
|
|
801
|
-
target.hidden = false
|
|
802
|
-
setTimeout(() => chip.remove(), 5000)
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
// ── Compose overlay ───────────────────────────────────────────────────────
|
|
806
|
-
const composeOverlayEl = root.querySelector('#compose-overlay')
|
|
807
|
-
const composeTaEl = document.getElementById('compose-textarea')
|
|
808
|
-
const composePreviewEl = document.getElementById('compose-preview')
|
|
809
|
-
const composeCollapseBtn = document.getElementById('btn-compose-collapse')
|
|
810
|
-
const composeSendBtn = document.getElementById('compose-send')
|
|
811
|
-
const composeUrgentBtn = document.getElementById('compose-urgent-toggle')
|
|
812
|
-
const composeChipsEl = document.getElementById('compose-chips')
|
|
813
|
-
const composeBtnAttach = document.getElementById('compose-attach')
|
|
814
|
-
|
|
815
|
-
let composeOpen = false
|
|
816
|
-
|
|
817
|
-
function openComposeMode() {
|
|
818
|
-
if (composeOpen) return
|
|
819
|
-
composeOpen = true
|
|
820
|
-
if (composeTaEl) composeTaEl.value = draft()
|
|
821
|
-
messages.hidden = true
|
|
822
|
-
composerEl.hidden = true
|
|
823
|
-
if (composeOverlayEl) composeOverlayEl.hidden = false
|
|
824
|
-
_switchComposeTab('write')
|
|
825
|
-
requestAnimationFrame(() => {
|
|
826
|
-
if (!composeTaEl) return
|
|
827
|
-
composeTaEl.focus()
|
|
828
|
-
const len = composeTaEl.value.length
|
|
829
|
-
composeTaEl.setSelectionRange(len, len)
|
|
830
|
-
})
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
function closeComposeMode() {
|
|
834
|
-
if (!composeOpen) return
|
|
835
|
-
composeOpen = false
|
|
836
|
-
messages.hidden = false
|
|
837
|
-
composerEl.hidden = false
|
|
838
|
-
if (composeOverlayEl) composeOverlayEl.hidden = true
|
|
839
|
-
textareaEl?.focus()
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
function toggleComposeMode() {
|
|
843
|
-
composeOpen ? closeComposeMode() : openComposeMode()
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
function _switchComposeTab(tab) {
|
|
847
|
-
root.querySelectorAll('.compose-tab').forEach(btn => {
|
|
848
|
-
const active = btn.dataset.tab === tab
|
|
849
|
-
btn.classList.toggle('compose-tab--active', active)
|
|
850
|
-
btn.setAttribute('aria-selected', String(active))
|
|
851
|
-
})
|
|
852
|
-
if (composeTaEl) composeTaEl.hidden = tab !== 'write'
|
|
853
|
-
if (composePreviewEl) composePreviewEl.hidden = tab !== 'preview'
|
|
854
|
-
if (tab === 'preview') _renderComposePreview()
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
async function _renderComposePreview() {
|
|
858
|
-
const text = composeTaEl?.value ?? ''
|
|
859
|
-
if (!text.trim()) {
|
|
860
|
-
if (composePreviewEl) composePreviewEl.innerHTML = '<p style="color:var(--text-muted)">Nothing to preview yet.</p>'
|
|
861
|
-
return
|
|
862
|
-
}
|
|
863
|
-
try {
|
|
864
|
-
const res = await fetch(`${window.__BASE_PATH__}/api/preview`, {
|
|
865
|
-
method: 'POST',
|
|
866
|
-
headers: { 'Content-Type': 'application/json' },
|
|
867
|
-
body: JSON.stringify({ text }),
|
|
868
|
-
})
|
|
869
|
-
if (res.ok) {
|
|
870
|
-
const { html } = await res.json()
|
|
871
|
-
if (composePreviewEl) composePreviewEl.innerHTML = sanitizeHtml(html)
|
|
872
|
-
}
|
|
873
|
-
} catch {
|
|
874
|
-
if (composePreviewEl) composePreviewEl.textContent = text
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
// Sync compose textarea → draft signal
|
|
879
|
-
composeTaEl?.addEventListener('input', () => { draft.set(composeTaEl.value) })
|
|
880
|
-
|
|
881
|
-
// Ctrl+Enter / Cmd+Enter in compose textarea → send and collapse
|
|
882
|
-
composeTaEl?.addEventListener('keydown', e => {
|
|
883
|
-
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
884
|
-
e.preventDefault()
|
|
885
|
-
sendMessage()
|
|
886
|
-
closeComposeMode()
|
|
887
|
-
}
|
|
888
|
-
})
|
|
889
|
-
|
|
890
|
-
// Tab strip
|
|
891
|
-
root.querySelectorAll('.compose-tab').forEach(btn => {
|
|
892
|
-
btn.addEventListener('click', () => _switchComposeTab(btn.dataset.tab))
|
|
893
|
-
})
|
|
894
|
-
|
|
895
|
-
// Collapse, send, and attach buttons
|
|
896
|
-
composeCollapseBtn?.addEventListener('click', closeComposeMode)
|
|
897
|
-
composeSendBtn?.addEventListener('click', () => { sendMessage(); closeComposeMode() })
|
|
898
|
-
composeUrgentBtn?.addEventListener('click', () => toggleUrgentMode())
|
|
899
|
-
composeBtnAttach?.addEventListener('click', () => fileInputEl.click())
|
|
900
|
-
composeChipsEl?.addEventListener('click', e => {
|
|
901
|
-
const btn = e.target.closest('.attachment-chip-remove')
|
|
902
|
-
if (!btn) return
|
|
903
|
-
_removeChipAt(parseInt(btn.dataset.index, 10))
|
|
904
|
-
})
|
|
905
|
-
|
|
906
|
-
// Paste images into the compose textarea
|
|
907
|
-
composeTaEl?.addEventListener('paste', e => {
|
|
908
|
-
const items = [...(e.clipboardData?.items ?? [])]
|
|
909
|
-
const imageFiles = items
|
|
910
|
-
.filter(item => item.kind === 'file' && item.type.startsWith('image/'))
|
|
911
|
-
.map(item => {
|
|
912
|
-
const file = item.getAsFile()
|
|
913
|
-
if (!file) return null
|
|
914
|
-
if (!file.name) {
|
|
915
|
-
const ext = item.type.split('/')[1] ?? 'png'
|
|
916
|
-
return new File([file], `paste-${Date.now()}.${ext}`, { type: item.type })
|
|
917
|
-
}
|
|
918
|
-
return file
|
|
919
|
-
})
|
|
920
|
-
.filter(Boolean)
|
|
921
|
-
if (imageFiles.length === 0) return
|
|
922
|
-
e.preventDefault()
|
|
923
|
-
uploadFiles(imageFiles)
|
|
924
|
-
})
|
|
925
|
-
|
|
926
|
-
// Drag-and-drop files onto the compose overlay
|
|
927
|
-
let composeDropOverlayEl = null
|
|
928
|
-
function ensureComposeDropOverlay() {
|
|
929
|
-
if (composeDropOverlayEl) return composeDropOverlayEl
|
|
930
|
-
composeDropOverlayEl = document.createElement('div')
|
|
931
|
-
composeDropOverlayEl.className = 'drop-overlay'
|
|
932
|
-
composeDropOverlayEl.textContent = 'Drop to attach'
|
|
933
|
-
composeOverlayEl?.appendChild(composeDropOverlayEl)
|
|
934
|
-
return composeDropOverlayEl
|
|
935
|
-
}
|
|
936
|
-
composeOverlayEl?.addEventListener('dragover', e => {
|
|
937
|
-
if (!e.dataTransfer.types.includes('Files')) return
|
|
938
|
-
e.preventDefault()
|
|
939
|
-
ensureComposeDropOverlay().hidden = false
|
|
940
|
-
})
|
|
941
|
-
composeOverlayEl?.addEventListener('dragleave', e => {
|
|
942
|
-
if (composeOverlayEl.contains(e.relatedTarget)) return
|
|
943
|
-
if (composeDropOverlayEl) composeDropOverlayEl.hidden = true
|
|
944
|
-
})
|
|
945
|
-
composeOverlayEl?.addEventListener('drop', e => {
|
|
946
|
-
e.preventDefault()
|
|
947
|
-
if (composeDropOverlayEl) composeDropOverlayEl.hidden = true
|
|
948
|
-
const files = [...(e.dataTransfer.files ?? [])]
|
|
949
|
-
if (files.length > 0) uploadFiles(files)
|
|
950
|
-
})
|
|
951
|
-
|
|
952
|
-
// Ctrl+E / Cmd+E anywhere to toggle compose mode
|
|
953
|
-
document.addEventListener('keydown', e => {
|
|
954
|
-
if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
|
|
955
|
-
e.preventDefault()
|
|
956
|
-
toggleComposeMode()
|
|
957
|
-
}
|
|
958
|
-
})
|
|
959
|
-
|
|
960
|
-
// ── Urgent send ───────────────────────────────────────────────────────────
|
|
961
|
-
const urgentMode = signal(false)
|
|
962
|
-
const composerFooter = root.querySelector('.composer')
|
|
963
|
-
|
|
964
|
-
const urgentClass = computed(() => ({ 'is-urgent': urgentMode() }))
|
|
965
|
-
|
|
966
|
-
function toggleUrgentMode() {
|
|
967
|
-
urgentMode.set(!urgentMode())
|
|
968
|
-
composerFooter?.classList.toggle('composer-urgent', urgentMode())
|
|
969
|
-
composeOverlayEl?.classList.toggle('composer-urgent', urgentMode())
|
|
970
|
-
composeUrgentBtn?.classList.toggle('is-urgent', urgentMode())
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
function sendMessage({ priority } = {}) {
|
|
974
|
-
const text = draft().trim()
|
|
975
|
-
if (!text && pendingAttachments.length === 0) return
|
|
976
|
-
const resolvedPriority = priority ?? (urgentMode() ? 'now' : 'normal')
|
|
977
|
-
ws.send({
|
|
978
|
-
t: 'msg.send',
|
|
979
|
-
body: {
|
|
980
|
-
channel_id: channelId,
|
|
981
|
-
text,
|
|
982
|
-
client_msg_id: `local_${Date.now()}`,
|
|
983
|
-
priority: resolvedPriority,
|
|
984
|
-
attachments: pendingAttachments.map(a => ({
|
|
985
|
-
upload_id: a.upload_id,
|
|
986
|
-
url: a.url,
|
|
987
|
-
filename: a.original_name,
|
|
988
|
-
mime_type: a.mime_type,
|
|
989
|
-
size_bytes: a.size_bytes,
|
|
990
|
-
}))
|
|
991
|
-
}
|
|
992
|
-
})
|
|
993
|
-
draft.set('')
|
|
994
|
-
pendingAttachments = []
|
|
995
|
-
renderChips()
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
function handleComposerKey(e) {
|
|
999
|
-
if (!mentionPickerEl.hidden) {
|
|
1000
|
-
if (e.key === 'ArrowDown') {
|
|
1001
|
-
e.preventDefault()
|
|
1002
|
-
mentionSelIdx = Math.min(mentionSelIdx + 1, mentionFiltered.length - 1)
|
|
1003
|
-
renderPicker()
|
|
1004
|
-
return
|
|
1005
|
-
}
|
|
1006
|
-
if (e.key === 'ArrowUp') {
|
|
1007
|
-
e.preventDefault()
|
|
1008
|
-
mentionSelIdx = Math.max(mentionSelIdx - 1, 0)
|
|
1009
|
-
renderPicker()
|
|
1010
|
-
return
|
|
1011
|
-
}
|
|
1012
|
-
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
1013
|
-
e.preventDefault()
|
|
1014
|
-
selectMention(mentionFiltered[mentionSelIdx])
|
|
1015
|
-
return
|
|
1016
|
-
}
|
|
1017
|
-
if (e.key === 'Escape') {
|
|
1018
|
-
closePicker()
|
|
1019
|
-
return
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1023
|
-
e.preventDefault()
|
|
1024
|
-
const priority = e.ctrlKey || e.metaKey ? 'now' : undefined
|
|
1025
|
-
sendMessage({ priority })
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
function appendMessage({ msg_id, seq, user_id, user_display_name, ts, text, rendered_text, attachments, reactions }) {
|
|
1030
|
-
if (messages.querySelector(`[data-msg-id="${msg_id}"]`)) return
|
|
1031
|
-
|
|
1032
|
-
// Date separator if day changed
|
|
1033
|
-
const dateKey = utcDateKey(ts)
|
|
1034
|
-
const lastMsg = messages.querySelector('article.message:last-of-type')
|
|
1035
|
-
if (lastMsg) {
|
|
1036
|
-
const lastTs = parseInt(lastMsg.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
|
|
1037
|
-
if (lastTs && utcDateKey(lastTs) !== dateKey) {
|
|
1038
|
-
messages.appendChild(makeDateSeparator(dateKey))
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
const article = makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, text, rendered_text, attachments }, { userId, userHandle })
|
|
1043
|
-
|
|
1044
|
-
// Ensure reaction bar exists in dynamically created messages
|
|
1045
|
-
if (!article.querySelector('.reaction-bar')) {
|
|
1046
|
-
const bar = document.createElement('div')
|
|
1047
|
-
bar.className = 'reaction-bar'
|
|
1048
|
-
article.appendChild(bar)
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
enableTaskCheckboxes(article)
|
|
1052
|
-
const toolbar = article.querySelector('.message-hover-actions')
|
|
1053
|
-
if (toolbar) renderQuickPicks(toolbar)
|
|
1054
|
-
messages.appendChild(article)
|
|
1055
|
-
renderReactionBar(article, reactions ?? [], msg_id)
|
|
1056
|
-
messages.scrollTop = messages.scrollHeight
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
// renderAttachment, formatBytes imported from shared/messages.js
|
|
1060
|
-
|
|
1061
|
-
function sanitizeHtml(html) {
|
|
1062
|
-
return String(html ?? '').replaceAll('<script>', '').replaceAll('</script>', '')
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
// Enable task-list checkboxes so they're clickable (renderer marks them disabled)
|
|
1066
|
-
function enableTaskCheckboxes(article) {
|
|
1067
|
-
for (const cb of article.querySelectorAll('.task-list-item-checkbox[disabled]')) {
|
|
1068
|
-
cb.removeAttribute('disabled')
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
// Delegated click: message sender name → open DM
|
|
1073
|
-
messages.addEventListener('click', e => {
|
|
1074
|
-
const handle = e.target.closest('.dm-trigger')
|
|
1075
|
-
if (!handle) return
|
|
1076
|
-
const targetUserId = handle.dataset.userId
|
|
1077
|
-
if (!targetUserId || targetUserId === userId) return
|
|
1078
|
-
ws.send({ t: 'dm.open', body: { target_user_id: targetUserId } })
|
|
1079
|
-
})
|
|
1080
|
-
|
|
1081
|
-
// ── Emoji picker ──────────────────────────────────────────────────────────
|
|
1082
|
-
|
|
1083
|
-
const RECENT_KEY = 'devchitchat_recent_emoji'
|
|
1084
|
-
const RECENT_MAX = 24
|
|
1085
|
-
|
|
1086
|
-
function loadRecentEmoji() {
|
|
1087
|
-
try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') } catch { return [] }
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
function saveRecentEmoji(emoji) {
|
|
1091
|
-
let recents = loadRecentEmoji().filter(e => e !== emoji)
|
|
1092
|
-
recents.unshift(emoji)
|
|
1093
|
-
if (recents.length > RECENT_MAX) recents = recents.slice(0, RECENT_MAX)
|
|
1094
|
-
localStorage.setItem(RECENT_KEY, JSON.stringify(recents))
|
|
1095
|
-
refreshAllQuickPicks()
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
function renderQuickPicks(toolbar) {
|
|
1099
|
-
const slot = toolbar.querySelector('.quick-picks')
|
|
1100
|
-
if (!slot) return
|
|
1101
|
-
const recents = loadRecentEmoji().slice(0, 4)
|
|
1102
|
-
slot.innerHTML = recents.map(emoji =>
|
|
1103
|
-
`<button class="btn-quick-react btn-icon" data-emoji="${escHtml(emoji)}" type="button" title="${escHtml(emoji)}">${emoji}</button>`
|
|
1104
|
-
).join('')
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
function refreshAllQuickPicks() {
|
|
1108
|
-
for (const toolbar of messages.querySelectorAll('.message-hover-actions')) {
|
|
1109
|
-
renderQuickPicks(toolbar)
|
|
1110
|
-
}
|
|
1111
|
-
if (threadRepliesEl) {
|
|
1112
|
-
for (const toolbar of threadRepliesEl.querySelectorAll('.message-hover-actions')) {
|
|
1113
|
-
renderQuickPicks(toolbar)
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
let emojiPickerEl = null
|
|
1119
|
-
let emojiPickerCurrentCat = 'smileys'
|
|
1120
|
-
let emojiPickerTarget = null // msg_id the picker is for
|
|
1121
|
-
|
|
1122
|
-
function buildEmojiPicker() {
|
|
1123
|
-
emojiPickerEl = document.createElement('div')
|
|
1124
|
-
emojiPickerEl.className = 'emoji-picker'
|
|
1125
|
-
|
|
1126
|
-
const searchInput = document.createElement('input')
|
|
1127
|
-
searchInput.type = 'search'
|
|
1128
|
-
searchInput.className = 'emoji-picker-search'
|
|
1129
|
-
searchInput.placeholder = 'Search emoji…'
|
|
1130
|
-
searchInput.setAttribute('aria-label', 'Search emoji')
|
|
1131
|
-
emojiPickerEl.appendChild(searchInput)
|
|
1132
|
-
|
|
1133
|
-
const tabs = document.createElement('div')
|
|
1134
|
-
tabs.className = 'emoji-picker-tabs'
|
|
1135
|
-
for (const cat of CATEGORIES) {
|
|
1136
|
-
const btn = document.createElement('button')
|
|
1137
|
-
btn.type = 'button'
|
|
1138
|
-
btn.className = 'emoji-picker-tab' + (cat.id === emojiPickerCurrentCat ? ' active' : '')
|
|
1139
|
-
btn.dataset.catId = cat.id
|
|
1140
|
-
btn.textContent = cat.label
|
|
1141
|
-
btn.title = cat.id
|
|
1142
|
-
tabs.appendChild(btn)
|
|
1143
|
-
}
|
|
1144
|
-
emojiPickerEl.appendChild(tabs)
|
|
1145
|
-
|
|
1146
|
-
const grid = document.createElement('div')
|
|
1147
|
-
grid.className = 'emoji-picker-grid'
|
|
1148
|
-
emojiPickerEl.appendChild(grid)
|
|
1149
|
-
|
|
1150
|
-
tabs.addEventListener('click', e => {
|
|
1151
|
-
const btn = e.target.closest('.emoji-picker-tab')
|
|
1152
|
-
if (!btn) return
|
|
1153
|
-
emojiPickerCurrentCat = btn.dataset.catId
|
|
1154
|
-
tabs.querySelectorAll('.emoji-picker-tab').forEach(b => b.classList.toggle('active', b.dataset.catId === emojiPickerCurrentCat))
|
|
1155
|
-
searchInput.value = ''
|
|
1156
|
-
renderEmojiGrid(null)
|
|
1157
|
-
})
|
|
1158
|
-
|
|
1159
|
-
searchInput.addEventListener('input', () => {
|
|
1160
|
-
renderEmojiGrid(searchInput.value.trim().toLowerCase())
|
|
1161
|
-
})
|
|
1162
|
-
|
|
1163
|
-
grid.addEventListener('click', e => {
|
|
1164
|
-
const btn = e.target.closest('button[data-emoji]')
|
|
1165
|
-
if (!btn) return
|
|
1166
|
-
const emoji = btn.dataset.emoji
|
|
1167
|
-
saveRecentEmoji(emoji)
|
|
1168
|
-
emojiPickerEl.dispatchEvent(new CustomEvent('emoji:pick', { bubbles: true, detail: { emoji } }))
|
|
1169
|
-
})
|
|
1170
|
-
|
|
1171
|
-
renderEmojiGrid(null)
|
|
1172
|
-
return emojiPickerEl
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
function renderEmojiGrid(query) {
|
|
1176
|
-
if (!emojiPickerEl) return
|
|
1177
|
-
const grid = emojiPickerEl.querySelector('.emoji-picker-grid')
|
|
1178
|
-
if (!grid) return
|
|
1179
|
-
|
|
1180
|
-
let emojiList
|
|
1181
|
-
if (query) {
|
|
1182
|
-
// Search across all categories
|
|
1183
|
-
const allEmoji = CATEGORIES.flatMap(c => c.emoji)
|
|
1184
|
-
const unique = [...new Set(allEmoji)]
|
|
1185
|
-
emojiList = unique.filter(e => {
|
|
1186
|
-
const name = EMOJI_NAMES[e] ?? ''
|
|
1187
|
-
return name.includes(query) || e.includes(query)
|
|
1188
|
-
})
|
|
1189
|
-
} else {
|
|
1190
|
-
if (emojiPickerCurrentCat === 'recent') {
|
|
1191
|
-
emojiList = loadRecentEmoji()
|
|
1192
|
-
} else {
|
|
1193
|
-
const cat = CATEGORIES.find(c => c.id === emojiPickerCurrentCat)
|
|
1194
|
-
emojiList = cat ? cat.emoji : []
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
grid.innerHTML = emojiList.map(e =>
|
|
1199
|
-
`<button type="button" data-emoji="${escHtml(e)}" title="${escHtml(EMOJI_NAMES[e] ?? e)}">${e}</button>`
|
|
1200
|
-
).join('')
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
function getOrBuildEmojiPicker() {
|
|
1204
|
-
if (!emojiPickerEl) buildEmojiPicker()
|
|
1205
|
-
return emojiPickerEl
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
function openEmojiPickerAt(anchorEl, msgId) {
|
|
1209
|
-
emojiPickerTarget = msgId
|
|
1210
|
-
const picker = getOrBuildEmojiPicker()
|
|
1211
|
-
|
|
1212
|
-
// Refresh recent tab if active
|
|
1213
|
-
if (emojiPickerCurrentCat === 'recent') renderEmojiGrid(null)
|
|
1214
|
-
|
|
1215
|
-
// Attach handler once (use named function to avoid duplicates)
|
|
1216
|
-
picker.onEmojiPickHandler = (e) => {
|
|
1217
|
-
const { emoji } = e.detail
|
|
1218
|
-
closeEmojiPicker()
|
|
1219
|
-
ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
|
|
1220
|
-
}
|
|
1221
|
-
picker.removeEventListener('emoji:pick', picker._boundEmojiPick)
|
|
1222
|
-
picker._boundEmojiPick = picker.onEmojiPickHandler
|
|
1223
|
-
picker.addEventListener('emoji:pick', picker._boundEmojiPick)
|
|
1224
|
-
|
|
1225
|
-
document.body.appendChild(picker)
|
|
1226
|
-
picker.style.position = 'fixed'
|
|
1227
|
-
picker.style.zIndex = '400'
|
|
1228
|
-
|
|
1229
|
-
// Position below the anchor, viewport-aware
|
|
1230
|
-
const rect = anchorEl.getBoundingClientRect()
|
|
1231
|
-
picker.style.top = `${rect.bottom + 4}px`
|
|
1232
|
-
picker.style.left = `${rect.left}px`
|
|
1233
|
-
|
|
1234
|
-
// Force layout so getBoundingClientRect is accurate
|
|
1235
|
-
requestAnimationFrame(() => {
|
|
1236
|
-
const pickerRect = picker.getBoundingClientRect()
|
|
1237
|
-
let left = rect.left
|
|
1238
|
-
if (left + pickerRect.width > window.innerWidth - 8) {
|
|
1239
|
-
left = window.innerWidth - 8 - pickerRect.width
|
|
1240
|
-
}
|
|
1241
|
-
if (left < 8) left = 8
|
|
1242
|
-
picker.style.left = `${left}px`
|
|
1243
|
-
|
|
1244
|
-
// Flip above if not enough room below
|
|
1245
|
-
if (rect.bottom + 4 + pickerRect.height > window.innerHeight - 8) {
|
|
1246
|
-
picker.style.top = `${rect.top - 4 - pickerRect.height}px`
|
|
1247
|
-
}
|
|
1248
|
-
})
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
function closeEmojiPicker() {
|
|
1252
|
-
if (emojiPickerEl && emojiPickerEl.parentNode) emojiPickerEl.parentNode.removeChild(emojiPickerEl)
|
|
1253
|
-
emojiPickerTarget = null
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
document.addEventListener('click', e => {
|
|
1257
|
-
// Close emoji picker on click-outside
|
|
1258
|
-
if (emojiPickerEl && emojiPickerEl.parentNode && !emojiPickerEl.contains(e.target)) {
|
|
1259
|
-
const isReactionAddBtn = e.target.closest('.reaction-add') || e.target.closest('.btn-react')
|
|
1260
|
-
if (!isReactionAddBtn) closeEmojiPicker()
|
|
1261
|
-
}
|
|
1262
|
-
}, { capture: true })
|
|
1263
|
-
|
|
1264
|
-
document.addEventListener('keydown', e => {
|
|
1265
|
-
if (e.key === 'Escape') {
|
|
1266
|
-
if (activeEditCancel) { activeEditCancel(); return }
|
|
1267
|
-
if (composeOpen) { closeComposeMode(); return }
|
|
1268
|
-
closeEmojiPicker()
|
|
1269
|
-
}
|
|
1270
|
-
})
|
|
1271
|
-
|
|
1272
|
-
// Delegated click on task-list checkboxes — toggle [ ] ↔ [x] and save via msg.edit
|
|
1273
|
-
messages.addEventListener('click', e => {
|
|
1274
|
-
const cb = e.target.closest('.task-list-item-checkbox')
|
|
1275
|
-
if (!cb) return
|
|
1276
|
-
e.preventDefault() // we control the toggle ourselves
|
|
1277
|
-
const article = cb.closest('article.message')
|
|
1278
|
-
if (!article) return
|
|
1279
|
-
const rawText = article.dataset.rawText
|
|
1280
|
-
if (!rawText) return
|
|
1281
|
-
const allCbs = Array.from(article.querySelectorAll('.task-list-item-checkbox'))
|
|
1282
|
-
const idx = allCbs.indexOf(cb)
|
|
1283
|
-
if (idx === -1) return
|
|
1284
|
-
let count = 0
|
|
1285
|
-
const newText = rawText.replace(/\[([ xX])\]/g, (match, state) => {
|
|
1286
|
-
if (count++ !== idx) return match
|
|
1287
|
-
return state.trim() === '' ? '[x]' : '[ ]'
|
|
1288
|
-
})
|
|
1289
|
-
if (newText === rawText) return
|
|
1290
|
-
cb.checked = !cb.checked // optimistic toggle
|
|
1291
|
-
article.dataset.rawText = newText
|
|
1292
|
-
ws.send({ t: 'msg.edit', body: { msg_id: article.dataset.msgId, channel_id: channelId, text: newText } })
|
|
1293
|
-
})
|
|
1294
|
-
|
|
1295
|
-
// Delegated click on .btn-quick-react — send reaction without opening picker
|
|
1296
|
-
messages.addEventListener('click', e => {
|
|
1297
|
-
const btn = e.target.closest('.btn-quick-react')
|
|
1298
|
-
if (!btn) return
|
|
1299
|
-
e.stopPropagation()
|
|
1300
|
-
const emoji = btn.dataset.emoji
|
|
1301
|
-
const msgId = btn.closest('article.message')?.dataset.msgId
|
|
1302
|
-
if (!emoji || !msgId) return
|
|
1303
|
-
saveRecentEmoji(emoji)
|
|
1304
|
-
ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
|
|
1305
|
-
})
|
|
1306
|
-
|
|
1307
|
-
// Delegated click on .reaction-pill — toggle reaction
|
|
1308
|
-
messages.addEventListener('click', e => {
|
|
1309
|
-
const pill = e.target.closest('.reaction-pill')
|
|
1310
|
-
if (!pill) return
|
|
1311
|
-
e.stopPropagation()
|
|
1312
|
-
const emoji = pill.dataset.emoji
|
|
1313
|
-
const msgId = pill.dataset.msgId
|
|
1314
|
-
if (!emoji || !msgId) return
|
|
1315
|
-
if (pill.classList.contains('reacted')) {
|
|
1316
|
-
ws.send({ t: 'reaction.remove', body: { msg_id: msgId, channel_id: channelId, emoji } })
|
|
1317
|
-
} else {
|
|
1318
|
-
ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
|
|
1319
|
-
}
|
|
1320
|
-
})
|
|
1321
|
-
|
|
1322
|
-
// Delegated click on .reaction-add or .btn-react — open emoji picker
|
|
1323
|
-
messages.addEventListener('click', e => {
|
|
1324
|
-
const addBtn = e.target.closest('.reaction-add')
|
|
1325
|
-
const reactBtn = e.target.closest('.btn-react')
|
|
1326
|
-
const btn = addBtn ?? reactBtn
|
|
1327
|
-
if (!btn) return
|
|
1328
|
-
e.stopPropagation()
|
|
1329
|
-
const msgId = addBtn?.dataset.msgId ?? btn.closest('article.message')?.dataset.msgId
|
|
1330
|
-
if (!msgId) return
|
|
1331
|
-
// Toggle: close if already open for this message
|
|
1332
|
-
if (emojiPickerEl && emojiPickerEl.parentNode && emojiPickerTarget === msgId) {
|
|
1333
|
-
closeEmojiPicker()
|
|
1334
|
-
return
|
|
1335
|
-
}
|
|
1336
|
-
openEmojiPickerAt(btn, msgId)
|
|
1337
|
-
})
|
|
1338
|
-
|
|
1339
|
-
// ── Thread panel: mirror all message-level interaction handlers ───────────
|
|
1340
|
-
// threadRepliesEl is a sibling outside the island root, so handlers on
|
|
1341
|
-
// `messages` don't reach it — duplicate the relevant delegated clicks here.
|
|
1342
|
-
|
|
1343
|
-
threadRepliesEl?.addEventListener('click', e => {
|
|
1344
|
-
const btn = e.target.closest('.btn-quick-react')
|
|
1345
|
-
if (!btn) return
|
|
1346
|
-
e.stopPropagation()
|
|
1347
|
-
const emoji = btn.dataset.emoji
|
|
1348
|
-
const msgId = btn.closest('article.message')?.dataset.msgId
|
|
1349
|
-
if (!emoji || !msgId) return
|
|
1350
|
-
saveRecentEmoji(emoji)
|
|
1351
|
-
ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
|
|
1352
|
-
})
|
|
1353
|
-
|
|
1354
|
-
threadRepliesEl?.addEventListener('click', e => {
|
|
1355
|
-
const pill = e.target.closest('.reaction-pill')
|
|
1356
|
-
if (!pill) return
|
|
1357
|
-
e.stopPropagation()
|
|
1358
|
-
const emoji = pill.dataset.emoji
|
|
1359
|
-
const msgId = pill.dataset.msgId
|
|
1360
|
-
if (!emoji || !msgId) return
|
|
1361
|
-
if (pill.classList.contains('reacted')) {
|
|
1362
|
-
ws.send({ t: 'reaction.remove', body: { msg_id: msgId, channel_id: channelId, emoji } })
|
|
1363
|
-
} else {
|
|
1364
|
-
ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
|
|
1365
|
-
}
|
|
1366
|
-
})
|
|
1367
|
-
|
|
1368
|
-
threadRepliesEl?.addEventListener('click', e => {
|
|
1369
|
-
const addBtn = e.target.closest('.reaction-add')
|
|
1370
|
-
const reactBtn = e.target.closest('.btn-react')
|
|
1371
|
-
const btn = addBtn ?? reactBtn
|
|
1372
|
-
if (!btn) return
|
|
1373
|
-
e.stopPropagation()
|
|
1374
|
-
const msgId = addBtn?.dataset.msgId ?? btn.closest('article.message')?.dataset.msgId
|
|
1375
|
-
if (!msgId) return
|
|
1376
|
-
if (emojiPickerEl && emojiPickerEl.parentNode && emojiPickerTarget === msgId) {
|
|
1377
|
-
closeEmojiPicker()
|
|
1378
|
-
return
|
|
1379
|
-
}
|
|
1380
|
-
openEmojiPickerAt(btn, msgId)
|
|
1381
|
-
})
|
|
1382
|
-
|
|
1383
|
-
threadRepliesEl?.addEventListener('click', e => {
|
|
1384
|
-
const btn = e.target.closest('.btn-msg-actions')
|
|
1385
|
-
if (!btn) return
|
|
1386
|
-
e.stopPropagation()
|
|
1387
|
-
const article = btn.closest('article.message')
|
|
1388
|
-
if (article) showContextMenu(article, btn)
|
|
1389
|
-
})
|
|
1390
|
-
|
|
1391
|
-
threadRepliesEl?.addEventListener('click', e => {
|
|
1392
|
-
const handle = e.target.closest('.dm-trigger')
|
|
1393
|
-
if (!handle) return
|
|
1394
|
-
const targetUserId = handle.dataset.userId
|
|
1395
|
-
if (!targetUserId || targetUserId === userId) return
|
|
1396
|
-
ws.send({ t: 'dm.open', body: { target_user_id: targetUserId } })
|
|
1397
|
-
})
|
|
1398
|
-
|
|
1399
|
-
// ── Mobile long-press → action sheet with emoji picker ────────────────────
|
|
1400
|
-
|
|
1401
|
-
addLongPress(messages, (e) => {
|
|
1402
|
-
const article = e.target.closest?.('article.message')
|
|
1403
|
-
if (!article) return
|
|
1404
|
-
const msgId = article.dataset.msgId
|
|
1405
|
-
if (!msgId) return
|
|
1406
|
-
|
|
1407
|
-
const itemsContainer = getItemsContainer()
|
|
1408
|
-
itemsContainer.innerHTML = ''
|
|
1409
|
-
|
|
1410
|
-
const pickerWrapper = document.createElement('div')
|
|
1411
|
-
pickerWrapper.className = 'action-sheet-emoji-picker-wrap'
|
|
1412
|
-
|
|
1413
|
-
const picker = getOrBuildEmojiPicker()
|
|
1414
|
-
pickerWrapper.appendChild(picker)
|
|
1415
|
-
itemsContainer.appendChild(pickerWrapper)
|
|
1416
|
-
|
|
1417
|
-
picker.removeEventListener('emoji:pick', picker._boundEmojiPick)
|
|
1418
|
-
picker._boundEmojiPick = (ev) => {
|
|
1419
|
-
const { emoji } = ev.detail
|
|
1420
|
-
saveRecentEmoji(emoji)
|
|
1421
|
-
dismissActionSheet()
|
|
1422
|
-
ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
|
|
1423
|
-
}
|
|
1424
|
-
picker.addEventListener('emoji:pick', picker._boundEmojiPick)
|
|
1425
|
-
emojiPickerTarget = msgId
|
|
1426
|
-
|
|
1427
|
-
showActionSheet({ label: 'React to this message', items: [] })
|
|
1428
|
-
})
|
|
1429
|
-
|
|
1430
|
-
// ── Inline edit ────────────────────────────────────────────────────────────
|
|
1431
|
-
|
|
1432
|
-
let activeEditCancel = null
|
|
1433
|
-
|
|
1434
|
-
function startInlineEdit(article) {
|
|
1435
|
-
if (article.querySelector('.message-edit-wrap')) return
|
|
1436
|
-
const textEl = article.querySelector('.message-text')
|
|
1437
|
-
if (!textEl) return
|
|
1438
|
-
const rawText = article.dataset.rawText ?? ''
|
|
1439
|
-
|
|
1440
|
-
// Build the edit widget
|
|
1441
|
-
const wrap = document.createElement('div')
|
|
1442
|
-
wrap.className = 'message-edit-wrap'
|
|
1443
|
-
|
|
1444
|
-
const tabStrip = document.createElement('div')
|
|
1445
|
-
tabStrip.className = 'message-edit-tabs'
|
|
1446
|
-
tabStrip.setAttribute('role', 'tablist')
|
|
1447
|
-
tabStrip.innerHTML = `
|
|
1448
|
-
<button class="message-edit-tab message-edit-tab--active" data-tab="write" role="tab" aria-selected="true" type="button">Write</button>
|
|
1449
|
-
<button class="message-edit-tab" data-tab="preview" role="tab" aria-selected="false" type="button">Preview</button>`
|
|
1450
|
-
|
|
1451
|
-
const textarea = document.createElement('textarea')
|
|
1452
|
-
textarea.className = 'message-edit-input'
|
|
1453
|
-
textarea.value = rawText
|
|
1454
|
-
|
|
1455
|
-
const preview = document.createElement('div')
|
|
1456
|
-
preview.className = 'message-edit-preview message-text'
|
|
1457
|
-
preview.hidden = true
|
|
1458
|
-
preview.setAttribute('aria-live', 'polite')
|
|
1459
|
-
|
|
1460
|
-
const toolbar = document.createElement('div')
|
|
1461
|
-
toolbar.className = 'message-edit-toolbar'
|
|
1462
|
-
toolbar.innerHTML = `
|
|
1463
|
-
<span class="message-edit-hint">Ctrl+Enter to save · Esc to cancel</span>
|
|
1464
|
-
<button class="btn-ghost btn-edit-cancel" type="button">Cancel</button>
|
|
1465
|
-
<button class="btn-primary btn-edit-save" type="button">Save</button>`
|
|
1466
|
-
|
|
1467
|
-
wrap.append(tabStrip, textarea, preview, toolbar)
|
|
1468
|
-
textEl.replaceWith(wrap)
|
|
1469
|
-
textarea.focus()
|
|
1470
|
-
textarea.setSelectionRange(rawText.length, rawText.length)
|
|
1471
|
-
|
|
1472
|
-
// Tab switching
|
|
1473
|
-
tabStrip.addEventListener('click', async e => {
|
|
1474
|
-
const btn = e.target.closest('.message-edit-tab')
|
|
1475
|
-
if (!btn) return
|
|
1476
|
-
const tab = btn.dataset.tab
|
|
1477
|
-
tabStrip.querySelectorAll('.message-edit-tab').forEach(b => {
|
|
1478
|
-
b.classList.toggle('message-edit-tab--active', b === btn)
|
|
1479
|
-
b.setAttribute('aria-selected', String(b === btn))
|
|
1480
|
-
})
|
|
1481
|
-
textarea.hidden = tab !== 'write'
|
|
1482
|
-
preview.hidden = tab !== 'preview'
|
|
1483
|
-
if (tab === 'preview') {
|
|
1484
|
-
const text = textarea.value.trim()
|
|
1485
|
-
if (!text) { preview.innerHTML = '<p style="color:var(--text-muted)">Nothing to preview yet.</p>'; return }
|
|
1486
|
-
try {
|
|
1487
|
-
const res = await fetch(`${window.__BASE_PATH__}/api/preview`, {
|
|
1488
|
-
method: 'POST',
|
|
1489
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1490
|
-
body: JSON.stringify({ text }),
|
|
1491
|
-
})
|
|
1492
|
-
if (res.ok) { const { html } = await res.json(); preview.innerHTML = sanitizeHtml(html) }
|
|
1493
|
-
} catch { preview.textContent = text }
|
|
1494
|
-
}
|
|
1495
|
-
})
|
|
1496
|
-
|
|
1497
|
-
function cancel() {
|
|
1498
|
-
if (!wrap.parentNode) return
|
|
1499
|
-
wrap.replaceWith(textEl)
|
|
1500
|
-
activeEditCancel = null
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
activeEditCancel = cancel
|
|
1504
|
-
|
|
1505
|
-
function save() {
|
|
1506
|
-
const newText = textarea.value.trim()
|
|
1507
|
-
if (!newText || newText === rawText) { cancel(); return }
|
|
1508
|
-
cancel()
|
|
1509
|
-
article.dataset.rawText = newText
|
|
1510
|
-
ws.send({ t: 'msg.edit', body: { msg_id: article.dataset.msgId, channel_id: channelId, text: newText } })
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
|
-
toolbar.querySelector('.btn-edit-save').addEventListener('click', save)
|
|
1514
|
-
toolbar.querySelector('.btn-edit-cancel').addEventListener('click', cancel)
|
|
1515
|
-
textarea.addEventListener('keydown', e => {
|
|
1516
|
-
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save() }
|
|
1517
|
-
if (e.key === 'Escape') cancel()
|
|
1518
|
-
})
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
// ── Context menu (desktop hover → … button) ────────────────────────────────
|
|
1522
|
-
|
|
1523
|
-
let activeContextMenu = null
|
|
1524
|
-
|
|
1525
|
-
function closeContextMenu() {
|
|
1526
|
-
if (activeContextMenu) { activeContextMenu.remove(); activeContextMenu = null }
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
function showContextMenu(article, anchorEl) {
|
|
1530
|
-
closeContextMenu()
|
|
1531
|
-
const isAuthor = article.dataset.userId === userId
|
|
1532
|
-
if (!isAuthor) return
|
|
1533
|
-
|
|
1534
|
-
const menu = document.createElement('div')
|
|
1535
|
-
menu.className = 'msg-context-menu'
|
|
1536
|
-
const editBtn = document.createElement('button')
|
|
1537
|
-
editBtn.className = 'msg-context-menu-item'
|
|
1538
|
-
editBtn.type = 'button'
|
|
1539
|
-
editBtn.textContent = 'Edit'
|
|
1540
|
-
editBtn.addEventListener('click', () => { closeContextMenu(); startInlineEdit(article) })
|
|
1541
|
-
menu.appendChild(editBtn)
|
|
1542
|
-
|
|
1543
|
-
const deleteBtn = document.createElement('button')
|
|
1544
|
-
deleteBtn.className = 'msg-context-menu-item msg-context-menu-item--danger'
|
|
1545
|
-
deleteBtn.type = 'button'
|
|
1546
|
-
deleteBtn.textContent = 'Delete'
|
|
1547
|
-
deleteBtn.addEventListener('click', () => {
|
|
1548
|
-
closeContextMenu()
|
|
1549
|
-
ws.send({ t: 'msg.delete', body: { msg_id: article.dataset.msgId, channel_id: channelId } })
|
|
1550
|
-
})
|
|
1551
|
-
menu.appendChild(deleteBtn)
|
|
1552
|
-
|
|
1553
|
-
document.body.appendChild(menu)
|
|
1554
|
-
activeContextMenu = menu
|
|
1555
|
-
|
|
1556
|
-
const rect = anchorEl.getBoundingClientRect()
|
|
1557
|
-
const menuRect = menu.getBoundingClientRect()
|
|
1558
|
-
let top = rect.bottom + window.scrollY + 4
|
|
1559
|
-
let left = rect.right + window.scrollX - menuRect.width
|
|
1560
|
-
if (left < 8) left = 8
|
|
1561
|
-
if (left + menuRect.width > window.innerWidth - 8) left = window.innerWidth - 8 - menuRect.width
|
|
1562
|
-
menu.style.top = `${top}px`
|
|
1563
|
-
menu.style.left = `${left}px`
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
document.addEventListener('click', e => {
|
|
1567
|
-
if (activeContextMenu && !activeContextMenu.contains(e.target)) closeContextMenu()
|
|
1568
|
-
}, { capture: true })
|
|
1569
|
-
|
|
1570
|
-
document.addEventListener('keydown', e => {
|
|
1571
|
-
if (e.key === 'Escape') closeContextMenu()
|
|
1572
|
-
})
|
|
1573
|
-
|
|
1574
|
-
// Delegated click: … button → open context menu
|
|
1575
|
-
messages.addEventListener('click', e => {
|
|
1576
|
-
const btn = e.target.closest('.btn-msg-actions')
|
|
1577
|
-
if (!btn) return
|
|
1578
|
-
e.stopPropagation()
|
|
1579
|
-
const article = btn.closest('article.message')
|
|
1580
|
-
if (article) showContextMenu(article, btn)
|
|
1581
|
-
})
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
ws.on('dm.opened', ({ channel_id, notify_only }) => {
|
|
1585
|
-
if (notify_only) return // target user — sidebar handles the notification
|
|
1586
|
-
window.location.href = `${window.__BASE_PATH__}/channels/${channel_id}`
|
|
1587
|
-
})
|
|
1588
|
-
|
|
1589
|
-
// ── Call: rtc.call_state — drives "N in call" row + sidebar badge ──────────
|
|
1590
|
-
|
|
1591
|
-
ws.on('rtc.call_state', (body) => {
|
|
1592
|
-
if (body.channel_id !== channelId) return
|
|
1593
|
-
// Don't overwrite the active call's ID when browsing a different channel —
|
|
1594
|
-
// callIdSig is what miniBarLeave uses to leave the call.
|
|
1595
|
-
if (!inCall() || body.channel_id === callChannelId) {
|
|
1596
|
-
callIdSig.set(body.call_id)
|
|
1597
|
-
}
|
|
1598
|
-
_updateCallStatusRow(body.call_id, body.count, body.users ?? [])
|
|
1599
|
-
_updateChannelBadge(body.count)
|
|
1600
|
-
})
|
|
1601
|
-
|
|
1602
|
-
function _updateCallStatusRow(activeCallId, count, users) {
|
|
1603
|
-
if (!callStatusEl) return
|
|
1604
|
-
if (inCall()) {
|
|
1605
|
-
// Already in the call — just update peer count
|
|
1606
|
-
if (peerCountEl) peerCountEl.textContent = count > 1 ? `${count} in call` : ''
|
|
1607
|
-
callStatusEl.hidden = true
|
|
1608
|
-
return
|
|
1609
|
-
}
|
|
1610
|
-
if (!activeCallId || count === 0) {
|
|
1611
|
-
callStatusEl.hidden = true
|
|
1612
|
-
return
|
|
1613
|
-
}
|
|
1614
|
-
callStatusEl.hidden = false
|
|
1615
|
-
if (callStatusInfo) callStatusInfo.textContent = `${count} in call`
|
|
1616
|
-
if (callStatusAvatars) {
|
|
1617
|
-
callStatusAvatars.innerHTML = users.slice(0, 5).map(u =>
|
|
1618
|
-
`<span class="call-status-avatar" title="${escHtml(u.user_id)}">${escHtml(u.user_id.slice(0, 2).toUpperCase())}</span>`
|
|
1619
|
-
).join('')
|
|
1620
|
-
}
|
|
1621
|
-
}
|
|
1622
|
-
|
|
1623
|
-
function _updateChannelBadge(count) {
|
|
1624
|
-
const li = document.querySelector(`.channel-link[data-channel-id="${channelId}"]`)?.closest('li')
|
|
1625
|
-
if (!li) return
|
|
1626
|
-
li.classList.toggle('call-active', count > 0)
|
|
1627
|
-
const badge = li.querySelector('.call-badge')
|
|
1628
|
-
if (badge) badge.textContent = count > 0 ? String(count) : ''
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
// ── Call: start / join / leave ─────────────────────────────────────────────
|
|
1632
|
-
|
|
1633
|
-
btnStartCall?.addEventListener('click', () => {
|
|
1634
|
-
ws.send({ t: 'rtc.call_create', body: { channel_id: channelId, kind: 'mesh' } })
|
|
1635
|
-
})
|
|
1636
|
-
|
|
1637
|
-
btnJoinCall?.addEventListener('click', () => {
|
|
1638
|
-
const id = callIdSig()
|
|
1639
|
-
if (id) ws.send({ t: 'rtc.join', body: { call_id: id } })
|
|
1640
|
-
})
|
|
1641
|
-
|
|
1642
|
-
btnLeaveCall?.addEventListener('click', leaveCall)
|
|
1643
|
-
|
|
1644
|
-
function leaveCall() {
|
|
1645
|
-
const id = callIdSig()
|
|
1646
|
-
if (!inCall() || !id) return
|
|
1647
|
-
ws.send({ t: 'rtc.leave', body: { call_id: id } })
|
|
1648
|
-
_teardownCall()
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
// ── Call: WS message handlers ──────────────────────────────────────────────
|
|
1652
|
-
|
|
1653
|
-
ws.on('rtc.call', (body) => {
|
|
1654
|
-
// Server confirmed call creation / found existing call — now join it
|
|
1655
|
-
if (body.ice_servers?.length) { iceServers = body.ice_servers; rtcManager.setIceServers(iceServers) }
|
|
1656
|
-
callIdSig.set(body.call_id)
|
|
1657
|
-
ws.send({ t: 'rtc.join', body: { call_id: body.call_id } })
|
|
1658
|
-
})
|
|
1659
|
-
|
|
1660
|
-
ws.on('rtc.joined', async (body) => {
|
|
1661
|
-
const { call_id, peer_id, peers } = body
|
|
1662
|
-
if (body.ice_servers?.length) { iceServers = body.ice_servers; rtcManager.setIceServers(iceServers) }
|
|
1663
|
-
selfPeerId.set(peer_id)
|
|
1664
|
-
callIdSig.set(call_id)
|
|
1665
|
-
callChannelId = channelId
|
|
1666
|
-
inCall.set(true)
|
|
1667
|
-
_showCallControls()
|
|
1668
|
-
_showTilePanel()
|
|
1669
|
-
_attachDeviceChangeListener()
|
|
1670
|
-
patchSettings({ last_channel_id: channelId })
|
|
1671
|
-
|
|
1672
|
-
// Start audio immediately; video is opt-in
|
|
1673
|
-
await _startAudio()
|
|
1674
|
-
|
|
1675
|
-
// Cache display names for existing peers, then connect as offerer
|
|
1676
|
-
for (const peer of peers) {
|
|
1677
|
-
if (peer.peer_id !== peer_id) {
|
|
1678
|
-
rtcManager.setDisplayName(peer.peer_id, peer.display_name)
|
|
1679
|
-
rtcManager.ensurePeer(peer.peer_id)
|
|
1680
|
-
rtcManager.negotiate(peer.peer_id)
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
})
|
|
1684
|
-
|
|
1685
|
-
ws.on('rtc.peer_event', ({ call_id, kind, peer }) => {
|
|
1686
|
-
if (kind === 'join' && peer.peer_id !== selfPeerId()) {
|
|
1687
|
-
rtcManager.setDisplayName(peer.peer_id, peer.display_name)
|
|
1688
|
-
// Existing peer receives new joiner's event — create answerer connection
|
|
1689
|
-
// (new joiner will send us an offer)
|
|
1690
|
-
rtcManager.ensurePeer(peer.peer_id)
|
|
1691
|
-
}
|
|
1692
|
-
if (kind === 'leave') {
|
|
1693
|
-
rtcManager.closePeer(peer.peer_id)
|
|
1694
|
-
}
|
|
1695
|
-
})
|
|
1696
|
-
|
|
1697
|
-
ws.on('rtc.offer_event', async ({ call_id, from_peer_id, sdp }) => {
|
|
1698
|
-
await rtcManager.handleRemoteOffer(from_peer_id, call_id, sdp)
|
|
1699
|
-
})
|
|
1700
|
-
|
|
1701
|
-
ws.on('rtc.answer_event', async ({ call_id, from_peer_id, sdp }) => {
|
|
1702
|
-
await rtcManager.handleRemoteAnswer(from_peer_id, sdp)
|
|
1703
|
-
})
|
|
1704
|
-
|
|
1705
|
-
ws.on('rtc.ice_event', async ({ from_peer_id, candidate }) => {
|
|
1706
|
-
await rtcManager.handleIceCandidate(from_peer_id, candidate)
|
|
1707
|
-
})
|
|
1708
|
-
|
|
1709
|
-
ws.on('rtc.stream_event', () => {
|
|
1710
|
-
// No pre-tile creation here — tile IDs must match between this handler
|
|
1711
|
-
// (which uses kind: 'cam'/'screen') and ontrack (which uses transceiver.mid,
|
|
1712
|
-
// a number like '1' or '2'). The mismatch left orphaned empty tiles.
|
|
1713
|
-
// Tiles are created in ontrack once the actual stream track arrives.
|
|
1714
|
-
})
|
|
1715
|
-
|
|
1716
|
-
ws.on('rtc.call_end', ({ call_id }) => {
|
|
1717
|
-
if (call_id === callIdSig()) _teardownCall()
|
|
1718
|
-
})
|
|
1719
|
-
|
|
1720
|
-
ws.on('rtc.left', () => {
|
|
1721
|
-
// Server confirmed our leave
|
|
1722
|
-
})
|
|
1723
|
-
|
|
1724
|
-
// ── Local media ────────────────────────────────────────────────────────────
|
|
1725
|
-
// Peer negotiation, transceiver slots, ICE queuing → RtcPeerManager
|
|
1726
|
-
|
|
1727
|
-
async function _startAudio() {
|
|
1728
|
-
if (audioStream) return
|
|
1729
|
-
try {
|
|
1730
|
-
const saved = loadSavedDevices()
|
|
1731
|
-
audioStream = await navigator.mediaDevices.getUserMedia({
|
|
1732
|
-
audio: saved.micId ? { deviceId: { ideal: saved.micId } } : true,
|
|
1733
|
-
video: false,
|
|
1734
|
-
})
|
|
1735
|
-
activeMicId = audioStream.getAudioTracks()[0]?.getSettings().deviceId ?? null
|
|
1736
|
-
audioStream.getAudioTracks().forEach(t => { t.enabled = !micMuted() })
|
|
1737
|
-
await refreshDevices() // labels now available after permission granted
|
|
1738
|
-
for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
|
|
1739
|
-
} catch {
|
|
1740
|
-
micMuted.set(true)
|
|
1741
|
-
}
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
async function toggleMic() {
|
|
1745
|
-
micMuted.set(!micMuted())
|
|
1746
|
-
audioStream?.getAudioTracks().forEach(t => { t.enabled = !micMuted() })
|
|
1747
|
-
if (ctrlMic) ctrlMic.textContent = micMuted() ? '🔇' : '🎙'
|
|
1748
|
-
if (miniBarMic) miniBarMic.textContent = micMuted() ? '🔇' : '🎙'
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
async function toggleCamera() {
|
|
1752
|
-
if (videoStream) {
|
|
1753
|
-
videoStream.getTracks().forEach(t => t.stop())
|
|
1754
|
-
_removeTile('local-cam')
|
|
1755
|
-
videoStream = null
|
|
1756
|
-
camOff.set(true)
|
|
1757
|
-
for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
|
|
1758
|
-
if (ctrlCam) ctrlCam.textContent = '📷'
|
|
1759
|
-
return
|
|
1760
|
-
}
|
|
1761
|
-
try {
|
|
1762
|
-
const saved = loadSavedDevices()
|
|
1763
|
-
const videoConstraint = saved.cameraId
|
|
1764
|
-
? { deviceId: { ideal: saved.cameraId }, width: 640, height: 360 }
|
|
1765
|
-
: { width: 640, height: 360 }
|
|
1766
|
-
videoStream = await navigator.mediaDevices.getUserMedia({ video: videoConstraint, audio: false })
|
|
1767
|
-
activeCameraId = videoStream.getVideoTracks()[0]?.getSettings().deviceId ?? null
|
|
1768
|
-
camOff.set(false)
|
|
1769
|
-
_renderTile('local-cam', videoStream, true, `${userHandle ?? 'You'} (cam)`)
|
|
1770
|
-
ws.send({ t: 'rtc.stream_publish', body: { call_id: callIdSig(), stream: { kind: 'camera' } } })
|
|
1771
|
-
for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
|
|
1772
|
-
if (ctrlCam) ctrlCam.textContent = '📷✓'
|
|
1773
|
-
} catch {
|
|
1774
|
-
// Camera denied
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
|
|
1778
|
-
async function toggleScreen() {
|
|
1779
|
-
if (screenStream) {
|
|
1780
|
-
screenStream.getTracks().forEach(t => t.stop())
|
|
1781
|
-
_removeTile('local-screen')
|
|
1782
|
-
screenStream = null
|
|
1783
|
-
screenSharing.set(false)
|
|
1784
|
-
for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
|
|
1785
|
-
if (ctrlScreen) ctrlScreen.textContent = '🖥'
|
|
1786
|
-
return
|
|
1787
|
-
}
|
|
1788
|
-
if (!navigator.mediaDevices?.getDisplayMedia) return
|
|
1789
|
-
try {
|
|
1790
|
-
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
|
|
1791
|
-
screenSharing.set(true)
|
|
1792
|
-
_renderTile('local-screen', screenStream, true, `${userHandle ?? 'You'} (screen)`)
|
|
1793
|
-
ws.send({ t: 'rtc.stream_publish', body: { call_id: callIdSig(), stream: { kind: 'screen' } } })
|
|
1794
|
-
screenStream.getVideoTracks()[0].addEventListener('ended', () => toggleScreen())
|
|
1795
|
-
for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
|
|
1796
|
-
if (ctrlScreen) ctrlScreen.textContent = '🖥✓'
|
|
1797
|
-
} catch { /* user cancelled */ }
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
// ── Audio element for remote peers ─────────────────────────────────────────
|
|
1801
|
-
|
|
1802
|
-
function _ensureRemoteAudio(stream, peerId) {
|
|
1803
|
-
if (document.querySelector(`audio[data-peer-id="${peerId}"]`)) return
|
|
1804
|
-
const audio = document.createElement('audio')
|
|
1805
|
-
audio.autoplay = true
|
|
1806
|
-
audio.dataset.peerId = peerId
|
|
1807
|
-
audio.srcObject = stream
|
|
1808
|
-
document.body.appendChild(audio)
|
|
1809
|
-
}
|
|
1810
|
-
|
|
1811
|
-
// ── Tile grid ──────────────────────────────────────────────────────────────
|
|
1812
|
-
|
|
1813
|
-
function _captureFrame(tile, label) {
|
|
1814
|
-
const video = tile.querySelector('video')
|
|
1815
|
-
if (!video || !video.videoWidth) return
|
|
1816
|
-
const canvas = document.createElement('canvas')
|
|
1817
|
-
canvas.width = video.videoWidth
|
|
1818
|
-
canvas.height = video.videoHeight
|
|
1819
|
-
canvas.getContext('2d').drawImage(video, 0, 0)
|
|
1820
|
-
const a = document.createElement('a')
|
|
1821
|
-
a.href = canvas.toDataURL('image/png')
|
|
1822
|
-
a.download = `capture-${label.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}.png`
|
|
1823
|
-
a.click()
|
|
1824
|
-
}
|
|
1825
|
-
|
|
1826
|
-
function _startCapture(tile, label, delay) {
|
|
1827
|
-
const countdown = tile.querySelector('.tile-countdown')
|
|
1828
|
-
const captureBtn = tile.querySelector('.tile-capture')
|
|
1829
|
-
if (tile._captureTimer) {
|
|
1830
|
-
clearInterval(tile._captureTimer)
|
|
1831
|
-
tile._captureTimer = null
|
|
1832
|
-
countdown.hidden = true
|
|
1833
|
-
captureBtn.textContent = '📸'
|
|
1834
|
-
return
|
|
1835
|
-
}
|
|
1836
|
-
if (delay === 0) { _captureFrame(tile, label); return }
|
|
1837
|
-
let remaining = delay
|
|
1838
|
-
countdown.textContent = remaining
|
|
1839
|
-
countdown.hidden = false
|
|
1840
|
-
captureBtn.textContent = '✕'
|
|
1841
|
-
tile._captureTimer = setInterval(() => {
|
|
1842
|
-
remaining--
|
|
1843
|
-
if (remaining <= 0) {
|
|
1844
|
-
clearInterval(tile._captureTimer)
|
|
1845
|
-
tile._captureTimer = null
|
|
1846
|
-
countdown.hidden = true
|
|
1847
|
-
captureBtn.textContent = '📸'
|
|
1848
|
-
_captureFrame(tile, label)
|
|
1849
|
-
} else {
|
|
1850
|
-
countdown.textContent = remaining
|
|
1851
|
-
}
|
|
1852
|
-
}, 1000)
|
|
1853
|
-
}
|
|
1854
|
-
|
|
1855
|
-
function _renderTile(tileId, stream, muted, label) {
|
|
1856
|
-
if (!tileGridEl) return
|
|
1857
|
-
let tile = tileGridEl.querySelector(`[data-peer="${tileId}"]`)
|
|
1858
|
-
if (!tile) {
|
|
1859
|
-
tile = document.createElement('div')
|
|
1860
|
-
tile.className = 'stream-tile'
|
|
1861
|
-
tile.dataset.peer = tileId
|
|
1862
|
-
tile.innerHTML = `<video autoplay playsinline controls ${muted ? 'muted' : ''}></video><span class="tile-label">${escHtml(label)}</span><div class="tile-capture-wrap"><button class="tile-pin" title="Move to top">⬆</button><button class="tile-capture" title="Capture photo">📸</button><div class="tile-capture-menu" hidden><button class="tile-capture-opt" data-delay="0">0s</button><button class="tile-capture-opt" data-delay="1">1s</button><button class="tile-capture-opt" data-delay="3">3s</button><button class="tile-capture-opt" data-delay="5">5s</button></div></div><div class="tile-countdown" hidden></div>`
|
|
1863
|
-
tile.querySelector('video').addEventListener('click', e => e.stopPropagation())
|
|
1864
|
-
tile.querySelector('.tile-pin').addEventListener('click', e => { e.stopPropagation(); _pinTile(tileId) })
|
|
1865
|
-
const menu = tile.querySelector('.tile-capture-menu')
|
|
1866
|
-
tile.querySelector('.tile-capture').addEventListener('click', e => {
|
|
1867
|
-
e.stopPropagation()
|
|
1868
|
-
if (tile._captureTimer) { _startCapture(tile, label, 0); return }
|
|
1869
|
-
menu.hidden = !menu.hidden
|
|
1870
|
-
})
|
|
1871
|
-
menu.querySelectorAll('.tile-capture-opt').forEach(btn => {
|
|
1872
|
-
btn.addEventListener('click', e => {
|
|
1873
|
-
e.stopPropagation()
|
|
1874
|
-
menu.hidden = true
|
|
1875
|
-
_startCapture(tile, label, parseInt(btn.dataset.delay))
|
|
1876
|
-
})
|
|
1877
|
-
})
|
|
1878
|
-
tileGridEl.appendChild(tile)
|
|
1879
|
-
_updateTileLayout()
|
|
1880
|
-
}
|
|
1881
|
-
if (stream) tile.querySelector('video').srcObject = stream
|
|
1882
|
-
return tile
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
function _removeTile(tileId) {
|
|
1886
|
-
tileGridEl?.querySelector(`[data-peer="${tileId}"]`)?.remove()
|
|
1887
|
-
_updateTileLayout()
|
|
1888
|
-
}
|
|
1889
|
-
|
|
1890
|
-
function _updateTileLayout() {
|
|
1891
|
-
if (!tileGridEl) return
|
|
1892
|
-
const count = tileGridEl.querySelectorAll('.stream-tile').length
|
|
1893
|
-
tileGridEl.classList.toggle('avatars-only', count >= 5)
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
function _pinTile(tileId) {
|
|
1897
|
-
if (pinnedPeerId === tileId) {
|
|
1898
|
-
tileGridEl?.classList.remove('pinned')
|
|
1899
|
-
tileGridEl?.querySelectorAll('.stream-tile').forEach(t => t.classList.remove('pinned-tile'))
|
|
1900
|
-
pinnedPeerId = null
|
|
1901
|
-
} else {
|
|
1902
|
-
tileGridEl?.classList.add('pinned')
|
|
1903
|
-
tileGridEl?.querySelectorAll('.stream-tile').forEach(t => t.classList.remove('pinned-tile'))
|
|
1904
|
-
tileGridEl?.querySelector(`[data-peer="${tileId}"]`)?.classList.add('pinned-tile')
|
|
1905
|
-
pinnedPeerId = tileId
|
|
1906
|
-
}
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
// ── Device switching ───────────────────────────────────────────────────────
|
|
1910
|
-
|
|
1911
|
-
async function switchCamera(deviceId) {
|
|
1912
|
-
const newStream = await navigator.mediaDevices.getUserMedia({
|
|
1913
|
-
video: { deviceId: { exact: deviceId } },
|
|
1914
|
-
})
|
|
1915
|
-
const newTrack = newStream.getVideoTracks()[0]
|
|
1916
|
-
await rtcManager.replaceTrack('camera', newTrack)
|
|
1917
|
-
|
|
1918
|
-
videoStream?.getTracks().forEach(t => t.stop())
|
|
1919
|
-
videoStream = newStream
|
|
1920
|
-
activeCameraId = deviceId
|
|
1921
|
-
saveDevices({ cameraId: deviceId })
|
|
1922
|
-
|
|
1923
|
-
const localTile = tileGridEl?.querySelector('[data-peer="local-cam"]')
|
|
1924
|
-
if (localTile) localTile.querySelector('video').srcObject = newStream
|
|
1925
|
-
}
|
|
1926
|
-
|
|
1927
|
-
async function switchMic(deviceId) {
|
|
1928
|
-
const newStream = await navigator.mediaDevices.getUserMedia({
|
|
1929
|
-
audio: { deviceId: { exact: deviceId } },
|
|
1930
|
-
})
|
|
1931
|
-
const newTrack = newStream.getAudioTracks()[0]
|
|
1932
|
-
newTrack.enabled = !micMuted()
|
|
1933
|
-
await rtcManager.replaceTrack('audio', newTrack)
|
|
1934
|
-
|
|
1935
|
-
audioStream?.getTracks().forEach(t => t.stop())
|
|
1936
|
-
audioStream = newStream
|
|
1937
|
-
activeMicId = deviceId
|
|
1938
|
-
saveDevices({ micId: deviceId })
|
|
1939
|
-
}
|
|
1940
|
-
|
|
1941
|
-
// ── Device change detection ────────────────────────────────────────────────
|
|
1942
|
-
|
|
1943
|
-
function _onDeviceChange() {
|
|
1944
|
-
refreshDevices().then(({ cameras, mics }) => {
|
|
1945
|
-
const cameraGone = activeCameraId && !cameras.find(d => d.deviceId === activeCameraId)
|
|
1946
|
-
const micGone = activeMicId && !mics.find(d => d.deviceId === activeMicId)
|
|
1947
|
-
if (cameraGone || micGone) _showDeviceWarning(cameraGone ? 'camera' : 'mic')
|
|
1948
|
-
if (devicePickerEl?.classList.contains('open')) _populatePicker()
|
|
1949
|
-
})
|
|
1950
|
-
}
|
|
1951
|
-
|
|
1952
|
-
function _attachDeviceChangeListener() {
|
|
1953
|
-
navigator.mediaDevices.addEventListener('devicechange', _onDeviceChange)
|
|
1954
|
-
}
|
|
1955
|
-
function _detachDeviceChangeListener() {
|
|
1956
|
-
navigator.mediaDevices.removeEventListener('devicechange', _onDeviceChange)
|
|
1957
|
-
}
|
|
1958
|
-
|
|
1959
|
-
// ── Device picker ──────────────────────────────────────────────────────────
|
|
1960
|
-
|
|
1961
|
-
let devicePickerEl = null
|
|
1962
|
-
|
|
1963
|
-
function _buildPicker() {
|
|
1964
|
-
devicePickerEl = document.createElement('div')
|
|
1965
|
-
devicePickerEl.className = 'device-picker'
|
|
1966
|
-
devicePickerEl.innerHTML = `
|
|
1967
|
-
<div class="device-picker-row">
|
|
1968
|
-
<label>Camera</label>
|
|
1969
|
-
<select id="dp-camera"></select>
|
|
1970
|
-
<video id="dp-preview" autoplay playsinline muted></video>
|
|
1971
|
-
</div>
|
|
1972
|
-
<div class="device-picker-row">
|
|
1973
|
-
<label>Microphone</label>
|
|
1974
|
-
<select id="dp-mic"></select>
|
|
1975
|
-
<canvas id="dp-level" width="80" height="12"></canvas>
|
|
1976
|
-
</div>
|
|
1977
|
-
<div class="device-picker-footer">
|
|
1978
|
-
<button id="dp-cancel" class="btn-ghost" type="button">Cancel</button>
|
|
1979
|
-
<button id="dp-apply" class="btn-primary" type="button">Switch</button>
|
|
1980
|
-
</div>
|
|
1981
|
-
`
|
|
1982
|
-
callControlsEl?.after(devicePickerEl)
|
|
1983
|
-
|
|
1984
|
-
devicePickerEl.querySelector('#dp-cancel').addEventListener('click', _closePicker)
|
|
1985
|
-
devicePickerEl.querySelector('#dp-apply').addEventListener('click', _applyPicker)
|
|
1986
|
-
|
|
1987
|
-
const cameraSelect = devicePickerEl.querySelector('#dp-camera')
|
|
1988
|
-
const previewVideo = devicePickerEl.querySelector('#dp-preview')
|
|
1989
|
-
|
|
1990
|
-
cameraSelect.addEventListener('change', async () => {
|
|
1991
|
-
devicePickerEl._previewStream?.getTracks().forEach(t => t.stop())
|
|
1992
|
-
devicePickerEl._previewStream = null
|
|
1993
|
-
if (!cameraSelect.value) return
|
|
1994
|
-
try {
|
|
1995
|
-
const stream = await navigator.mediaDevices.getUserMedia({
|
|
1996
|
-
video: { deviceId: { exact: cameraSelect.value } },
|
|
1997
|
-
})
|
|
1998
|
-
previewVideo.srcObject = stream
|
|
1999
|
-
devicePickerEl._previewStream = stream
|
|
2000
|
-
} catch { /* camera unavailable */ }
|
|
2001
|
-
})
|
|
2002
|
-
}
|
|
2003
|
-
|
|
2004
|
-
function _populatePicker() {
|
|
2005
|
-
const { cameras, mics } = availableDevices
|
|
2006
|
-
const cameraSelect = devicePickerEl?.querySelector('#dp-camera')
|
|
2007
|
-
const micSelect = devicePickerEl?.querySelector('#dp-mic')
|
|
2008
|
-
if (cameraSelect) {
|
|
2009
|
-
cameraSelect.innerHTML = cameras
|
|
2010
|
-
.map(d => `<option value="${escHtml(d.deviceId)}"${d.deviceId === activeCameraId ? ' selected' : ''}>${escHtml(d.label || 'Camera')}</option>`)
|
|
2011
|
-
.join('')
|
|
2012
|
-
}
|
|
2013
|
-
if (micSelect) {
|
|
2014
|
-
micSelect.innerHTML = mics
|
|
2015
|
-
.map(d => `<option value="${escHtml(d.deviceId)}"${d.deviceId === activeMicId ? ' selected' : ''}>${escHtml(d.label || 'Microphone')}</option>`)
|
|
2016
|
-
.join('')
|
|
2017
|
-
}
|
|
2018
|
-
}
|
|
2019
|
-
|
|
2020
|
-
async function _openPicker() {
|
|
2021
|
-
if (!devicePickerEl) _buildPicker()
|
|
2022
|
-
await refreshDevices()
|
|
2023
|
-
_populatePicker()
|
|
2024
|
-
devicePickerEl.classList.add('open')
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
|
-
function _closePicker() {
|
|
2028
|
-
devicePickerEl?._previewStream?.getTracks().forEach(t => t.stop())
|
|
2029
|
-
if (devicePickerEl) devicePickerEl._previewStream = null
|
|
2030
|
-
devicePickerEl?.classList.remove('open')
|
|
2031
|
-
}
|
|
2032
|
-
|
|
2033
|
-
async function _applyPicker() {
|
|
2034
|
-
const cameraId = devicePickerEl?.querySelector('#dp-camera')?.value
|
|
2035
|
-
const micId = devicePickerEl?.querySelector('#dp-mic')?.value
|
|
2036
|
-
try {
|
|
2037
|
-
if (cameraId && cameraId !== activeCameraId && videoStream) await switchCamera(cameraId)
|
|
2038
|
-
if (micId && micId !== activeMicId) await switchMic(micId)
|
|
2039
|
-
ctrlDevices?.classList.remove('device-warning')
|
|
2040
|
-
} catch { /* device unavailable — leave current stream in place */ }
|
|
2041
|
-
_closePicker()
|
|
2042
|
-
}
|
|
2043
|
-
|
|
2044
|
-
ctrlDevices?.addEventListener('click', () => {
|
|
2045
|
-
devicePickerEl?.classList.contains('open') ? _closePicker() : _openPicker()
|
|
2046
|
-
})
|
|
2047
|
-
|
|
2048
|
-
// ── Device warning toast ───────────────────────────────────────────────────
|
|
2049
|
-
|
|
2050
|
-
function _showDeviceWarning(kind) {
|
|
2051
|
-
const label = kind === 'camera' ? 'Camera' : 'Microphone'
|
|
2052
|
-
const toast = document.createElement('div')
|
|
2053
|
-
toast.className = 'device-warning-toast'
|
|
2054
|
-
toast.textContent = `${label} disconnected — click ⚙ to switch`
|
|
2055
|
-
document.body.appendChild(toast)
|
|
2056
|
-
setTimeout(() => toast.remove(), 6000)
|
|
2057
|
-
ctrlDevices?.classList.add('device-warning')
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
// ── Controls visibility ────────────────────────────────────────────────────
|
|
2061
|
-
|
|
2062
|
-
function _showCallControls() {
|
|
2063
|
-
callStatusEl && (callStatusEl.hidden = true)
|
|
2064
|
-
callControlsEl?.classList.add('active')
|
|
2065
|
-
if (btnStartCall) btnStartCall.hidden = true
|
|
2066
|
-
}
|
|
2067
|
-
|
|
2068
|
-
function _hideCallControls() {
|
|
2069
|
-
callControlsEl?.classList.remove('active')
|
|
2070
|
-
if (btnStartCall) btnStartCall.hidden = false
|
|
2071
|
-
}
|
|
2072
|
-
|
|
2073
|
-
// ── Tile panel show / hide ─────────────────────────────────────────────────
|
|
2074
|
-
|
|
2075
|
-
const LAYOUT_KEY = 'devchitchat_tile_layout'
|
|
2076
|
-
|
|
2077
|
-
function _showTilePanel() {
|
|
2078
|
-
document.querySelector('.main-content')?.classList.add('has-call')
|
|
2079
|
-
tilePanelEl?.classList.add('active')
|
|
2080
|
-
}
|
|
2081
|
-
|
|
2082
|
-
function _hideTilePanel() {
|
|
2083
|
-
document.querySelector('.main-content')?.classList.remove('has-call')
|
|
2084
|
-
tilePanelEl?.classList.remove('active')
|
|
2085
|
-
tilePanelEl?.classList.remove('collapsed')
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
|
-
// Restore collapse state from localStorage
|
|
2089
|
-
try {
|
|
2090
|
-
const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
|
|
2091
|
-
if (saved.collapsed) tilePanelEl?.classList.add('collapsed')
|
|
2092
|
-
if (saved.overlayRight && saved.overlayTop && tilePanelEl) {
|
|
2093
|
-
tilePanelEl.style.right = saved.overlayRight
|
|
2094
|
-
tilePanelEl.style.top = saved.overlayTop
|
|
2095
|
-
}
|
|
2096
|
-
} catch { /* ignore */ }
|
|
2097
|
-
|
|
2098
|
-
// Collapse toggle
|
|
2099
|
-
document.getElementById('tile-panel-collapse')?.addEventListener('click', () => {
|
|
2100
|
-
const collapsed = tilePanelEl?.classList.toggle('collapsed')
|
|
2101
|
-
try {
|
|
2102
|
-
const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
|
|
2103
|
-
localStorage.setItem(LAYOUT_KEY, JSON.stringify({ ...saved, collapsed: !!collapsed }))
|
|
2104
|
-
} catch { /* ignore */ }
|
|
2105
|
-
})
|
|
2106
|
-
|
|
2107
|
-
// Overlay drag (mobile only)
|
|
2108
|
-
;(function _attachOverlayDrag(panel) {
|
|
2109
|
-
if (!panel) return
|
|
2110
|
-
if (window.matchMedia('(min-width: 1025px)').matches) return
|
|
2111
|
-
|
|
2112
|
-
const header = panel.querySelector('.tile-panel-header')
|
|
2113
|
-
if (!header) return
|
|
2114
|
-
|
|
2115
|
-
let startX, startY, startRight, startTop
|
|
2116
|
-
|
|
2117
|
-
function onMove(e) {
|
|
2118
|
-
e.preventDefault() // stop page scroll while dragging the tile panel
|
|
2119
|
-
const clientX = e.touches ? e.touches[0].clientX : e.clientX
|
|
2120
|
-
const clientY = e.touches ? e.touches[0].clientY : e.clientY
|
|
2121
|
-
const dx = startX - clientX
|
|
2122
|
-
const dy = clientY - startY
|
|
2123
|
-
const newRight = Math.max(0, Math.min(startRight + dx, window.innerWidth - 60))
|
|
2124
|
-
const newTop = Math.max(0, Math.min(startTop + dy, window.innerHeight - 60))
|
|
2125
|
-
panel.style.right = newRight + 'px'
|
|
2126
|
-
panel.style.top = newTop + 'px'
|
|
2127
|
-
}
|
|
2128
|
-
|
|
2129
|
-
function onEnd() {
|
|
2130
|
-
document.removeEventListener('mousemove', onMove)
|
|
2131
|
-
document.removeEventListener('mouseup', onEnd)
|
|
2132
|
-
document.removeEventListener('touchmove', onMove)
|
|
2133
|
-
document.removeEventListener('touchend', onEnd)
|
|
2134
|
-
try {
|
|
2135
|
-
const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
|
|
2136
|
-
localStorage.setItem(LAYOUT_KEY, JSON.stringify({
|
|
2137
|
-
...saved,
|
|
2138
|
-
overlayRight: panel.style.right,
|
|
2139
|
-
overlayTop: panel.style.top,
|
|
2140
|
-
}))
|
|
2141
|
-
} catch { /* ignore */ }
|
|
2142
|
-
}
|
|
2143
|
-
|
|
2144
|
-
header.addEventListener('mousedown', e => {
|
|
2145
|
-
startX = e.clientX; startY = e.clientY
|
|
2146
|
-
startRight = parseInt(panel.style.right) || 0
|
|
2147
|
-
startTop = parseInt(panel.style.top) || 0
|
|
2148
|
-
document.addEventListener('mousemove', onMove)
|
|
2149
|
-
document.addEventListener('mouseup', onEnd)
|
|
2150
|
-
})
|
|
2151
|
-
|
|
2152
|
-
header.addEventListener('touchstart', e => {
|
|
2153
|
-
e.preventDefault() // prevent scroll from starting on the drag handle
|
|
2154
|
-
startX = e.touches[0].clientX; startY = e.touches[0].clientY
|
|
2155
|
-
startRight = parseInt(panel.style.right) || 0
|
|
2156
|
-
startTop = parseInt(panel.style.top) || 0
|
|
2157
|
-
document.addEventListener('touchmove', onMove, { passive: false })
|
|
2158
|
-
document.addEventListener('touchend', onEnd)
|
|
2159
|
-
}, { passive: false })
|
|
2160
|
-
})(tilePanelEl)
|
|
2161
|
-
|
|
2162
|
-
// ── Mini-bar (persists while navigating away during a call) ───────────────
|
|
2163
|
-
|
|
2164
|
-
function _showMiniBar() {
|
|
2165
|
-
if (!miniBarEl) return
|
|
2166
|
-
if (miniBarName) miniBarName.textContent = channelName()
|
|
2167
|
-
miniBarEl.classList.add('active')
|
|
2168
|
-
}
|
|
2169
|
-
|
|
2170
|
-
function _hideMiniBar() {
|
|
2171
|
-
miniBarEl?.classList.remove('active')
|
|
2172
|
-
}
|
|
2173
|
-
|
|
2174
|
-
ctrlMic?.addEventListener('click', toggleMic)
|
|
2175
|
-
ctrlCam?.addEventListener('click', toggleCamera)
|
|
2176
|
-
ctrlScreen?.addEventListener('click', toggleScreen)
|
|
2177
|
-
miniBarMic?.addEventListener('click', toggleMic)
|
|
2178
|
-
|
|
2179
|
-
miniBarReturn?.addEventListener('click', () => {
|
|
2180
|
-
if (callChannelId) navigateTo(`${window.__BASE_PATH__}/channels/${callChannelId}`, false)
|
|
2181
|
-
})
|
|
2182
|
-
|
|
2183
|
-
miniBarLeave?.addEventListener('click', () => {
|
|
2184
|
-
leaveCall()
|
|
2185
|
-
})
|
|
2186
|
-
|
|
2187
|
-
// Show mini-bar when user navigates to a different channel while in a call
|
|
2188
|
-
document.addEventListener('channelnavigated', (e) => {
|
|
2189
|
-
if (inCall() && e.detail?.channelId !== channelId) {
|
|
2190
|
-
_showMiniBar()
|
|
2191
|
-
}
|
|
2192
|
-
})
|
|
2193
|
-
|
|
2194
|
-
// SPA navigation: router morphed .chat-panel and dispatched this event.
|
|
2195
|
-
// Re-initialise chat state for the new channel without touching RTC.
|
|
2196
|
-
document.addEventListener('chatpanel:navigated', (e) => {
|
|
2197
|
-
const { channelId: newId, name, topic, kind, seedSeq: newSeedSeq, seedFirstSeq: newFirstSeq, seedHasMore: newHasMore } = e.detail
|
|
2198
|
-
|
|
2199
|
-
if (composeOpen && newId !== channelId) closeComposeMode()
|
|
2200
|
-
|
|
2201
|
-
// Morph strips dynamically-added content (reactions, attachments, timestamps, etc.)
|
|
2202
|
-
// but may preserve data-hydrated="1". Always clear and re-hydrate.
|
|
2203
|
-
for (const a of messages.querySelectorAll('article.message[data-hydrated]')) {
|
|
2204
|
-
delete a.dataset.hydrated
|
|
2205
|
-
}
|
|
2206
|
-
hydrateSeedMessages()
|
|
2207
|
-
|
|
2208
|
-
if (newId === channelId) return // same channel — re-hydrate only, no WS channel change
|
|
2209
|
-
|
|
2210
|
-
// Leave old channel subscription on the server
|
|
2211
|
-
ws.send({ t: 'channel.leave', body: { channel_id: channelId } })
|
|
2212
|
-
|
|
2213
|
-
// Update local identity
|
|
2214
|
-
channelId = newId
|
|
2215
|
-
channelKind = kind
|
|
2216
|
-
channelName.set(name)
|
|
2217
|
-
channelTopic.set(topic)
|
|
2218
|
-
afterSeq = newSeedSeq
|
|
2219
|
-
|
|
2220
|
-
// Reset pagination state for new channel
|
|
2221
|
-
oldestSeq = newFirstSeq ?? 0
|
|
2222
|
-
loadingMore = false
|
|
2223
|
-
if (sentinelEl) {
|
|
2224
|
-
sentinelEl.hidden = !newHasMore
|
|
2225
|
-
if (newHasMore) loadMoreObserver.observe(sentinelEl)
|
|
2226
|
-
else loadMoreObserver.unobserve(sentinelEl)
|
|
2227
|
-
}
|
|
2228
|
-
|
|
2229
|
-
// Update browser chrome
|
|
2230
|
-
document.title = `#${name} — devchitchat`
|
|
2231
|
-
const textarea = root.querySelector('#message-input')
|
|
2232
|
-
if (textarea) textarea.placeholder = `Message in ${name}`
|
|
2233
|
-
|
|
2234
|
-
closePicker()
|
|
2235
|
-
|
|
2236
|
-
// Join new channel — server responds with channel.joined + rtc.call_state.
|
|
2237
|
-
// channel.joined handler sends msg.list if afterSeq > 0, which will append
|
|
2238
|
-
// any messages that arrived after the seed snapshot.
|
|
2239
|
-
ws.send({ t: 'channel.join', body: { channel_id: channelId } })
|
|
2240
|
-
})
|
|
2241
|
-
|
|
2242
|
-
// ── Teardown ───────────────────────────────────────────────────────────────
|
|
2243
|
-
|
|
2244
|
-
function _teardownCall() {
|
|
2245
|
-
rtcManager.teardown()
|
|
2246
|
-
|
|
2247
|
-
audioStream?.getTracks().forEach(t => t.stop()); audioStream = null
|
|
2248
|
-
videoStream?.getTracks().forEach(t => t.stop()); videoStream = null
|
|
2249
|
-
screenStream?.getTracks().forEach(t => t.stop()); screenStream = null
|
|
2250
|
-
|
|
2251
|
-
document.querySelectorAll('audio[data-peer-id]').forEach(a => { a.srcObject = null; a.remove() })
|
|
2252
|
-
if (tileGridEl) tileGridEl.innerHTML = ''
|
|
2253
|
-
_updateTileLayout()
|
|
2254
|
-
_hideCallControls()
|
|
2255
|
-
_hideTilePanel()
|
|
2256
|
-
_hideMiniBar()
|
|
2257
|
-
_closePicker()
|
|
2258
|
-
_detachDeviceChangeListener()
|
|
2259
|
-
ctrlDevices?.classList.remove('device-warning')
|
|
2260
|
-
|
|
2261
|
-
micMuted.set(false)
|
|
2262
|
-
camOff.set(false)
|
|
2263
|
-
screenSharing.set(false)
|
|
2264
|
-
inCall.set(false)
|
|
2265
|
-
selfPeerId.set(null)
|
|
2266
|
-
callChannelId = null
|
|
2267
|
-
pinnedPeerId = null
|
|
2268
|
-
activeCameraId = null
|
|
2269
|
-
activeMicId = null
|
|
2270
|
-
}
|
|
2271
|
-
|
|
2272
|
-
// ── Mobile back button ─────────────────────────────────────────────────────
|
|
2273
|
-
|
|
2274
|
-
root.querySelector('.btn-back-mobile')?.addEventListener('click', () => {
|
|
2275
|
-
document.body.classList.add('sidebar-open')
|
|
2276
|
-
patchSettings({ mobile_chat_open: false })
|
|
2277
|
-
})
|
|
2278
|
-
|
|
2279
|
-
// ── Exports (rdbljs bindings) ──────────────────────────────────────────────
|
|
2280
|
-
|
|
2281
|
-
return { draft, channelName, channelTopic, urgentMode, urgentClass, sendMessage, handleComposerKey, toggleUrgentMode, toggleComposeMode }
|
|
2282
|
-
}
|