ajaxter-chat 2.0.1 → 3.0.3
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 +119 -128
- 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 +10 -3
- package/dist/components/ChatScreen/index.js +142 -57
- package/dist/components/ChatWidget.js +192 -98
- package/dist/components/EmojiPicker/index.d.ts +8 -0
- package/dist/components/EmojiPicker/index.js +18 -0
- package/dist/components/HomeScreen/index.d.ts +2 -3
- package/dist/components/HomeScreen/index.js +25 -41
- package/dist/components/MaintenanceView/index.d.ts +0 -1
- package/dist/components/MaintenanceView/index.js +4 -6
- package/dist/components/RecentChatsScreen/index.d.ts +4 -3
- package/dist/components/RecentChatsScreen/index.js +7 -37
- package/dist/components/Tabs/BottomTabs.d.ts +1 -1
- package/dist/components/Tabs/BottomTabs.js +25 -20
- package/dist/components/TicketScreen/index.d.ts +3 -3
- package/dist/components/TicketScreen/index.js +39 -56
- package/dist/components/UserListScreen/index.d.ts +2 -4
- package/dist/components/UserListScreen/index.js +33 -62
- package/dist/config/index.d.ts +7 -3
- package/dist/config/index.js +28 -25
- package/dist/hooks/useChat.d.ts +8 -3
- package/dist/hooks/useChat.js +22 -18
- package/dist/hooks/useRemoteConfig.d.ts +6 -0
- package/dist/hooks/useRemoteConfig.js +26 -0
- package/dist/hooks/useWebRTC.d.ts +11 -0
- package/dist/hooks/useWebRTC.js +112 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.js +8 -4
- package/dist/types/index.d.ts +62 -21
- package/dist/utils/chat.d.ts +13 -0
- package/dist/utils/chat.js +62 -0
- package/dist/utils/theme.d.ts +3 -1
- package/dist/utils/theme.js +14 -7
- package/package.json +4 -4
- 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 +403 -139
- package/src/components/ChatWidget.tsx +394 -250
- package/src/components/EmojiPicker/index.tsx +48 -0
- package/src/components/HomeScreen/index.tsx +58 -82
- package/src/components/MaintenanceView/index.tsx +6 -9
- package/src/components/RecentChatsScreen/index.tsx +51 -96
- package/src/components/Tabs/BottomTabs.tsx +45 -37
- package/src/components/TicketScreen/index.tsx +87 -133
- package/src/components/UserListScreen/index.tsx +75 -153
- package/src/config/index.ts +32 -26
- package/src/hooks/useChat.ts +31 -14
- package/src/hooks/useRemoteConfig.ts +26 -0
- package/src/hooks/useWebRTC.ts +130 -0
- package/src/index.ts +26 -15
- package/src/types/index.ts +85 -40
- package/src/utils/chat.ts +70 -0
- package/src/utils/theme.ts +18 -7
- package/dist/hooks/useUsers.d.ts +0 -7
- package/dist/hooks/useUsers.js +0 -26
- package/dist/services/userService.d.ts +0 -2
- package/dist/services/userService.js +0 -9
- package/dist/src/components/ChatScreen/index.d.ts +0 -12
- package/dist/src/components/ChatScreen/index.js +0 -83
- package/dist/src/components/ChatWidget.d.ts +0 -4
- package/dist/src/components/ChatWidget.js +0 -141
- package/dist/src/components/HomeScreen/index.d.ts +0 -9
- package/dist/src/components/HomeScreen/index.js +0 -71
- package/dist/src/components/MaintenanceView/index.d.ts +0 -7
- package/dist/src/components/MaintenanceView/index.js +0 -16
- package/dist/src/components/RecentChatsScreen/index.d.ts +0 -16
- package/dist/src/components/RecentChatsScreen/index.js +0 -38
- package/dist/src/components/Tabs/BottomTabs.d.ts +0 -10
- package/dist/src/components/Tabs/BottomTabs.js +0 -29
- package/dist/src/components/TicketScreen/index.d.ts +0 -9
- package/dist/src/components/TicketScreen/index.js +0 -71
- package/dist/src/components/UserListScreen/index.d.ts +0 -13
- package/dist/src/components/UserListScreen/index.js +0 -64
- package/dist/src/config/index.d.ts +0 -3
- package/dist/src/config/index.js +0 -38
- package/dist/src/hooks/useChat.d.ts +0 -8
- package/dist/src/hooks/useChat.js +0 -26
- package/dist/src/hooks/useUsers.d.ts +0 -7
- package/dist/src/hooks/useUsers.js +0 -26
- package/dist/src/index.d.ts +0 -14
- package/dist/src/index.js +0 -13
- package/dist/src/services/userService.d.ts +0 -2
- package/dist/src/services/userService.js +0 -9
- package/dist/src/types/index.d.ts +0 -59
- package/dist/src/types/index.js +0 -1
- package/dist/src/utils/theme.d.ts +0 -3
- package/dist/src/utils/theme.js +0 -13
- package/src/hooks/useUsers.ts +0 -27
- package/src/services/userService.ts +0 -9
|
@@ -1,159 +1,297 @@
|
|
|
1
|
-
import React, { useState, useRef, useEffect } from 'react';
|
|
2
|
-
import { ChatMessage, ChatUser,
|
|
3
|
-
import {
|
|
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';
|
|
4
5
|
|
|
5
6
|
interface ChatScreenProps {
|
|
6
|
-
activeUser:
|
|
7
|
-
messages:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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;
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
15
|
-
activeUser, messages,
|
|
23
|
+
activeUser, messages, config, isPaused, isReported, isBlocked,
|
|
24
|
+
onSend, onBack, onClose, onTogglePause, onReport, onBlock, onStartCall,
|
|
16
25
|
}) => {
|
|
17
|
-
const
|
|
18
|
-
const [
|
|
19
|
-
const
|
|
20
|
-
const
|
|
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);
|
|
21
32
|
|
|
22
|
-
|
|
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);
|
|
23
38
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
39
|
+
useEffect(() => { endRef.current?.scrollIntoView({ behavior:'smooth' }); }, [messages]);
|
|
40
|
+
|
|
41
|
+
const handleSend = useCallback(() => {
|
|
42
|
+
if (!text.trim() || isPaused || isBlocked) return;
|
|
43
|
+
onSend(text.trim());
|
|
27
44
|
setText('');
|
|
28
45
|
inputRef.current?.focus();
|
|
29
|
-
};
|
|
46
|
+
}, [text, isPaused, isBlocked, onSend]);
|
|
30
47
|
|
|
31
48
|
const handleKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
32
49
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
|
33
50
|
};
|
|
34
51
|
|
|
35
|
-
|
|
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);
|
|
36
108
|
|
|
37
109
|
return (
|
|
38
|
-
<div style={{ display:
|
|
39
|
-
|
|
110
|
+
<div style={{ display:'flex', flexDirection:'column', height:'100%', animation:'cw-slideIn 0.22s ease', position:'relative' }}>
|
|
111
|
+
|
|
112
|
+
{/* ── Header ── */}
|
|
40
113
|
<div style={{
|
|
41
|
-
|
|
42
|
-
padding: '
|
|
43
|
-
display: 'flex',
|
|
44
|
-
alignItems: 'center',
|
|
45
|
-
gap: '10px',
|
|
46
|
-
flexShrink: 0,
|
|
114
|
+
background:`linear-gradient(135deg,${config.primaryColor},${config.primaryColor}cc)`,
|
|
115
|
+
padding:'12px 14px', display:'flex', alignItems:'center', gap:10, flexShrink:0,
|
|
47
116
|
}}>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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"/>
|
|
51
123
|
</svg>
|
|
52
124
|
</button>
|
|
53
125
|
|
|
54
|
-
<div style={{
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
58
|
-
fontWeight: 700, fontSize: '13px', color: '#fff', flexShrink: 0,
|
|
59
|
-
}}>
|
|
60
|
-
{initials}
|
|
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' }} />
|
|
61
129
|
</div>
|
|
62
130
|
|
|
63
|
-
<div style={{ flex:
|
|
64
|
-
<div style={{ fontWeight:
|
|
65
|
-
|
|
66
|
-
</div>
|
|
67
|
-
<div style={{ fontSize: '11px', color: 'rgba(255,255,255,0.8)', display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
68
|
-
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: '#a8f0c6', display: 'inline-block' }} />
|
|
69
|
-
Online
|
|
70
|
-
</div>
|
|
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>
|
|
71
134
|
</div>
|
|
72
135
|
|
|
73
|
-
|
|
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">
|
|
74
150
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
|
75
|
-
<path d="
|
|
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"/>
|
|
76
152
|
</svg>
|
|
77
153
|
</button>
|
|
78
154
|
</div>
|
|
79
155
|
|
|
80
|
-
{/*
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
+
))}
|
|
86
203
|
{messages.length === 0 && (
|
|
87
|
-
<div style={{
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
fontFamily: t.fontFamily,
|
|
91
|
-
}}>
|
|
92
|
-
<div style={{ fontSize: '28px', marginBottom: 8 }}>💬</div>
|
|
93
|
-
Say hi to {activeUser.name}!
|
|
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}!
|
|
94
207
|
</div>
|
|
95
208
|
)}
|
|
96
|
-
{messages.map(msg => (
|
|
97
|
-
<Bubble key={msg.id} msg={msg} primaryColor={t.primaryColor} font={t.fontFamily} />
|
|
98
|
-
))}
|
|
99
209
|
<div ref={endRef} />
|
|
100
210
|
</div>
|
|
101
211
|
|
|
102
|
-
{/* Input bar
|
|
103
|
-
<div style={{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 */}
|
|
147
289
|
{text.trim() && (
|
|
148
|
-
<button
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
154
|
-
transition: 'transform 0.15s',
|
|
155
|
-
}}
|
|
156
|
-
>
|
|
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
|
+
}}>
|
|
157
295
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
|
158
296
|
<path d="M22 2L11 13M22 2L15 22L11 13L2 9L22 2Z" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
159
297
|
</svg>
|
|
@@ -161,45 +299,171 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
|
161
299
|
)}
|
|
162
300
|
</div>
|
|
163
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
|
+
)}
|
|
164
374
|
</div>
|
|
165
375
|
);
|
|
166
376
|
};
|
|
167
377
|
|
|
168
|
-
|
|
378
|
+
// ── Sub-components ─────────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
const Bubble: React.FC<{ msg: ChatMessage; peer: ChatUser; primaryColor: string }> = ({ msg, peer, primaryColor }) => {
|
|
169
381
|
const isMe = msg.senderId === 'me';
|
|
170
|
-
|
|
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
|
+
|
|
171
403
|
return (
|
|
172
|
-
<div style={{ display:
|
|
404
|
+
<div style={{ display:'flex', flexDirection:'column', alignItems: isMe?'flex-end':'flex-start', gap:3 }}>
|
|
173
405
|
<div style={{
|
|
174
|
-
maxWidth:
|
|
406
|
+
maxWidth:'76%', padding:'10px 13px',
|
|
175
407
|
borderRadius: isMe ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
|
|
176
408
|
backgroundColor: isMe ? primaryColor : '#fff',
|
|
177
409
|
color: isMe ? '#fff' : '#1a2332',
|
|
178
|
-
fontSize:
|
|
179
|
-
boxShadow:
|
|
180
|
-
|
|
410
|
+
fontSize:14, lineHeight:1.5,
|
|
411
|
+
boxShadow:'0 1px 4px rgba(0,0,0,0.07)',
|
|
412
|
+
wordBreak:'break-word',
|
|
181
413
|
}}>
|
|
182
|
-
{
|
|
414
|
+
{content}
|
|
183
415
|
</div>
|
|
184
|
-
<span style={{ fontSize:
|
|
416
|
+
<span style={{ fontSize:11, color:'#b0bec5', padding:'0 4px' }}>{formatTime(msg.timestamp)}</span>
|
|
185
417
|
</div>
|
|
186
418
|
);
|
|
187
419
|
};
|
|
188
420
|
|
|
189
|
-
const
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
style={{
|
|
194
|
-
|
|
195
|
-
|
|
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'}
|
|
196
438
|
>
|
|
197
|
-
{
|
|
439
|
+
<span>{icon}</span> {label}
|
|
198
440
|
</button>
|
|
199
441
|
);
|
|
200
442
|
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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,
|
|
205
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
|
+
}
|