@devchitchat/chat 4.3.0 → 4.4.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/migrate/011-message-threads.js +6 -0
- package/package.json +4 -8
- package/pages/channels/[channelId].js +17 -9
- package/pages/channels/[channelId].phtml +19 -1
- package/pages/public/client/islands/call.js +180 -6
- package/pages/public/client/islands/sidebar.js +1 -0
- package/pages/public/client/shared/messages.js +1 -1
- package/pages/public/themes/base.css +133 -5
- package/src/adapters/SqliteMessageRepository.js +34 -8
- package/src/db/initDb.js +3 -0
- package/src/services/MessageService.js +16 -3
- package/src/ws/ChatServer.js +2 -1
- package/src/ws/handlers/authHandlers.js +8 -1
- package/src/ws/handlers/messageHandlers.js +28 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devchitchat/chat",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"description": "A small chat app. p2p video and screenshare.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "bun --watch index.js",
|
|
@@ -8,17 +8,13 @@
|
|
|
8
8
|
"migrate": "bun scripts/migrate.js",
|
|
9
9
|
"test": "bun test",
|
|
10
10
|
"generate-vapid": "bun scripts/generate-vapid.js",
|
|
11
|
-
"create-secrets": "kubectl create secret generic chat-web-vapid --from-env-file=.env
|
|
12
|
-
"docker-build": "./docker-build-k3s.sh",
|
|
13
|
-
"local-deploy": "kubectl config use-context ${KUBE_CONTEXT:-k3s-local} && kubectl apply -f charts/web/templates/deployment.yaml -n ${KUBE_NAMESPACE:-default}",
|
|
11
|
+
"create-secrets": "kubectl --context=k3s-local create secret generic chat-web-vapid --from-env-file=.env -n default --dry-run=client -o yaml | kubectl --context=k3s-local apply -f -",
|
|
14
12
|
"backup": "bun scripts/backup.js",
|
|
15
13
|
"backup-uploads": "bun scripts/backup-uploads.js",
|
|
16
|
-
"
|
|
17
|
-
"backup-now": "kubectl config use-context ${KUBE_CONTEXT:-k3s-local} && kubectl create job --from=cronjob/chat-backup chat-backup-$(date +%s) -n ${KUBE_NAMESPACE:-default}",
|
|
18
|
-
"backup-pull": "kubectl config use-context ${KUBE_CONTEXT:-k3s-local} && kubectl cp $(kubectl get pod -l app=chat-web -o jsonpath='{.items[0].metadata.name}' -n ${KUBE_NAMESPACE:-default}):/var/lib/chat/backups ../backups -n ${KUBE_NAMESPACE:-default}",
|
|
14
|
+
"backup-now": "kubectl --context=k3s-local create job --from=cronjob/chat-backup chat-backup-$(date +%s) -n default",
|
|
19
15
|
"restore": "bun scripts/restore.js",
|
|
20
16
|
"restore-uploads": "bun scripts/restore-uploads.js",
|
|
21
|
-
"push": "
|
|
17
|
+
"push": "infra push",
|
|
22
18
|
"purge-cf-cache": "bun scripts/purge-cf-cache.js"
|
|
23
19
|
},
|
|
24
20
|
"type": "module",
|
|
@@ -49,6 +49,9 @@ export async function GET(req) {
|
|
|
49
49
|
const seedMessages = reactionService
|
|
50
50
|
? reactionService.enrichWithReactions({ messages: rawSeedMessages, requestingUserId: user.user_id })
|
|
51
51
|
: rawSeedMessages
|
|
52
|
+
|
|
53
|
+
const seedMsgIds = rawSeedMessages.map(m => m.msg_id)
|
|
54
|
+
const replyCounts = messageService.getReplyCountsForMessages({ msgIds: seedMsgIds })
|
|
52
55
|
const seedSeq = seedMessages.length ? seedMessages[seedMessages.length - 1].seq : 0
|
|
53
56
|
const seedFirstSeq = seedMessages.length ? seedMessages[0].seq : 0
|
|
54
57
|
const seedHasMore = seedFirstSeq > 1
|
|
@@ -84,15 +87,20 @@ export async function GET(req) {
|
|
|
84
87
|
base: BASE_PATH,
|
|
85
88
|
seedFirstSeq,
|
|
86
89
|
seedHasMore,
|
|
87
|
-
seedMessages: seedMessages.map(m =>
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
90
|
+
seedMessages: seedMessages.map(m => {
|
|
91
|
+
const replyCount = replyCounts[m.msg_id] ?? 0
|
|
92
|
+
return {
|
|
93
|
+
...m,
|
|
94
|
+
raw_text: m.text,
|
|
95
|
+
text: sanitizeForFrontEnd(renderMarkdown(m.text).html),
|
|
96
|
+
ts_fmt: new Date(m.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
|
97
|
+
attachments_json: m.attachments?.length ? JSON.stringify(m.attachments) : '',
|
|
98
|
+
reactions_json: m.reactions?.length ? JSON.stringify(m.reactions) : '',
|
|
99
|
+
edited_at: m.edited_at ?? '',
|
|
100
|
+
reply_count: replyCount,
|
|
101
|
+
reply_count_label: replyCount > 0 ? `View ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}` : '',
|
|
102
|
+
}
|
|
103
|
+
}),
|
|
96
104
|
seedSeq,
|
|
97
105
|
hubs: hubsWithChannels,
|
|
98
106
|
}
|
|
@@ -135,10 +135,11 @@
|
|
|
135
135
|
<div class="messages" id="messages" aria-live="polite" aria-label="Messages" role="log">
|
|
136
136
|
<div class="load-more-sentinel" id="load-more-sentinel" aria-hidden="true" hidden></div>
|
|
137
137
|
{{#each seedMessages}}
|
|
138
|
-
<article class="message" data-seq="{{seq}}" data-msg-id="{{msg_id}}" data-key="message_id" data-user-id="{{user_id}}" data-raw-text="{{raw_text}}" data-edited-at="{{edited_at}}" data-attachments="{{attachments_json}}" data-reactions="{{reactions_json}}">
|
|
138
|
+
<article class="message" data-seq="{{seq}}" data-msg-id="{{msg_id}}" data-key="message_id" data-user-id="{{user_id}}" data-raw-text="{{raw_text}}" data-edited-at="{{edited_at}}" data-attachments="{{attachments_json}}" data-reactions="{{reactions_json}}" data-reply-count="{{reply_count}}">
|
|
139
139
|
<span class="message-handle" data-user-id="{{user_id}}">{{user_display_name}}</span>
|
|
140
140
|
<time class="message-time" datetime="{{ts}}">{{ts_fmt}}{{#if edited_at}}<span class="message-edited">(edited)</span>{{/if}}</time>
|
|
141
141
|
<div class="message-text">{{{text}}}</div>
|
|
142
|
+
{{#if reply_count_label}}<a class="thread-replies-link" href="#" data-msg-id="{{msg_id}}">{{reply_count_label}}</a>{{/if}}
|
|
142
143
|
<div class="reaction-bar"></div>
|
|
143
144
|
</article>
|
|
144
145
|
{{/each}}
|
|
@@ -199,4 +200,21 @@
|
|
|
199
200
|
<div class="tile-grid" id="tile-grid"></div>
|
|
200
201
|
</div>
|
|
201
202
|
|
|
203
|
+
<!-- Thread panel: absolute overlay on the right, shown when a thread is open -->
|
|
204
|
+
<div class="thread-panel" id="thread-panel">
|
|
205
|
+
<div class="thread-panel-header">
|
|
206
|
+
<span class="thread-panel-title">Thread</span>
|
|
207
|
+
<button class="btn-icon thread-panel-close" id="thread-panel-close" aria-label="Close thread" type="button">✕</button>
|
|
208
|
+
</div>
|
|
209
|
+
<div class="thread-body">
|
|
210
|
+
<div class="thread-anchor" id="thread-anchor"></div>
|
|
211
|
+
<div class="thread-divider"><span>Replies</span></div>
|
|
212
|
+
<div class="thread-replies" id="thread-replies" role="log" aria-label="Thread replies"></div>
|
|
213
|
+
</div>
|
|
214
|
+
<footer class="thread-composer">
|
|
215
|
+
<textarea class="thread-textarea" id="thread-input" placeholder="Reply in thread…" rows="2" aria-label="Reply in thread"></textarea>
|
|
216
|
+
<button class="btn-send thread-send-btn" id="thread-send" type="button">Reply</button>
|
|
217
|
+
</footer>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
202
220
|
</div><!-- /.main-content -->
|
|
@@ -72,9 +72,10 @@ export default function CallIsland(root) {
|
|
|
72
72
|
// ── @mention picker state ──────────────────────────────────────────────────
|
|
73
73
|
let channelMembers = [] // [{ user_id, handle, display_name }] — non-bot users
|
|
74
74
|
let channelBots = [] // [{ user_id, handle, display_name }] — bot users
|
|
75
|
-
let mentionFiltered
|
|
76
|
-
let mentionStart
|
|
77
|
-
let mentionSelIdx
|
|
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
|
|
78
79
|
|
|
79
80
|
// ── Call state ─────────────────────────────────────────────────────────────
|
|
80
81
|
const inCall = signal(false)
|
|
@@ -168,6 +169,13 @@ export default function CallIsland(root) {
|
|
|
168
169
|
const quickPicks = document.createElement('span')
|
|
169
170
|
quickPicks.className = 'quick-picks'
|
|
170
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)
|
|
171
179
|
const reactBtn = document.createElement('button')
|
|
172
180
|
reactBtn.className = 'btn-react btn-icon'
|
|
173
181
|
reactBtn.type = 'button'
|
|
@@ -187,6 +195,22 @@ export default function CallIsland(root) {
|
|
|
187
195
|
renderQuickPicks(toolbar)
|
|
188
196
|
}
|
|
189
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
|
+
|
|
190
214
|
// Apply inline rendering (URLs, @mentions) to server-rendered message text.
|
|
191
215
|
// Walk text nodes instead of replacing innerHTML so that <a> tags already
|
|
192
216
|
// rendered server-side (e.g. from markdown link syntax) are preserved.
|
|
@@ -328,13 +352,13 @@ export default function CallIsland(root) {
|
|
|
328
352
|
|
|
329
353
|
function selectMention(member) {
|
|
330
354
|
if (!member) return
|
|
331
|
-
const textarea = root.querySelector('#message-input')
|
|
355
|
+
const textarea = activeMentionTextarea ?? root.querySelector('#message-input')
|
|
332
356
|
if (!textarea) return
|
|
333
357
|
const cursor = textarea.selectionStart
|
|
334
358
|
const val = textarea.value
|
|
335
359
|
const insert = `@${member.handle} `
|
|
336
360
|
textarea.value = val.substring(0, mentionStart) + insert + val.substring(cursor)
|
|
337
|
-
draft.set(textarea.value)
|
|
361
|
+
if (textarea.id === 'message-input') draft.set(textarea.value)
|
|
338
362
|
const pos = mentionStart + insert.length
|
|
339
363
|
textarea.setSelectionRange(pos, pos)
|
|
340
364
|
closePicker()
|
|
@@ -368,7 +392,13 @@ export default function CallIsland(root) {
|
|
|
368
392
|
openPicker(filtered, start)
|
|
369
393
|
}
|
|
370
394
|
|
|
371
|
-
root.querySelector('#message-input')
|
|
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
|
+
})
|
|
372
402
|
|
|
373
403
|
// ── Chat: connect + join channel ───────────────────────────────────────────
|
|
374
404
|
|
|
@@ -423,6 +453,7 @@ export default function CallIsland(root) {
|
|
|
423
453
|
|
|
424
454
|
ws.on('msg.event', (body) => {
|
|
425
455
|
if (body.channel_id !== channelId) return
|
|
456
|
+
if (body.parent_msg_id) return // thread reply — handled via thread.reply_event
|
|
426
457
|
appendMessage(body)
|
|
427
458
|
afterSeq = body.seq
|
|
428
459
|
})
|
|
@@ -463,6 +494,149 @@ export default function CallIsland(root) {
|
|
|
463
494
|
document.title = `#${body.channel.name} — devchitchat`
|
|
464
495
|
})
|
|
465
496
|
|
|
497
|
+
// ── Thread panel ───────────────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
const threadPanelEl = document.getElementById('thread-panel')
|
|
500
|
+
const threadBodyEl = threadPanelEl?.querySelector('.thread-body')
|
|
501
|
+
const threadAnchorEl = document.getElementById('thread-anchor')
|
|
502
|
+
const threadRepliesEl = document.getElementById('thread-replies')
|
|
503
|
+
const threadInputEl = document.getElementById('thread-input')
|
|
504
|
+
const threadSendBtn = document.getElementById('thread-send')
|
|
505
|
+
const threadCloseBtn = document.getElementById('thread-panel-close')
|
|
506
|
+
|
|
507
|
+
let activeThreadParentId = null
|
|
508
|
+
|
|
509
|
+
function updateReplyCountLink(article, count) {
|
|
510
|
+
let link = article.querySelector('.thread-replies-link')
|
|
511
|
+
if (count > 0) {
|
|
512
|
+
const label = `View ${count} ${count === 1 ? 'reply' : 'replies'}`
|
|
513
|
+
if (!link) {
|
|
514
|
+
link = document.createElement('a')
|
|
515
|
+
link.className = 'thread-replies-link'
|
|
516
|
+
link.href = '#'
|
|
517
|
+
link.dataset.msgId = article.dataset.msgId
|
|
518
|
+
const reactionBar = article.querySelector('.reaction-bar')
|
|
519
|
+
if (reactionBar) article.insertBefore(link, reactionBar)
|
|
520
|
+
else article.appendChild(link)
|
|
521
|
+
}
|
|
522
|
+
link.textContent = label
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function openThread(parentMsgId) {
|
|
527
|
+
activeThreadParentId = parentMsgId
|
|
528
|
+
const parentArticle = messages.querySelector(`[data-msg-id="${parentMsgId}"]`)
|
|
529
|
+
|
|
530
|
+
// Render anchor (clone parent message, strip hover actions)
|
|
531
|
+
if (threadAnchorEl) {
|
|
532
|
+
threadAnchorEl.innerHTML = ''
|
|
533
|
+
if (parentArticle) {
|
|
534
|
+
const clone = parentArticle.cloneNode(true)
|
|
535
|
+
clone.querySelector('.message-hover-actions')?.remove()
|
|
536
|
+
clone.querySelector('.thread-replies-link')?.remove()
|
|
537
|
+
threadAnchorEl.appendChild(clone)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (threadRepliesEl) threadRepliesEl.innerHTML = '<p class="thread-loading">Loading…</p>'
|
|
542
|
+
threadPanelEl?.classList.add('active')
|
|
543
|
+
|
|
544
|
+
ws.send({ t: 'thread.list', body: { parent_msg_id: parentMsgId, channel_id: channelId } })
|
|
545
|
+
|
|
546
|
+
setTimeout(() => threadInputEl?.focus(), 50)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function closeThread() {
|
|
550
|
+
threadPanelEl?.classList.remove('active')
|
|
551
|
+
activeThreadParentId = null
|
|
552
|
+
if (threadAnchorEl) threadAnchorEl.innerHTML = ''
|
|
553
|
+
if (threadRepliesEl) threadRepliesEl.innerHTML = ''
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
threadCloseBtn?.addEventListener('click', closeThread)
|
|
557
|
+
|
|
558
|
+
function sendThreadReply() {
|
|
559
|
+
const text = threadInputEl?.value.trim()
|
|
560
|
+
if (!text || !activeThreadParentId) return
|
|
561
|
+
ws.send({ t: 'msg.send', body: { channel_id: channelId, text, parent_msg_id: activeThreadParentId } })
|
|
562
|
+
if (threadInputEl) threadInputEl.value = ''
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
threadSendBtn?.addEventListener('click', sendThreadReply)
|
|
566
|
+
threadInputEl?.addEventListener('input', handleComposerInput)
|
|
567
|
+
threadInputEl?.addEventListener('focus', () => {
|
|
568
|
+
activeMentionTextarea = threadInputEl
|
|
569
|
+
root.querySelector('.thread-composer')?.prepend(mentionPickerEl)
|
|
570
|
+
})
|
|
571
|
+
threadInputEl?.addEventListener('keydown', e => {
|
|
572
|
+
if (!mentionPickerEl.hidden) {
|
|
573
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); mentionSelIdx = Math.min(mentionSelIdx + 1, mentionFiltered.length - 1); renderPicker(); return }
|
|
574
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); mentionSelIdx = Math.max(mentionSelIdx - 1, 0); renderPicker(); return }
|
|
575
|
+
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); selectMention(mentionFiltered[mentionSelIdx]); return }
|
|
576
|
+
if (e.key === 'Escape') { closePicker(); return }
|
|
577
|
+
}
|
|
578
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendThreadReply() }
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
ws.on('thread.list_result', ({ parent_msg_id, replies }) => {
|
|
582
|
+
if (parent_msg_id !== activeThreadParentId) return
|
|
583
|
+
if (!threadRepliesEl) return
|
|
584
|
+
threadRepliesEl.innerHTML = ''
|
|
585
|
+
if (!replies.length) {
|
|
586
|
+
threadRepliesEl.innerHTML = '<p class="thread-empty">No replies yet. Be the first!</p>'
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
for (const reply of replies) {
|
|
590
|
+
const article = makeMessageEl(reply, { userId, userHandle })
|
|
591
|
+
threadRepliesEl.appendChild(article)
|
|
592
|
+
}
|
|
593
|
+
if (threadBodyEl) threadBodyEl.scrollTop = threadBodyEl.scrollHeight
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
ws.on('thread.reply_event', ({ parent_msg_id, channel_id: evtChannelId, reply }) => {
|
|
597
|
+
if (evtChannelId !== channelId) return
|
|
598
|
+
|
|
599
|
+
// Update reply count on parent message in the channel list
|
|
600
|
+
const parentArticle = messages.querySelector(`[data-msg-id="${parent_msg_id}"]`)
|
|
601
|
+
if (parentArticle) {
|
|
602
|
+
const current = parseInt(parentArticle.dataset.replyCount ?? '0', 10)
|
|
603
|
+
const next = current + 1
|
|
604
|
+
parentArticle.dataset.replyCount = String(next)
|
|
605
|
+
updateReplyCountLink(parentArticle, next)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Append reply to thread panel if it's open for this parent
|
|
609
|
+
if (activeThreadParentId === parent_msg_id && threadRepliesEl) {
|
|
610
|
+
const emptyEl = threadRepliesEl.querySelector('.thread-empty')
|
|
611
|
+
if (emptyEl) emptyEl.remove()
|
|
612
|
+
const article = makeMessageEl(reply, { userId, userHandle })
|
|
613
|
+
threadRepliesEl.appendChild(article)
|
|
614
|
+
if (threadBodyEl) threadBodyEl.scrollTop = threadBodyEl.scrollHeight
|
|
615
|
+
}
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
// Delegated click: reply button → open thread panel
|
|
619
|
+
messages.addEventListener('click', e => {
|
|
620
|
+
const btn = e.target.closest('.btn-reply')
|
|
621
|
+
if (!btn) return
|
|
622
|
+
e.stopPropagation()
|
|
623
|
+
const article = btn.closest('article.message')
|
|
624
|
+
const msgId = article?.dataset.msgId
|
|
625
|
+
if (!msgId) return
|
|
626
|
+
openThread(msgId)
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
// Delegated click: "view N replies" link → open thread panel
|
|
630
|
+
messages.addEventListener('click', e => {
|
|
631
|
+
const link = e.target.closest('.thread-replies-link')
|
|
632
|
+
if (!link) return
|
|
633
|
+
e.preventDefault()
|
|
634
|
+
e.stopPropagation()
|
|
635
|
+
const msgId = link.dataset.msgId
|
|
636
|
+
if (!msgId) return
|
|
637
|
+
openThread(msgId)
|
|
638
|
+
})
|
|
639
|
+
|
|
466
640
|
// ── Chat: composer ─────────────────────────────────────────────────────────
|
|
467
641
|
|
|
468
642
|
// Pending attachments: [{ upload_id, url, original_name, mime_type, size_bytes }]
|
|
@@ -134,7 +134,7 @@ export function makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, tex
|
|
|
134
134
|
const isSelf = userId != null && user_id === userId
|
|
135
135
|
const attachmentHtml = (attachments ?? []).map(a => renderAttachment(a)).join('')
|
|
136
136
|
const editedHtml = edited_at ? '<span class="message-edited">(edited)</span>' : ''
|
|
137
|
-
const actionsHtml = `<div class="message-hover-actions"><span class="quick-picks"></span><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>`
|
|
137
|
+
const actionsHtml = `<div class="message-hover-actions"><span class="quick-picks"></span><button class="btn-reply btn-icon" type="button" title="Reply in thread" aria-label="Reply in thread">↩</button><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
138
|
const textHtml = rendered_text ?? (text ? renderText(text, { userHandle }) : '')
|
|
139
139
|
article.innerHTML = `
|
|
140
140
|
<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>
|
|
@@ -539,7 +539,7 @@ details summary {
|
|
|
539
539
|
.stream-tile video { width: 160px; height: 90px; object-fit: cover; display: block; }
|
|
540
540
|
|
|
541
541
|
/* ── Chat panel ──────────────────────────────────────────────────────────── */
|
|
542
|
-
.chat-panel { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
|
542
|
+
.chat-panel { flex: 1; display: flex; flex-direction: column; min-height: 0; position: relative; }
|
|
543
543
|
.chat-header { padding: 14px 20px; padding-top: calc(14px + env(safe-area-inset-top)); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }
|
|
544
544
|
.chat-title {
|
|
545
545
|
font-size: 1rem;
|
|
@@ -820,14 +820,17 @@ details summary {
|
|
|
820
820
|
.btn-compose-expand:hover { opacity: 1; }
|
|
821
821
|
|
|
822
822
|
/* ── Compose overlay ─────────────────────────────────────────────────────── */
|
|
823
|
+
/* Absolute so it reliably covers the full chat panel,
|
|
824
|
+
independent of the surrounding flex layout. */
|
|
823
825
|
.compose-overlay {
|
|
824
|
-
display:
|
|
826
|
+
display: none;
|
|
825
827
|
flex-direction: column;
|
|
826
|
-
|
|
827
|
-
|
|
828
|
+
position: absolute;
|
|
829
|
+
inset: 0;
|
|
830
|
+
z-index: 20;
|
|
828
831
|
background: var(--bg-base);
|
|
829
832
|
}
|
|
830
|
-
.compose-overlay[hidden] { display:
|
|
833
|
+
.compose-overlay:not([hidden]) { display: flex; }
|
|
831
834
|
|
|
832
835
|
.compose-overlay-header {
|
|
833
836
|
display: flex;
|
|
@@ -1332,6 +1335,131 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
|
|
|
1332
1335
|
/* Pinned tile: move to top, single column always */
|
|
1333
1336
|
.tile-grid.pinned .stream-tile.pinned-tile { order: -1; }
|
|
1334
1337
|
|
|
1338
|
+
/* ── Thread panel ─────────────────────────────────────────────────────────── */
|
|
1339
|
+
|
|
1340
|
+
/* .main-content needs position relative so the absolute thread panel anchors to it */
|
|
1341
|
+
.main-content { position: relative; }
|
|
1342
|
+
|
|
1343
|
+
.thread-panel {
|
|
1344
|
+
display: none;
|
|
1345
|
+
flex-direction: column;
|
|
1346
|
+
background: var(--bg-sidebar);
|
|
1347
|
+
border-left: 1px solid var(--border);
|
|
1348
|
+
position: absolute;
|
|
1349
|
+
top: 0;
|
|
1350
|
+
right: 0;
|
|
1351
|
+
width: 380px;
|
|
1352
|
+
height: 100%;
|
|
1353
|
+
z-index: 30;
|
|
1354
|
+
overflow: hidden;
|
|
1355
|
+
}
|
|
1356
|
+
.thread-panel.active { display: flex; }
|
|
1357
|
+
|
|
1358
|
+
.thread-panel-header {
|
|
1359
|
+
display: flex;
|
|
1360
|
+
align-items: center;
|
|
1361
|
+
padding: 14px 20px;
|
|
1362
|
+
border-bottom: 1px solid var(--border);
|
|
1363
|
+
background: var(--bg-topbar);
|
|
1364
|
+
flex-shrink: 0;
|
|
1365
|
+
}
|
|
1366
|
+
.thread-panel-title {
|
|
1367
|
+
flex: 1;
|
|
1368
|
+
font-size: 14px;
|
|
1369
|
+
font-weight: 600;
|
|
1370
|
+
}
|
|
1371
|
+
.thread-panel-close {
|
|
1372
|
+
font-size: 14px;
|
|
1373
|
+
opacity: 0.7;
|
|
1374
|
+
}
|
|
1375
|
+
.thread-panel-close:hover { opacity: 1; }
|
|
1376
|
+
|
|
1377
|
+
.thread-body {
|
|
1378
|
+
flex: 1;
|
|
1379
|
+
overflow-y: auto;
|
|
1380
|
+
display: flex;
|
|
1381
|
+
flex-direction: column;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
.thread-anchor {
|
|
1385
|
+
padding: 10px 12px;
|
|
1386
|
+
border-bottom: 1px solid var(--border);
|
|
1387
|
+
background: var(--bg-hover, var(--bg-topbar));
|
|
1388
|
+
}
|
|
1389
|
+
/* Hide interactive elements in the anchor clone */
|
|
1390
|
+
.thread-anchor .message-hover-actions { display: none !important; }
|
|
1391
|
+
.thread-anchor .thread-replies-link { display: none !important; }
|
|
1392
|
+
|
|
1393
|
+
.thread-divider {
|
|
1394
|
+
display: flex;
|
|
1395
|
+
align-items: center;
|
|
1396
|
+
gap: 8px;
|
|
1397
|
+
padding: 6px 12px;
|
|
1398
|
+
font-size: 11px;
|
|
1399
|
+
font-weight: 600;
|
|
1400
|
+
color: var(--color-muted);
|
|
1401
|
+
border-bottom: 1px solid var(--border);
|
|
1402
|
+
text-transform: uppercase;
|
|
1403
|
+
letter-spacing: 0.04em;
|
|
1404
|
+
flex-shrink: 0;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
.thread-replies {
|
|
1408
|
+
flex: 1;
|
|
1409
|
+
padding: 4px 0;
|
|
1410
|
+
}
|
|
1411
|
+
.thread-replies .message {
|
|
1412
|
+
padding: 4px 12px;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
.thread-empty,
|
|
1416
|
+
.thread-loading {
|
|
1417
|
+
padding: 24px 16px;
|
|
1418
|
+
color: var(--color-muted);
|
|
1419
|
+
font-size: 13px;
|
|
1420
|
+
text-align: center;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
.thread-composer {
|
|
1424
|
+
display: flex;
|
|
1425
|
+
flex-direction: column;
|
|
1426
|
+
gap: 8px;
|
|
1427
|
+
padding: 10px 12px;
|
|
1428
|
+
border-top: 1px solid var(--border);
|
|
1429
|
+
flex-shrink: 0;
|
|
1430
|
+
}
|
|
1431
|
+
.thread-textarea {
|
|
1432
|
+
width: 100%;
|
|
1433
|
+
box-sizing: border-box;
|
|
1434
|
+
padding: 8px;
|
|
1435
|
+
background: var(--bg-input, var(--bg));
|
|
1436
|
+
border: 1px solid var(--border);
|
|
1437
|
+
border-radius: var(--radius-sm, 4px);
|
|
1438
|
+
color: var(--color-text);
|
|
1439
|
+
font-family: inherit;
|
|
1440
|
+
font-size: 13px;
|
|
1441
|
+
resize: none;
|
|
1442
|
+
min-height: 56px;
|
|
1443
|
+
}
|
|
1444
|
+
.thread-textarea:focus { outline: none; border-color: var(--color-accent); }
|
|
1445
|
+
.thread-send-btn { align-self: flex-end; }
|
|
1446
|
+
|
|
1447
|
+
/* "View N replies" link under messages */
|
|
1448
|
+
.thread-replies-link {
|
|
1449
|
+
display: inline-block;
|
|
1450
|
+
font-size: 12px;
|
|
1451
|
+
color: var(--color-link, var(--color-accent));
|
|
1452
|
+
margin: 2px 0 0;
|
|
1453
|
+
cursor: pointer;
|
|
1454
|
+
text-decoration: none;
|
|
1455
|
+
}
|
|
1456
|
+
.thread-replies-link:hover { text-decoration: underline; }
|
|
1457
|
+
|
|
1458
|
+
/* Mobile: full-width overlay */
|
|
1459
|
+
@media (max-width: 700px) {
|
|
1460
|
+
.thread-panel { width: 100%; }
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1335
1463
|
/* ── Call: status row (shown when a call is active, user hasn't joined) ──── */
|
|
1336
1464
|
.call-status {
|
|
1337
1465
|
display: flex;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { runTransaction } from '../db/transaction.js'
|
|
2
2
|
|
|
3
|
-
const MSG_COLS = `m.msg_id, m.seq, m.user_id, u.display_name AS user_display_name, m.ts, m.text, m.edited_at, m.attachments_json`
|
|
3
|
+
const MSG_COLS = `m.msg_id, m.seq, m.user_id, u.display_name AS user_display_name, m.ts, m.text, m.edited_at, m.attachments_json, m.parent_msg_id`
|
|
4
4
|
|
|
5
5
|
export class SqliteMessageRepository {
|
|
6
6
|
constructor({ db }) {
|
|
@@ -11,14 +11,14 @@ export class SqliteMessageRepository {
|
|
|
11
11
|
* Atomically allocates the next seq, inserts the message and an audit event.
|
|
12
12
|
* Returns { seq }.
|
|
13
13
|
*/
|
|
14
|
-
insertMessage({ msgId, channelId, userId, now, text, clientMsgId, priority = 'normal', attachmentsJson = null }) {
|
|
14
|
+
insertMessage({ msgId, channelId, userId, now, text, clientMsgId, priority = 'normal', attachmentsJson = null, parentMsgId = null }) {
|
|
15
15
|
return runTransaction(this.db, () => {
|
|
16
16
|
const row = this.db.prepare('SELECT MAX(seq) AS max_seq FROM messages WHERE channel_id = ?').get(channelId)
|
|
17
17
|
const seq = (row?.max_seq || 0) + 1
|
|
18
18
|
|
|
19
19
|
this.db.prepare(
|
|
20
|
-
`INSERT INTO messages (msg_id, channel_id, seq, user_id, ts, text, client_msg_id, priority, attachments_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
21
|
-
).run(msgId, channelId, seq, userId, now, text, clientMsgId, priority, attachmentsJson)
|
|
20
|
+
`INSERT INTO messages (msg_id, channel_id, seq, user_id, ts, text, client_msg_id, priority, attachments_json, parent_msg_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
21
|
+
).run(msgId, channelId, seq, userId, now, text, clientMsgId, priority, attachmentsJson, parentMsgId ?? null)
|
|
22
22
|
|
|
23
23
|
this.db.prepare(
|
|
24
24
|
`INSERT INTO events (ts, actor_user_id, scope_kind, scope_id, type, body_json)
|
|
@@ -33,7 +33,7 @@ export class SqliteMessageRepository {
|
|
|
33
33
|
const rows = this.db.prepare(
|
|
34
34
|
`SELECT ${MSG_COLS}
|
|
35
35
|
FROM messages m LEFT JOIN users u ON m.user_id = u.user_id
|
|
36
|
-
WHERE m.channel_id = ? AND m.seq > ? AND m.deleted_at IS NULL ORDER BY m.seq ASC LIMIT ?`
|
|
36
|
+
WHERE m.channel_id = ? AND m.seq > ? AND m.deleted_at IS NULL AND m.parent_msg_id IS NULL ORDER BY m.seq ASC LIMIT ?`
|
|
37
37
|
).all(channelId, afterSeq, limit)
|
|
38
38
|
return rows.map(r => ({
|
|
39
39
|
...r,
|
|
@@ -46,7 +46,7 @@ export class SqliteMessageRepository {
|
|
|
46
46
|
const rows = this.db.prepare(
|
|
47
47
|
`SELECT ${MSG_COLS}
|
|
48
48
|
FROM messages m LEFT JOIN users u ON m.user_id = u.user_id
|
|
49
|
-
WHERE m.channel_id = ? AND m.deleted_at IS NULL
|
|
49
|
+
WHERE m.channel_id = ? AND m.deleted_at IS NULL AND m.parent_msg_id IS NULL
|
|
50
50
|
ORDER BY m.seq DESC LIMIT ?`
|
|
51
51
|
).all(channelId, limit)
|
|
52
52
|
return rows.reverse().map(r => ({
|
|
@@ -58,7 +58,7 @@ export class SqliteMessageRepository {
|
|
|
58
58
|
|
|
59
59
|
getById(msgId) {
|
|
60
60
|
return this.db.prepare(
|
|
61
|
-
`SELECT msg_id, channel_id, seq, user_id, ts, text, deleted_at FROM messages WHERE msg_id = ?`
|
|
61
|
+
`SELECT msg_id, channel_id, seq, user_id, ts, text, deleted_at, parent_msg_id FROM messages WHERE msg_id = ?`
|
|
62
62
|
).get(msgId) ?? null
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -78,7 +78,7 @@ export class SqliteMessageRepository {
|
|
|
78
78
|
const rows = this.db.prepare(
|
|
79
79
|
`SELECT ${MSG_COLS}
|
|
80
80
|
FROM messages m LEFT JOIN users u ON m.user_id = u.user_id
|
|
81
|
-
WHERE m.channel_id = ? AND m.seq < ? AND m.deleted_at IS NULL
|
|
81
|
+
WHERE m.channel_id = ? AND m.seq < ? AND m.deleted_at IS NULL AND m.parent_msg_id IS NULL
|
|
82
82
|
ORDER BY m.seq DESC LIMIT ?`
|
|
83
83
|
).all(channelId, beforeSeq, limit)
|
|
84
84
|
return rows.reverse().map(r => ({
|
|
@@ -87,4 +87,30 @@ export class SqliteMessageRepository {
|
|
|
87
87
|
attachments_json: undefined,
|
|
88
88
|
}))
|
|
89
89
|
}
|
|
90
|
+
|
|
91
|
+
listReplies({ parentMsgId }) {
|
|
92
|
+
const rows = this.db.prepare(
|
|
93
|
+
`SELECT ${MSG_COLS}
|
|
94
|
+
FROM messages m LEFT JOIN users u ON m.user_id = u.user_id
|
|
95
|
+
WHERE m.parent_msg_id = ? AND m.deleted_at IS NULL ORDER BY m.seq ASC`
|
|
96
|
+
).all(parentMsgId)
|
|
97
|
+
return rows.map(r => ({
|
|
98
|
+
...r,
|
|
99
|
+
attachments: r.attachments_json ? JSON.parse(r.attachments_json) : [],
|
|
100
|
+
attachments_json: undefined,
|
|
101
|
+
}))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
getReplyCountsForMessages({ msgIds }) {
|
|
105
|
+
if (!msgIds.length) return {}
|
|
106
|
+
const placeholders = msgIds.map(() => '?').join(',')
|
|
107
|
+
const rows = this.db.prepare(
|
|
108
|
+
`SELECT parent_msg_id, COUNT(*) AS reply_count FROM messages
|
|
109
|
+
WHERE parent_msg_id IN (${placeholders}) AND deleted_at IS NULL
|
|
110
|
+
GROUP BY parent_msg_id`
|
|
111
|
+
).all(...msgIds)
|
|
112
|
+
const result = {}
|
|
113
|
+
for (const row of rows) result[row.parent_msg_id] = row.reply_count
|
|
114
|
+
return result
|
|
115
|
+
}
|
|
90
116
|
}
|
package/src/db/initDb.js
CHANGED
|
@@ -105,9 +105,11 @@ export const createSchema = (db) => {
|
|
|
105
105
|
priority TEXT NOT NULL DEFAULT 'normal',
|
|
106
106
|
attachments_json TEXT,
|
|
107
107
|
edited_at INTEGER,
|
|
108
|
+
parent_msg_id TEXT REFERENCES messages(msg_id),
|
|
108
109
|
FOREIGN KEY(channel_id) REFERENCES channels(channel_id),
|
|
109
110
|
FOREIGN KEY(user_id) REFERENCES users(user_id)
|
|
110
111
|
);
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_msg_id) WHERE parent_msg_id IS NOT NULL;
|
|
111
113
|
|
|
112
114
|
CREATE TABLE IF NOT EXISTS uploads (
|
|
113
115
|
upload_id TEXT PRIMARY KEY,
|
|
@@ -173,6 +175,7 @@ export const createSchema = (db) => {
|
|
|
173
175
|
try { db.exec(`ALTER TABLE messages ADD COLUMN priority TEXT NOT NULL DEFAULT 'normal'`) } catch { /* already exists */ }
|
|
174
176
|
try { db.exec(`ALTER TABLE messages ADD COLUMN attachments_json TEXT`) } catch { /* already exists */ }
|
|
175
177
|
try { db.exec(`ALTER TABLE messages ADD COLUMN edited_at INTEGER`) } catch { /* already exists */ }
|
|
178
|
+
try { db.exec(`ALTER TABLE messages ADD COLUMN parent_msg_id TEXT REFERENCES messages(msg_id)`) } catch { /* already exists */ }
|
|
176
179
|
|
|
177
180
|
// Bot tokens — added after initial schema
|
|
178
181
|
db.exec(`
|
|
@@ -16,7 +16,7 @@ export class MessageService {
|
|
|
16
16
|
this.uploadService = uploadService
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
sendMessage({ channelId, userId, text, clientMsgId = null, priority = 'normal', attachments = [] }) {
|
|
19
|
+
sendMessage({ channelId, userId, text, clientMsgId = null, priority = 'normal', attachments = [], parentMsgId = null }) {
|
|
20
20
|
if (!this.channelService.isMember(channelId, userId)) throw new ServiceError('FORBIDDEN', 'Not a member of channel')
|
|
21
21
|
if (!text?.trim() && attachments.length === 0) throw new ServiceError('BAD_REQUEST', 'Message text or attachment required')
|
|
22
22
|
if (!['normal', 'async', 'now'].includes(priority)) throw new ServiceError('BAD_REQUEST', 'Invalid priority')
|
|
@@ -28,7 +28,8 @@ export class MessageService {
|
|
|
28
28
|
const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : null
|
|
29
29
|
|
|
30
30
|
const { seq } = this.messageRepo.insertMessage({
|
|
31
|
-
msgId, channelId, userId, now, text: trimmed, clientMsgId, priority, attachmentsJson
|
|
31
|
+
msgId, channelId, userId, now, text: trimmed, clientMsgId, priority, attachmentsJson,
|
|
32
|
+
parentMsgId: parentMsgId ?? null
|
|
32
33
|
})
|
|
33
34
|
|
|
34
35
|
if (trimmed) {
|
|
@@ -56,7 +57,7 @@ export class MessageService {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
return { msg_id: msgId, seq, ts: now, priority, attachments: enrichedAttachments }
|
|
60
|
+
return { msg_id: msgId, seq, ts: now, priority, attachments: enrichedAttachments, parent_msg_id: parentMsgId ?? null }
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
editMessage({ msgId, channelId, userId, newText }) {
|
|
@@ -119,4 +120,16 @@ export class MessageService {
|
|
|
119
120
|
: rows
|
|
120
121
|
return { messages, has_more: hasMore }
|
|
121
122
|
}
|
|
123
|
+
|
|
124
|
+
listThreadReplies({ parentMsgId, channelId, userId }) {
|
|
125
|
+
if (!this.channelService.isMember(channelId, userId)) throw new ServiceError('FORBIDDEN', 'Not a member of channel')
|
|
126
|
+
const parent = this.messageRepo.getById(parentMsgId)
|
|
127
|
+
if (!parent) throw new ServiceError('NOT_FOUND', 'Message not found')
|
|
128
|
+
if (parent.channel_id !== channelId) throw new ServiceError('BAD_REQUEST', 'Message does not belong to this channel')
|
|
129
|
+
return this.messageRepo.listReplies({ parentMsgId })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getReplyCountsForMessages({ msgIds }) {
|
|
133
|
+
return this.messageRepo.getReplyCountsForMessages({ msgIds })
|
|
134
|
+
}
|
|
122
135
|
}
|
package/src/ws/ChatServer.js
CHANGED
|
@@ -22,7 +22,7 @@ import { SqliteSignalingRepository } from '../adapters/SqliteSignalingRepository
|
|
|
22
22
|
import { handleHello, handleInviteRedeem, handleSignIn, handleSignOut, handleAdminInviteCreate, handleAdminInviteList, handleAdminInviteRevoke, handleAdminUserList, handleAdminUserSetRoles, handleAdminUserSetPassword, handleAdminUserSetDisplayName, handleAdminBotCreate, handleAdminBotList, handleAdminBotTokenCreate, handleAdminBotTokenRevoke, handleAdminBotSetChannels } from './handlers/authHandlers.js'
|
|
23
23
|
import { handleHubList, handleHubCreate, handleHubUpdate, handleHubDelete, handleHubAddMember, handleHubRemoveMember, handleHubListMembers, handleHubReorder } from './handlers/hubHandlers.js'
|
|
24
24
|
import { handleChannelList, handleChannelCreate, handleChannelUpdate, handleChannelDelete, handleChannelJoin, handleChannelLeave, handleChannelReorder, handleChannelAddMember, handleChannelRemoveMember, handleChannelListMembers, handleUserList, handleBotList, handleDmOpen, handleDmList } from './handlers/channelHandlers.js'
|
|
25
|
-
import { handleMsgSend, handleMsgList, handleMsgEdit, handleMsgDelete, handleSearchQuery, handlePresenceSubscribe } from './handlers/messageHandlers.js'
|
|
25
|
+
import { handleMsgSend, handleMsgList, handleMsgEdit, handleMsgDelete, handleThreadList, handleSearchQuery, handlePresenceSubscribe } from './handlers/messageHandlers.js'
|
|
26
26
|
import { handleRtcCallCreate, handleRtcJoin, handleRtcOffer, handleRtcAnswer, handleRtcIce, handleRtcStreamPublish, handleRtcLeave, handleRtcEndCall } from './handlers/rtcHandlers.js'
|
|
27
27
|
import { handlePushSubscribe, handlePushUnsubscribe } from './handlers/pushHandlers.js'
|
|
28
28
|
import { handleReactionAdd, handleReactionRemove } from './handlers/reactionHandlers.js'
|
|
@@ -224,6 +224,7 @@ export class ChatServer {
|
|
|
224
224
|
case 'msg.edit': return handleMsgEdit(ws, msg, ctx)
|
|
225
225
|
case 'msg.delete': return handleMsgDelete(ws, msg, ctx)
|
|
226
226
|
case 'msg.list': return handleMsgList(ws, msg, ctx)
|
|
227
|
+
case 'thread.list': return handleThreadList(ws, msg, ctx)
|
|
227
228
|
case 'search.query': return handleSearchQuery(ws, msg, ctx)
|
|
228
229
|
case 'presence.subscribe': return handlePresenceSubscribe(ws, msg, ctx)
|
|
229
230
|
// RTC
|
|
@@ -145,8 +145,15 @@ export function handleAdminBotTokenRevoke(ws, msg, ctx) {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
export function handleAdminBotSetChannels(ws, msg, ctx) {
|
|
148
|
-
const { botService, sendWs } = ctx
|
|
148
|
+
const { botService, sendWs, connections } = ctx
|
|
149
149
|
const { user_id, channel_ids } = msg.body || {}
|
|
150
150
|
botService.setBotChannels({ userId: user_id, channelIds: channel_ids, requestingUserId: ws.data.userId })
|
|
151
151
|
sendWs(ws, { t: 'admin.bot_updated', reply_to: msg.id, ok: true, body: { user_id } })
|
|
152
|
+
|
|
153
|
+
// Notify the bot's active WebSocket connection so it can re-join channels.
|
|
154
|
+
for (const [, conn] of connections) {
|
|
155
|
+
if (conn.data.userId === user_id) {
|
|
156
|
+
sendWs(conn, { t: 'bot.channels_updated', body: { user_id } })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
152
159
|
}
|
|
@@ -5,23 +5,32 @@ import { renderMarkdown } from '@devchitchat/index97/markdown'
|
|
|
5
5
|
|
|
6
6
|
export function handleMsgSend(ws, msg, ctx) {
|
|
7
7
|
const { messageService, deliveryService, sendWs, publishChannel, dispatchMentions } = ctx
|
|
8
|
-
const { channel_id, text, client_msg_id, priority, attachments } = msg.body || {}
|
|
8
|
+
const { channel_id, text, client_msg_id, priority, attachments, parent_msg_id } = msg.body || {}
|
|
9
9
|
const result = messageService.sendMessage({
|
|
10
10
|
channelId: channel_id, userId: ws.data.userId, text, clientMsgId: client_msg_id, priority,
|
|
11
|
-
attachments: Array.isArray(attachments) ? attachments : []
|
|
11
|
+
attachments: Array.isArray(attachments) ? attachments : [],
|
|
12
|
+
parentMsgId: parent_msg_id ?? null
|
|
12
13
|
})
|
|
13
14
|
|
|
14
15
|
sendWs(ws, { t: 'msg.ack', reply_to: msg.id, ok: true, body: { msg_id: result.msg_id, seq: result.seq, client_msg_id, priority: result.priority } })
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
})
|
|
17
|
+
const eventBody = {
|
|
18
|
+
msg_id: result.msg_id, channel_id, seq: result.seq,
|
|
19
|
+
user_id: ws.data.userId, user_display_name: ws.data.displayName,
|
|
20
|
+
ts: result.ts, text, rendered_text: renderMarkdown(text).html,
|
|
21
|
+
priority: result.priority, attachments: result.attachments ?? [],
|
|
22
|
+
parent_msg_id: result.parent_msg_id ?? null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
publishChannel(channel_id, { t: 'msg.event', ok: true, body: eventBody })
|
|
26
|
+
|
|
27
|
+
// If this is a thread reply, also publish thread.reply_event so open thread panels update
|
|
28
|
+
if (result.parent_msg_id) {
|
|
29
|
+
publishChannel(channel_id, {
|
|
30
|
+
t: 'thread.reply_event', ok: true,
|
|
31
|
+
body: { parent_msg_id: result.parent_msg_id, channel_id, reply: eventBody }
|
|
32
|
+
})
|
|
33
|
+
}
|
|
25
34
|
|
|
26
35
|
deliveryService.advance({ channelId: channel_id, userId: ws.data.userId, afterSeq: result.seq })
|
|
27
36
|
dispatchMentions({ channelId: channel_id, senderId: ws.data.userId, text, seq: result.seq, priority: result.priority })
|
|
@@ -69,6 +78,14 @@ export function handleMsgDelete(ws, msg, ctx) {
|
|
|
69
78
|
})
|
|
70
79
|
}
|
|
71
80
|
|
|
81
|
+
export function handleThreadList(ws, msg, ctx) {
|
|
82
|
+
const { messageService, sendWs } = ctx
|
|
83
|
+
const { parent_msg_id, channel_id } = msg.body || {}
|
|
84
|
+
const replies = messageService.listThreadReplies({ parentMsgId: parent_msg_id, channelId: channel_id, userId: ws.data.userId })
|
|
85
|
+
const withRendered = replies.map(m => ({ ...m, rendered_text: renderMarkdown(m.text).html }))
|
|
86
|
+
sendWs(ws, { t: 'thread.list_result', reply_to: msg.id, ok: true, body: { parent_msg_id, channel_id, replies: withRendered } })
|
|
87
|
+
}
|
|
88
|
+
|
|
72
89
|
export function handleSearchQuery(ws, msg, ctx) {
|
|
73
90
|
const { auth, channelService, searchService, sendWs } = ctx
|
|
74
91
|
const { channel_id, q, limit } = msg.body || {}
|