ajaxter-chat 3.0.8 → 3.0.10

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.
Files changed (35) hide show
  1. package/dist/components/ChatScreen/index.d.ts +2 -0
  2. package/dist/components/ChatScreen/index.js +283 -34
  3. package/dist/components/ChatWidget.js +111 -15
  4. package/dist/components/HomeScreen/index.d.ts +2 -0
  5. package/dist/components/HomeScreen/index.js +2 -2
  6. package/dist/components/Tabs/BottomTabs.d.ts +0 -1
  7. package/dist/components/Tabs/BottomTabs.js +13 -20
  8. package/dist/components/TicketDetailScreen/index.d.ts +9 -0
  9. package/dist/components/TicketDetailScreen/index.js +46 -0
  10. package/dist/components/TicketFormScreen/index.d.ts +9 -0
  11. package/dist/components/TicketFormScreen/index.js +76 -0
  12. package/dist/components/TicketScreen/index.d.ts +2 -1
  13. package/dist/components/TicketScreen/index.js +8 -35
  14. package/dist/components/UserListScreen/index.d.ts +4 -0
  15. package/dist/components/UserListScreen/index.js +21 -3
  16. package/dist/types/index.d.ts +3 -1
  17. package/dist/utils/fileName.d.ts +2 -0
  18. package/dist/utils/fileName.js +7 -0
  19. package/dist/utils/messageSound.d.ts +4 -0
  20. package/dist/utils/messageSound.js +51 -0
  21. package/dist/utils/widgetSession.d.ts +13 -0
  22. package/dist/utils/widgetSession.js +24 -0
  23. package/package.json +1 -1
  24. package/src/components/ChatScreen/index.tsx +415 -58
  25. package/src/components/ChatWidget.tsx +140 -17
  26. package/src/components/HomeScreen/index.tsx +4 -2
  27. package/src/components/Tabs/BottomTabs.tsx +2 -22
  28. package/src/components/TicketDetailScreen/index.tsx +111 -0
  29. package/src/components/TicketFormScreen/index.tsx +151 -0
  30. package/src/components/TicketScreen/index.tsx +18 -58
  31. package/src/components/UserListScreen/index.tsx +51 -5
  32. package/src/types/index.ts +4 -0
  33. package/src/utils/fileName.ts +6 -0
  34. package/src/utils/messageSound.ts +47 -0
  35. package/src/utils/widgetSession.ts +34 -0
@@ -1,9 +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
+ import { shortAttachmentLabel } from '../../utils/fileName';
4
5
  import { shouldShowPrivacyNotice, dismissPrivacyNotice } from '../../utils/privacyConsent';
5
6
  import { EmojiPicker } from '../EmojiPicker';
6
- import { SlideNavMenu } from '../SlideNavMenu';
7
7
 
8
8
  interface ChatScreenProps {
9
9
  activeUser: ChatUser;
@@ -24,22 +24,27 @@ interface ChatScreenProps {
24
24
  /** Other devs (excl. viewer) — for transfer when staff chats with a customer */
25
25
  otherDevelopers?: ChatUser[];
26
26
  onTransferToDeveloper?: (dev: ChatUser) => void;
27
+ messageSoundEnabled?: boolean;
28
+ onToggleMessageSound?: (enabled: boolean) => void;
27
29
  }
28
30
 
29
31
  export const ChatScreen: React.FC<ChatScreenProps> = ({
30
32
  activeUser, messages, config, isPaused, isReported, isBlocked,
31
33
  onSend, onBack, onClose, onTogglePause, onReport, onBlock, onStartCall, onNavAction,
32
34
  otherDevelopers = [], onTransferToDeveloper,
35
+ messageSoundEnabled = true,
36
+ onToggleMessageSound,
33
37
  }) => {
34
38
  const [text, setText] = useState('');
35
39
  const [showEmoji, setShowEmoji] = useState(false);
36
40
  const [showMenu, setShowMenu] = useState(false);
37
- const [slideMenuOpen, setSlideMenuOpen] = useState(false);
38
41
  const [transferOpen, setTransferOpen] = useState(false);
39
42
  const [isRecording, setIsRecording] = useState(false);
40
43
  const [recordSec, setRecordSec] = useState(0);
41
44
  const [showConfirm, setShowConfirm] = useState<'report'|'block'|'pause'|null>(null);
42
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));
43
48
 
