ajaxter-chat 3.0.9 → 3.0.11

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.
@@ -1,10 +1,9 @@
1
1
  import React, { useState, useRef, useEffect, useCallback } from 'react';
2
- import { ChatMessage, ChatUser, WidgetConfig, UserListContext } from '../../types';
2
+ import { ChatMessage, ChatUser, WidgetConfig, UserListContext, ChatType } from '../../types';
3
3
  import { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText } from '../../utils/chat';
4
4
  import { shortAttachmentLabel } from '../../utils/fileName';
5
5
  import { shouldShowPrivacyNotice, dismissPrivacyNotice } from '../../utils/privacyConsent';
6
6
  import { EmojiPicker } from '../EmojiPicker';
7
- import { SlideNavMenu } from '../SlideNavMenu';
8
7
 
9
8
  interface ChatScreenProps {
10
9
  activeUser: ChatUser;
@@ -39,12 +38,13 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
39
38
  const [text, setText] = useState('');
40
39
  const [showEmoji, setShowEmoji] = useState(false);
41
40
  const [showMenu, setShowMenu] = useState(false);
42
- const [slideMenuOpen, setSlideMenuOpen] = useState(false);
43
41
  const [transferOpen, setTransferOpen] = useState(false);
44
42
  const [isRecording, setIsRecording] = useState(false);
45
43
  const [recordSec, setRecordSec] = useState(0);
46
44
  const [showConfirm, setShowConfirm] = useState<'report'|'block'|'pause'|null>(null);
47
45
  const [showPrivacy, setShowPrivacy] = useState(false);
46
+ const [pendingAttach, setPendingAttach] = useState<{ file: File; url: string } | null>(null);
47
+ const [waveBars, setWaveBars] = useState<number[]>(() => Array(24).fill(0.08));
48
48
 
49
49
  const endRef = useRef<HTMLDivElement>(null);
50
50
  const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -52,6 +52,11 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
52
52
  const recordTimer = useRef<ReturnType<typeof setInterval> | null>(null);
53
53
  const mediaRecorder = useRef<MediaRecorder | null>(null);
54
54
  const recordChunks = useRef<BlobPart[]>([]);
55
+ const discardRecordingRef = useRef(false);
56
+ const waveStreamRef = useRef<MediaStream | null>(null);
57
+ const audioCtxRef = useRef<AudioContext | null>(null);
58
+ const analyserRef = useRef<AnalyserNode | null>(null);
59
+ const waveRafRef = useRef<number>(0);
55
60
 
56
61
  useEffect(() => { endRef.current?.scrollIntoView({ behavior:'smooth' }); }, [messages]);
57
62
 
@@ -74,56 +79,177 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
74
79
  setShowPrivacy(false);
75
80
  }, [config.id]);
76
81
 
82
+ const clearPendingAttach = useCallback((revoke: boolean) => {
83
+ setPendingAttach(prev => {
84
+ if (prev && revoke) URL.revokeObjectURL(prev.url);
85
+ return null;
86
+ });
87
+ }, []);
88
+
77
89
  const handleSend = useCallback(() => {
78
- if (!text.trim() || isPaused || isBlocked) return;
90
+ if (isPaused || isBlocked) return;
91
+ if (pendingAttach) {
92
+ const { file, url } = pendingAttach;
93
+ const body = text.trim();
94
+ onSend(body || ' ', 'attachment', {
95
+ attachmentName: file.name,
96
+ attachmentSize: `${(file.size / 1024).toFixed(1)} KB`,
97
+ attachmentUrl: url,
98
+ attachmentMime: file.type,
99
+ });
100
+ setPendingAttach(null);
101
+ setText('');
102
+ inputRef.current?.focus();
103
+ return;
104
+ }
105
+ if (!text.trim()) return;
79
106
  onSend(text.trim());
80
107
  setText('');
81
108
  inputRef.current?.focus();
82
- }, [text, isPaused, isBlocked, onSend]);
109
+ }, [text, isPaused, isBlocked, onSend, pendingAttach]);
83
110
 
84
111
  const handleKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
85
112
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
86
113
  };
87
114
 
