ajaxter-chat 1.0.3 → 3.0.1
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/README.md +124 -241
- package/dist/components/BlockList/index.d.ts +10 -0
- package/dist/components/BlockList/index.js +33 -0
- package/dist/components/CallScreen/index.d.ts +13 -0
- package/dist/components/CallScreen/index.js +48 -0
- package/dist/components/ChatScreen/index.d.ts +19 -0
- package/dist/components/ChatScreen/index.js +168 -0
- package/dist/components/ChatWidget.d.ts +0 -24
- package/dist/components/ChatWidget.js +228 -43
- package/dist/components/EmojiPicker/index.d.ts +8 -0
- package/dist/components/EmojiPicker/index.js +18 -0
- package/dist/components/HomeScreen/index.d.ts +8 -0
- package/dist/components/HomeScreen/index.js +55 -0
- package/dist/components/MaintenanceView/index.d.ts +0 -1
- package/dist/components/MaintenanceView/index.js +13 -52
- package/dist/components/RecentChatsScreen/index.d.ts +17 -0
- package/dist/components/RecentChatsScreen/index.js +8 -0
- package/dist/components/Tabs/BottomTabs.d.ts +10 -0
- package/dist/components/Tabs/BottomTabs.js +34 -0
- package/dist/components/TicketScreen/index.d.ts +9 -0
- package/dist/components/TicketScreen/index.js +54 -0
- package/dist/components/UserListScreen/index.d.ts +11 -0
- package/dist/components/UserListScreen/index.js +35 -0
- package/dist/config/index.d.ts +3 -16
- package/dist/config/index.js +20 -103
- package/dist/hooks/useChat.d.ts +10 -9
- package/dist/hooks/useChat.js +22 -40
- package/dist/hooks/useRemoteConfig.d.ts +6 -0
- package/dist/hooks/useRemoteConfig.js +22 -0
- package/dist/hooks/useWebRTC.d.ts +11 -0
- package/dist/hooks/useWebRTC.js +112 -0
- package/dist/index.d.ts +16 -11
- package/dist/index.js +15 -16
- package/dist/types/index.d.ts +66 -38
- package/dist/utils/chat.d.ts +13 -0
- package/dist/utils/chat.js +62 -0
- package/dist/utils/theme.d.ts +3 -2
- package/dist/utils/theme.js +13 -21
- package/package.json +10 -20
- package/public/chatData.json +162 -0
- package/src/components/BlockList/index.tsx +94 -0
- package/src/components/CallScreen/index.tsx +144 -0
- package/src/components/ChatScreen/index.tsx +469 -0
- package/src/components/ChatWidget.tsx +471 -0
- package/src/components/EmojiPicker/index.tsx +48 -0
- package/src/components/HomeScreen/index.tsx +106 -0
- package/src/components/MaintenanceView/index.tsx +38 -0
- package/src/components/RecentChatsScreen/index.tsx +63 -0
- package/src/components/Tabs/BottomTabs.tsx +90 -0
- package/src/components/TicketScreen/index.tsx +124 -0
- package/src/components/UserListScreen/index.tsx +103 -0
- package/src/config/index.ts +40 -0
- package/src/hooks/useChat.ts +48 -0
- package/src/hooks/useRemoteConfig.ts +20 -0
- package/src/hooks/useWebRTC.ts +130 -0
- package/src/index.ts +29 -0
- package/src/types/index.ts +127 -0
- package/src/utils/chat.ts +70 -0
- package/src/utils/theme.ts +27 -0
- package/dist/components/BottomNav/index.d.ts +0 -10
- package/dist/components/BottomNav/index.js +0 -32
- package/dist/components/ChatBox/index.d.ts +0 -15
- package/dist/components/ChatBox/index.js +0 -228
- package/dist/components/ChatButton/index.d.ts +0 -9
- package/dist/components/ChatButton/index.js +0 -17
- package/dist/components/ChatWindow/index.d.ts +0 -10
- package/dist/components/ChatWindow/index.js +0 -286
- package/dist/components/HomeView/index.d.ts +0 -12
- package/dist/components/HomeView/index.js +0 -51
- package/dist/components/UserList/index.d.ts +0 -13
- package/dist/components/UserList/index.js +0 -136
- package/dist/hooks/useUsers.d.ts +0 -14
- package/dist/hooks/useUsers.js +0 -32
- package/dist/services/userService.d.ts +0 -7
- package/dist/services/userService.js +0 -18
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
2
|
+
import { ChatMessage, ChatUser, WidgetConfig } from '../../types';
|
|
3
|
+
import { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText } from '../../utils/chat';
|
|
4
|
+
import { EmojiPicker } from '../EmojiPicker';
|
|
5
|
+
|
|
6
|
+
interface ChatScreenProps {
|
|
7
|
+
activeUser: ChatUser;
|
|
8
|
+
messages: ChatMessage[];
|
|
9
|
+
config: WidgetConfig;
|
|
10
|
+
isPaused: boolean;
|
|
11
|
+
isReported: boolean;
|
|
12
|
+
isBlocked: boolean;
|
|
13
|
+
onSend: (text: string, type?: ChatMessage['type'], extra?: Partial<ChatMessage>) => void;
|
|
14
|
+
onBack: () => void;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
onTogglePause:() => void;
|
|
17
|
+
onReport: () => void;
|
|
18
|
+
onBlock: () => void;
|
|
19
|
+
onStartCall: (withVideo: boolean) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
23
|
+
activeUser, messages, config, isPaused, isReported, isBlocked,
|
|
24
|
+
onSend, onBack, onClose, onTogglePause, onReport, onBlock, onStartCall,
|
|
25
|
+
}) => {
|
|
26
|
+
const [text, setText] = useState('');
|
|
27
|
+
const [showEmoji, setShowEmoji] = useState(false);
|
|
28
|
+
const [showMenu, setShowMenu] = useState(false);
|
|
29
|
+
const [isRecording, setIsRecording] = useState(false);
|
|
30
|
+
const [recordSec, setRecordSec] = useState(0);
|
|
31
|
+
const [showConfirm, setShowConfirm] = useState<'report'|'block'|'pause'|null>(null);
|
|
32
|
+
|
|
33
|
+
const endRef = useRef<HTMLDivElement>(null);
|
|
34
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
35
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
36
|
+
const recordTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
37
|
+
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
|
38
|
+
|
|
39
|
+
useEffect(() => { endRef.current?.scrollIntoView({ behavior:'smooth' }); }, [messages]);
|
|
40
|
+
|
|
41
|
+
const handleSend = useCallback(() => {
|
|
42
|
+
if (!text.trim() || isPaused || isBlocked) return;
|
|
43
|
+
onSend(text.trim());
|
|
44
|
+
setText('');
|
|
45
|
+
inputRef.current?.focus();
|
|
46
|
+
}, [text, isPaused, isBlocked, onSend]);
|
|
47
|
+
|
|
48
|
+
const handleKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
49
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Voice recording
|
|
53
|
+
const startRecording = async () => {
|
|
54
|
+
if (isPaused || isBlocked) return;
|
|
55
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
56
|
+
const mr = new MediaRecorder(stream);
|
|
57
|
+
mediaRecorder.current = mr;
|
|
58
|
+
const chunks: BlobPart[] = [];
|
|
59
|
+
mr.ondataavailable = e => chunks.push(e.data);
|
|
60
|
+
mr.onstop = () => {
|
|
61
|
+
stream.getTracks().forEach(t => t.stop());
|
|
62
|
+
// In production: upload blob, get URL, then send as voice message
|
|
63
|
+
onSend('[Voice Message]', 'voice', { voiceDuration: recordSec });
|
|
64
|
+
setRecordSec(0);
|
|
65
|
+
};
|
|
66
|
+
mr.start();
|
|
67
|
+
setIsRecording(true);
|
|
68
|
+
recordTimer.current = setInterval(() => setRecordSec(s => s + 1), 1000);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const stopRecording = () => {
|
|
72
|
+
mediaRecorder.current?.stop();
|
|
73
|
+
if (recordTimer.current) clearInterval(recordTimer.current);
|
|
74
|
+
setIsRecording(false);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Attachment
|
|
78
|
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
79
|
+
const file = e.target.files?.[0];
|
|
80
|
+
if (!file || isPaused || isBlocked) return;
|
|
81
|
+
onSend(`[Attachment: ${file.name}]`, 'attachment', {
|
|
82
|
+
attachmentName: file.name,
|
|
83
|
+
attachmentSize: `${(file.size / 1024).toFixed(1)} KB`,
|
|
84
|
+
});
|
|
85
|
+
e.target.value = '';
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Download transcript
|
|
89
|
+
const handleTranscript = () => {
|
|
90
|
+
const content = generateTranscript(messages, activeUser);
|
|
91
|
+
downloadText(content, `chat-${activeUser.name.replace(/\s+/g,'_')}-${Date.now()}.txt`);
|
|
92
|
+
setShowMenu(false);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Confirm actions
|
|
96
|
+
const handleConfirm = (action: 'report'|'block'|'pause') => {
|
|
97
|
+
setShowConfirm(null); setShowMenu(false);
|
|
98
|
+
if (action === 'report') onReport();
|
|
99
|
+
if (action === 'block') onBlock();
|
|
100
|
+
if (action === 'pause') onTogglePause();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const peerAvatar = avatarColor(activeUser.name);
|
|
104
|
+
const peerInit = initials(activeUser.name);
|
|
105
|
+
|
|
106
|
+
// Group messages by date
|
|
107
|
+
const grouped = groupByDate(messages);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div style={{ display:'flex', flexDirection:'column', height:'100%', animation:'cw-slideIn 0.22s ease', position:'relative' }}>
|
|
111
|
+
|
|
112
|
+
{/* ── Header ── */}
|
|
113
|
+
<div style={{
|
|
114
|
+
background:`linear-gradient(135deg,${config.primaryColor},${config.primaryColor}cc)`,
|
|
115
|
+
padding:'12px 14px', display:'flex', alignItems:'center', gap:10, flexShrink:0,
|
|
116
|
+
}}>
|
|
117
|
+
{/* hamburger/back */}
|
|
118
|
+
<button onClick={onBack} style={hdrBtn}>
|
|
119
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
|
120
|
+
<line x1="3" y1="6" x2="21" y2="6" stroke="#fff" strokeWidth="2.2" strokeLinecap="round"/>
|
|
121
|
+
<line x1="3" y1="12" x2="21" y2="12" stroke="#fff" strokeWidth="2.2" strokeLinecap="round"/>
|
|
122
|
+
<line x1="3" y1="18" x2="21" y2="18" stroke="#fff" strokeWidth="2.2" strokeLinecap="round"/>
|
|
123
|
+
</svg>
|
|
124
|
+
</button>
|
|
125
|
+
|
|
126
|
+
<div style={{ width:36, height:36, borderRadius:'50%', backgroundColor:peerAvatar, display:'flex', alignItems:'center', justifyContent:'center', color:'#fff', fontWeight:700, fontSize:13, flexShrink:0, position:'relative' }}>
|
|
127
|
+
{peerInit}
|
|
128
|
+
<span style={{ position:'absolute', bottom:0, right:0, width:9, height:9, borderRadius:'50%', border:'2px solid', borderColor:'transparent', backgroundColor: activeUser.status==='online'?'#22c55e':activeUser.status==='away'?'#f59e0b':'#9ca3af' }} />
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div style={{ flex:1, minWidth:0 }}>
|
|
132
|
+
<div style={{ fontWeight:700, fontSize:14, color:'#fff', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{activeUser.name}</div>
|
|
133
|
+
<div style={{ fontSize:11, color:'rgba(255,255,255,0.8)' }}>{activeUser.designation}</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{/* Title "Support" */}
|
|
137
|
+
<span style={{ fontSize:14, fontWeight:700, color:'#fff', opacity:0.9 }}>Support</span>
|
|
138
|
+
|
|
139
|
+
{/* Call button */}
|
|
140
|
+
{config.allowWebCall && (
|
|
141
|
+
<button onClick={() => onStartCall(false)} style={hdrBtn} title="Voice Call">
|
|
142
|
+
<svg width="17" height="17" viewBox="0 0 24 24" fill="none">
|
|
143
|
+
<path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07A19.5 19.5 0 013.07 10.8a19.79 19.79 0 01-3.07-8.68A2 2 0 012 0h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L6.09 7.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 14.92v2z" fill="#fff"/>
|
|
144
|
+
</svg>
|
|
145
|
+
</button>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{/* Maximize/expand icon */}
|
|
149
|
+
<button style={hdrBtn} title="Fullscreen">
|
|
150
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
|
151
|
+
<path d="M8 3H5a2 2 0 00-2 2v3M21 8V5a2 2 0 00-2-2h-3M3 16v3a2 2 0 002 2h3M16 21h3a2 2 0 002-2v-3" stroke="#fff" strokeWidth="2" strokeLinecap="round"/>
|
|
152
|
+
</svg>
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{/* ── Paused / Blocked / Reported banners ── */}
|
|
157
|
+
{isPaused && (
|
|
158
|
+
<div style={{ background:'#fef3c7', padding:'8px 16px', fontSize:12, fontWeight:600, color:'#92400e', textAlign:'center', flexShrink:0, display:'flex', alignItems:'center', justifyContent:'center', gap:6 }}>
|
|
159
|
+
⏸ Chat is paused — users cannot send messages
|
|
160
|
+
<button onClick={onTogglePause} style={{ background:'#92400e', color:'#fff', border:'none', borderRadius:6, padding:'2px 8px', fontSize:11, cursor:'pointer', marginLeft:4 }}>Resume</button>
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
{isBlocked && (
|
|
164
|
+
<div style={{ background:'#fee2e2', padding:'8px 16px', fontSize:12, fontWeight:600, color:'#991b1b', textAlign:'center', flexShrink:0 }}>
|
|
165
|
+
🚫 This user is blocked
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
{isReported && (
|
|
169
|
+
<div style={{ background:'#fef3c7', padding:'6px 16px', fontSize:11, color:'#92400e', textAlign:'center', flexShrink:0 }}>
|
|
170
|
+
⚠️ This chat has been reported
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{/* ── "Enter your details" card (ticket chat body placeholder) ── */}
|
|
175
|
+
<div style={{ padding:'14px 14px 0', flexShrink:0 }}>
|
|
176
|
+
<div style={{
|
|
177
|
+
background:`linear-gradient(135deg, ${config.primaryColor}, ${config.primaryColor}cc)`,
|
|
178
|
+
borderRadius:12, padding:'14px 16px',
|
|
179
|
+
display:'flex', alignItems:'center', gap:12, cursor:'pointer',
|
|
180
|
+
}}>
|
|
181
|
+
<div style={{ width:32, height:32, borderRadius:'50%', background:'rgba(255,255,255,0.25)', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0 }}>
|
|
182
|
+
<span style={{ fontSize:16 }}>❓</span>
|
|
183
|
+
</div>
|
|
184
|
+
<div>
|
|
185
|
+
<div style={{ fontWeight:700, fontSize:14, color:'#fff' }}>Enter your details</div>
|
|
186
|
+
<div style={{ fontSize:12, color:'rgba(255,255,255,0.85)' }}>Click here to provide your information</div>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
{/* ── Messages ── */}
|
|
192
|
+
<div style={{ flex:1, overflowY:'auto', padding:'14px', display:'flex', flexDirection:'column', gap:10, background:'#f8f9fc' }}
|
|
193
|
+
className="cw-scroll"
|
|
194
|
+
>
|
|
195
|
+
{grouped.map(({ date, msgs }) => (
|
|
196
|
+
<React.Fragment key={date}>
|
|
197
|
+
<DateDivider label={date} />
|
|
198
|
+
{msgs.map(msg => (
|
|
199
|
+
<Bubble key={msg.id} msg={msg} peer={activeUser} primaryColor={config.primaryColor} />
|
|
200
|
+
))}
|
|
201
|
+
</React.Fragment>
|
|
202
|
+
))}
|
|
203
|
+
{messages.length === 0 && (
|
|
204
|
+
<div style={{ margin:'auto', textAlign:'center', color:'#c4cad4', fontSize:13 }}>
|
|
205
|
+
<div style={{ fontSize:28, marginBottom:8 }}>💬</div>
|
|
206
|
+
Say hello to {activeUser.name}!
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
<div ref={endRef} />
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
{/* ── Input bar ── */}
|
|
213
|
+
<div style={{ borderTop:'1px solid #eef0f5', padding:'10px 14px', background:'#fff', flexShrink:0, position:'relative' }}>
|
|
214
|
+
|
|
215
|
+
{/* Emoji picker */}
|
|
216
|
+
{showEmoji && config.allowEmoji && (
|
|
217
|
+
<EmojiPicker
|
|
218
|
+
primaryColor={config.primaryColor}
|
|
219
|
+
onSelect={e => setText(t => t + e)}
|
|
220
|
+
onClose={() => setShowEmoji(false)}
|
|
221
|
+
/>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{/* Recording indicator */}
|
|
225
|
+
{isRecording && (
|
|
226
|
+
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8, padding:'6px 12px', background:'#fee2e2', borderRadius:8 }}>
|
|
227
|
+
<span style={{ width:8, height:8, borderRadius:'50%', background:'#ef4444', display:'inline-block', animation:'cw-pulse 1s infinite' }} />
|
|
228
|
+
<span style={{ fontSize:13, color:'#991b1b', fontWeight:600 }}>Recording {recordSec}s</span>
|
|
229
|
+
<button onClick={stopRecording} style={{ marginLeft:'auto', background:'#ef4444', color:'#fff', border:'none', borderRadius:6, padding:'3px 10px', fontSize:12, cursor:'pointer' }}>Stop</button>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
<div style={{ display:'flex', alignItems:'flex-end', gap:8 }}>
|
|
234
|
+
<textarea
|
|
235
|
+
ref={inputRef}
|
|
236
|
+
value={text}
|
|
237
|
+
onChange={e => setText(e.target.value)}
|
|
238
|
+
onKeyDown={handleKey}
|
|
239
|
+
placeholder={isPaused || isBlocked ? 'Chat is unavailable' : 'Message...'}
|
|
240
|
+
disabled={isPaused || isBlocked || isRecording}
|
|
241
|
+
rows={1}
|
|
242
|
+
style={{
|
|
243
|
+
flex:1, resize:'none', border:'1.5px solid #e5e7eb',
|
|
244
|
+
borderRadius:22, padding:'9px 14px',
|
|
245
|
+
fontSize:14, outline:'none', lineHeight:1.5,
|
|
246
|
+
maxHeight:80, overflowY:'auto', color:'#1a2332',
|
|
247
|
+
background: isPaused || isBlocked ? '#f9fafb' : '#fff',
|
|
248
|
+
transition:'border-color 0.2s',
|
|
249
|
+
fontFamily:'inherit',
|
|
250
|
+
}}
|
|
251
|
+
onFocus={e => (e.target.style.borderColor = config.primaryColor)}
|
|
252
|
+
onBlur={e => (e.target.style.borderColor = '#e5e7eb')}
|
|
253
|
+
/>
|
|
254
|
+
|
|
255
|
+
{/* Emoji */}
|
|
256
|
+
{config.allowEmoji && (
|
|
257
|
+
<ActionBtn onClick={() => setShowEmoji(v => !v)} title="Emoji">
|
|
258
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none">
|
|
259
|
+
<circle cx="12" cy="12" r="10" stroke="#9ca3af" strokeWidth="1.8"/>
|
|
260
|
+
<path d="M8 14s1.5 2 4 2 4-2 4-2" stroke="#9ca3af" strokeWidth="1.8" strokeLinecap="round"/>
|
|
261
|
+
<circle cx="9" cy="9" r="1" fill="#9ca3af"/><circle cx="15" cy="9" r="1" fill="#9ca3af"/>
|
|
262
|
+
</svg>
|
|
263
|
+
</ActionBtn>
|
|
264
|
+
)}
|
|
265
|
+
|
|
266
|
+
{/* Attachment */}
|
|
267
|
+
{config.allowAttachment && (
|
|
268
|
+
<>
|
|
269
|
+
<input ref={fileRef} type="file" style={{ display:'none' }} onChange={handleFileChange} />
|
|
270
|
+
<ActionBtn onClick={() => fileRef.current?.click()} title="Attach file">
|
|
271
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none">
|
|
272
|
+
<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="#9ca3af" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
|
273
|
+
</svg>
|
|
274
|
+
</ActionBtn>
|
|
275
|
+
</>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
{/* Voice */}
|
|
279
|
+
{config.allowVoiceMessage && !isRecording && (
|
|
280
|
+
<ActionBtn onClick={startRecording} title="Voice message">
|
|
281
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none">
|
|
282
|
+
<path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z" stroke="#9ca3af" strokeWidth="1.8" strokeLinecap="round"/>
|
|
283
|
+
<path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="#9ca3af" strokeWidth="1.8" strokeLinecap="round"/>
|
|
284
|
+
</svg>
|
|
285
|
+
</ActionBtn>
|
|
286
|
+
)}
|
|
287
|
+
|
|
288
|
+
{/* Send */}
|
|
289
|
+
{text.trim() && (
|
|
290
|
+
<button onClick={handleSend} style={{
|
|
291
|
+
width:36, height:36, borderRadius:'50%', backgroundColor:config.primaryColor,
|
|
292
|
+
border:'none', cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0,
|
|
293
|
+
transition:'transform 0.15s',
|
|
294
|
+
}}>
|
|
295
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
|
296
|
+
<path d="M22 2L11 13M22 2L15 22L11 13L2 9L22 2Z" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
297
|
+
</svg>
|
|
298
|
+
</button>
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
{/* ── 3-dot overflow menu ── */}
|
|
304
|
+
<div style={{ position:'absolute', top:12, right:52, zIndex:50 }}>
|
|
305
|
+
<button
|
|
306
|
+
onClick={() => setShowMenu(v => !v)}
|
|
307
|
+
style={{ ...hdrBtn, background:'rgba(255,255,255,0.18)' }}
|
|
308
|
+
title="More options"
|
|
309
|
+
>
|
|
310
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
|
311
|
+
<circle cx="12" cy="5" r="1.5" fill="#fff"/>
|
|
312
|
+
<circle cx="12" cy="12" r="1.5" fill="#fff"/>
|
|
313
|
+
<circle cx="12" cy="19" r="1.5" fill="#fff"/>
|
|
314
|
+
</svg>
|
|
315
|
+
</button>
|
|
316
|
+
|
|
317
|
+
{showMenu && (
|
|
318
|
+
<div style={{
|
|
319
|
+
position:'absolute', top:'100%', right:0, marginTop:4,
|
|
320
|
+
background:'#fff', borderRadius:12,
|
|
321
|
+
boxShadow:'0 8px 30px rgba(0,0,0,0.16)',
|
|
322
|
+
padding:'6px', minWidth:200, zIndex:200,
|
|
323
|
+
animation:'cw-fadeUp 0.18s ease',
|
|
324
|
+
}}>
|
|
325
|
+
{config.allowTranscriptDownload && (
|
|
326
|
+
<MenuItem icon="📥" label="Download Transcript" onClick={handleTranscript} />
|
|
327
|
+
)}
|
|
328
|
+
<MenuItem icon={isPaused ? '▶️' : '⏸'} label={isPaused ? 'Resume Chat' : 'Pause Chat'} onClick={() => { setShowMenu(false); setShowConfirm('pause'); }} />
|
|
329
|
+
{config.allowReport && !isReported && (
|
|
330
|
+
<MenuItem icon="⚠️" label="Report Chat" onClick={() => { setShowMenu(false); setShowConfirm('report'); }} />
|
|
331
|
+
)}
|
|
332
|
+
{config.allowBlock && activeUser.type === 'user' && !isBlocked && (
|
|
333
|
+
<MenuItem icon="🚫" label="Block User" onClick={() => { setShowMenu(false); setShowConfirm('block'); }} danger />
|
|
334
|
+
)}
|
|
335
|
+
<div style={{ borderTop:'1px solid #f0f2f5', margin:'4px 0' }} />
|
|
336
|
+
<MenuItem icon="✕" label="Close Chat" onClick={onClose} />
|
|
337
|
+
</div>
|
|
338
|
+
)}
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
{/* ── Confirm dialog ── */}
|
|
342
|
+
{showConfirm && (
|
|
343
|
+
<div style={{
|
|
344
|
+
position:'absolute', inset:0, background:'rgba(0,0,0,0.45)',
|
|
345
|
+
display:'flex', alignItems:'center', justifyContent:'center', zIndex:300,
|
|
346
|
+
borderRadius:'inherit',
|
|
347
|
+
}}>
|
|
348
|
+
<div style={{ background:'#fff', borderRadius:16, padding:'24px 20px', width:280, boxShadow:'0 16px 48px rgba(0,0,0,0.22)', animation:'cw-fadeUp 0.2s ease' }}>
|
|
349
|
+
<div style={{ fontWeight:800, fontSize:16, color:'#1a2332', marginBottom:8 }}>
|
|
350
|
+
{showConfirm === 'pause' && (isPaused ? 'Resume Chat?' : 'Pause Chat?')}
|
|
351
|
+
{showConfirm === 'report' && 'Report this chat?'}
|
|
352
|
+
{showConfirm === 'block' && 'Block this user?'}
|
|
353
|
+
</div>
|
|
354
|
+
<p style={{ fontSize:13, color:'#7b8fa1', lineHeight:1.6, margin:'0 0 18px' }}>
|
|
355
|
+
{showConfirm === 'pause' && (isPaused ? 'The user will be able to send messages again.' : 'The user will not be able to send new messages.')}
|
|
356
|
+
{showConfirm === 'report' && 'This chat will be flagged for review by the admin team.'}
|
|
357
|
+
{showConfirm === 'block' && 'This user will be blocked and added to your block list. You can unblock them later.'}
|
|
358
|
+
</p>
|
|
359
|
+
<div style={{ display:'flex', gap:10 }}>
|
|
360
|
+
<button onClick={() => setShowConfirm(null)} style={{ flex:1, padding:'9px', borderRadius:10, border:'1.5px solid #e5e7eb', background:'#fff', cursor:'pointer', fontSize:13, fontWeight:600, color:'#374151' }}>
|
|
361
|
+
Cancel
|
|
362
|
+
</button>
|
|
363
|
+
<button onClick={() => handleConfirm(showConfirm)} style={{
|
|
364
|
+
flex:1, padding:'9px', borderRadius:10, border:'none',
|
|
365
|
+
background: showConfirm==='block' ? '#ef4444' : config.primaryColor,
|
|
366
|
+
color:'#fff', cursor:'pointer', fontSize:13, fontWeight:700,
|
|
367
|
+
}}>
|
|
368
|
+
Confirm
|
|
369
|
+
</button>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
</div>
|
|
375
|
+
);
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// ── Sub-components ─────────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
const Bubble: React.FC<{ msg: ChatMessage; peer: ChatUser; primaryColor: string }> = ({ msg, peer, primaryColor }) => {
|
|
381
|
+
const isMe = msg.senderId === 'me';
|
|
382
|
+
|
|
383
|
+
const content = msg.type === 'voice' ? (
|
|
384
|
+
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
|
385
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
|
386
|
+
<path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z" stroke={isMe?'#fff':'#374151'} strokeWidth="2" strokeLinecap="round"/>
|
|
387
|
+
<path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4" stroke={isMe?'#fff':'#374151'} strokeWidth="2" strokeLinecap="round"/>
|
|
388
|
+
</svg>
|
|
389
|
+
<span>Voice Message {msg.voiceDuration ? `· ${msg.voiceDuration}s` : ''}</span>
|
|
390
|
+
</div>
|
|
391
|
+
) : msg.type === 'attachment' ? (
|
|
392
|
+
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
|
393
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
|
394
|
+
<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':'#374151'} strokeWidth="2" strokeLinecap="round"/>
|
|
395
|
+
</svg>
|
|
396
|
+
<div>
|
|
397
|
+
<div style={{ fontWeight:600 }}>{msg.attachmentName ?? 'File'}</div>
|
|
398
|
+
{msg.attachmentSize && <div style={{ fontSize:11, opacity:0.75 }}>{msg.attachmentSize}</div>}
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
) : <span>{msg.text}</span>;
|
|
402
|
+
|
|
403
|
+
return (
|
|
404
|
+
<div style={{ display:'flex', flexDirection:'column', alignItems: isMe?'flex-end':'flex-start', gap:3 }}>
|
|
405
|
+
<div style={{
|
|
406
|
+
maxWidth:'76%', padding:'10px 13px',
|
|
407
|
+
borderRadius: isMe ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
|
|
408
|
+
backgroundColor: isMe ? primaryColor : '#fff',
|
|
409
|
+
color: isMe ? '#fff' : '#1a2332',
|
|
410
|
+
fontSize:14, lineHeight:1.5,
|
|
411
|
+
boxShadow:'0 1px 4px rgba(0,0,0,0.07)',
|
|
412
|
+
wordBreak:'break-word',
|
|
413
|
+
}}>
|
|
414
|
+
{content}
|
|
415
|
+
</div>
|
|
416
|
+
<span style={{ fontSize:11, color:'#b0bec5', padding:'0 4px' }}>{formatTime(msg.timestamp)}</span>
|
|
417
|
+
</div>
|
|
418
|
+
);
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const DateDivider: React.FC<{ label: string }> = ({ label }) => (
|
|
422
|
+
<div style={{ display:'flex', alignItems:'center', gap:10, margin:'4px 0' }}>
|
|
423
|
+
<div style={{ flex:1, height:1, background:'#e5e7eb' }} />
|
|
424
|
+
<span style={{ fontSize:11, fontWeight:600, color:'#9ca3af', whiteSpace:'nowrap' }}>{label}</span>
|
|
425
|
+
<div style={{ flex:1, height:1, background:'#e5e7eb' }} />
|
|
426
|
+
</div>
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const MenuItem: React.FC<{ icon: string; label: string; onClick: () => void; danger?: boolean }> = ({ icon, label, onClick, danger }) => (
|
|
430
|
+
<button onClick={onClick} style={{
|
|
431
|
+
display:'flex', alignItems:'center', gap:10, width:'100%', padding:'9px 12px',
|
|
432
|
+
background:'none', border:'none', borderRadius:8, cursor:'pointer', textAlign:'left',
|
|
433
|
+
fontSize:13, fontWeight:600, color: danger ? '#ef4444' : '#374151',
|
|
434
|
+
transition:'background 0.12s',
|
|
435
|
+
}}
|
|
436
|
+
onMouseEnter={e => (e.currentTarget as HTMLElement).style.background = danger ? '#fee2e2' : '#f3f4f6'}
|
|
437
|
+
onMouseLeave={e => (e.currentTarget as HTMLElement).style.background = 'none'}
|
|
438
|
+
>
|
|
439
|
+
<span>{icon}</span> {label}
|
|
440
|
+
</button>
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const ActionBtn: React.FC<{ onClick: () => void; title: string; children: React.ReactNode }> = ({ onClick, title, children }) => (
|
|
444
|
+
<button onClick={onClick} title={title} style={{
|
|
445
|
+
background:'none', border:'none', cursor:'pointer', padding:'7px',
|
|
446
|
+
borderRadius:'50%', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0,
|
|
447
|
+
transition:'background 0.13s',
|
|
448
|
+
}}
|
|
449
|
+
onMouseEnter={e => (e.currentTarget as HTMLElement).style.background = '#f3f4f6'}
|
|
450
|
+
onMouseLeave={e => (e.currentTarget as HTMLElement).style.background = 'none'}
|
|
451
|
+
>{children}</button>
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
const hdrBtn: React.CSSProperties = {
|
|
455
|
+
background:'rgba(255,255,255,0.2)', border:'none', borderRadius:'50%',
|
|
456
|
+
width:32, height:32, display:'flex', alignItems:'center', justifyContent:'center',
|
|
457
|
+
cursor:'pointer', flexShrink:0,
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// Group messages by date
|
|
461
|
+
function groupByDate(messages: ChatMessage[]): { date: string; msgs: ChatMessage[] }[] {
|
|
462
|
+
const map = new Map<string, ChatMessage[]>();
|
|
463
|
+
messages.forEach(m => {
|
|
464
|
+
const d = formatDate(m.timestamp);
|
|
465
|
+
if (!map.has(d)) map.set(d, []);
|
|
466
|
+
map.get(d)!.push(m);
|
|
467
|
+
});
|
|
468
|
+
return Array.from(map.entries()).map(([date, msgs]) => ({ date, msgs }));
|
|
469
|
+
}
|