@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
type UseVoiceRecorderOptions = {
|
||||
onRecordingComplete?: (audioBlob: Blob) => void;
|
||||
onRecordingComplete?: (audioFile: File, audioUrl: string) => void;
|
||||
};
|
||||
|
||||
export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
@@ -18,6 +18,33 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const audioUrlRef = useRef<string | null>(null);
|
||||
const onRecordingCompleteRef = useRef(onRecordingComplete);
|
||||
|
||||
useEffect(() => {
|
||||
onRecordingCompleteRef.current = onRecordingComplete;
|
||||
}, [onRecordingComplete]);
|
||||
|
||||
const clearRecording = useCallback((options?: { revoke?: boolean }) => {
|
||||
const shouldRevoke = options?.revoke !== false;
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
|
||||
if (shouldRevoke && audioUrlRef.current) {
|
||||
URL.revokeObjectURL(audioUrlRef.current);
|
||||
}
|
||||
|
||||
audioUrlRef.current = null;
|
||||
chunksRef.current = [];
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setAudioUrl(null);
|
||||
setAudioFile(null);
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
@@ -35,14 +62,15 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(chunksRef.current, { type: "audio/webm" });
|
||||
const file = new File([audioBlob], "voice-message.webm", {
|
||||
const file = new File([audioBlob], `voice-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
});
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
audioUrlRef.current = url;
|
||||
setAudioUrl(url);
|
||||
setAudioFile(file);
|
||||
onRecordingComplete?.(audioBlob);
|
||||
onRecordingCompleteRef.current?.(file, url);
|
||||
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
@@ -52,7 +80,7 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
} catch (err) {
|
||||
console.error("Microphone access error:", err);
|
||||
}
|
||||
}, [onRecordingComplete]);
|
||||
}, []);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
@@ -65,24 +93,9 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
|
||||
chunksRef.current = [];
|
||||
setIsRecording(false);
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setAudioUrl(null);
|
||||
setAudioFile(null);
|
||||
}, [audioUrl, isRecording]);
|
||||
clearRecording({ revoke: true });
|
||||
}, [clearRecording, isRecording]);
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (!audioRef.current) return;
|
||||
@@ -136,24 +149,18 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
}, [isPlaying, updateProgress]);
|
||||
|
||||
return {
|
||||
// state
|
||||
isRecording,
|
||||
isPlaying,
|
||||
audioUrl,
|
||||
audioFile,
|
||||
currentTime,
|
||||
duration,
|
||||
|
||||
// refs
|
||||
audioRef,
|
||||
|
||||
// actions
|
||||
startRecording,
|
||||
stopRecording,
|
||||
togglePlayPause,
|
||||
resetRecorder,
|
||||
|
||||
// helpers
|
||||
clearRecording,
|
||||
progress: duration ? currentTime / duration : 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
import Button from '@/components/Button'
|
||||
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import { useSingleUpload } from '@/pages/uploader/hooks/useUploader'
|
||||
import { toast } from '@/shared/toast'
|
||||
import {
|
||||
CloseCircle,
|
||||
DocumentText,
|
||||
Microphone,
|
||||
Paperclip2,
|
||||
Pause,
|
||||
Play,
|
||||
} from 'iconsax-react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type FC,
|
||||
} from 'react'
|
||||
|
||||
export type ChatComposerAttachment = {
|
||||
type: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type ChatComposerSubmitPayload = {
|
||||
content: string
|
||||
attachments: ChatComposerAttachment[]
|
||||
}
|
||||
|
||||
type PendingFile = {
|
||||
id: string
|
||||
file: File
|
||||
previewUrl: string | null
|
||||
key?: string
|
||||
status: 'uploading' | 'done' | 'error'
|
||||
}
|
||||
|
||||
type PendingVoice = {
|
||||
id: string
|
||||
file: File
|
||||
previewUrl: string
|
||||
key?: string
|
||||
status: 'uploading' | 'done' | 'error'
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onSubmit: (payload: ChatComposerSubmitPayload) => void | Promise<void>
|
||||
isSubmitting?: boolean
|
||||
submitLabel?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
replyTo?: {
|
||||
id: string
|
||||
content: string
|
||||
senderName?: string
|
||||
} | null
|
||||
onCancelReply?: () => void
|
||||
}
|
||||
|
||||
const isImageFile = (file: File) => file.type.startsWith('image/')
|
||||
|
||||
const VoicePreviewChip: FC<{
|
||||
voice: PendingVoice
|
||||
onRemove: () => void
|
||||
}> = ({ voice, onRemove }) => {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (!audio.duration) return
|
||||
setProgress(audio.currentTime / audio.duration)
|
||||
}
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false)
|
||||
setProgress(0)
|
||||
}
|
||||
|
||||
audio.addEventListener('timeupdate', onTimeUpdate)
|
||||
audio.addEventListener('ended', onEnded)
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate)
|
||||
audio.removeEventListener('ended', onEnded)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause()
|
||||
setIsPlaying(false)
|
||||
} else {
|
||||
void audio.play()
|
||||
setIsPlaying(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-16 w-[140px] items-center gap-2 rounded-lg border border-[#EBEDF5] bg-[#F5F7FC] px-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlayPause}
|
||||
disabled={voice.status === 'uploading'}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-black disabled:opacity-50"
|
||||
aria-label={isPlaying ? 'توقف' : 'پخش'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} color="#fff" variant="Bold" />
|
||||
) : (
|
||||
<Play size={16} color="#fff" variant="Bold" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex h-8 flex-1 items-center justify-center gap-[2px]">
|
||||
{Array.from({ length: 12 }).map((_, i) => {
|
||||
const passed = i / 12 <= progress
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[2px] rounded-full transition-all"
|
||||
style={{
|
||||
height: `${6 + ((i * 5) % 12)}px`,
|
||||
backgroundColor: passed ? '#0047FF' : '#E5E7EB',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{voice.status === 'uploading' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/35">
|
||||
<span className="size-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{voice.status === 'error' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-red-500/70 px-1">
|
||||
<span className="text-center text-[10px] text-white">خطا</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="absolute -left-1.5 -top-1.5 z-10 rounded-full bg-white shadow-sm"
|
||||
aria-label="حذف صدا"
|
||||
>
|
||||
<CloseCircle size={18} color="#EF4444" variant="Bold" />
|
||||
</button>
|
||||
|
||||
<audio ref={audioRef} src={voice.previewUrl} className="hidden" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChatComposer: FC<Props> = ({
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
submitLabel = 'ارسال پیام',
|
||||
label = 'پیام شما',
|
||||
placeholder = 'متن پیام خود را بنویسید...',
|
||||
replyTo = null,
|
||||
onCancelReply,
|
||||
}) => {
|
||||
const inputId = useId()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [message, setMessage] = useState('')
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([])
|
||||
const [pendingVoices, setPendingVoices] = useState<PendingVoice[]>([])
|
||||
const singleUpload = useSingleUpload()
|
||||
const clearRecordingRef = useRef<(options?: { revoke?: boolean }) => void>(
|
||||
() => undefined,
|
||||
)
|
||||
|
||||
const uploadVoice = useCallback(
|
||||
async (item: PendingVoice) => {
|
||||
try {
|
||||
const result = await singleUpload.mutateAsync(item.file)
|
||||
setPendingVoices((prev) =>
|
||||
prev.map((voice) =>
|
||||
voice.id === item.id
|
||||
? { ...voice, key: result.data.key, status: 'done' }
|
||||
: voice,
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
setPendingVoices((prev) =>
|
||||
prev.map((voice) =>
|
||||
voice.id === item.id
|
||||
? { ...voice, status: 'error' }
|
||||
: voice,
|
||||
),
|
||||
)
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
}
|
||||
},
|
||||
[singleUpload],
|
||||
)
|
||||
|
||||
const handleRecordingComplete = useCallback(
|
||||
(file: File, url: string) => {
|
||||
const item: PendingVoice = {
|
||||
id: `voice-${Date.now()}-${Math.random()}`,
|
||||
file,
|
||||
previewUrl: url,
|
||||
status: 'uploading',
|
||||
}
|
||||
setPendingVoices((prev) => [...prev, item])
|
||||
clearRecordingRef.current({ revoke: false })
|
||||
void uploadVoice(item)
|
||||
},
|
||||
[uploadVoice],
|
||||
)
|
||||
|
||||
const { isRecording, startRecording, stopRecording, clearRecording } =
|
||||
useVoiceRecorder({
|
||||
onRecordingComplete: handleRecordingComplete,
|
||||
})
|
||||
|
||||
clearRecordingRef.current = clearRecording
|
||||
|
||||
const isUploading =
|
||||
pendingFiles.some((f) => f.status === 'uploading') ||
|
||||
pendingVoices.some((v) => v.status === 'uploading')
|
||||
|
||||
const isBusy = isSubmitting || singleUpload.isPending || isUploading
|
||||
|
||||
const revokePreview = (previewUrl: string | null) => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl)
|
||||
}
|
||||
|
||||
const resetComposer = useCallback(() => {
|
||||
setPendingFiles((prev) => {
|
||||
prev.forEach((item) => revokePreview(item.previewUrl))
|
||||
return []
|
||||
})
|
||||
setPendingVoices((prev) => {
|
||||
prev.forEach((item) => revokePreview(item.previewUrl))
|
||||
return []
|
||||
})
|
||||
setMessage('')
|
||||
clearRecording({ revoke: true })
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}, [clearRecording])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pendingFiles.forEach((item) => revokePreview(item.previewUrl))
|
||||
pendingVoices.forEach((item) => revokePreview(item.previewUrl))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const uploadFile = async (item: PendingFile) => {
|
||||
try {
|
||||
const result = await singleUpload.mutateAsync(item.file)
|
||||
setPendingFiles((prev) =>
|
||||
prev.map((file) =>
|
||||
file.id === item.id
|
||||
? { ...file, key: result.data.key, status: 'done' }
|
||||
: file,
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
setPendingFiles((prev) =>
|
||||
prev.map((file) =>
|
||||
file.id === item.id ? { ...file, status: 'error' } : file,
|
||||
),
|
||||
)
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilesSelected = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const selected = Array.from(event.target.files ?? [])
|
||||
if (!selected.length) return
|
||||
|
||||
const nextItems: PendingFile[] = selected.map((file) => ({
|
||||
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random()}`,
|
||||
file,
|
||||
previewUrl: isImageFile(file) ? URL.createObjectURL(file) : null,
|
||||
status: 'uploading',
|
||||
}))
|
||||
|
||||
setPendingFiles((prev) => [...prev, ...nextItems])
|
||||
event.target.value = ''
|
||||
|
||||
for (const item of nextItems) {
|
||||
await uploadFile(item)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFile = (id: string) => {
|
||||
setPendingFiles((prev) => {
|
||||
const target = prev.find((item) => item.id === id)
|
||||
revokePreview(target?.previewUrl ?? null)
|
||||
return prev.filter((item) => item.id !== id)
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveVoice = (id: string) => {
|
||||
setPendingVoices((prev) => {
|
||||
const target = prev.find((item) => item.id === id)
|
||||
revokePreview(target?.previewUrl ?? null)
|
||||
return prev.filter((item) => item.id !== id)
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const uploadedFiles = pendingFiles.filter(
|
||||
(item) => item.status === 'done' && item.key,
|
||||
)
|
||||
const uploadedVoices = pendingVoices.filter(
|
||||
(item) => item.status === 'done' && item.key,
|
||||
)
|
||||
|
||||
if (
|
||||
!message.trim() &&
|
||||
uploadedFiles.length === 0 &&
|
||||
uploadedVoices.length === 0
|
||||
) {
|
||||
toast('لطفاً پیام یا فایل ضمیمه وارد کنید', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (isUploading) {
|
||||
toast('لطفاً تا پایان آپلود فایلها صبر کنید', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
pendingFiles.some((item) => item.status === 'error') ||
|
||||
pendingVoices.some((item) => item.status === 'error')
|
||||
) {
|
||||
toast(
|
||||
'برخی فایلها آپلود نشدند. آنها را حذف یا دوباره انتخاب کنید',
|
||||
'error',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const attachments: ChatComposerAttachment[] = [
|
||||
...uploadedFiles.map((item) => ({
|
||||
type: 'uploads_attach',
|
||||
url: item.key!,
|
||||
})),
|
||||
...uploadedVoices.map((item) => ({
|
||||
type: 'voice',
|
||||
url: item.key!,
|
||||
})),
|
||||
]
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
content: message.trim(),
|
||||
attachments,
|
||||
})
|
||||
resetComposer()
|
||||
} catch (error) {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const hasAttachments =
|
||||
pendingFiles.length > 0 || pendingVoices.length > 0 || isRecording
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && <div className="text-sm mb-2 text-black">{label}</div>}
|
||||
|
||||
{replyTo && (
|
||||
<div className="mb-3 flex items-start justify-between gap-3 rounded-xl border border-[#D6E0FF] bg-[#F5F8FF] px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-[#0047FF]">
|
||||
در پاسخ به
|
||||
{replyTo.senderName ? ` ${replyTo.senderName}` : ''}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-[#8C90A3]">
|
||||
{replyTo.content || 'پیام ضمیمهدار'}
|
||||
</p>
|
||||
</div>
|
||||
{onCancelReply && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelReply}
|
||||
className="shrink-0"
|
||||
aria-label="لغو پاسخ"
|
||||
>
|
||||
<CloseCircle size={18} color="#8C90A3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-border bg-white transition-colors focus-within:border-[#0047FF]">
|
||||
{hasAttachments && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-[#EBEDF5] px-3 pt-3 pb-2">
|
||||
{pendingFiles.map((item) => (
|
||||
<div key={item.id} className="relative size-16">
|
||||
<div className="relative size-full overflow-hidden rounded-lg border border-[#EBEDF5] bg-[#F5F7FC]">
|
||||
{item.previewUrl ? (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt={item.file.name}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full flex-col items-center justify-center gap-1 px-1">
|
||||
<DocumentText
|
||||
size={20}
|
||||
color="#0047FF"
|
||||
/>
|
||||
<span className="w-full truncate text-center text-[10px] text-[#8C90A3]">
|
||||
{item.file.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status === 'uploading' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/35">
|
||||
<span className="size-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status === 'error' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-red-500/70 px-1">
|
||||
<span className="text-center text-[10px] text-white">
|
||||
خطا
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFile(item.id)}
|
||||
className="absolute -left-1.5 -top-1.5 z-10 rounded-full bg-white shadow-sm"
|
||||
aria-label="حذف فایل"
|
||||
>
|
||||
<CloseCircle
|
||||
size={18}
|
||||
color="#EF4444"
|
||||
variant="Bold"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pendingVoices.map((voice) => (
|
||||
<VoicePreviewChip
|
||||
key={voice.id}
|
||||
voice={voice}
|
||||
onRemove={() => handleRemoveVoice(voice.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isRecording && (
|
||||
<div className="flex h-16 items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-3">
|
||||
<span className="size-2 animate-pulse rounded-full bg-red-500" />
|
||||
<span className="text-xs text-red-600">
|
||||
در حال ضبط...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="w-full h-32 resize-none bg-transparent p-4 pb-14 text-sm outline-none"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-3 left-3 flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id={inputId}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFilesSelected}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isSubmitting}
|
||||
className="flex size-8 items-center justify-center rounded-lg bg-[#EEF2FF] transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
aria-label="افزودن فایل"
|
||||
>
|
||||
<Paperclip2 size={18} color="#0047FF" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={
|
||||
isRecording ? stopRecording : startRecording
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
className="flex size-8 items-center justify-center rounded-lg bg-[#FFF1D7] transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
aria-label={
|
||||
isRecording ? 'توقف ضبط' : 'ضبط صدا'
|
||||
}
|
||||
>
|
||||
<Microphone
|
||||
size={20}
|
||||
color={isRecording ? '#EF4444' : 'black'}
|
||||
variant={isRecording ? 'Bold' : 'Outline'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
label={submitLabel}
|
||||
onClick={handleSubmit}
|
||||
className="w-[150px]"
|
||||
isLoading={isBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatComposer
|
||||
@@ -1,11 +1,12 @@
|
||||
import { type FC } from 'react'
|
||||
import moment from 'moment-jalaali'
|
||||
import { Paperclip2 } from 'iconsax-react'
|
||||
import { Paperclip2, ArrowRotateLeft } from 'iconsax-react'
|
||||
import { getFileNameAndExtensionFromUrl } from '@/config/func'
|
||||
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
|
||||
import VoicePlayer from '@/components/VoicePlayer'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import type { ChatAttachmentType } from '../type/Types'
|
||||
import type { ChatAttachmentType, ChatParentMessageType } from '../type/Types'
|
||||
import { getChatSenderName } from '../type/Types'
|
||||
|
||||
type Props = {
|
||||
content: string
|
||||
@@ -17,6 +18,9 @@ type Props = {
|
||||
avatarUrl?: string | null
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
parent?: ChatParentMessageType | null
|
||||
parentSenderLabel?: string
|
||||
onReply?: () => void
|
||||
}
|
||||
|
||||
const ChatMessage: FC<Props> = ({
|
||||
@@ -29,6 +33,9 @@ const ChatMessage: FC<Props> = ({
|
||||
avatarUrl,
|
||||
firstName,
|
||||
lastName,
|
||||
parent,
|
||||
parentSenderLabel,
|
||||
onReply,
|
||||
}) => {
|
||||
const fileAttachments = attachments.filter((a) => a.type !== 'voice')
|
||||
const voiceAttachments = attachments.filter((a) => a.type === 'voice')
|
||||
@@ -38,6 +45,10 @@ const ChatMessage: FC<Props> = ({
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const parentName = parent
|
||||
? getChatSenderName(parent.admin || parent.user)
|
||||
: ''
|
||||
|
||||
const avatar = (
|
||||
<UserAvatar
|
||||
src={avatarUrl}
|
||||
@@ -55,11 +66,41 @@ const ChatMessage: FC<Props> = ({
|
||||
isAdmin ? 'rounded-tl-none' : 'rounded-tr-none'
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-1 text-xs mb-2 text-[#8C90A3]">
|
||||
<span className="font-medium text-black">{senderLabel}:</span>
|
||||
{senderName ? <span>{senderName}</span> : null}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex gap-1 text-xs mb-2 text-[#8C90A3]">
|
||||
<span className="font-medium text-black">
|
||||
{senderLabel}:
|
||||
</span>
|
||||
{senderName ? <span>{senderName}</span> : null}
|
||||
</div>
|
||||
|
||||
{onReply && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReply}
|
||||
className="flex shrink-0 items-center gap-1 text-[11px] text-[#0047FF] hover:opacity-80 transition-opacity"
|
||||
aria-label="پاسخ"
|
||||
>
|
||||
<ArrowRotateLeft size={14} color="#0047FF" />
|
||||
پاسخ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parent && (
|
||||
<div className="mb-3 rounded-xl border border-[#D6E0FF] bg-white/70 px-3 py-2">
|
||||
<div className="text-[11px] text-[#0047FF]">
|
||||
در پاسخ به
|
||||
{parentSenderLabel || parentName
|
||||
? ` ${parentSenderLabel || parentName}`
|
||||
: ''}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-[#8C90A3]">
|
||||
{parent.content || 'پیام ضمیمهدار'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content && (
|
||||
<p className="text-sm text-black leading-7 whitespace-pre-wrap">
|
||||
{content}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import Button from '@/components/Button'
|
||||
import UploadBox from '@/components/UploadBox'
|
||||
import { Microphone, Play, Pause } from 'iconsax-react'
|
||||
import { useState, type FC } from 'react'
|
||||
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder'
|
||||
import { useMultiUpload, useSingleUpload } from '@/pages/uploader/hooks/useUploader'
|
||||
import type { AddChatMessageType } from '../type/Types'
|
||||
import { getChatSenderName } from '../type/Types'
|
||||
import { useAddChatMessage, useGetChatMessages } from '../hooks/useChatData'
|
||||
import { useState, useMemo, type FC } from 'react'
|
||||
import { toast } from '@/shared/toast'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import { getChatSenderName, type ChatMessageType } from '../type/Types'
|
||||
import { useAddChatMessage, useGetChatMessages } from '../hooks/useChatData'
|
||||
import ChatComposer, {
|
||||
type ChatComposerSubmitPayload,
|
||||
} from './ChatComposer'
|
||||
import ChatMessage from './ChatMessage'
|
||||
|
||||
export type ChatSectionProps = {
|
||||
@@ -18,7 +14,12 @@ export type ChatSectionProps = {
|
||||
senderLabel?: string
|
||||
userLabel?: string
|
||||
submitLabel?: string
|
||||
uploadLabel?: string
|
||||
}
|
||||
|
||||
type ReplyTarget = {
|
||||
id: string
|
||||
content: string
|
||||
senderName?: string
|
||||
}
|
||||
|
||||
const ChatSection: FC<ChatSectionProps> = ({
|
||||
@@ -28,83 +29,38 @@ const ChatSection: FC<ChatSectionProps> = ({
|
||||
senderLabel = 'پشتیبان',
|
||||
userLabel = 'شما',
|
||||
submitLabel = 'ارسال پیام',
|
||||
uploadLabel = 'فایلهای ضمیمه',
|
||||
}) => {
|
||||
const [message, setMessage] = useState('')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [replyTo, setReplyTo] = useState<ReplyTarget | null>(null)
|
||||
const addMessage = useAddChatMessage()
|
||||
const { data, refetch, isPending: isLoadingMessages } =
|
||||
useGetChatMessages(refId)
|
||||
const singleUpload = useSingleUpload()
|
||||
const multiUpload = useMultiUpload()
|
||||
|
||||
const {
|
||||
isRecording,
|
||||
isPlaying,
|
||||
audioUrl,
|
||||
audioRef,
|
||||
togglePlayPause,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
progress,
|
||||
audioFile,
|
||||
resetRecorder,
|
||||
} = useVoiceRecorder()
|
||||
const messages = useMemo(() => {
|
||||
const list = data?.data ?? []
|
||||
return [...list].reverse()
|
||||
}, [data?.data])
|
||||
|
||||
const messages = data?.data ?? []
|
||||
const isSubmitting =
|
||||
singleUpload.isPending || multiUpload.isPending || addMessage.isPending
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!message.trim() && !audioFile && files.length === 0) {
|
||||
toast('لطفاً پیام یا فایل ضمیمه وارد کنید', 'error')
|
||||
return
|
||||
const buildReplyTarget = (item: ChatMessageType): ReplyTarget => {
|
||||
const participant = item.admin || item.user
|
||||
return {
|
||||
id: String(item.id),
|
||||
content: item.content,
|
||||
senderName: getChatSenderName(participant),
|
||||
}
|
||||
}
|
||||
|
||||
const params: AddChatMessageType = {
|
||||
attachments: [],
|
||||
content: message.trim(),
|
||||
}
|
||||
|
||||
if (audioFile) {
|
||||
await singleUpload.mutateAsync(audioFile, {
|
||||
onSuccess: (data) => {
|
||||
params.attachments.push({
|
||||
type: 'voice',
|
||||
url: data?.data?.key,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (files.length) {
|
||||
await multiUpload.mutateAsync(files, {
|
||||
onSuccess: (data) => {
|
||||
data?.data?.forEach((item) => {
|
||||
params.attachments.push({
|
||||
type: 'uploads_attach',
|
||||
url: item.key,
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
addMessage.mutate(
|
||||
{ refId, params },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast('پیام شما با موفقیت ارسال شد')
|
||||
refetch()
|
||||
setMessage('')
|
||||
setFiles([])
|
||||
resetRecorder()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
},
|
||||
const handleSubmit = async (payload: ChatComposerSubmitPayload) => {
|
||||
await addMessage.mutateAsync({
|
||||
refId,
|
||||
params: {
|
||||
content: payload.content,
|
||||
attachments: payload.attachments,
|
||||
...(replyTo ? { parentId: replyTo.id } : {}),
|
||||
},
|
||||
)
|
||||
})
|
||||
toast('پیام شما با موفقیت ارسال شد')
|
||||
setReplyTo(null)
|
||||
refetch()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -145,6 +101,17 @@ const ChatSection: FC<ChatSectionProps> = ({
|
||||
avatarUrl={item.user.avatarUrl}
|
||||
firstName={item.user.firstName}
|
||||
lastName={item.user.lastName}
|
||||
parent={item.parent}
|
||||
parentSenderLabel={
|
||||
item.parent?.admin
|
||||
? senderLabel
|
||||
: item.parent?.user
|
||||
? userLabel
|
||||
: undefined
|
||||
}
|
||||
onReply={() =>
|
||||
setReplyTo(buildReplyTarget(item))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -162,6 +129,17 @@ const ChatSection: FC<ChatSectionProps> = ({
|
||||
avatarUrl={item.admin.avatarUrl}
|
||||
firstName={item.admin.firstName}
|
||||
lastName={item.admin.lastName}
|
||||
parent={item.parent}
|
||||
parentSenderLabel={
|
||||
item.parent?.admin
|
||||
? senderLabel
|
||||
: item.parent?.user
|
||||
? userLabel
|
||||
: undefined
|
||||
}
|
||||
onReply={() =>
|
||||
setReplyTo(buildReplyTarget(item))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -170,79 +148,13 @@ const ChatSection: FC<ChatSectionProps> = ({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-[#EBEDF5]">
|
||||
<div className="text-sm mb-2 text-black">پیام شما</div>
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="w-full h-32 bg-white border border-border rounded-xl p-4 text-sm resize-none outline-none focus:border-[#0047FF] transition-colors"
|
||||
placeholder="متن پیام خود را بنویسید..."
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
className="absolute left-4 bottom-4 bg-[#FFF1D7] size-8 rounded-lg flex items-center justify-center hover:opacity-90 transition-opacity"
|
||||
aria-label={isRecording ? 'توقف ضبط' : 'ضبط صدا'}
|
||||
>
|
||||
<Microphone
|
||||
size={20}
|
||||
color={isRecording ? '#EF4444' : 'black'}
|
||||
variant={isRecording ? 'Bold' : 'Outline'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{audioUrl && (
|
||||
<div className="w-full h-12 bg-white border border-border rounded-xl flex items-center px-3 gap-3 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlayPause}
|
||||
className="w-8 h-8 rounded-full bg-black flex items-center justify-center shrink-0"
|
||||
aria-label={isPlaying ? 'توقف' : 'پخش'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} color="#fff" variant="Bold" />
|
||||
) : (
|
||||
<Play size={16} color="#fff" variant="Bold" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 flex items-center gap-[2px] h-8">
|
||||
{Array.from({ length: 60 }).map((_, i) => {
|
||||
const passed = i / 60 <= progress
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[2px] rounded-full transition-all"
|
||||
style={{
|
||||
height: `${Math.random() * 18 + 4}px`,
|
||||
backgroundColor: passed
|
||||
? '#0047FF'
|
||||
: '#E5E7EB',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<audio ref={audioRef} src={audioUrl} className="hidden" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<UploadBox label={uploadLabel} isMultiple onChange={setFiles} />
|
||||
</div>
|
||||
|
||||
<div className="flex mt-8 justify-end">
|
||||
<Button
|
||||
label={submitLabel}
|
||||
onClick={handleSubmit}
|
||||
className="w-[150px]"
|
||||
isLoading={isSubmitting}
|
||||
<div className="mt-8 border-t border-[#EBEDF5] pt-6">
|
||||
<ChatComposer
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={addMessage.isPending}
|
||||
submitLabel={submitLabel}
|
||||
replyTo={replyTo}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export { default as ChatSection } from './components/ChatSection'
|
||||
export { default as ChatMessage } from './components/ChatMessage'
|
||||
export { default as ChatComposer } from './components/ChatComposer'
|
||||
export type { ChatSectionProps } from './components/ChatSection'
|
||||
export type {
|
||||
ChatComposerAttachment,
|
||||
ChatComposerSubmitPayload,
|
||||
} from './components/ChatComposer'
|
||||
export {
|
||||
useGetChatMessages,
|
||||
useAddChatMessage,
|
||||
@@ -10,6 +15,7 @@ export type {
|
||||
ChatAttachmentType,
|
||||
ChatMessageType,
|
||||
ChatMessagesResponseType,
|
||||
ChatParentMessageType,
|
||||
ChatParticipantType,
|
||||
} from './type/Types'
|
||||
export { getChatSenderName } from './type/Types'
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ChatAttachmentType = {
|
||||
export type AddChatMessageType = {
|
||||
content: string
|
||||
attachments: ChatAttachmentType[]
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
export type ChatParticipantType = {
|
||||
@@ -18,6 +19,13 @@ export type ChatParticipantType = {
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
export type ChatParentMessageType = {
|
||||
id: string
|
||||
content: string
|
||||
admin?: ChatParticipantType | null
|
||||
user?: ChatParticipantType | null
|
||||
}
|
||||
|
||||
export type ChatMessageType = {
|
||||
admin?: ChatParticipantType | null
|
||||
user?: ChatParticipantType | null
|
||||
@@ -25,6 +33,7 @@ export type ChatMessageType = {
|
||||
content: string
|
||||
createdAt: string
|
||||
id: string
|
||||
parent?: ChatParentMessageType | null
|
||||
}
|
||||
|
||||
export type ChatMessagesResponseType = BaseResponse<ChatMessageType[]>
|
||||
@@ -38,11 +47,13 @@ export type TicketsResponseType = ChatMessagesResponseType
|
||||
|
||||
export const getChatSenderName = (
|
||||
participant?: ChatParticipantType | null,
|
||||
fallback?: string,
|
||||
): string => {
|
||||
if (!participant) return ''
|
||||
if (!participant && !fallback) return ''
|
||||
return (
|
||||
[participant.firstName, participant.lastName].filter(Boolean).join(' ') ||
|
||||
participant.phone ||
|
||||
[participant?.firstName, participant?.lastName].filter(Boolean).join(' ') ||
|
||||
participant?.phone ||
|
||||
fallback ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user