115
+ const recordSecRef = useRef(0);
116
+
117
+ const stopWaveLoop = useCallback(() => {
118
+ if (waveRafRef.current) {
119
+ cancelAnimationFrame(waveRafRef.current);
120
+ waveRafRef.current = 0;
121
+ }
122
+ analyserRef.current = null;
123
+ void audioCtxRef.current?.close();
124
+ audioCtxRef.current = null;
125
+ waveStreamRef.current = null;
126
+ setWaveBars(Array(24).fill(0.08));
127
+ }, []);
128
+
88
129
  const startRecording = async () => {
89
130
  if (isPaused || isBlocked) return;
90
131
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
132
+ waveStreamRef.current = stream;
133
+ discardRecordingRef.current = false;
134
+ setRecordSec(0);
135
+ recordSecRef.current = 0;
136
+
137
+ try {
138
+ const audioCtx = new AudioContext();
139
+ await audioCtx.resume();
140
+ audioCtxRef.current = audioCtx;
141
+ const source = audioCtx.createMediaStreamSource(stream);
142
+ const analyser = audioCtx.createAnalyser();
143
+ analyser.fftSize = 128;
144
+ analyser.smoothingTimeConstant = 0.65;
145
+ source.connect(analyser);
146
+ analyserRef.current = analyser;
147
+ const data = new Uint8Array(analyser.frequencyBinCount);
148
+ const tick = () => {
149
+ const a = analyserRef.current;
150
+ if (!a) return;
151
+ a.getByteFrequencyData(data);
152
+ const bars: number[] = [];
153
+ const step = Math.max(1, Math.floor(data.length / 24));
154
+ for (let i = 0; i < 24; i++) {
155
+ const v = data[Math.min(i * step, data.length - 1)] / 255;
156
+ bars.push(Math.max(0.08, v));
157
+ }
158
+ setWaveBars(bars);
159
+ waveRafRef.current = requestAnimationFrame(tick);
160
+ };
161
+ waveRafRef.current = requestAnimationFrame(tick);
162
+ } catch {
163
+ /* optional waveform */
164
+ }
165
+
91
166
  recordChunks.current = [];
92
167
  const mr = new MediaRecorder(stream);
93
168
  mediaRecorder.current = mr;
94
169
  mr.ondataavailable = e => { if (e.data.size) recordChunks.current.push(e.data); };
95
170
  mr.onstop = () => {
171
+ stopWaveLoop();
96
172
  stream.getTracks().forEach(t => t.stop());
97
173
  const chunks = recordChunks.current;
174
+ if (discardRecordingRef.current) {
175
+ discardRecordingRef.current = false;
176
+ setRecordSec(0);
177
+ recordSecRef.current = 0;
178
+ return;
179
+ }
98
180
  if (!chunks.length) {
99
181
  setRecordSec(0);
182
+ recordSecRef.current = 0;
100
183
  return;
101
184
  }
102
185
  const blob = new Blob(chunks, { type: chunks[0] instanceof Blob ? (chunks[0] as Blob).type : 'audio/webm' });
103
186
  const voiceUrl = URL.createObjectURL(blob);
104
- const dur = Math.max(1, recordSec);
187
+ const dur = Math.max(1, recordSecRef.current);
105
188
  onSend('Voice message', 'voice', { voiceDuration: dur, voiceUrl });
106
189
  setRecordSec(0);
190
+ recordSecRef.current = 0;
107
191
  };
108
192
  mr.start(200);
109
193
  setIsRecording(true);
110
- recordTimer.current = setInterval(() => setRecordSec(s => s + 1), 1000);
194
+ recordTimer.current = setInterval(() => {
195
+ setRecordSec(s => {
196
+ const n = s + 1;
197
+ recordSecRef.current = n;
198
+ return n;
199
+ });
200
+ }, 1000);
111
201
  };
112
202
 
113
- const stopRecording = () => {
203
+ const cancelRecording = () => {
204
+ if (!isRecording) return;
205
+ discardRecordingRef.current = true;
206
+ if (recordTimer.current) {
207
+ clearInterval(recordTimer.current);
208
+ recordTimer.current = null;
209
+ }
114
210
  mediaRecorder.current?.stop();
115
- if (recordTimer.current) clearInterval(recordTimer.current);
116
211
  setIsRecording(false);
117
212
  };
118
213
 
214
+ const stopRecordingSend = () => {
215
+ if (!isRecording) return;
216
+ discardRecordingRef.current = false;
217
+ if (recordTimer.current) {
218
+ clearInterval(recordTimer.current);
219
+ recordTimer.current = null;
220
+ }
221
+ mediaRecorder.current?.stop();
222
+ setIsRecording(false);
223
+ };
224
+
225
+ const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
226
+ if (isPaused || isBlocked || !config.allowAttachment) return;
227
+ const items = e.clipboardData?.items;
228
+ if (!items?.length) return;
229
+ for (let i = 0; i < items.length; i++) {
230
+ const item = items[i];
231
+ if (item.kind === 'file' && item.type.startsWith('image/')) {
232
+ const f = item.getAsFile();
233
+ if (f) {
234
+ e.preventDefault();
235
+ const url = URL.createObjectURL(f);
236
+ setPendingAttach(prev => {
237
+ if (prev) URL.revokeObjectURL(prev.url);
238
+ return { file: f, url };
239
+ });
240
+ return;
241
+ }
242
+ }
243
+ }
244
+ };
245
+
119
246
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
120
247
  const file = e.target.files?.[0];
