ajaxter-chat 3.0.4 → 3.0.6
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 +6 -1
- package/dist/components/ChatScreen/index.js +180 -47
- package/dist/components/ChatWidget.js +43 -3
- package/dist/components/HomeScreen/index.d.ts +2 -1
- package/dist/components/HomeScreen/index.js +130 -51
- package/dist/components/SlideNavMenu.d.ts +14 -0
- package/dist/components/SlideNavMenu.js +74 -0
- package/dist/components/UserListScreen/index.d.ts +2 -0
- package/dist/components/UserListScreen/index.js +8 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/types/index.d.ts +19 -0
- package/package.json +1 -1
- package/public/chatData.json +6 -0
- package/src/components/ChatScreen/index.tsx +378 -171
- package/src/components/ChatWidget.tsx +47 -3
- package/src/components/HomeScreen/index.tsx +244 -80
- package/src/components/SlideNavMenu.tsx +142 -0
- package/src/components/UserListScreen/index.tsx +10 -3
- package/src/index.ts +1 -0
- package/src/types/index.ts +19 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
2
|
-
import { ChatMessage, ChatUser, WidgetConfig } from '../../types';
|
|
2
|
+
import { ChatMessage, ChatUser, WidgetConfig, UserListContext } from '../../types';
|
|
3
3
|
import { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText } from '../../utils/chat';
|
|
4
4
|
import { EmojiPicker } from '../EmojiPicker';
|
|
5
|
+
import { SlideNavMenu } from '../SlideNavMenu';
|
|
5
6
|
|
|
6
7
|
interface ChatScreenProps {
|
|
7
8
|
activeUser: ChatUser;
|
|
@@ -17,15 +18,23 @@ interface ChatScreenProps {
|
|
|
17
18
|
onReport: () => void;
|
|
18
19
|
onBlock: () => void;
|
|
19
20
|
onStartCall: (withVideo: boolean) => void;
|
|
21
|
+
/** Navigate to support list, colleague list, or tickets (from slide menu) */
|
|
22
|
+
onNavAction: (ctx: UserListContext | 'ticket') => void;
|
|
23
|
+
/** Other devs (excl. viewer) — for transfer when staff chats with a customer */
|
|
24
|
+
otherDevelopers?: ChatUser[];
|
|
25
|
+
onTransferToDeveloper?: (dev: ChatUser) => void;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
23
29
|
activeUser, messages, config, isPaused, isReported, isBlocked,
|
|
24
|
-
onSend, onBack, onClose, onTogglePause, onReport, onBlock, onStartCall,
|
|
30
|
+
onSend, onBack, onClose, onTogglePause, onReport, onBlock, onStartCall, onNavAction,
|
|
31
|
+
otherDevelopers = [], onTransferToDeveloper,
|
|
25
32
|
}) => {
|
|
26
33
|
const [text, setText] = useState('');
|
|
27
34
|
const [showEmoji, setShowEmoji] = useState(false);
|
|
28
35
|
const [showMenu, setShowMenu] = useState(false);
|
|
36
|
+
const [slideMenuOpen, setSlideMenuOpen] = useState(false);
|
|
37
|
+
const [transferOpen, setTransferOpen] = useState(false);
|
|
29
38
|
const [isRecording, setIsRecording] = useState(false);
|
|
30
39
|
const [recordSec, setRecordSec] = useState(0);
|
|
31
40
|
const [showConfirm, setShowConfirm] = useState<'report'|'block'|'pause'|null>(null);
|
|
@@ -35,6 +44,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
|
35
44
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
36
45
|
const recordTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
37
46
|
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
|
47
|
+
const recordChunks = useRef<BlobPart[]>([]);
|
|
38
48
|
|
|
39
49
|
useEffect(() => { endRef.current?.scrollIntoView({ behavior:'smooth' }); }, [messages]);
|
|
40
50
|
|
|
@@ -49,21 +59,27 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
|
49
59
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
|
50
60
|
};
|
|
51
61
|
|
|
52
|
-
// Voice recording
|
|
53
62
|
const startRecording = async () => {
|
|
54
63
|
if (isPaused || isBlocked) return;
|
|
55
64
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
65
|
+
recordChunks.current = [];
|
|
56
66
|
const mr = new MediaRecorder(stream);
|
|
57
67
|
mediaRecorder.current = mr;
|
|
58
|
-
|
|
59
|
-
mr.ondataavailable = e => chunks.push(e.data);
|
|
68
|
+
mr.ondataavailable = e => { if (e.data.size) recordChunks.current.push(e.data); };
|
|
60
69
|
mr.onstop = () => {
|
|
61
70
|
stream.getTracks().forEach(t => t.stop());
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
const chunks = recordChunks.current;
|
|
72
|
+
if (!chunks.length) {
|
|
73
|
+
setRecordSec(0);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const blob = new Blob(chunks, { type: chunks[0] instanceof Blob ? (chunks[0] as Blob).type : 'audio/webm' });
|
|
77
|
+
const voiceUrl = URL.createObjectURL(blob);
|
|
78
|
+
const dur = Math.max(1, recordSec);
|
|
79
|
+
onSend('Voice message', 'voice', { voiceDuration: dur, voiceUrl });
|
|
64
80
|
setRecordSec(0);
|
|
65
81
|
};
|
|
66
|
-
mr.start();
|
|
82
|
+
mr.start(200);
|
|
67
83
|
setIsRecording(true);
|
|
68
84
|
recordTimer.current = setInterval(() => setRecordSec(s => s + 1), 1000);
|
|
69
85
|
};
|
|
@@ -74,25 +90,24 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
|
74
90
|
setIsRecording(false);
|
|
75
91
|
};
|
|
76
92
|
|
|
77
|
-
// Attachment
|
|
78
93
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
79
94
|
const file = e.target.files?.[0];
|
|
80
95
|
if (!file || isPaused || isBlocked) return;
|
|
81
|
-
|
|
96
|
+
const attachmentUrl = URL.createObjectURL(file);
|
|
97
|
+
onSend(file.name, 'attachment', {
|
|
82
98
|
attachmentName: file.name,
|
|
83
99
|
attachmentSize: `${(file.size / 1024).toFixed(1)} KB`,
|
|
100
|
+
attachmentUrl,
|
|
84
101
|
});
|
|
85
102
|
e.target.value = '';
|
|
86
103
|
};
|
|
87
104
|
|
|
88
|
-
// Download transcript
|
|
89
105
|
const handleTranscript = () => {
|
|
90
106
|
const content = generateTranscript(messages, activeUser);
|
|
91
107
|
downloadText(content, `chat-${activeUser.name.replace(/\s+/g,'_')}-${Date.now()}.txt`);
|
|
92
108
|
setShowMenu(false);
|
|
93
109
|
};
|
|
94
110
|
|
|
95
|
-
// Confirm actions
|
|
96
111
|
const handleConfirm = (action: 'report'|'block'|'pause') => {
|
|
97
112
|
setShowConfirm(null); setShowMenu(false);
|
|
98
113
|
if (action === 'report') onReport();
|
|
@@ -102,20 +117,33 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
|
102
117
|
|
|
103
118
|
const peerAvatar = avatarColor(activeUser.name);
|
|
104
119
|
const peerInit = initials(activeUser.name);
|
|
105
|
-
|
|
106
|
-
// Group messages by date
|
|
107
120
|
const grouped = groupByDate(messages);
|
|
108
121
|
|
|
122
|
+
const viewerIsDev = config.viewerType === 'developer';
|
|
123
|
+
const headerRole =
|
|
124
|
+
viewerIsDev
|
|
125
|
+
? (activeUser.type === 'user' ? 'Customer' : 'Developer')
|
|
126
|
+
: 'Support';
|
|
127
|
+
|
|
109
128
|
return (
|
|
110
|
-
<div style={{ display:'flex', flexDirection:'column', height:'100%', animation:'cw-slideIn 0.22s ease', position:'relative' }}>
|
|
129
|
+
<div style={{ display:'flex', flexDirection:'column', height:'100%', animation:'cw-slideIn 0.22s ease', position:'relative', overflow: 'hidden' }}>
|
|
130
|
+
|
|
131
|
+
<SlideNavMenu
|
|
132
|
+
open={slideMenuOpen}
|
|
133
|
+
onClose={() => setSlideMenuOpen(false)}
|
|
134
|
+
primaryColor={config.primaryColor}
|
|
135
|
+
chatType={config.chatType}
|
|
136
|
+
viewerType={config.viewerType ?? 'user'}
|
|
137
|
+
onSelect={onNavAction}
|
|
138
|
+
onBackHome={onBack}
|
|
139
|
+
/>
|
|
111
140
|
|
|
112
141
|
{/* ── Header ── */}
|
|
113
142
|
<div style={{
|
|
114
143
|
background:`linear-gradient(135deg,${config.primaryColor},${config.primaryColor}cc)`,
|
|
115
|
-
padding:'12px
|
|
144
|
+
padding:'10px 12px', display:'flex', alignItems:'center', gap:8, flexShrink:0,
|
|
116
145
|
}}>
|
|
117
|
-
{
|
|
118
|
-
<button onClick={onBack} style={hdrBtn}>
|
|
146
|
+
<button type="button" onClick={() => setSlideMenuOpen(true)} style={hdrBtn} aria-label="Open menu">
|
|
119
147
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
|
120
148
|
<line x1="3" y1="6" x2="21" y2="6" stroke="#fff" strokeWidth="2.2" strokeLinecap="round"/>
|
|
121
149
|
<line x1="3" y1="12" x2="21" y2="12" stroke="#fff" strokeWidth="2.2" strokeLinecap="round"/>
|
|
@@ -133,31 +161,53 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
|
133
161
|
<div style={{ fontSize:11, color:'rgba(255,255,255,0.8)' }}>{activeUser.designation}</div>
|
|
134
162
|
</div>
|
|
135
163
|
|
|
136
|
-
{
|
|
137
|
-
<span style={{ fontSize:14, fontWeight:700, color:'#fff', opacity:0.9 }}>Support</span>
|
|
164
|
+
<span style={{ fontSize:13, fontWeight:700, color:'#fff', opacity:0.95, flexShrink:0 }}>{headerRole}</span>
|
|
138
165
|
|
|
139
|
-
{/* Call button */}
|
|
140
166
|
{config.allowWebCall && (
|
|
141
|
-
<button onClick={() => onStartCall(false)} style={hdrBtn} title="Voice Call">
|
|
167
|
+
<button type="button" onClick={() => onStartCall(false)} style={hdrBtn} title="Voice Call">
|
|
142
168
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none">
|
|
143
169
|
<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
170
|
</svg>
|
|
145
171
|
</button>
|
|
146
172
|
)}
|
|
147
173
|
|
|
148
|
-
{
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<
|
|
174
|
+
<button type="button" onClick={() => setShowMenu(v => !v)} style={{ ...hdrBtn, background:'rgba(255,255,255,0.2)' }} title="More options" aria-expanded={showMenu}>
|
|
175
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
|
176
|
+
<circle cx="12" cy="5" r="1.5" fill="#fff"/>
|
|
177
|
+
<circle cx="12" cy="12" r="1.5" fill="#fff"/>
|
|
178
|
+
<circle cx="12" cy="19" r="1.5" fill="#fff"/>
|
|
152
179
|
</svg>
|
|
153
180
|
</button>
|
|
154
181
|
</div>
|
|
155
182
|
|
|
156
|
-
{
|
|
183
|
+
{showMenu && (
|
|
184
|
+
<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' }}>
|
|
185
|
+
{config.allowTranscriptDownload && (
|
|
186
|
+
<MenuItem icon="📥" label="Download Transcript" onClick={handleTranscript} />
|
|
187
|
+
)}
|
|
188
|
+
{viewerIsDev && activeUser.type === 'user' && otherDevelopers.length > 0 && onTransferToDeveloper && (
|
|
189
|
+
<MenuItem
|
|
190
|
+
icon="🔀"
|
|
191
|
+
label="Transfer to developer"
|
|
192
|
+
onClick={() => { setShowMenu(false); setTransferOpen(true); }}
|
|
193
|
+
/>
|
|
194
|
+
)}
|
|
195
|
+
<MenuItem icon={isPaused ? '▶️' : '⏸'} label={isPaused ? 'Resume Chat' : 'Pause Chat'} onClick={() => { setShowMenu(false); setShowConfirm('pause'); }} />
|
|
196
|
+
{config.allowReport && !isReported && (
|
|
197
|
+
<MenuItem icon="⚠️" label="Report Chat" onClick={() => { setShowMenu(false); setShowConfirm('report'); }} />
|
|
198
|
+
)}
|
|
199
|
+
{config.allowBlock && activeUser.type === 'user' && !isBlocked && (
|
|
200
|
+
<MenuItem icon="🚫" label="Block User" onClick={() => { setShowMenu(false); setShowConfirm('block'); }} danger />
|
|
201
|
+
)}
|
|
202
|
+
<div style={{ borderTop:'1px solid #f0f2f5', margin:'4px 0' }} />
|
|
203
|
+
<MenuItem icon="✕" label="Close Chat" onClick={onClose} />
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
157
207
|
{isPaused && (
|
|
158
208
|
<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
209
|
⏸ 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>
|
|
210
|
+
<button type="button" onClick={onTogglePause} style={{ background:'#92400e', color:'#fff', border:'none', borderRadius:6, padding:'2px 8px', fontSize:11, cursor:'pointer', marginLeft:4 }}>Resume</button>
|
|
161
211
|
</div>
|
|
162
212
|
)}
|
|
163
213
|
{isBlocked && (
|
|
@@ -171,24 +221,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
|
171
221
|
</div>
|
|
172
222
|
)}
|
|
173
223
|
|
|
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
224
|
<div style={{ flex:1, overflowY:'auto', padding:'14px', display:'flex', flexDirection:'column', gap:10, background:'#f8f9fc' }}
|
|
193
225
|
className="cw-scroll"
|
|
194
226
|
>
|
|
@@ -196,7 +228,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
|
196
228
|
<React.Fragment key={date}>
|
|
197
229
|
<DateDivider label={date} />
|
|
198
230
|
{msgs.map(msg => (
|
|
199
|
-
<Bubble key={msg.id} msg={msg}
|
|
231
|
+
<Bubble key={msg.id} msg={msg} primaryColor={config.primaryColor} />
|
|
200
232
|
))}
|
|
201
233
|
</React.Fragment>
|
|
202
234
|
))}
|
|
@@ -209,10 +241,9 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
|
209
241
|
<div ref={endRef} />
|
|
210
242
|
</div>
|
|
211
243
|
|
|
212
|
-
{/*
|
|
213
|
-
<div style={{ borderTop:'1px solid #eef0f5', padding:'10px
|
|
244
|
+
{/* Composer — reference layout */}
|
|
245
|
+
<div style={{ borderTop:'1px solid #eef0f5', padding:'10px 12px 8px', background:'#fff', flexShrink:0, position:'relative' }}>
|
|
214
246
|
|
|
215
|
-
{/* Emoji picker */}
|
|
216
247
|
{showEmoji && config.allowEmoji && (
|
|
217
248
|
<EmojiPicker
|
|
218
249
|
primaryColor={config.primaryColor}
|
|
@@ -221,124 +252,192 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
|
221
252
|
/>
|
|
222
253
|
)}
|
|
223
254
|
|
|
224
|
-
{/* Recording indicator */}
|
|
225
255
|
{isRecording && (
|
|
226
|
-
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8, padding:'
|
|
256
|
+
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8, padding:'8px 12px', background:'#fee2e2', borderRadius:10 }}>
|
|
227
257
|
<span style={{ width:8, height:8, borderRadius:'50%', background:'#ef4444', display:'inline-block', animation:'cw-pulse 1s infinite' }} />
|
|
228
258
|
<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:'
|
|
259
|
+
<button type="button" onClick={stopRecording} style={{ marginLeft:'auto', background:'#ef4444', color:'#fff', border:'none', borderRadius:6, padding:'4px 12px', fontSize:12, cursor:'pointer', fontWeight:600 }}>Stop & send</button>
|
|
230
260
|
</div>
|
|
231
261
|
)}
|
|
232
262
|
|
|
233
|
-
<div style={{
|
|
263
|
+
<div style={{
|
|
264
|
+
border: `1.5px solid ${isPaused || isBlocked ? '#e5e7eb' : '#bfdbfe'}`,
|
|
265
|
+
borderRadius: 16,
|
|
266
|
+
padding: '10px 12px 8px',
|
|
267
|
+
background: isPaused || isBlocked ? '#f9fafb' : '#fff',
|
|
268
|
+
}}>
|
|
234
269
|
<textarea
|
|
235
270
|
ref={inputRef}
|
|
236
271
|
value={text}
|
|
237
272
|
onChange={e => setText(e.target.value)}
|
|
238
273
|
onKeyDown={handleKey}
|
|
239
|
-
placeholder={isPaused || isBlocked ? 'Chat is unavailable' : '
|
|
274
|
+
placeholder={isPaused || isBlocked ? 'Chat is unavailable' : 'Compose your message…'}
|
|
240
275
|
disabled={isPaused || isBlocked || isRecording}
|
|
241
|
-
rows={
|
|
276
|
+
rows={2}
|
|
242
277
|
style={{
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
278
|
+
width: '100%',
|
|
279
|
+
resize: 'none',
|
|
280
|
+
border: 'none',
|
|
281
|
+
outline: 'none',
|
|
282
|
+
fontSize: 14,
|
|
283
|
+
lineHeight: 1.45,
|
|
284
|
+
color: '#1a2332',
|
|
285
|
+
background: 'transparent',
|
|
286
|
+
maxHeight: 88,
|
|
287
|
+
overflowY: 'auto',
|
|
288
|
+
fontFamily: 'inherit',
|
|
289
|
+
marginBottom: 8,
|
|
250
290
|
}}
|
|
251
|
-
onFocus={e => (e.target.style.borderColor = config.primaryColor)}
|
|
252
|
-
onBlur={e => (e.target.style.borderColor = '#e5e7eb')}
|
|
253
291
|
/>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
292
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
|
293
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
294
|
+
{config.allowEmoji && (
|
|
295
|
+
<ActionBtn onClick={() => setShowEmoji(v => !v)} title="Emoji">
|
|
296
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
297
|
+
<circle cx="12" cy="12" r="10" stroke="#94a3b8" strokeWidth="1.8"/>
|
|
298
|
+
<path d="M8 14s1.5 2 4 2 4-2 4-2" stroke="#94a3b8" strokeWidth="1.8" strokeLinecap="round"/>
|
|
299
|
+
<circle cx="9" cy="9" r="1" fill="#94a3b8"/><circle cx="15" cy="9" r="1" fill="#94a3b8"/>
|
|
300
|
+
</svg>
|
|
301
|
+
</ActionBtn>
|
|
302
|
+
)}
|
|
303
|
+
{config.allowAttachment && (
|
|
304
|
+
<>
|
|
305
|
+
<input ref={fileRef} type="file" style={{ display:'none' }} onChange={handleFileChange} />
|
|
306
|
+
<ActionBtn onClick={() => fileRef.current?.click()} title="Attach file">
|
|
307
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
308
|
+
<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="#94a3b8" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
|
309
|
+
</svg>
|
|
310
|
+
</ActionBtn>
|
|
311
|
+
</>
|
|
312
|
+
)}
|
|
313
|
+
{config.allowVoiceMessage && !isRecording && (
|
|
314
|
+
<ActionBtn onClick={startRecording} title="Voice message">
|
|
315
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
316
|
+
<path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z" stroke="#94a3b8" strokeWidth="1.8" strokeLinecap="round"/>
|
|
317
|
+
<path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="#94a3b8" strokeWidth="1.8" strokeLinecap="round"/>
|
|
318
|
+
</svg>
|
|
319
|
+
</ActionBtn>
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
<button
|
|
323
|
+
type="button"
|
|
324
|
+
onClick={handleSend}
|
|
325
|
+
disabled={!text.trim() || isPaused || isBlocked || isRecording}
|
|
326
|
+
style={{
|
|
327
|
+
width: 36,
|
|
328
|
+
height: 36,
|
|
329
|
+
borderRadius: '50%',
|
|
330
|
+
backgroundColor: text.trim() && !isPaused && !isBlocked ? config.primaryColor : '#e2e8f0',
|
|
331
|
+
border: 'none',
|
|
332
|
+
cursor: text.trim() && !isPaused && !isBlocked ? 'pointer' : 'default',
|
|
333
|
+
display: 'flex',
|
|
334
|
+
alignItems: 'center',
|
|
335
|
+
justifyContent: 'center',
|
|
336
|
+
flexShrink: 0,
|
|
337
|
+
transition: 'background 0.15s',
|
|
338
|
+
}}
|
|
339
|
+
title="Send"
|
|
340
|
+
>
|
|
295
341
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
|
296
|
-
<path d="M22 2L11 13M22 2L15 22L11 13L2 9L22 2Z" stroke=
|
|
342
|
+
<path d="M22 2L11 13M22 2L15 22L11 13L2 9L22 2Z" stroke={text.trim() && !isPaused && !isBlocked ? '#fff' : '#94a3b8'} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
297
343
|
</svg>
|
|
298
344
|
</button>
|
|
299
|
-
|
|
345
|
+
</div>
|
|
300
346
|
</div>
|
|
347
|
+
|
|
348
|
+
{(config.footerPoweredBy || config.branch) && (
|
|
349
|
+
<p style={{ margin: '10px 0 0', textAlign: 'center', fontSize: 12, color: '#94a3b8' }}>
|
|
350
|
+
{config.footerPoweredBy}
|
|
351
|
+
{config.footerPoweredBy && config.branch ? ' · ' : ''}
|
|
352
|
+
{config.branch && <span style={{ fontWeight: 600, color: '#64748b' }}>{config.branch}</span>}
|
|
353
|
+
</p>
|
|
354
|
+
)}
|
|
301
355
|
</div>
|
|
302
356
|
|
|
303
|
-
{
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
357
|
+
{transferOpen && otherDevelopers.length > 0 && onTransferToDeveloper && (
|
|
358
|
+
<div
|
|
359
|
+
style={{
|
|
360
|
+
position: 'absolute',
|
|
361
|
+
inset: 0,
|
|
362
|
+
background: 'rgba(0,0,0,0.45)',
|
|
363
|
+
display: 'flex',
|
|
364
|
+
alignItems: 'center',
|
|
365
|
+
justifyContent: 'center',
|
|
366
|
+
zIndex: 280,
|
|
367
|
+
padding: 16,
|
|
368
|
+
}}
|
|
309
369
|
>
|
|
310
|
-
<
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
370
|
+
<div
|
|
371
|
+
style={{
|
|
372
|
+
background: '#fff',
|
|
373
|
+
borderRadius: 16,
|
|
374
|
+
padding: '18px 16px',
|
|
375
|
+
width: '100%',
|
|
376
|
+
maxWidth: 320,
|
|
377
|
+
maxHeight: '70%',
|
|
378
|
+
overflow: 'hidden',
|
|
379
|
+
display: 'flex',
|
|
380
|
+
flexDirection: 'column',
|
|
381
|
+
boxShadow: '0 16px 48px rgba(0,0,0,0.22)',
|
|
382
|
+
}}
|
|
383
|
+
>
|
|
384
|
+
<div style={{ fontWeight: 800, fontSize: 16, color: '#1a2332', marginBottom: 6 }}>
|
|
385
|
+
Transfer chat to
|
|
386
|
+
</div>
|
|
387
|
+
<p style={{ fontSize: 12, color: '#7b8fa1', margin: '0 0 12px', lineHeight: 1.5 }}>
|
|
388
|
+
Assign this conversation to another developer. History is kept and a handoff note is added.
|
|
389
|
+
</p>
|
|
390
|
+
<div className="cw-scroll" style={{ flex: 1, overflowY: 'auto', margin: '0 -4px' }}>
|
|
391
|
+
{otherDevelopers.map(dev => (
|
|
392
|
+
<button
|
|
393
|
+
key={dev.uid}
|
|
394
|
+
type="button"
|
|
395
|
+
onClick={() => {
|
|
396
|
+
onTransferToDeveloper(dev);
|
|
397
|
+
setTransferOpen(false);
|
|
398
|
+
}}
|
|
399
|
+
style={{
|
|
400
|
+
width: '100%',
|
|
401
|
+
textAlign: 'left',
|
|
402
|
+
padding: '12px 12px',
|
|
403
|
+
marginBottom: 6,
|
|
404
|
+
border: '1px solid #eef0f5',
|
|
405
|
+
borderRadius: 12,
|
|
406
|
+
background: '#f8fafc',
|
|
407
|
+
cursor: 'pointer',
|
|
408
|
+
fontSize: 14,
|
|
409
|
+
fontWeight: 600,
|
|
410
|
+
color: '#1e293b',
|
|
411
|
+
}}
|
|
412
|
+
>
|
|
413
|
+
{dev.name}
|
|
414
|
+
<span style={{ display: 'block', fontSize: 11, fontWeight: 500, color: '#64748b', marginTop: 2 }}>
|
|
415
|
+
{dev.designation}
|
|
416
|
+
</span>
|
|
417
|
+
</button>
|
|
418
|
+
))}
|
|
419
|
+
</div>
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
onClick={() => setTransferOpen(false)}
|
|
423
|
+
style={{
|
|
424
|
+
marginTop: 12,
|
|
425
|
+
padding: '10px',
|
|
426
|
+
borderRadius: 10,
|
|
427
|
+
border: '1.5px solid #e5e7eb',
|
|
428
|
+
background: '#fff',
|
|
429
|
+
fontWeight: 600,
|
|
430
|
+
fontSize: 13,
|
|
431
|
+
color: '#475569',
|
|
432
|
+
cursor: 'pointer',
|
|
433
|
+
}}
|
|
434
|
+
>
|
|
435
|
+
Cancel
|
|
436
|
+
</button>
|
|
337
437
|
</div>
|
|
338
|
-
|
|
339
|
-
|
|
438
|
+
</div>
|
|
439
|
+
)}
|
|
340
440
|
|
|
341
|
-
{/* ── Confirm dialog ── */}
|
|
342
441
|
{showConfirm && (
|
|
343
442
|
<div style={{
|
|
344
443
|
position:'absolute', inset:0, background:'rgba(0,0,0,0.45)',
|
|
@@ -357,10 +456,10 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
|
357
456
|
{showConfirm === 'block' && 'This user will be blocked and added to your block list. You can unblock them later.'}
|
|
358
457
|
</p>
|
|
359
458
|
<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' }}>
|
|
459
|
+
<button type="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
460
|
Cancel
|
|
362
461
|
</button>
|
|
363
|
-
<button onClick={() => handleConfirm(showConfirm)} style={{
|
|
462
|
+
<button type="button" onClick={() => handleConfirm(showConfirm)} style={{
|
|
364
463
|
flex:1, padding:'9px', borderRadius:10, border:'none',
|
|
365
464
|
background: showConfirm==='block' ? '#ef4444' : config.primaryColor,
|
|
366
465
|
color:'#fff', cursor:'pointer', fontSize:13, fontWeight:700,
|
|
@@ -375,35 +474,138 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
|
|
|
375
474
|
);
|
|
376
475
|
};
|
|
377
476
|
|
|
378
|
-
|
|
477
|
+
const VoiceRow: React.FC<{ msg: ChatMessage; isMe: boolean; primaryColor: string }> = ({ msg, isMe, primaryColor }) => {
|
|
478
|
+
const audioRef = useRef<HTMLAudioElement>(null);
|
|
479
|
+
const [playing, setPlaying] = useState(false);
|
|
480
|
+
const [current, setCurrent] = useState(0);
|
|
481
|
+
const [dur, setDur] = useState(msg.voiceDuration ?? 0);
|
|
482
|
+
|
|
483
|
+
const url = msg.voiceUrl;
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
const a = audioRef.current;
|
|
486
|
+
if (!a || !url) return;
|
|
487
|
+
const onMeta = () => setDur(a.duration || msg.voiceDuration || 0);
|
|
488
|
+
const onTime = () => setCurrent(a.currentTime);
|
|
489
|
+
a.addEventListener('loadedmetadata', onMeta);
|
|
490
|
+
a.addEventListener('timeupdate', onTime);
|
|
491
|
+
return () => {
|
|
492
|
+
a.removeEventListener('loadedmetadata', onMeta);
|
|
493
|
+
a.removeEventListener('timeupdate', onTime);
|
|
494
|
+
};
|
|
495
|
+
}, [url, msg.voiceDuration]);
|
|
379
496
|
|
|
380
|
-
const
|
|
381
|
-
|
|
497
|
+
const toggle = () => {
|
|
498
|
+
const a = audioRef.current;
|
|
499
|
+
if (!a) return;
|
|
500
|
+
if (playing) { a.pause(); setPlaying(false); }
|
|
501
|
+
else { void a.play().then(() => setPlaying(true)).catch(() => {}); }
|
|
502
|
+
};
|
|
382
503
|
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
504
|
+
const pct = dur > 0 ? Math.min(100, (current / dur) * 100) : 0;
|
|
505
|
+
const timeLabel = fmtTime(Math.floor(current)) + ' / ' + fmtTime(Math.floor(dur || msg.voiceDuration || 0));
|
|
506
|
+
|
|
507
|
+
if (!url) {
|
|
508
|
+
return (
|
|
509
|
+
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
|
510
|
+
<span style={{ fontSize:13 }}>🎤</span>
|
|
511
|
+
<span style={{ fontSize:13 }}>Voice message{msg.voiceDuration ? ` · ${msg.voiceDuration}s` : ''}</span>
|
|
512
|
+
</div>
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return (
|
|
517
|
+
<div style={{ display:'flex', alignItems:'center', gap:10, minWidth: 200 }}>
|
|
518
|
+
{url && (
|
|
519
|
+
<audio
|
|
520
|
+
ref={audioRef}
|
|
521
|
+
src={url}
|
|
522
|
+
preload="metadata"
|
|
523
|
+
onPlay={() => setPlaying(true)}
|
|
524
|
+
onPause={() => setPlaying(false)}
|
|
525
|
+
onEnded={() => { setPlaying(false); setCurrent(0); }}
|
|
526
|
+
/>
|
|
527
|
+
)}
|
|
528
|
+
<button
|
|
529
|
+
type="button"
|
|
530
|
+
onClick={toggle}
|
|
531
|
+
style={{
|
|
532
|
+
width: 36,
|
|
533
|
+
height: 36,
|
|
534
|
+
borderRadius: '50%',
|
|
535
|
+
border: 'none',
|
|
536
|
+
background: isMe ? 'rgba(255,255,255,0.95)' : '#fff',
|
|
537
|
+
color: isMe ? primaryColor : primaryColor,
|
|
538
|
+
cursor: 'pointer',
|
|
539
|
+
display: 'flex',
|
|
540
|
+
alignItems: 'center',
|
|
541
|
+
justifyContent: 'center',
|
|
542
|
+
flexShrink: 0,
|
|
543
|
+
boxShadow: '0 1px 4px rgba(0,0,0,0.12)',
|
|
544
|
+
}}
|
|
545
|
+
aria-label={playing ? 'Pause' : 'Play'}
|
|
546
|
+
>
|
|
547
|
+
{playing ? (
|
|
548
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
|
|
549
|
+
) : (
|
|
550
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
551
|
+
)}
|
|
552
|
+
</button>
|
|
553
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
554
|
+
<div style={{ height: 4, borderRadius: 2, background: isMe ? 'rgba(255,255,255,0.35)' : '#e2e8f0', overflow: 'hidden' }}>
|
|
555
|
+
<div style={{ width: `${pct}%`, height: '100%', background: isMe ? '#fff' : primaryColor, borderRadius: 2, transition: 'width 0.1s linear' }} />
|
|
556
|
+
</div>
|
|
557
|
+
<div style={{ fontSize: 11, marginTop: 4, opacity: 0.9 }}>{timeLabel}</div>
|
|
558
|
+
</div>
|
|
390
559
|
</div>
|
|
391
|
-
)
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
560
|
+
);
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const AttachmentRow: React.FC<{ msg: ChatMessage; isMe: boolean; primaryColor: string }> = ({ msg, isMe, primaryColor }) => {
|
|
564
|
+
const name = msg.attachmentName ?? 'File';
|
|
565
|
+
const href = msg.attachmentUrl;
|
|
566
|
+
return (
|
|
567
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
|
568
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
|
|
569
|
+
<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"/>
|
|
395
570
|
</svg>
|
|
396
|
-
<div>
|
|
397
|
-
<div style={{ fontWeight:
|
|
398
|
-
{msg.attachmentSize && <div style={{ fontSize:11, opacity:0.
|
|
571
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
572
|
+
<div style={{ fontWeight: 700, fontSize: 14, wordBreak: 'break-word' }}>{name}</div>
|
|
573
|
+
{msg.attachmentSize && <div style={{ fontSize: 11, opacity: 0.8 }}>{msg.attachmentSize}</div>}
|
|
399
574
|
</div>
|
|
575
|
+
{href && (
|
|
576
|
+
<a
|
|
577
|
+
href={href}
|
|
578
|
+
download={name}
|
|
579
|
+
style={{
|
|
580
|
+
fontSize: 12,
|
|
581
|
+
fontWeight: 700,
|
|
582
|
+
color: isMe ? '#fff' : primaryColor,
|
|
583
|
+
textDecoration: 'underline',
|
|
584
|
+
whiteSpace: 'nowrap',
|
|
585
|
+
}}
|
|
586
|
+
>
|
|
587
|
+
Download
|
|
588
|
+
</a>
|
|
589
|
+
)}
|
|
400
590
|
</div>
|
|
401
|
-
)
|
|
591
|
+
);
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const Bubble: React.FC<{ msg: ChatMessage; primaryColor: string }> = ({ msg, primaryColor }) => {
|
|
595
|
+
const isMe = msg.senderId === 'me';
|
|
596
|
+
|
|
597
|
+
const content = msg.type === 'voice' ? (
|
|
598
|
+
<VoiceRow msg={msg} isMe={isMe} primaryColor={primaryColor} />
|
|
599
|
+
) : msg.type === 'attachment' ? (
|
|
600
|
+
<AttachmentRow msg={msg} isMe={isMe} primaryColor={primaryColor} />
|
|
601
|
+
) : (
|
|
602
|
+
<span>{msg.text}</span>
|
|
603
|
+
);
|
|
402
604
|
|
|
403
605
|
return (
|
|
404
606
|
<div style={{ display:'flex', flexDirection:'column', alignItems: isMe?'flex-end':'flex-start', gap:3 }}>
|
|
405
607
|
<div style={{
|
|
406
|
-
maxWidth:'
|
|
608
|
+
maxWidth:'85%', padding:'10px 13px',
|
|
407
609
|
borderRadius: isMe ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
|
|
408
610
|
backgroundColor: isMe ? primaryColor : '#fff',
|
|
409
611
|
color: isMe ? '#fff' : '#1a2332',
|
|
@@ -421,13 +623,13 @@ const Bubble: React.FC<{ msg: ChatMessage; peer: ChatUser; primaryColor: string
|
|
|
421
623
|
const DateDivider: React.FC<{ label: string }> = ({ label }) => (
|
|
422
624
|
<div style={{ display:'flex', alignItems:'center', gap:10, margin:'4px 0' }}>
|
|
423
625
|
<div style={{ flex:1, height:1, background:'#e5e7eb' }} />
|
|
424
|
-
<span style={{ fontSize:11, fontWeight:600, color:'#
|
|
626
|
+
<span style={{ fontSize:11, fontWeight:600, color:'#64748b', whiteSpace:'nowrap' }}>{label}</span>
|
|
425
627
|
<div style={{ flex:1, height:1, background:'#e5e7eb' }} />
|
|
426
628
|
</div>
|
|
427
629
|
);
|
|
428
630
|
|
|
429
631
|
const MenuItem: React.FC<{ icon: string; label: string; onClick: () => void; danger?: boolean }> = ({ icon, label, onClick, danger }) => (
|
|
430
|
-
<button onClick={onClick} style={{
|
|
632
|
+
<button type="button" onClick={onClick} style={{
|
|
431
633
|
display:'flex', alignItems:'center', gap:10, width:'100%', padding:'9px 12px',
|
|
432
634
|
background:'none', border:'none', borderRadius:8, cursor:'pointer', textAlign:'left',
|
|
433
635
|
fontSize:13, fontWeight:600, color: danger ? '#ef4444' : '#374151',
|
|
@@ -441,12 +643,12 @@ const MenuItem: React.FC<{ icon: string; label: string; onClick: () => void; dan
|
|
|
441
643
|
);
|
|
442
644
|
|
|
443
645
|
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:'
|
|
646
|
+
<button type="button" onClick={onClick} title={title} style={{
|
|
647
|
+
background:'none', border:'none', cursor:'pointer', padding:'8px',
|
|
446
648
|
borderRadius:'50%', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0,
|
|
447
649
|
transition:'background 0.13s',
|
|
448
650
|
}}
|
|
449
|
-
onMouseEnter={e => (e.currentTarget as HTMLElement).style.background = '#
|
|
651
|
+
onMouseEnter={e => (e.currentTarget as HTMLElement).style.background = '#f1f5f9'}
|
|
450
652
|
onMouseLeave={e => (e.currentTarget as HTMLElement).style.background = 'none'}
|
|
451
653
|
>{children}</button>
|
|
452
654
|
);
|
|
@@ -457,7 +659,6 @@ const hdrBtn: React.CSSProperties = {
|
|
|
457
659
|
cursor:'pointer', flexShrink:0,
|
|
458
660
|
};
|
|
459
661
|
|
|
460
|
-
// Group messages by date
|
|
461
662
|
function groupByDate(messages: ChatMessage[]): { date: string; msgs: ChatMessage[] }[] {
|
|
462
663
|
const map = new Map<string, ChatMessage[]>();
|
|
463
664
|
messages.forEach(m => {
|
|
@@ -467,3 +668,9 @@ function groupByDate(messages: ChatMessage[]): { date: string; msgs: ChatMessage
|
|
|
467
668
|
});
|
|
468
669
|
return Array.from(map.entries()).map(([date, msgs]) => ({ date, msgs }));
|
|
469
670
|
}
|
|
671
|
+
|
|
672
|
+
function fmtTime(sec: number): string {
|
|
673
|
+
const m = Math.floor(sec / 60);
|
|
674
|
+
const s = Math.max(0, sec % 60);
|
|
675
|
+
return `${m}:${String(s).padStart(2, '0')}`;
|
|
676
|
+
}
|