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.
- package/dist/components/ChatScreen/index.d.ts +2 -0
- package/dist/components/ChatScreen/index.js +283 -34
- package/dist/components/ChatWidget.js +111 -15
- package/dist/components/HomeScreen/index.d.ts +2 -0
- package/dist/components/HomeScreen/index.js +2 -2
- package/dist/components/Tabs/BottomTabs.d.ts +0 -1
- package/dist/components/Tabs/BottomTabs.js +13 -20
- package/dist/components/TicketDetailScreen/index.d.ts +9 -0
- package/dist/components/TicketDetailScreen/index.js +46 -0
- package/dist/components/TicketFormScreen/index.d.ts +9 -0
- package/dist/components/TicketFormScreen/index.js +76 -0
- package/dist/components/TicketScreen/index.d.ts +2 -1
- package/dist/components/TicketScreen/index.js +8 -35
- package/dist/components/UserListScreen/index.d.ts +4 -0
- package/dist/components/UserListScreen/index.js +21 -3
- package/dist/types/index.d.ts +3 -1
- package/dist/utils/fileName.d.ts +2 -0
- package/dist/utils/fileName.js +7 -0
- package/dist/utils/messageSound.d.ts +4 -0
- package/dist/utils/messageSound.js +51 -0
- package/dist/utils/widgetSession.d.ts +13 -0
- package/dist/utils/widgetSession.js +24 -0
- package/package.json +1 -1
- package/src/components/ChatScreen/index.tsx +415 -58
- package/src/components/ChatWidget.tsx +140 -17
- package/src/components/HomeScreen/index.tsx +4 -2
- package/src/components/Tabs/BottomTabs.tsx +2 -22
- package/src/components/TicketDetailScreen/index.tsx +111 -0
- package/src/components/TicketFormScreen/index.tsx +151 -0
- package/src/components/TicketScreen/index.tsx +18 -58
- package/src/components/UserListScreen/index.tsx +51 -5
- package/src/types/index.ts +4 -0
- package/src/utils/fileName.ts +6 -0
- package/src/utils/messageSound.ts +47 -0
- 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 (
|
|
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,
|
|
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(() =>
|
|
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
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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={
|
|
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
|
-
<
|
|
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
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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: '
|
|
643
|
-
|
|
644
|
-
<
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
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 => {
|