121
248
  if (!file || isPaused || isBlocked) return;
122
- const attachmentUrl = URL.createObjectURL(file);
123
- onSend(file.name, 'attachment', {
124
- attachmentName: file.name,
125
- attachmentSize: `${(file.size / 1024).toFixed(1)} KB`,
126
- attachmentUrl,
249
+ const url = URL.createObjectURL(file);
250
+ setPendingAttach(prev => {
251
+ if (prev) URL.revokeObjectURL(prev.url);
252
+ return { file, url };
127
253
  });
128
254
  e.target.value = '';
129
255
  };
@@ -154,26 +280,14 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
154
280
  return (
155
281
  <div style={{ display:'flex', flexDirection:'column', height:'100%', animation:'cw-slideIn 0.22s ease', position:'relative', overflow: 'hidden' }}>
156
282
 
157
- <SlideNavMenu
158
- open={slideMenuOpen}
159
- onClose={() => setSlideMenuOpen(false)}
160
- primaryColor={config.primaryColor}
161
- chatType={config.chatType}
162
- viewerType={config.viewerType ?? 'user'}
163
- onSelect={onNavAction}
164
- onBackHome={onBack}
165
- />
166
-
167
283
  {/* ── Header ── */}
168
284
  <div style={{
169
285
  background:`linear-gradient(135deg,${config.primaryColor},${config.primaryColor}cc)`,
170
286
  padding:'10px 12px', display:'flex', alignItems:'center', gap:8, flexShrink:0,
171
287
  }}>
172
- <button type="button" onClick={() => setSlideMenuOpen(true)} style={hdrBtn} aria-label="Open menu">
288
+ <button type="button" onClick={onBack} style={hdrBtn} aria-label="Back">
173
289
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
174
- <line x1="3" y1="6" x2="21" y2="6" stroke="#fff" strokeWidth="2.2" strokeLinecap="round"/>
175
- <line x1="3" y1="12" x2="21" y2="12" stroke="#fff" strokeWidth="2.2" strokeLinecap="round"/>
176
- <line x1="3" y1="18" x2="21" y2="18" stroke="#fff" strokeWidth="2.2" strokeLinecap="round"/>
290
+ <path d="M19 12H5M5 12L12 19M5 12L12 5" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
177
291
  </svg>
178
292
  </button>
179
293
 
@@ -252,6 +366,15 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
252
366
 
253
367
  {showMenu && (
254
368
  <div style={{ position:'absolute', top:52, right:12, zIndex:120, background:'#fff', borderRadius:12, boxShadow:'0 8px 30px rgba(0,0,0,0.16)', padding:'6px', minWidth:200, animation:'cw-fadeUp 0.18s ease' }}>
369
+ {navEntriesForChat(config.chatType, viewerIsDev).map(item => (
370
+ <MenuItem
371
+ key={item.key}
372
+ icon={item.icon}
373
+ label={item.label}
374
+ onClick={() => { setShowMenu(false); onNavAction(item.key); }}
375
+ />
376
+ ))}
377
+ <div style={{ borderTop:'1px solid #f0f2f5', margin:'4px 0' }} />
255
378
  {config.allowTranscriptDownload && (
256
379
  <MenuItem icon="📥" label="Download Transcript" onClick={handleTranscript} />
257
380
  )}
@@ -377,10 +500,91 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
377
500
  )}
378
501
 
379
502
  {isRecording && (
380
- <div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8, padding:'8px 12px', background:'#fee2e2', borderRadius:10 }}>
381
- <span style={{ width:8, height:8, borderRadius:'50%', background:'#ef4444', display:'inline-block', animation:'cw-pulse 1s infinite' }} />
382
- <span style={{ fontSize:13, color:'#991b1b', fontWeight:600 }}>Recording {recordSec}s</span>
383
- <button type="button" onClick={stopRecording} style={{ marginLeft:'auto', background:'#ef4444', color:'#fff', border:'none', borderRadius:6, padding:'4px 12px', fontSize:12, cursor:'pointer', fontWeight:600 }}>Stop & send</button>
503
+ <div
504
+ style={{
505
+ marginBottom: 10,
506
+ padding: '12px 12px 14px',
507
+ background: '#fff',
508
+ borderRadius: 14,
509
+ border: '1px solid #e8ecf1',
510
+ boxShadow: '0 1px 4px rgba(15,23,42,0.06)',
511
+ }}
512
+ >
513
+ <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10, marginBottom: 10 }}>
514
+ <button
515
+ type="button"
516
+ onClick={cancelRecording}
517
+ title="Discard recording"
518
+ aria-label="Discard recording"
519
+ style={{
520
+ background: 'none',
521
+ border: 'none',
522
+ cursor: 'pointer',
523
+ padding: 6,
524
+ lineHeight: 0,
525
+ flexShrink: 0,
526
+ }}
527
+ >
528
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
529
+ <path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" />
530
+ <path d="M10 11v6M14 11v6" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" />
531
+ </svg>
532
+ </button>
533
+ <div style={{ display: 'flex', alignItems: 'flex-end', gap: 3, height: 44, flex: 1, justifyContent: 'flex-end', minWidth: 0 }}>
534
+ {waveBars.map((h, i) => (
535
+ <span
536
+ key={i}
537
+ style={{
538
+ width: 3,
539
+ borderRadius: 2,
540
+ background: '#cbd5e1',
541
+ height: `${8 + h * 36}px`,
542
+ transition: 'height 0.05s ease-out',
543
+ }}
544
+ />
545
+ ))}
546
+ </div>
547
+ </div>
548
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
549
+ <div style={{ flex: 1 }} />
550
+ <div
551
+ style={{
552
+ background: '#ef4444',
553
+ color: '#fff',
554
+ fontWeight: 700,
555
+ fontSize: 13,
556
+ padding: '6px 14px',
557
+ borderRadius: 999,
558
+ minWidth: 52,
559
+ textAlign: 'center',
560
+ }}
561
+ >
562
+ {fmtTime(recordSec)}
563
+ </div>
564
+ <button
565
+ type="button"
566
+ onClick={stopRecordingSend}
567
+ title="Send voice message"
568
+ aria-label="Send voice message"
569
+ style={{
570
+ width: 44,
571
+ height: 44,
572
+ borderRadius: '50%',
573
+ border: 'none',
574
+ background: config.primaryColor,
575
+ cursor: 'pointer',
576
+ display: 'flex',
577
+ alignItems: 'center',
578
+ justifyContent: 'center',
579
+ flexShrink: 0,
580
+ boxShadow: `0 4px 14px ${config.primaryColor}55`,
581
+ }}
582
+ >
583
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
584
+ <path d="M22 2L11 13M22 2L15 22L11 13L2 9L22 2Z" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
585
+ </svg>
586
+ </button>
587
+ </div>
384
588
  </div>
