@devchitchat/chat 4.2.3 → 4.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devchitchat/chat",
3
- "version": "4.2.3",
3
+ "version": "4.3.0",
4
4
  "description": "A small chat app. p2p video and screenshare.",
5
5
  "scripts": {
6
6
  "dev": "bun --watch index.js",
@@ -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
+ }
@@ -11,7 +11,7 @@
11
11
  <ul each="channels" key="channel_id" class="channel-list">
12
12
  <template>
13
13
  <li class="channel-item" cls="className" draggable="true">
14
- <a attr="href:url" text="name" class="channel-link"
14
+ <a attr="href:url; title:name" text="name" class="channel-link"
15
15
  data-channel-id=""
16
16
  data-channel-name=""
17
17
  data-channel-topic=""
@@ -23,8 +23,9 @@
23
23
  </template>
24
24
  {{#each channels}}
25
25
  <li data-key="{{channel_id}}" class="channel-item {{className}}" cls="className" draggable="true">
26
- <a attr="href:url" text="name" class="channel-link"
26
+ <a attr="href:url; title:name" text="name" class="channel-link"
27
27
  href="{{base}}/channels/{{channel_id}}"
28
+ title="{{name}}"
28
29
  data-channel-id="{{channel_id}}"
29
30
  data-channel-name="{{name}}"
30
31
  data-channel-topic="{{topic}}"
@@ -157,8 +158,36 @@
157
158
  <textarea model="draft" id="message-input" placeholder="Message in {{channel.name}}" aria-label="Write a message"
158
159
  rows="1" onkeydown="handleComposerKey">
159
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">&#x26F6;</button>
160
163
  <button onclick="sendMessage" class="btn-send" aria-label="Send message" type="button">Send</button>
161
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">&#x2715;</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">&#x1F514;</button>
185
+ <button class="btn-attach btn-icon" id="compose-attach" type="button"
186
+ title="Attach file" aria-label="Attach file">&#x1F4CE;</button>
187
+ <button class="btn-send" id="compose-send" type="button">Send</button>
188
+ </footer>
189
+ </div>
190
+
162
191
  </section><!-- /.chat-panel -->
163
192
 
164
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
- chipsEl.innerHTML = pendingAttachments.map((a, i) => `
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
- const idx = parseInt(btn.dataset.index, 10)
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
- chipsEl.appendChild(chip)
608
- chipsEl.hidden = false
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-input')) return // already editing
1017
- const textEl = article.querySelector('.message-text')
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
- textEl.replaceWith(textarea)
1025
- textarea.focus()
1026
- textarea.setSelectionRange(rawText.length, rawText.length)
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 = '<button class="btn-edit-save btn-primary" type="button">Save</button><button class="btn-edit-cancel btn-ghost" type="button">Cancel</button>'
1031
- textarea.after(toolbar)
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
- textarea.replaceWith(textEl)
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' && !e.shiftKey) { e.preventDefault(); save() }
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
  }
@@ -92,15 +92,19 @@ details summary {
92
92
  color: var(--text-secondary);
93
93
  text-decoration: none;
94
94
  flex: 1;
95
+ min-width: 0;
95
96
  height: 2.3rem;
96
- display: flex;
97
- align-items: center;
97
+ display: block;
98
+ line-height: 2.3rem;
98
99
  padding-left: .7rem;
99
100
  padding-right: .7rem;
100
101
  user-select: none;
101
102
  -webkit-user-select: none;
102
103
  -webkit-touch-callout: none;
103
104
  transition: color var(--transition), background var(--transition);
105
+ overflow: hidden;
106
+ white-space: nowrap;
107
+ text-overflow: ellipsis;
104
108
  }
105
109
 
106
110
  .channel-item a:hover {
@@ -753,6 +757,7 @@ details summary {
753
757
  border-radius: 0;
754
758
  }
755
759
 
760
+ .composer[hidden] { display: none; }
756
761
  .composer {
757
762
  margin-top: auto;
758
763
  display: flex;
@@ -804,6 +809,97 @@ details summary {
804
809
  .composer.composer-urgent textarea,
805
810
  .composer.composer-urgent textarea:focus { border-color: var(--color-danger); }
806
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
+
807
903
  /* ── Load-more sentinel ──────────────────────────────────────────────────── */
808
904
  .load-more-sentinel {
809
905
  height: 1px;
@@ -1800,35 +1896,80 @@ form.inline { display: inline; }
1800
1896
  margin-left: 4px;
1801
1897
  }
1802
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; }
1803
1929
  .message-edit-input {
1930
+ display: block;
1804
1931
  width: 100%;
1805
- min-height: 60px;
1806
- padding: 6px 8px;
1932
+ min-height: 80px;
1933
+ max-height: 320px;
1934
+ padding: 10px 12px;
1807
1935
  font: inherit;
1808
1936
  font-size: inherit;
1809
1937
  line-height: 1.5;
1810
- border: 1px solid var(--border);
1811
- border-radius: var(--radius-sm);
1812
- background: var(--bg-input, var(--bg-sidebar));
1938
+ border: none;
1939
+ background: transparent;
1813
1940
  color: var(--text-primary);
1814
1941
  resize: vertical;
1815
1942
  box-sizing: border-box;
1816
- grid-column: 1 / -1;
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;
1817
1952
  }
1818
1953
 
1819
1954
  .message-edit-toolbar {
1820
1955
  display: flex;
1956
+ align-items: center;
1821
1957
  gap: 6px;
1822
- margin-top: 6px;
1823
- grid-column: 1 / -1;
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;
1824
1965
  }
1825
1966
  .message-edit-toolbar .btn-primary {
1826
- padding: 6px 14px;
1827
- font-size: 14px;
1967
+ padding: 5px 14px;
1968
+ font-size: 13px;
1828
1969
  font-weight: 600;
1829
1970
  }
1830
1971
  .message-edit-toolbar .btn-ghost {
1831
- font-size: 14px;
1972
+ font-size: 13px;
1832
1973
  }
1833
1974
 
1834
1975
  /* Hover action toolbar — floats above the message on hover (Discord-style) */