44
49
  const endRef = useRef<HTMLDivElement>(null);
45
50
  const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -47,6 +52,11 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
47
52
  const recordTimer = useRef<ReturnType<typeof setInterval> | null>(null);
48
53
  const mediaRecorder = useRef<MediaRecorder | null>(null);
49
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);
50
60
 
51
61
  useEffect(() => { endRef.current?.scrollIntoView({ behavior:'smooth' }); }, [messages]);
52
62
 
@@ -69,56 +79,177 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
69
79
  setShowPrivacy(false);
70
80
  }, [config.id]);
71
81
 
82
+ const clearPendingAttach = useCallback((revoke: boolean) => {
83
+ setPendingAttach(prev => {
84
+ if (prev && revoke) URL.revokeObjectURL(prev.url);
85
+ return null;
86
+ });
87
+ }, []);
88
+
72
89
  const handleSend = useCallback(() => {
73
- 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;
74
106
  onSend(text.trim());
75
107
  setText('');
76
108
  inputRef.current?.focus();
77
- }, [text, isPaused, isBlocked, onSend]);
109
+ }, [text, isPaused, isBlocked, onSend, pendingAttach]);
78
110
 
79
111
  const handleKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
80
112
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
81
113
  };
82
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
+
83
129
  const startRecording = async () => {
84
130
  if (isPaused || isBlocked) return;
85
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
+
86
166
  recordChunks.current = [];
87
167
  const mr = new MediaRecorder(stream);
88
168
  mediaRecorder.current = mr;
89
169
  mr.ondataavailable = e => { if (e.data.size) recordChunks.current.push(e.data); };
90
170
  mr.onstop = () => {
171
+ stopWaveLoop();
91
172
  stream.getTracks().forEach(t => t.stop());
92
173
  const chunks = recordChunks.current;
174
+ if (discardRecordingRef.current) {
175
+ discardRecordingRef.current = false;
176
+ setRecordSec(0);
177
+ recordSecRef.current = 0;
178
+ return;
179
+ }
93
180
  if (!chunks.length) {
94
181
  setRecordSec(0);
182
+ recordSecRef.current = 0;
95
183
  return;
96
184
  }
97
185
  const blob = new Blob(chunks, { type: chunks[0] instanceof Blob ? (chunks[0] as Blob).type : 'audio/webm' });
98
186
  const voiceUrl = URL.createObjectURL(blob);
99
- const dur = Math.max(1, recordSec);
187
+ const dur = Math.max(1, recordSecRef.current);
100
188
  onSend('Voice message', 'voice', { voiceDuration: dur, voiceUrl });
101
189
  setRecordSec(0);
190
+ recordSecRef.current = 0;
102
191
  };
103
192
  mr.start(200);
104
193
  setIsRecording(true);
105
- 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);
106
201
  };
107
202
 
108
- 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
+ }
109
210
  mediaRecorder.current?.stop();
110
- if (recordTimer.current) clearInterval(recordTimer.current);
111
211
  setIsRecording(false);
112
212
  };
113
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
+
114
246
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
115
247
  const file = e.target.files?.[0];
116
248
  if (!file || isPaused || isBlocked) return;
