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.
- package/dist/components/ChatScreen/index.js +256 -34
- package/dist/components/ChatWidget.js +21 -3
- package/dist/components/HomeScreen/index.d.ts +5 -1
- package/dist/components/HomeScreen/index.js +6 -4
- package/dist/components/RecentChatsScreen/index.d.ts +1 -0
- package/dist/components/RecentChatsScreen/index.js +28 -6
- package/dist/components/TicketFormScreen/index.js +19 -13
- package/dist/components/TicketScreen/index.d.ts +1 -0
- package/dist/components/TicketScreen/index.js +31 -9
- package/dist/components/UserListScreen/index.d.ts +4 -0
- package/dist/components/UserListScreen/index.js +40 -12
- package/dist/types/index.d.ts +2 -0
- package/package.json +1 -1
- package/src/components/ChatScreen/index.tsx +365 -62
- package/src/components/ChatWidget.tsx +28 -6
- package/src/components/HomeScreen/index.tsx +12 -5
- package/src/components/RecentChatsScreen/index.tsx +97 -44
- package/src/components/TicketFormScreen/index.tsx +17 -6
- package/src/components/TicketScreen/index.tsx +63 -11
- package/src/components/UserListScreen/index.tsx +87 -15
- package/src/types/index.ts +2 -0
|
@@ -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 (
|
|
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,
|
|
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(() =>
|
|
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
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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={
|
|
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
|
-
<
|
|
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
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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: '
|
|
693
|
-
|
|
694
|
-
<
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 => {
|