@devchitchat/chat 4.4.2 → 4.4.4
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/package.json +2 -2
- package/pages/public/client/islands/call.js +81 -3
- package/pages/public/client/shared/messages.js +9 -6
- package/pages/public/themes/base.css +409 -259
- package/pages/public/themes/dark.css +37 -16
- package/pages/public/themes/forest.css +13 -3
- package/pages/public/themes/light.css +37 -17
- package/pages/public/themes/ocean.css +13 -3
- package/pages/public/themes/rose.css +13 -3
- package/src/services/MessageService.js +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devchitchat/chat",
|
|
3
|
-
"version": "4.4.
|
|
3
|
+
"version": "4.4.4",
|
|
4
4
|
"description": "A small chat app. p2p video and screenshare.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "bun --watch index.js",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"styles.css"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@devchitchat/index97": "^3.
|
|
32
|
+
"@devchitchat/index97": "^3.3.0",
|
|
33
33
|
"@devchitchat/rdbljs": "^2.0.1"
|
|
34
34
|
},
|
|
35
35
|
"main": "index.js",
|
|
@@ -460,11 +460,13 @@ export default function CallIsland(root) {
|
|
|
460
460
|
|
|
461
461
|
ws.on('msg.deleted', ({ msg_id }) => {
|
|
462
462
|
const article = messages.querySelector(`[data-msg-id="${msg_id}"]`)
|
|
463
|
+
?? threadRepliesEl?.querySelector(`[data-msg-id="${msg_id}"]`)
|
|
463
464
|
if (article) article.remove()
|
|
464
465
|
})
|
|
465
466
|
|
|
466
467
|
ws.on('msg.edited', ({ msg_id, text, edited_at, rendered_text }) => {
|
|
467
468
|
const article = messages.querySelector(`[data-msg-id="${msg_id}"]`)
|
|
469
|
+
?? threadRepliesEl?.querySelector(`[data-msg-id="${msg_id}"]`)
|
|
468
470
|
if (!article) return
|
|
469
471
|
const textEl = article.querySelector('.message-text')
|
|
470
472
|
if (textEl) { textEl.innerHTML = sanitizeHtml(rendered_text); enableTaskCheckboxes(article) }
|
|
@@ -483,7 +485,9 @@ export default function CallIsland(root) {
|
|
|
483
485
|
})
|
|
484
486
|
|
|
485
487
|
ws.on('reaction.event', ({ msg_id, reactions }) => {
|
|
488
|
+
// Check both the main message list and the open thread panel
|
|
486
489
|
const article = messages.querySelector(`[data-msg-id="${msg_id}"]`)
|
|
490
|
+
?? threadRepliesEl?.querySelector(`[data-msg-id="${msg_id}"]`)
|
|
487
491
|
if (article) renderReactionBar(article, reactions ?? [], msg_id)
|
|
488
492
|
})
|
|
489
493
|
|
|
@@ -589,7 +593,9 @@ export default function CallIsland(root) {
|
|
|
589
593
|
return
|
|
590
594
|
}
|
|
591
595
|
for (const reply of replies) {
|
|
592
|
-
const article = makeMessageEl(reply, { userId, userHandle })
|
|
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)
|
|
593
599
|
threadRepliesEl.appendChild(article)
|
|
594
600
|
}
|
|
595
601
|
if (threadBodyEl) threadBodyEl.scrollTop = threadBodyEl.scrollHeight
|
|
@@ -611,7 +617,8 @@ export default function CallIsland(root) {
|
|
|
611
617
|
if (activeThreadParentId === parent_msg_id && threadRepliesEl) {
|
|
612
618
|
const emptyEl = threadRepliesEl.querySelector('.thread-empty')
|
|
613
619
|
if (emptyEl) emptyEl.remove()
|
|
614
|
-
const article = makeMessageEl(reply, { userId, userHandle })
|
|
620
|
+
const article = makeMessageEl(reply, { userId, userHandle, isThreadReply: true })
|
|
621
|
+
renderQuickPicks(article.querySelector('.message-hover-actions'))
|
|
615
622
|
threadRepliesEl.appendChild(article)
|
|
616
623
|
if (threadBodyEl) threadBodyEl.scrollTop = threadBodyEl.scrollHeight
|
|
617
624
|
}
|
|
@@ -1101,6 +1108,11 @@ export default function CallIsland(root) {
|
|
|
1101
1108
|
for (const toolbar of messages.querySelectorAll('.message-hover-actions')) {
|
|
1102
1109
|
renderQuickPicks(toolbar)
|
|
1103
1110
|
}
|
|
1111
|
+
if (threadRepliesEl) {
|
|
1112
|
+
for (const toolbar of threadRepliesEl.querySelectorAll('.message-hover-actions')) {
|
|
1113
|
+
renderQuickPicks(toolbar)
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1104
1116
|
}
|
|
1105
1117
|
|
|
1106
1118
|
let emojiPickerEl = null
|
|
@@ -1251,6 +1263,7 @@ export default function CallIsland(root) {
|
|
|
1251
1263
|
|
|
1252
1264
|
document.addEventListener('keydown', e => {
|
|
1253
1265
|
if (e.key === 'Escape') {
|
|
1266
|
+
if (activeEditCancel) { activeEditCancel(); return }
|
|
1254
1267
|
if (composeOpen) { closeComposeMode(); return }
|
|
1255
1268
|
closeEmojiPicker()
|
|
1256
1269
|
}
|
|
@@ -1323,6 +1336,66 @@ export default function CallIsland(root) {
|
|
|
1323
1336
|
openEmojiPickerAt(btn, msgId)
|
|
1324
1337
|
})
|
|
1325
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
|
+
|
|
1326
1399
|
// ── Mobile long-press → action sheet with emoji picker ────────────────────
|
|
1327
1400
|
|
|
1328
1401
|
addLongPress(messages, (e) => {
|
|
@@ -1356,6 +1429,8 @@ export default function CallIsland(root) {
|
|
|
1356
1429
|
|
|
1357
1430
|
// ── Inline edit ────────────────────────────────────────────────────────────
|
|
1358
1431
|
|
|
1432
|
+
let activeEditCancel = null
|
|
1433
|
+
|
|
1359
1434
|
function startInlineEdit(article) {
|
|
1360
1435
|
if (article.querySelector('.message-edit-wrap')) return
|
|
1361
1436
|
const textEl = article.querySelector('.message-text')
|
|
@@ -1420,13 +1495,16 @@ export default function CallIsland(root) {
|
|
|
1420
1495
|
})
|
|
1421
1496
|
|
|
1422
1497
|
function cancel() {
|
|
1498
|
+
if (!wrap.parentNode) return
|
|
1423
1499
|
wrap.replaceWith(textEl)
|
|
1500
|
+
activeEditCancel = null
|
|
1424
1501
|
}
|
|
1425
1502
|
|
|
1503
|
+
activeEditCancel = cancel
|
|
1504
|
+
|
|
1426
1505
|
function save() {
|
|
1427
1506
|
const newText = textarea.value.trim()
|
|
1428
1507
|
if (!newText || newText === rawText) { cancel(); return }
|
|
1429
|
-
textEl.textContent = newText
|
|
1430
1508
|
cancel()
|
|
1431
1509
|
article.dataset.rawText = newText
|
|
1432
1510
|
ws.send({ t: 'msg.edit', body: { msg_id: article.dataset.msgId, channel_id: channelId, text: newText } })
|
|
@@ -38,9 +38,9 @@ export function makeDateSeparator(dateKey) {
|
|
|
38
38
|
*/
|
|
39
39
|
// Combined regex (operates on raw text before HTML-escaping):
|
|
40
40
|
// group 1 (+ inner 2, 3) — markdown link: [text](url)
|
|
41
|
-
// group 4 — bare https?:// URL (excludes []() so it can't swallow
|
|
41
|
+
// group 4 — bare https?:// URL (excludes []()* so it can't swallow markdown bold/link syntax)
|
|
42
42
|
// group 5 — @mention
|
|
43
|
-
const INLINE_RE = /(\[([^\]]*)\]\((https?:\/\/[^)]+)\))|(https?:\/\/[^\s<>"'[\]()]+)|(@[a-zA-Z0-9_.-]+)/g
|
|
43
|
+
const INLINE_RE = /(\[([^\]]*)\]\((https?:\/\/[^)]+)\))|(https?:\/\/[^\s<>"'[\]()*]+)|(@[a-zA-Z0-9_.-]+)/g
|
|
44
44
|
|
|
45
45
|
export function renderText(text, { userHandle } = {}) {
|
|
46
46
|
let result = ''
|
|
@@ -54,7 +54,7 @@ export function renderText(text, { userHandle } = {}) {
|
|
|
54
54
|
// [link text](https://url)
|
|
55
55
|
result += `<a href="${escHtml(mdUrl)}" target="_blank" rel="noopener noreferrer">${escHtml(mdText)}</a>`
|
|
56
56
|
} else if (bareUrl) {
|
|
57
|
-
const trimmed = bareUrl.replace(/[.,!?;:)]+$/, '')
|
|
57
|
+
const trimmed = bareUrl.replace(/[.,!?;:)*]+$/, '')
|
|
58
58
|
const trailing = bareUrl.slice(trimmed.length)
|
|
59
59
|
result += `<a href="${escHtml(trimmed)}" target="_blank" rel="noopener noreferrer">${escHtml(trimmed)}</a>${escHtml(trailing)}`
|
|
60
60
|
} else if (mention) {
|
|
@@ -120,9 +120,10 @@ export function renderAttachment(a) {
|
|
|
120
120
|
/**
|
|
121
121
|
* Build a <article class="message"> element.
|
|
122
122
|
* @param {{ msg_id, seq, user_id, user_display_name, ts, text, attachments }} msg
|
|
123
|
-
* @param {{ userId?: string, userHandle?: string }} [ctx]
|
|
123
|
+
* @param {{ userId?: string, userHandle?: string, isThreadReply?: boolean }} [ctx]
|
|
124
|
+
* isThreadReply — omits the "Reply in thread" button (threads can't be nested)
|
|
124
125
|
*/
|
|
125
|
-
export function makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, text, rendered_text, edited_at, attachments }, { userId, userHandle } = {}) {
|
|
126
|
+
export function makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, text, rendered_text, edited_at, attachments }, { userId, userHandle, isThreadReply = false } = {}) {
|
|
126
127
|
const article = document.createElement('article')
|
|
127
128
|
article.className = 'message'
|
|
128
129
|
article.dataset.seq = seq
|
|
@@ -134,13 +135,15 @@ export function makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, tex
|
|
|
134
135
|
const isSelf = userId != null && user_id === userId
|
|
135
136
|
const attachmentHtml = (attachments ?? []).map(a => renderAttachment(a)).join('')
|
|
136
137
|
const editedHtml = edited_at ? '<span class="message-edited">(edited)</span>' : ''
|
|
137
|
-
const
|
|
138
|
+
const replyBtn = isThreadReply ? '' : '<button class="btn-reply btn-icon" type="button" title="Reply in thread" aria-label="Reply in thread">↩</button>'
|
|
139
|
+
const actionsHtml = `<div class="message-hover-actions"><span class="quick-picks"></span>${replyBtn}<button class="btn-react btn-icon" type="button" title="Add reaction" aria-label="Add reaction">🙂</button>${isSelf ? '<button class="btn-msg-actions btn-icon" type="button" title="Message actions">…</button>' : ''}</div>`
|
|
138
140
|
const textHtml = rendered_text ?? (text ? renderText(text, { userHandle }) : '')
|
|
139
141
|
article.innerHTML = `
|
|
140
142
|
<span class="message-handle${isSelf ? '' : ' dm-trigger'}" data-user-id="${escHtml(user_id)}" title="${isSelf ? '' : 'Send a direct message'}">${escHtml(user_display_name ?? user_id)}</span>
|
|
141
143
|
<time class="message-time" datetime="${ts}">${time}${editedHtml}</time>
|
|
142
144
|
${textHtml ? `<div class="message-text">${textHtml}</div>` : ''}
|
|
143
145
|
${attachmentHtml}
|
|
146
|
+
<div class="reaction-bar"></div>
|
|
144
147
|
${actionsHtml}
|
|
145
148
|
`
|
|
146
149
|
return article
|