117
- const attachmentUrl = URL.createObjectURL(file);
118
- onSend(file.name, 'attachment', {
119
- attachmentName: file.name,
120
- attachmentSize: `${(file.size / 1024).toFixed(1)} KB`,
121
- attachmentUrl,
249
+ const url = URL.createObjectURL(file);
250
+ setPendingAttach(prev => {
251
+ if (prev) URL.revokeObjectURL(prev.url);
252
+ return { file, url };
122
253
  });
123
254
  e.target.value = '';
124
255
  };
@@ -149,26 +280,14 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
149
280
  return (
150
281
  <div style={{ display:'flex', flexDirection:'column', height:'100%', animation:'cw-slideIn 0.22s ease', position:'relative', overflow: 'hidden' }}>
151
282
 
152
- <SlideNavMenu
153
- open={slideMenuOpen}
154
- onClose={() => setSlideMenuOpen(false)}
155
- primaryColor={config.primaryColor}
156
- chatType={config.chatType}
157
- viewerType={config.viewerType ?? 'user'}
158
- onSelect={onNavAction}
159
- onBackHome={onBack}
160
- />
161
-
162
283
  {/* ── Header ── */}
163
284
  <div style={{
164
285
  background:`linear-gradient(135deg,${config.primaryColor},${config.primaryColor}cc)`,
165
286
  padding:'10px 12px', display:'flex', alignItems:'center', gap:8, flexShrink:0,
166
287
  }}>
167
- <button type="button" onClick={() => setSlideMenuOpen(true)} style={hdrBtn} aria-label="Open menu">
288
+ <button type="button" onClick={onBack} style={hdrBtn} aria-label="Back">
168
289
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
169
- <line x1="3" y1="6" x2="21" y2="6" stroke="#fff" strokeWidth="2.2" strokeLinecap="round"/>
170
- <line x1="3" y1="12" x2="21" y2="12" stroke="#fff" strokeWidth="2.2" strokeLinecap="round"/>
171
- <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"/>
172
291
  </svg>
173
292
  </button>
174
293
 
@@ -184,6 +303,50 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
184
303
 
185
304
  <span style={{ fontSize:13, fontWeight:700, color:'#fff', opacity:0.95, flexShrink:0 }}>{headerRole}</span>
186
305
 
306
+ {onToggleMessageSound && (
307
+ <label
308
+ style={{
309
+ display: 'flex',
310
+ alignItems: 'center',
311
+ gap: 6,
312
+ cursor: 'pointer',
313
+ flexShrink: 0,
314
+ marginLeft: 4,
315
+ }}
316
+ >
317
+ <span style={{ fontSize: 10, color: 'rgba(255,255,255,0.85)', fontWeight: 600 }}>Sound</span>
318
+ <button
319
+ type="button"
320
+ role="switch"
321
+ aria-checked={messageSoundEnabled}
322
+ onClick={() => onToggleMessageSound(!messageSoundEnabled)}
323
+ style={{
324
+ width: 36,
325
+ height: 20,
326
+ borderRadius: 10,
327
+ border: 'none',
328
+ background: messageSoundEnabled ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.2)',
329
+ position: 'relative',
330
+ cursor: 'pointer',
331
+ padding: 0,
332
+ }}
333
+ >
334
+ <span
335
+ style={{
336
+ position: 'absolute',
337
+ top: 2,
338
+ left: messageSoundEnabled ? 18 : 2,
339
+ width: 16,
340
+ height: 16,
341
+ borderRadius: '50%',
342
+ background: '#fff',
343
+ transition: 'left 0.15s ease',
344
+ }}
345
+ />
346
+ </button>
347
+ </label>
348
+ )}
349
+
187
350
  {config.allowWebCall && (
188
351
  <button type="button" onClick={() => onStartCall(false)} style={hdrBtn} title="Voice Call">
189
352
  <svg width="17" height="17" viewBox="0 0 24 24" fill="none">
@@ -203,6 +366,15 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
203
366
 
204
367
  {showMenu && (
205
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' }} />
206
378
  {config.allowTranscriptDownload && (
207
379
  <MenuItem icon="📥" label="Download Transcript" onClick={handleTranscript} />
208
380
  )}
@@ -328,10 +500,91 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
328
500
  )}
329
501
 
330
502
  {isRecording && (
331
- <div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8, padding:'8px 12px', background:'#fee2e2', borderRadius:10 }}>
332
- <span style={{ width:8, height:8, borderRadius:'50%', background:'#ef4444', display:'inline-block', animation:'cw-pulse 1s infinite' }} />
333
- <span style={{ fontSize:13, color:'#991b1b', fontWeight:600 }}>Recording {recordSec}s</span>
334
- <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>
335
588
  </div>
336
589
  )}
337
590
 
@@ -341,11 +594,79 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
341
594
  padding: '10px 12px 8px',
342
595
  background: isPaused || isBlocked ? '#f9fafb' : '#fff',
343
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
+ )}
344
664
  <textarea
