@devchitchat/chat 4.2.4 → 4.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/preview
|
|
3
|
+
*
|
|
4
|
+
* Renders markdown text server-side and returns the HTML.
|
|
5
|
+
* Used by the client-side compose overlay preview tab.
|
|
6
|
+
*
|
|
7
|
+
* Body: { text: string }
|
|
8
|
+
* Returns: { html: string }
|
|
9
|
+
*/
|
|
10
|
+
import { sessionFromRequest } from '../../../src/context.js'
|
|
11
|
+
import { renderMarkdown } from '@devchitchat/index97/markdown'
|
|
12
|
+
|
|
13
|
+
export async function POST(req) {
|
|
14
|
+
const session = sessionFromRequest(req)
|
|
15
|
+
if (!session?.user) return new Response('Unauthorized', { status: 401 })
|
|
16
|
+
|
|
17
|
+
let body
|
|
18
|
+
try {
|
|
19
|
+
body = await req.json()
|
|
20
|
+
} catch {
|
|
21
|
+
return new Response('Bad Request', { status: 400 })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const text = String(body.text ?? '').slice(0, 50_000)
|
|
25
|
+
const { html } = renderMarkdown(text)
|
|
26
|
+
return Response.json({ html })
|
|
27
|
+
}
|
|
@@ -158,8 +158,36 @@
|
|
|
158
158
|
<textarea model="draft" id="message-input" placeholder="Message in {{channel.name}}" aria-label="Write a message"
|
|
159
159
|
rows="1" onkeydown="handleComposerKey">
|
|
160
160
|
</textarea>
|
|
161
|
+
<button onclick="toggleComposeMode" class="btn-compose-expand btn-icon" type="button"
|
|
162
|
+
title="Compose mode (Ctrl+E)" aria-label="Open compose mode">⛶</button>
|
|
161
163
|
<button onclick="sendMessage" class="btn-send" aria-label="Send message" type="button">Send</button>
|
|
162
164
|
</footer>
|
|
165
|
+
|
|
166
|
+
<!-- Compose overlay — full-panel editor with Write / Preview tabs -->
|
|
167
|
+
<div class="compose-overlay" id="compose-overlay" hidden>
|
|
168
|
+
<div class="compose-overlay-header">
|
|
169
|
+
<div class="compose-tabs" role="tablist">
|
|
170
|
+
<button class="compose-tab compose-tab--active" data-tab="write"
|
|
171
|
+
role="tab" aria-selected="true" type="button">Write</button>
|
|
172
|
+
<button class="compose-tab" data-tab="preview"
|
|
173
|
+
role="tab" aria-selected="false" type="button">Preview</button>
|
|
174
|
+
</div>
|
|
175
|
+
<button class="btn-compose-collapse btn-icon" id="btn-compose-collapse"
|
|
176
|
+
type="button" title="Collapse (Esc)" aria-label="Collapse compose mode">✕</button>
|
|
177
|
+
</div>
|
|
178
|
+
<div class="attachment-chips" id="compose-chips" hidden></div>
|
|
179
|
+
<textarea class="compose-textarea" id="compose-textarea"
|
|
180
|
+
placeholder="Write your message… (Ctrl+Enter to send)" aria-label="Compose message"></textarea>
|
|
181
|
+
<div class="compose-preview message-text" id="compose-preview" hidden aria-live="polite"></div>
|
|
182
|
+
<footer class="compose-overlay-footer">
|
|
183
|
+
<button class="btn-urgent-toggle" id="compose-urgent-toggle" type="button"
|
|
184
|
+
title="Toggle urgent mode" aria-label="Toggle urgent send">🔔</button>
|
|
185
|
+
<button class="btn-attach btn-icon" id="compose-attach" type="button"
|
|
186
|
+
title="Attach file" aria-label="Attach file">📎</button>
|
|
187
|
+
<button class="btn-send" id="compose-send" type="button">Send</button>
|
|
188
|
+
</footer>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
163
191
|
</section><!-- /.chat-panel -->
|
|
164
192
|
|
|
165
193
|
<!-- Tile panel: sibling of chat-panel, never inside it -->
|
|
@@ -583,32 +583,197 @@ export default function CallIsland(root) {
|
|
|
583
583
|
}
|
|
584
584
|
|
|
585
585
|
function renderChips() {
|
|
586
|
-
|
|
586
|
+
const html = pendingAttachments.map((a, i) => `
|
|
587
587
|
<span class="attachment-chip" data-index="${i}">
|
|
588
588
|
<span class="attachment-chip-name">${escHtml(a.original_name)}</span>
|
|
589
589
|
<button type="button" class="attachment-chip-remove" data-index="${i}" aria-label="Remove ${escHtml(a.original_name)}">×</button>
|
|
590
590
|
</span>
|
|
591
591
|
`).join('')
|
|
592
|
+
chipsEl.innerHTML = html
|
|
592
593
|
chipsEl.hidden = pendingAttachments.length === 0
|
|
594
|
+
if (composeChipsEl) {
|
|
595
|
+
composeChipsEl.innerHTML = html
|
|
596
|
+
composeChipsEl.hidden = pendingAttachments.length === 0
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function _removeChipAt(idx) {
|
|
601
|
+
pendingAttachments.splice(idx, 1)
|
|
602
|
+
renderChips()
|
|
593
603
|
}
|
|
594
604
|
|
|
595
605
|
chipsEl.addEventListener('click', e => {
|
|
596
606
|
const btn = e.target.closest('.attachment-chip-remove')
|
|
597
607
|
if (!btn) return
|
|
598
|
-
|
|
599
|
-
pendingAttachments.splice(idx, 1)
|
|
600
|
-
renderChips()
|
|
608
|
+
_removeChipAt(parseInt(btn.dataset.index, 10))
|
|
601
609
|
})
|
|
602
610
|
|
|
603
611
|
function showComposerError(msg) {
|
|
612
|
+
const target = composeOpen ? composeChipsEl : chipsEl
|
|
613
|
+
if (!target) return
|
|
604
614
|
const chip = document.createElement('span')
|
|
605
615
|
chip.className = 'attachment-chip attachment-chip-error'
|
|
606
616
|
chip.textContent = msg
|
|
607
|
-
|
|
608
|
-
|
|
617
|
+
target.appendChild(chip)
|
|
618
|
+
target.hidden = false
|
|
609
619
|
setTimeout(() => chip.remove(), 5000)
|
|
610
620
|
}
|
|
611
621
|
|
|
622
|
+
// ── Compose overlay ───────────────────────────────────────────────────────
|
|
623
|
+
const composeOverlayEl = root.querySelector('#compose-overlay')
|
|
624
|
+
const composeTaEl = document.getElementById('compose-textarea')
|
|
625
|
+
const composePreviewEl = document.getElementById('compose-preview')
|
|
626
|
+
const composeCollapseBtn = document.getElementById('btn-compose-collapse')
|
|
627
|
+
const composeSendBtn = document.getElementById('compose-send')
|
|
628
|
+
const composeUrgentBtn = document.getElementById('compose-urgent-toggle')
|
|
629
|
+
const composeChipsEl = document.getElementById('compose-chips')
|
|
630
|
+
const composeBtnAttach = document.getElementById('compose-attach')
|
|
631
|
+
|
|
632
|
+
let composeOpen = false
|
|
633
|
+
|
|
634
|
+
function openComposeMode() {
|
|
635
|
+
if (composeOpen) return
|
|
636
|
+
composeOpen = true
|
|
637
|
+
if (composeTaEl) composeTaEl.value = draft()
|
|
638
|
+
messages.hidden = true
|
|
639
|
+
composerEl.hidden = true
|
|
640
|
+
if (composeOverlayEl) composeOverlayEl.hidden = false
|
|
641
|
+
_switchComposeTab('write')
|
|
642
|
+
requestAnimationFrame(() => {
|
|
643
|
+
if (!composeTaEl) return
|
|
644
|
+
composeTaEl.focus()
|
|
645
|
+
const len = composeTaEl.value.length
|
|
646
|
+
composeTaEl.setSelectionRange(len, len)
|
|
647
|
+
})
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function closeComposeMode() {
|
|
651
|
+
if (!composeOpen) return
|
|
652
|
+
composeOpen = false
|
|
653
|
+
messages.hidden = false
|
|
654
|
+
composerEl.hidden = false
|
|
655
|
+
if (composeOverlayEl) composeOverlayEl.hidden = true
|
|
656
|
+
textareaEl?.focus()
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function toggleComposeMode() {
|
|
660
|
+
composeOpen ? closeComposeMode() : openComposeMode()
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function _switchComposeTab(tab) {
|
|
664
|
+
root.querySelectorAll('.compose-tab').forEach(btn => {
|
|
665
|
+
const active = btn.dataset.tab === tab
|
|
666
|
+
btn.classList.toggle('compose-tab--active', active)
|
|
667
|
+
btn.setAttribute('aria-selected', String(active))
|
|
668
|
+
})
|
|
669
|
+
if (composeTaEl) composeTaEl.hidden = tab !== 'write'
|
|
670
|
+
if (composePreviewEl) composePreviewEl.hidden = tab !== 'preview'
|
|
671
|
+
if (tab === 'preview') _renderComposePreview()
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function _renderComposePreview() {
|
|
675
|
+
const text = composeTaEl?.value ?? ''
|
|
676
|
+
if (!text.trim()) {
|
|
677
|
+
if (composePreviewEl) composePreviewEl.innerHTML = '<p style="color:var(--text-muted)">Nothing to preview yet.</p>'
|
|
678
|
+
return
|
|
679
|
+
}
|
|
680
|
+
try {
|
|
681
|
+
const res = await fetch(`${window.__BASE_PATH__}/api/preview`, {
|
|
682
|
+
method: 'POST',
|
|
683
|
+
headers: { 'Content-Type': 'application/json' },
|
|
684
|
+
body: JSON.stringify({ text }),
|
|
685
|
+
})
|
|
686
|
+
if (res.ok) {
|
|
687
|
+
const { html } = await res.json()
|
|
688
|
+
if (composePreviewEl) composePreviewEl.innerHTML = sanitizeHtml(html)
|
|
689
|
+
}
|
|
690
|
+
} catch {
|
|
691
|
+
if (composePreviewEl) composePreviewEl.textContent = text
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Sync compose textarea → draft signal
|
|
696
|
+
composeTaEl?.addEventListener('input', () => { draft.set(composeTaEl.value) })
|
|
697
|
+
|
|
698
|
+
// Ctrl+Enter / Cmd+Enter in compose textarea → send and collapse
|
|
699
|
+
composeTaEl?.addEventListener('keydown', e => {
|
|
700
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
701
|
+
e.preventDefault()
|
|
702
|
+
sendMessage()
|
|
703
|
+
closeComposeMode()
|
|
704
|
+
}
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
// Tab strip
|
|
708
|
+
root.querySelectorAll('.compose-tab').forEach(btn => {
|
|
709
|
+
btn.addEventListener('click', () => _switchComposeTab(btn.dataset.tab))
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
// Collapse, send, and attach buttons
|
|
713
|
+
composeCollapseBtn?.addEventListener('click', closeComposeMode)
|
|
714
|
+
composeSendBtn?.addEventListener('click', () => { sendMessage(); closeComposeMode() })
|
|
715
|
+
composeUrgentBtn?.addEventListener('click', () => toggleUrgentMode())
|
|
716
|
+
composeBtnAttach?.addEventListener('click', () => fileInputEl.click())
|
|
717
|
+
composeChipsEl?.addEventListener('click', e => {
|
|
718
|
+
const btn = e.target.closest('.attachment-chip-remove')
|
|
719
|
+
if (!btn) return
|
|
720
|
+
_removeChipAt(parseInt(btn.dataset.index, 10))
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
// Paste images into the compose textarea
|
|
724
|
+
composeTaEl?.addEventListener('paste', e => {
|
|
725
|
+
const items = [...(e.clipboardData?.items ?? [])]
|
|
726
|
+
const imageFiles = items
|
|
727
|
+
.filter(item => item.kind === 'file' && item.type.startsWith('image/'))
|
|
728
|
+
.map(item => {
|
|
729
|
+
const file = item.getAsFile()
|
|
730
|
+
if (!file) return null
|
|
731
|
+
if (!file.name) {
|
|
732
|
+
const ext = item.type.split('/')[1] ?? 'png'
|
|
733
|
+
return new File([file], `paste-${Date.now()}.${ext}`, { type: item.type })
|
|
734
|
+
}
|
|
735
|
+
return file
|
|
736
|
+
})
|
|
737
|
+
.filter(Boolean)
|
|
738
|
+
if (imageFiles.length === 0) return
|
|
739
|
+
e.preventDefault()
|
|
740
|
+
uploadFiles(imageFiles)
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
// Drag-and-drop files onto the compose overlay
|
|
744
|
+
let composeDropOverlayEl = null
|
|
745
|
+
function ensureComposeDropOverlay() {
|
|
746
|
+
if (composeDropOverlayEl) return composeDropOverlayEl
|
|
747
|
+
composeDropOverlayEl = document.createElement('div')
|
|
748
|
+
composeDropOverlayEl.className = 'drop-overlay'
|
|
749
|
+
composeDropOverlayEl.textContent = 'Drop to attach'
|
|
750
|
+
composeOverlayEl?.appendChild(composeDropOverlayEl)
|
|
751
|
+
return composeDropOverlayEl
|
|
752
|
+
}
|
|
753
|
+
composeOverlayEl?.addEventListener('dragover', e => {
|
|
754
|
+
if (!e.dataTransfer.types.includes('Files')) return
|
|
755
|
+
e.preventDefault()
|
|
756
|
+
ensureComposeDropOverlay().hidden = false
|
|
757
|
+
})
|
|
758
|
+
composeOverlayEl?.addEventListener('dragleave', e => {
|
|
759
|
+
if (composeOverlayEl.contains(e.relatedTarget)) return
|
|
760
|
+
if (composeDropOverlayEl) composeDropOverlayEl.hidden = true
|
|
761
|
+
})
|
|
762
|
+
composeOverlayEl?.addEventListener('drop', e => {
|
|
763
|
+
e.preventDefault()
|
|
764
|
+
if (composeDropOverlayEl) composeDropOverlayEl.hidden = true
|
|
765
|
+
const files = [...(e.dataTransfer.files ?? [])]
|
|
766
|
+
if (files.length > 0) uploadFiles(files)
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
// Ctrl+E / Cmd+E anywhere to toggle compose mode
|
|
770
|
+
document.addEventListener('keydown', e => {
|
|
771
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
|
|
772
|
+
e.preventDefault()
|
|
773
|
+
toggleComposeMode()
|
|
774
|
+
}
|
|
775
|
+
})
|
|
776
|
+
|
|
612
777
|
// ── Urgent send ───────────────────────────────────────────────────────────
|
|
613
778
|
const urgentMode = signal(false)
|
|
614
779
|
const composerFooter = root.querySelector('.composer')
|
|
@@ -618,6 +783,8 @@ export default function CallIsland(root) {
|
|
|
618
783
|
function toggleUrgentMode() {
|
|
619
784
|
urgentMode.set(!urgentMode())
|
|
620
785
|
composerFooter?.classList.toggle('composer-urgent', urgentMode())
|
|
786
|
+
composeOverlayEl?.classList.toggle('composer-urgent', urgentMode())
|
|
787
|
+
composeUrgentBtn?.classList.toggle('is-urgent', urgentMode())
|
|
621
788
|
}
|
|
622
789
|
|
|
623
790
|
function sendMessage({ priority } = {}) {
|
|
@@ -908,6 +1075,7 @@ export default function CallIsland(root) {
|
|
|
908
1075
|
|
|
909
1076
|
document.addEventListener('keydown', e => {
|
|
910
1077
|
if (e.key === 'Escape') {
|
|
1078
|
+
if (composeOpen) { closeComposeMode(); return }
|
|
911
1079
|
closeEmojiPicker()
|
|
912
1080
|
}
|
|
913
1081
|
})
|
|
@@ -1013,42 +1181,85 @@ export default function CallIsland(root) {
|
|
|
1013
1181
|
// ── Inline edit ────────────────────────────────────────────────────────────
|
|
1014
1182
|
|
|
1015
1183
|
function startInlineEdit(article) {
|
|
1016
|
-
if (article.querySelector('.message-edit-
|
|
1017
|
-
const textEl
|
|
1184
|
+
if (article.querySelector('.message-edit-wrap')) return
|
|
1185
|
+
const textEl = article.querySelector('.message-text')
|
|
1018
1186
|
if (!textEl) return
|
|
1019
1187
|
const rawText = article.dataset.rawText ?? ''
|
|
1020
1188
|
|
|
1189
|
+
// Build the edit widget
|
|
1190
|
+
const wrap = document.createElement('div')
|
|
1191
|
+
wrap.className = 'message-edit-wrap'
|
|
1192
|
+
|
|
1193
|
+
const tabStrip = document.createElement('div')
|
|
1194
|
+
tabStrip.className = 'message-edit-tabs'
|
|
1195
|
+
tabStrip.setAttribute('role', 'tablist')
|
|
1196
|
+
tabStrip.innerHTML = `
|
|
1197
|
+
<button class="message-edit-tab message-edit-tab--active" data-tab="write" role="tab" aria-selected="true" type="button">Write</button>
|
|
1198
|
+
<button class="message-edit-tab" data-tab="preview" role="tab" aria-selected="false" type="button">Preview</button>`
|
|
1199
|
+
|
|
1021
1200
|
const textarea = document.createElement('textarea')
|
|
1022
1201
|
textarea.className = 'message-edit-input'
|
|
1023
1202
|
textarea.value = rawText
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1203
|
+
|
|
1204
|
+
const preview = document.createElement('div')
|
|
1205
|
+
preview.className = 'message-edit-preview message-text'
|
|
1206
|
+
preview.hidden = true
|
|
1207
|
+
preview.setAttribute('aria-live', 'polite')
|
|
1027
1208
|
|
|
1028
1209
|
const toolbar = document.createElement('div')
|
|
1029
1210
|
toolbar.className = 'message-edit-toolbar'
|
|
1030
|
-
toolbar.innerHTML =
|
|
1031
|
-
|
|
1211
|
+
toolbar.innerHTML = `
|
|
1212
|
+
<span class="message-edit-hint">Ctrl+Enter to save · Esc to cancel</span>
|
|
1213
|
+
<button class="btn-ghost btn-edit-cancel" type="button">Cancel</button>
|
|
1214
|
+
<button class="btn-primary btn-edit-save" type="button">Save</button>`
|
|
1215
|
+
|
|
1216
|
+
wrap.append(tabStrip, textarea, preview, toolbar)
|
|
1217
|
+
textEl.replaceWith(wrap)
|
|
1218
|
+
textarea.focus()
|
|
1219
|
+
textarea.setSelectionRange(rawText.length, rawText.length)
|
|
1220
|
+
|
|
1221
|
+
// Tab switching
|
|
1222
|
+
tabStrip.addEventListener('click', async e => {
|
|
1223
|
+
const btn = e.target.closest('.message-edit-tab')
|
|
1224
|
+
if (!btn) return
|
|
1225
|
+
const tab = btn.dataset.tab
|
|
1226
|
+
tabStrip.querySelectorAll('.message-edit-tab').forEach(b => {
|
|
1227
|
+
b.classList.toggle('message-edit-tab--active', b === btn)
|
|
1228
|
+
b.setAttribute('aria-selected', String(b === btn))
|
|
1229
|
+
})
|
|
1230
|
+
textarea.hidden = tab !== 'write'
|
|
1231
|
+
preview.hidden = tab !== 'preview'
|
|
1232
|
+
if (tab === 'preview') {
|
|
1233
|
+
const text = textarea.value.trim()
|
|
1234
|
+
if (!text) { preview.innerHTML = '<p style="color:var(--text-muted)">Nothing to preview yet.</p>'; return }
|
|
1235
|
+
try {
|
|
1236
|
+
const res = await fetch(`${window.__BASE_PATH__}/api/preview`, {
|
|
1237
|
+
method: 'POST',
|
|
1238
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1239
|
+
body: JSON.stringify({ text }),
|
|
1240
|
+
})
|
|
1241
|
+
if (res.ok) { const { html } = await res.json(); preview.innerHTML = sanitizeHtml(html) }
|
|
1242
|
+
} catch { preview.textContent = text }
|
|
1243
|
+
}
|
|
1244
|
+
})
|
|
1032
1245
|
|
|
1033
1246
|
function cancel() {
|
|
1034
|
-
|
|
1035
|
-
toolbar.remove()
|
|
1247
|
+
wrap.replaceWith(textEl)
|
|
1036
1248
|
}
|
|
1037
1249
|
|
|
1038
1250
|
function save() {
|
|
1039
1251
|
const newText = textarea.value.trim()
|
|
1040
|
-
if (!newText) { cancel(); return }
|
|
1041
|
-
if (newText === rawText) { cancel(); return }
|
|
1042
|
-
// Optimistic: show new text immediately (unrendered), server will push rendered version via msg.edited
|
|
1252
|
+
if (!newText || newText === rawText) { cancel(); return }
|
|
1043
1253
|
textEl.textContent = newText
|
|
1044
1254
|
cancel()
|
|
1255
|
+
article.dataset.rawText = newText
|
|
1045
1256
|
ws.send({ t: 'msg.edit', body: { msg_id: article.dataset.msgId, channel_id: channelId, text: newText } })
|
|
1046
1257
|
}
|
|
1047
1258
|
|
|
1048
1259
|
toolbar.querySelector('.btn-edit-save').addEventListener('click', save)
|
|
1049
1260
|
toolbar.querySelector('.btn-edit-cancel').addEventListener('click', cancel)
|
|
1050
1261
|
textarea.addEventListener('keydown', e => {
|
|
1051
|
-
if (e.key === 'Enter'
|
|
1262
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save() }
|
|
1052
1263
|
if (e.key === 'Escape') cancel()
|
|
1053
1264
|
})
|
|
1054
1265
|
}
|
|
@@ -1731,6 +1942,8 @@ export default function CallIsland(root) {
|
|
|
1731
1942
|
document.addEventListener('chatpanel:navigated', (e) => {
|
|
1732
1943
|
const { channelId: newId, name, topic, kind, seedSeq: newSeedSeq, seedFirstSeq: newFirstSeq, seedHasMore: newHasMore } = e.detail
|
|
1733
1944
|
|
|
1945
|
+
if (composeOpen && newId !== channelId) closeComposeMode()
|
|
1946
|
+
|
|
1734
1947
|
// Morph strips dynamically-added content (reactions, attachments, timestamps, etc.)
|
|
1735
1948
|
// but may preserve data-hydrated="1". Always clear and re-hydrate.
|
|
1736
1949
|
for (const a of messages.querySelectorAll('article.message[data-hydrated]')) {
|
|
@@ -1811,5 +2024,5 @@ export default function CallIsland(root) {
|
|
|
1811
2024
|
|
|
1812
2025
|
// ── Exports (rdbljs bindings) ──────────────────────────────────────────────
|
|
1813
2026
|
|
|
1814
|
-
return { draft, channelName, channelTopic, urgentMode, urgentClass, sendMessage, handleComposerKey, toggleUrgentMode }
|
|
2027
|
+
return { draft, channelName, channelTopic, urgentMode, urgentClass, sendMessage, handleComposerKey, toggleUrgentMode, toggleComposeMode }
|
|
1815
2028
|
}
|
|
@@ -757,6 +757,7 @@ details summary {
|
|
|
757
757
|
border-radius: 0;
|
|
758
758
|
}
|
|
759
759
|
|
|
760
|
+
.composer[hidden] { display: none; }
|
|
760
761
|
.composer {
|
|
761
762
|
margin-top: auto;
|
|
762
763
|
display: flex;
|
|
@@ -808,6 +809,97 @@ details summary {
|
|
|
808
809
|
.composer.composer-urgent textarea,
|
|
809
810
|
.composer.composer-urgent textarea:focus { border-color: var(--color-danger); }
|
|
810
811
|
|
|
812
|
+
/* Expand button in the composer bar */
|
|
813
|
+
.btn-compose-expand {
|
|
814
|
+
flex-shrink: 0;
|
|
815
|
+
align-self: center;
|
|
816
|
+
font-size: 16px;
|
|
817
|
+
opacity: 0.5;
|
|
818
|
+
transition: opacity var(--transition);
|
|
819
|
+
}
|
|
820
|
+
.btn-compose-expand:hover { opacity: 1; }
|
|
821
|
+
|
|
822
|
+
/* ── Compose overlay ─────────────────────────────────────────────────────── */
|
|
823
|
+
.compose-overlay {
|
|
824
|
+
display: flex;
|
|
825
|
+
flex-direction: column;
|
|
826
|
+
flex: 1;
|
|
827
|
+
min-height: 0;
|
|
828
|
+
background: var(--bg-base);
|
|
829
|
+
}
|
|
830
|
+
.compose-overlay[hidden] { display: none; }
|
|
831
|
+
|
|
832
|
+
.compose-overlay-header {
|
|
833
|
+
display: flex;
|
|
834
|
+
align-items: center;
|
|
835
|
+
justify-content: space-between;
|
|
836
|
+
padding: 6px 12px 0;
|
|
837
|
+
border-bottom: 1px solid var(--border);
|
|
838
|
+
flex-shrink: 0;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
.compose-tabs { display: flex; }
|
|
842
|
+
|
|
843
|
+
.compose-tab {
|
|
844
|
+
background: none;
|
|
845
|
+
border: none;
|
|
846
|
+
border-bottom: 2px solid transparent;
|
|
847
|
+
padding: 8px 14px;
|
|
848
|
+
cursor: pointer;
|
|
849
|
+
color: var(--text-secondary);
|
|
850
|
+
font-size: 13px;
|
|
851
|
+
font-weight: 500;
|
|
852
|
+
transition: color var(--transition), border-color var(--transition);
|
|
853
|
+
margin-bottom: -1px; /* sit on top of the header border */
|
|
854
|
+
}
|
|
855
|
+
.compose-tab:hover { color: var(--text-primary); }
|
|
856
|
+
.compose-tab--active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
857
|
+
|
|
858
|
+
.btn-compose-collapse {
|
|
859
|
+
font-size: 14px;
|
|
860
|
+
opacity: 0.5;
|
|
861
|
+
transition: opacity var(--transition);
|
|
862
|
+
}
|
|
863
|
+
.btn-compose-collapse:hover { opacity: 1; }
|
|
864
|
+
|
|
865
|
+
.compose-textarea {
|
|
866
|
+
flex: 1;
|
|
867
|
+
min-height: 0;
|
|
868
|
+
resize: none;
|
|
869
|
+
border: none;
|
|
870
|
+
background: var(--bg-base);
|
|
871
|
+
color: var(--text-primary);
|
|
872
|
+
font-family: var(--font-sans);
|
|
873
|
+
font-size: 14px;
|
|
874
|
+
line-height: 1.6;
|
|
875
|
+
padding: 16px 20px;
|
|
876
|
+
outline: none;
|
|
877
|
+
overflow-y: auto;
|
|
878
|
+
}
|
|
879
|
+
.compose-textarea::placeholder { color: var(--text-muted); }
|
|
880
|
+
|
|
881
|
+
.compose-preview {
|
|
882
|
+
flex: 1;
|
|
883
|
+
min-height: 0;
|
|
884
|
+
overflow-y: auto;
|
|
885
|
+
padding: 16px 20px;
|
|
886
|
+
line-height: 1.6;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
.compose-overlay-footer {
|
|
890
|
+
display: flex;
|
|
891
|
+
align-items: center;
|
|
892
|
+
justify-content: flex-end;
|
|
893
|
+
gap: 8px;
|
|
894
|
+
padding: 10px 16px;
|
|
895
|
+
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
|
896
|
+
border-top: 1px solid var(--border);
|
|
897
|
+
flex-shrink: 0;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/* Urgent mode inside compose overlay */
|
|
901
|
+
.compose-overlay.composer-urgent .compose-textarea { border-left: 3px solid var(--color-danger); }
|
|
902
|
+
|
|
811
903
|
/* ── Load-more sentinel ──────────────────────────────────────────────────── */
|
|
812
904
|
.load-more-sentinel {
|
|
813
905
|
height: 1px;
|
|
@@ -1804,35 +1896,80 @@ form.inline { display: inline; }
|
|
|
1804
1896
|
margin-left: 4px;
|
|
1805
1897
|
}
|
|
1806
1898
|
|
|
1899
|
+
.message-edit-wrap {
|
|
1900
|
+
grid-column: 1 / -1;
|
|
1901
|
+
border: 1px solid var(--accent);
|
|
1902
|
+
border-radius: var(--radius-md);
|
|
1903
|
+
overflow: hidden;
|
|
1904
|
+
background: var(--bg-input, var(--bg-sidebar));
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
.message-edit-tabs {
|
|
1908
|
+
display: flex;
|
|
1909
|
+
border-bottom: 1px solid var(--border);
|
|
1910
|
+
padding: 0 4px;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
.message-edit-tab {
|
|
1914
|
+
background: none;
|
|
1915
|
+
border: none;
|
|
1916
|
+
border-bottom: 2px solid transparent;
|
|
1917
|
+
padding: 6px 12px;
|
|
1918
|
+
cursor: pointer;
|
|
1919
|
+
color: var(--text-secondary);
|
|
1920
|
+
font-size: 12px;
|
|
1921
|
+
font-weight: 500;
|
|
1922
|
+
margin-bottom: -1px;
|
|
1923
|
+
transition: color var(--transition), border-color var(--transition);
|
|
1924
|
+
}
|
|
1925
|
+
.message-edit-tab:hover { color: var(--text-primary); }
|
|
1926
|
+
.message-edit-tab--active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
1927
|
+
|
|
1928
|
+
.message-edit-input[hidden] { display: none; }
|
|
1807
1929
|
.message-edit-input {
|
|
1930
|
+
display: block;
|
|
1808
1931
|
width: 100%;
|
|
1809
|
-
min-height:
|
|
1810
|
-
|
|
1932
|
+
min-height: 80px;
|
|
1933
|
+
max-height: 320px;
|
|
1934
|
+
padding: 10px 12px;
|
|
1811
1935
|
font: inherit;
|
|
1812
1936
|
font-size: inherit;
|
|
1813
1937
|
line-height: 1.5;
|
|
1814
|
-
border:
|
|
1815
|
-
|
|
1816
|
-
background: var(--bg-input, var(--bg-sidebar));
|
|
1938
|
+
border: none;
|
|
1939
|
+
background: transparent;
|
|
1817
1940
|
color: var(--text-primary);
|
|
1818
1941
|
resize: vertical;
|
|
1819
1942
|
box-sizing: border-box;
|
|
1820
|
-
|
|
1943
|
+
outline: none;
|
|
1944
|
+
overflow-y: auto;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
.message-edit-preview {
|
|
1948
|
+
min-height: 80px;
|
|
1949
|
+
max-height: 320px;
|
|
1950
|
+
padding: 10px 12px;
|
|
1951
|
+
overflow-y: auto;
|
|
1821
1952
|
}
|
|
1822
1953
|
|
|
1823
1954
|
.message-edit-toolbar {
|
|
1824
1955
|
display: flex;
|
|
1956
|
+
align-items: center;
|
|
1825
1957
|
gap: 6px;
|
|
1826
|
-
|
|
1827
|
-
|
|
1958
|
+
padding: 8px 10px;
|
|
1959
|
+
border-top: 1px solid var(--border);
|
|
1960
|
+
}
|
|
1961
|
+
.message-edit-hint {
|
|
1962
|
+
font-size: 11px;
|
|
1963
|
+
color: var(--text-muted);
|
|
1964
|
+
margin-right: auto;
|
|
1828
1965
|
}
|
|
1829
1966
|
.message-edit-toolbar .btn-primary {
|
|
1830
|
-
padding:
|
|
1831
|
-
font-size:
|
|
1967
|
+
padding: 5px 14px;
|
|
1968
|
+
font-size: 13px;
|
|
1832
1969
|
font-weight: 600;
|
|
1833
1970
|
}
|
|
1834
1971
|
.message-edit-toolbar .btn-ghost {
|
|
1835
|
-
font-size:
|
|
1972
|
+
font-size: 13px;
|
|
1836
1973
|
}
|
|
1837
1974
|
|
|
1838
1975
|
/* Hover action toolbar — floats above the message on hover (Discord-style) */
|
|
@@ -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
|
}
|