385
589
  )}
386
590
 
@@ -390,11 +594,79 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
390
594
  padding: '10px 12px 8px',
391
595
  background: isPaused || isBlocked ? '#f9fafb' : '#fff',
392
596
  }}>
597
+ {pendingAttach && (
598
+ <div
599
+ style={{
600
+ display: 'flex',
601
+ alignItems: 'center',
602
+ gap: 10,
603
+ marginBottom: 10,
604
+ padding: '8px 10px',
605
+ borderRadius: 10,
606
+ background: '#f8fafc',
607
+ border: '1px solid #fecaca',
608
+ position: 'relative',
609
+ }}
610
+ >
611
+ <div
612
+ style={{
613
+ width: 40,
614
+ height: 40,
615
+ borderRadius: 8,
616
+ background: config.primaryColor,
617
+ display: 'flex',
618
+ alignItems: 'center',
619
+ justifyContent: 'center',
620
+ flexShrink: 0,
621
+ }}
622
+ >
623
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
624
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66L9.41 17.41a2 2 0 01-2.83-2.83l8.49-8.48" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
625
+ </svg>
626
+ </div>
627
+ <div style={{ flex: 1, minWidth: 0 }}>
628
+ <div style={{ fontWeight: 700, fontSize: 13, color: '#1a2332', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={pendingAttach.file.name}>
629
+ {pendingAttach.file.name}
630
+ </div>
631
+ <div style={{ fontSize: 11, color: '#94a3b8', fontWeight: 600, textTransform: 'uppercase' }}>
632
+ {(pendingAttach.file.type.split('/')[1] || 'file').slice(0, 8)}
633
+ </div>
634
+ </div>
635
+ <button
636
+ type="button"
637
+ onClick={() => clearPendingAttach(true)}
638
+ title="Remove attachment"
639
+ aria-label="Remove attachment"
640
+ style={{
641
+ position: 'absolute',
642
+ top: 6,
643
+ right: 6,
644
+ width: 22,
645
+ height: 22,
646
+ borderRadius: '50%',
647
+ border: 'none',
648
+ background: '#ef4444',
649
+ color: '#fff',
650
+ cursor: 'pointer',
651
+ fontSize: 15,
652
+ fontWeight: 700,
653
+ lineHeight: 1,
654
+ display: 'flex',
655
+ alignItems: 'center',
656
+ justifyContent: 'center',
657
+ padding: 0,
658
+ }}
659
+ >
660
+ ×
661
+ </button>
662
+ </div>
663
+ )}
393
664
  <textarea
394
665
  ref={inputRef}
395
666
  value={text}
396
667
  onChange={e => setText(e.target.value)}
397
668
  onKeyDown={handleKey}
669
+ onPaste={handlePaste}
398
670
  placeholder={isPaused || isBlocked ? 'Chat is unavailable' : 'Compose your message…'}
399
671
  disabled={isPaused || isBlocked || isRecording}
400
672
  rows={2}
@@ -446,14 +718,14 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
446
718
  <button
447
719
  type="button"
448
720
  onClick={handleSend}
449
- disabled={!text.trim() || isPaused || isBlocked || isRecording}
721
+ disabled={(!text.trim() && !pendingAttach) || isPaused || isBlocked || isRecording}
450
722
  style={{
451
723
  width: 36,
452
724
  height: 36,
453
725
  borderRadius: '50%',
454
- backgroundColor: text.trim() && !isPaused && !isBlocked ? config.primaryColor : '#e2e8f0',
726
+ backgroundColor: (text.trim() || pendingAttach) && !isPaused && !isBlocked ? config.primaryColor : '#e2e8f0',
455
727
  border: 'none',
456
- cursor: text.trim() && !isPaused && !isBlocked ? 'pointer' : 'default',
728
+ cursor: (text.trim() || pendingAttach) && !isPaused && !isBlocked ? 'pointer' : 'default',
457
729
  display: 'flex',
458
730
  alignItems: 'center',
459
731
  justifyContent: 'center',
@@ -463,7 +735,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
463
735
  title="Send"
464
736
  >
465
737
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
466
- <path d="M22 2L11 13M22 2L15 22L11 13L2 9L22 2Z" stroke={text.trim() && !isPaused && !isBlocked ? '#fff' : '#94a3b8'} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
738
+ <path d="M22 2L11 13M22 2L15 22L11 13L2 9L22 2Z" stroke={(text.trim() || pendingAttach) && !isPaused && !isBlocked ? '#fff' : '#94a3b8'} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
467
739
  </svg>
468
740
  </button>
469
741
  </div>
@@ -688,33 +960,48 @@ const AttachmentRow: React.FC<{ msg: ChatMessage; isMe: boolean; primaryColor: s
688
960
  const name = msg.attachmentName ?? 'File';
689
961
  const href = msg.attachmentUrl;
690
962
  const label = shortAttachmentLabel(name, 10);
963
+ const mime = msg.attachmentMime ?? '';
964
+ const isImage = mime.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(name);
691
965
  return (
692
- <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
693
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
694
- <path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66L9.41 17.41a2 2 0 01-2.83-2.83l8.49-8.48" stroke={isMe ? '#fff' : '#334155'} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
695
- </svg>
696
- <div style={{ flex: 1, minWidth: 0 }}>
697
- {href ? (
698
- <a
699
- href={href}
700
- download={name}
701
- title={name}
702
- style={{
703
- fontWeight: 700,
704
- fontSize: 14,
705
- wordBreak: 'break-word',
706
- color: isMe ? '#fff' : primaryColor,
707
- textDecoration: 'underline',
708
- }}
709
- >
710
- [{label}]
711
- </a>
712
- ) : (
713
- <div style={{ fontWeight: 700, fontSize: 14, wordBreak: 'break-word' }} title={name}>
714
- [{label}]
715
- </div>
966
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'stretch', gap: 8, flexWrap: 'wrap' }}>
967
+ {isImage && href && (
968
+ <a href={href} download={name} title={name} style={{ alignSelf: 'flex-start', lineHeight: 0 }}>
969
+ <img
970
+ src={href}
971
+ alt=""
972
+ style={{ maxWidth: 220, maxHeight: 200, borderRadius: 10, objectFit: 'cover', display: 'block' }}
973
+ />
974
+ </a>
975
+ )}
976
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
977
+ {!isImage && (
978
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
979
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66L9.41 17.41a2 2 0 01-2.83-2.83l8.49-8.48" stroke={isMe ? '#fff' : '#334155'} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
980
+ </svg>
716
981
  )}
717
- {msg.attachmentSize && <div style={{ fontSize: 11, opacity: 0.8 }}>{msg.attachmentSize}</div>}
982
+ <div style={{ flex: 1, minWidth: 0 }}>
983
+ {href ? (
984
+ <a
985
+ href={href}
986
+ download={name}
987
+ title={name}
988
+ style={{
989
+ fontWeight: 700,
990
+ fontSize: 14,
991
+ wordBreak: 'break-word',
992
+ color: isMe ? '#fff' : primaryColor,
993
+ textDecoration: 'underline',
994
+ }}
995
+ >
996
+ [{label}]
997
+ </a>
998
+ ) : (
999
+ <div style={{ fontWeight: 700, fontSize: 14, wordBreak: 'break-word' }} title={name}>
1000
+ [{label}]
1001
+ </div>
1002
+ )}
1003
+ {msg.attachmentSize && <div style={{ fontSize: 11, opacity: 0.8 }}>{msg.attachmentSize}</div>}
1004
+ </div>
718
1005
  </div>
719
1006
  </div>
720
1007
  );
@@ -723,10 +1010,16 @@ const AttachmentRow: React.FC<{ msg: ChatMessage; isMe: boolean; primaryColor: s
723
1010
  const Bubble: React.FC<{ msg: ChatMessage; primaryColor: string }> = ({ msg, primaryColor }) => {
724
1011
  const isMe = msg.senderId === 'me';
725
1012
 
1013
+ const caption = msg.text.trim();
726
1014
  const content = msg.type === 'voice' ? (
727
1015
  <VoiceRow msg={msg} isMe={isMe} primaryColor={primaryColor} />
728
1016
  ) : msg.type === 'attachment' ? (
729
- <AttachmentRow msg={msg} isMe={isMe} primaryColor={primaryColor} />
1017
+ <>
1018
+ <AttachmentRow msg={msg} isMe={isMe} primaryColor={primaryColor} />
1019
+ {caption && caption !== ' ' && (
1020
+ <div style={{ marginTop: 6, fontSize: 14, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{msg.text}</div>
1021
+ )}
1022
+ </>
730
1023
  ) : (
731
1024
  <span>{msg.text}</span>
732
1025
  );
@@ -788,6 +1081,16 @@ const hdrBtn: React.CSSProperties = {
788
1081
  cursor:'pointer', flexShrink:0,
789
1082
  };
790
1083
 
1084
+ function navEntriesForChat(chatType: ChatType, isStaff: boolean): { key: UserListContext | 'ticket'; label: string; icon: string }[] {
1085
+ const showSupport = chatType === 'SUPPORT' || chatType === 'BOTH';
1086
+ const showChat = chatType === 'CHAT' || chatType === 'BOTH';
1087
+ const items: { key: UserListContext | 'ticket'; label: string; icon: string }[] = [];
1088
+ if (showSupport) items.push({ key: 'support', icon: '🛠', label: isStaff ? 'Provide Support' : 'Need Support' });
1089
+ if (showChat) items.push({ key: 'conversation', icon: '💬', label: isStaff ? 'Chat with developer' : 'New Conversation' });
1090
+ items.push({ key: 'ticket', icon: '🎫', label: 'Raise ticket' });
1091
+ return items;
1092
+ }
1093
+
791
1094
  function groupByDate(messages: ChatMessage[]): { date: string; msgs: ChatMessage[] }[] {
792
1095
  const map = new Map<string, ChatMessage[]>();
793
1096
  messages.forEach(m => {