345
665
  ref={inputRef}
346
666
  value={text}
347
667
  onChange={e => setText(e.target.value)}
348
668
  onKeyDown={handleKey}
669
+ onPaste={handlePaste}
349
670
  placeholder={isPaused || isBlocked ? 'Chat is unavailable' : 'Compose your message…'}
350
671
  disabled={isPaused || isBlocked || isRecording}
351
672
  rows={2}
@@ -397,14 +718,14 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
397
718
  <button
398
719
  type="button"
399
720
  onClick={handleSend}
400
- disabled={!text.trim() || isPaused || isBlocked || isRecording}
721
+ disabled={(!text.trim() && !pendingAttach) || isPaused || isBlocked || isRecording}
401
722
  style={{
402
723
  width: 36,
403
724
  height: 36,
404
725
  borderRadius: '50%',
405
- backgroundColor: text.trim() && !isPaused && !isBlocked ? config.primaryColor : '#e2e8f0',
726
+ backgroundColor: (text.trim() || pendingAttach) && !isPaused && !isBlocked ? config.primaryColor : '#e2e8f0',
406
727
  border: 'none',
407
- cursor: text.trim() && !isPaused && !isBlocked ? 'pointer' : 'default',
728
+ cursor: (text.trim() || pendingAttach) && !isPaused && !isBlocked ? 'pointer' : 'default',
408
729
  display: 'flex',
409
730
  alignItems: 'center',
410
731
  justifyContent: 'center',
@@ -414,7 +735,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
414
735
  title="Send"
415
736
  >
416
737
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
417
- <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"/>
418
739
  </svg>
419
740
  </button>
420
741
  </div>
@@ -638,30 +959,50 @@ const VoiceRow: React.FC<{ msg: ChatMessage; isMe: boolean; primaryColor: string
638
959
  const AttachmentRow: React.FC<{ msg: ChatMessage; isMe: boolean; primaryColor: string }> = ({ msg, isMe, primaryColor }) => {
639
960
  const name = msg.attachmentName ?? 'File';
640
961
  const href = msg.attachmentUrl;
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);
641
965
  return (
642
- <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
643
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
644
- <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"/>
645
- </svg>
646
- <div style={{ flex: 1, minWidth: 0 }}>
647
- <div style={{ fontWeight: 700, fontSize: 14, wordBreak: 'break-word' }}>{name}</div>
648
- {msg.attachmentSize && <div style={{ fontSize: 11, opacity: 0.8 }}>{msg.attachmentSize}</div>}
649
- </div>
650
- {href && (
651
- <a
652
- href={href}
653
- download={name}
654
- style={{
655
- fontSize: 12,
656
- fontWeight: 700,
657
- color: isMe ? '#fff' : primaryColor,
658
- textDecoration: 'underline',
659
- whiteSpace: 'nowrap',
660
- }}
661
- >
662
- Download
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
+ />
663
974
  </a>
664
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>
981
+ )}
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>
1005
+ </div>
665
1006
  </div>
666
1007
  );
667
1008
  };
@@ -669,10 +1010,16 @@ const AttachmentRow: React.FC<{ msg: ChatMessage; isMe: boolean; primaryColor: s
669
1010
  const Bubble: React.FC<{ msg: ChatMessage; primaryColor: string }> = ({ msg, primaryColor }) => {
670
1011
  const isMe = msg.senderId === 'me';
671
1012
 
1013
+ const caption = msg.text.trim();
672
1014
  const content = msg.type === 'voice' ? (
673
1015
  <VoiceRow msg={msg} isMe={isMe} primaryColor={primaryColor} />
674
1016
  ) : msg.type === 'attachment' ? (
675
- <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
+ </>
676
1023
  ) : (
677
1024
  <span>{msg.text}</span>
678
1025
  );
@@ -734,6 +1081,16 @@ const hdrBtn: React.CSSProperties = {
734
1081
  cursor:'pointer', flexShrink:0,
735
1082
  };
736
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
+
737
1094
  function groupByDate(messages: ChatMessage[]): { date: string; msgs: ChatMessage[] }[] {
738
1095
  const map = new Map<string, ChatMessage[]>();
739
1096
  messages.forEach(m => {