@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.
@@ -0,0 +1,6 @@
1
+ export function run(db) {
2
+ db.exec(`
3
+ ALTER TABLE messages ADD COLUMN parent_msg_id TEXT REFERENCES messages(msg_id);
4
+ CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_msg_id) WHERE parent_msg_id IS NOT NULL;
5
+ `)
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devchitchat/chat",
3
- "version": "4.3.0",
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 --namespace=${KUBE_NAMESPACE:-default} --dry-run=client -o yaml | kubectl apply -f -",
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
- "deploy-backup": "kubectl config use-context ${KUBE_CONTEXT:-k3s-local} && helm upgrade --install chat-web charts/web -f charts/web/values.local.yaml -n ${KUBE_NAMESPACE:-default}",
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": "bun docker-build && bun local-deploy",
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
- ...m,
89
- raw_text: m.text,
90
- text: sanitizeForFrontEnd(renderMarkdown(m.text).html),
91
- ts_fmt: new Date(m.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
92
- attachments_json: m.attachments?.length ? JSON.stringify(m.attachments) : '',
93
- reactions_json: m.reactions?.length ? JSON.stringify(m.reactions) : '',
94
- edited_at: m.edited_at ?? '',
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">&#x2715;</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 = [] // current filtered subset
76
- let mentionStart = -1 // index of '@' in textarea.value
77
- let mentionSelIdx = 0 // keyboard-selected row
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 = '&#x21A9;'
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')?.addEventListener('input', handleComposerInput)
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 }]
@@ -959,6 +959,7 @@ export default function SidebarIsland(root) {
959
959
  }
960
960
  }))
961
961
  }
962
+ renderDms()
962
963
  if (dmUnread().has(newId)) {
963
964
  const next = new Set(dmUnread()); next.delete(newId); dmUnread.set(next)
964
965
  }
@@ -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">&#x21A9;</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: flex;
826
+ display: none;
825
827
  flex-direction: column;
826
- flex: 1;
827
- min-height: 0;
828
+ position: absolute;
829
+ inset: 0;
830
+ z-index: 20;
828
831
  background: var(--bg-base);
829
832
  }
830
- .compose-overlay[hidden] { display: none; }
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
  }
@@ -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
- publishChannel(channel_id, {
17
- t: 'msg.event', ok: true,
18
- body: {
19
- msg_id: result.msg_id, channel_id, seq: result.seq,
20
- user_id: ws.data.userId, user_display_name: ws.data.displayName,
21
- ts: result.ts, text, rendered_text: renderMarkdown(text).html,
22
- priority: result.priority, attachments: result.attachments ?? []
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 || {}