This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
type UseVoiceRecorderOptions = {
|
type UseVoiceRecorderOptions = {
|
||||||
onRecordingComplete?: (audioBlob: Blob) => void;
|
onRecordingComplete?: (audioFile: File, audioUrl: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||||
@@ -18,6 +18,33 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
|||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const chunksRef = useRef<Blob[]>([]);
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
const animationFrameRef = useRef<number | null>(null);
|
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 () => {
|
const startRecording = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -35,14 +62,15 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
|||||||
|
|
||||||
mediaRecorder.onstop = () => {
|
mediaRecorder.onstop = () => {
|
||||||
const audioBlob = new Blob(chunksRef.current, { type: "audio/webm" });
|
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",
|
type: "audio/webm",
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
audioUrlRef.current = url;
|
||||||
setAudioUrl(url);
|
setAudioUrl(url);
|
||||||
setAudioFile(file);
|
setAudioFile(file);
|
||||||
onRecordingComplete?.(audioBlob);
|
onRecordingCompleteRef.current?.(file, url);
|
||||||
|
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
};
|
};
|
||||||
@@ -52,7 +80,7 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Microphone access error:", err);
|
console.error("Microphone access error:", err);
|
||||||
}
|
}
|
||||||
}, [onRecordingComplete]);
|
}, []);
|
||||||
|
|
||||||
const stopRecording = useCallback(() => {
|
const stopRecording = useCallback(() => {
|
||||||
if (mediaRecorderRef.current && isRecording) {
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
@@ -65,24 +93,9 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
|||||||
if (mediaRecorderRef.current && isRecording) {
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
mediaRecorderRef.current.stop();
|
mediaRecorderRef.current.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
audioRef.current.currentTime = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audioUrl) {
|
|
||||||
URL.revokeObjectURL(audioUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
chunksRef.current = [];
|
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
setIsPlaying(false);
|
clearRecording({ revoke: true });
|
||||||
setCurrentTime(0);
|
}, [clearRecording, isRecording]);
|
||||||
setDuration(0);
|
|
||||||
setAudioUrl(null);
|
|
||||||
setAudioFile(null);
|
|
||||||
}, [audioUrl, isRecording]);
|
|
||||||
|
|
||||||
const togglePlayPause = useCallback(() => {
|
const togglePlayPause = useCallback(() => {
|
||||||
if (!audioRef.current) return;
|
if (!audioRef.current) return;
|
||||||
@@ -136,24 +149,18 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
|||||||
}, [isPlaying, updateProgress]);
|
}, [isPlaying, updateProgress]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// state
|
|
||||||
isRecording,
|
isRecording,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
audioUrl,
|
audioUrl,
|
||||||
audioFile,
|
audioFile,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
|
|
||||||
// refs
|
|
||||||
audioRef,
|
audioRef,
|
||||||
|
|
||||||
// actions
|
|
||||||
startRecording,
|
startRecording,
|
||||||
stopRecording,
|
stopRecording,
|
||||||
togglePlayPause,
|
togglePlayPause,
|
||||||
resetRecorder,
|
resetRecorder,
|
||||||
|
clearRecording,
|
||||||
// helpers
|
|
||||||
progress: duration ? currentTime / duration : 0,
|
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,13 @@
|
|||||||
import { type FC } from 'react'
|
import { type FC } from 'react'
|
||||||
import moment from 'moment-jalaali'
|
import moment from 'moment-jalaali'
|
||||||
import { Paperclip2 } from 'iconsax-react'
|
import { Paperclip2, ArrowRotateLeft } from 'iconsax-react'
|
||||||
import { getFileNameAndExtensionFromUrl } from '@/config/func'
|
import { getFileNameAndExtensionFromUrl } from '@/config/func'
|
||||||
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
|
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
|
||||||
import VoicePlayer from '@/components/VoicePlayer'
|
import VoicePlayer from '@/components/VoicePlayer'
|
||||||
import UserAvatar from '@/components/UserAvatar'
|
import UserAvatar from '@/components/UserAvatar'
|
||||||
import type { ChatAttachmentType } from '../type/Types'
|
import TrashWithConfrim from '@/components/TrashWithConfrim'
|
||||||
|
import type { ChatAttachmentType, ChatParentMessageType } from '../type/Types'
|
||||||
|
import { getChatSenderName } from '../type/Types'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
content: string
|
content: string
|
||||||
@@ -17,6 +19,12 @@ type Props = {
|
|||||||
avatarUrl?: string | null
|
avatarUrl?: string | null
|
||||||
firstName?: string | null
|
firstName?: string | null
|
||||||
lastName?: string | null
|
lastName?: string | null
|
||||||
|
parent?: ChatParentMessageType | null
|
||||||
|
parentSenderLabel?: string
|
||||||
|
onReply?: () => void
|
||||||
|
canDelete?: boolean
|
||||||
|
onDelete?: () => void
|
||||||
|
isDeleting?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatMessage: FC<Props> = ({
|
const ChatMessage: FC<Props> = ({
|
||||||
@@ -29,6 +37,12 @@ const ChatMessage: FC<Props> = ({
|
|||||||
avatarUrl,
|
avatarUrl,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
|
parent,
|
||||||
|
parentSenderLabel,
|
||||||
|
onReply,
|
||||||
|
canDelete = false,
|
||||||
|
onDelete,
|
||||||
|
isDeleting = false,
|
||||||
}) => {
|
}) => {
|
||||||
const fileAttachments = attachments.filter((a) => a.type !== 'voice')
|
const fileAttachments = attachments.filter((a) => a.type !== 'voice')
|
||||||
const voiceAttachments = attachments.filter((a) => a.type === 'voice')
|
const voiceAttachments = attachments.filter((a) => a.type === 'voice')
|
||||||
@@ -38,6 +52,10 @@ const ChatMessage: FC<Props> = ({
|
|||||||
window.open(url, '_blank', 'noopener,noreferrer')
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parentName = parent
|
||||||
|
? getChatSenderName(parent.admin || parent.user)
|
||||||
|
: ''
|
||||||
|
|
||||||
const avatar = (
|
const avatar = (
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
@@ -55,11 +73,50 @@ const ChatMessage: FC<Props> = ({
|
|||||||
isAdmin ? 'rounded-tl-none' : 'rounded-tr-none'
|
isAdmin ? 'rounded-tl-none' : 'rounded-tr-none'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex gap-1 text-xs mb-2 text-[#8C90A3]">
|
<div className="flex gap-1 text-xs mb-2 text-[#8C90A3]">
|
||||||
<span className="font-medium text-black">{senderLabel}:</span>
|
<span className="font-medium text-black">
|
||||||
|
{senderLabel}:
|
||||||
|
</span>
|
||||||
{senderName ? <span>{senderName}</span> : null}
|
{senderName ? <span>{senderName}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{onReply && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReply}
|
||||||
|
className="flex items-center gap-1 text-[11px] text-[#0047FF] hover:opacity-80 transition-opacity"
|
||||||
|
aria-label="پاسخ"
|
||||||
|
>
|
||||||
|
<ArrowRotateLeft size={14} color="#0047FF" />
|
||||||
|
پاسخ
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canDelete && onDelete && (
|
||||||
|
<TrashWithConfrim
|
||||||
|
onDelete={onDelete}
|
||||||
|
isloading={isDeleting}
|
||||||
|
colorIcon="#EF4444"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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 && (
|
{content && (
|
||||||
<p className="text-sm text-black leading-7 whitespace-pre-wrap">
|
<p className="text-sm text-black leading-7 whitespace-pre-wrap">
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import Button from '@/components/Button'
|
|
||||||
import RefreshButton from '@/components/RefreshButton'
|
import RefreshButton from '@/components/RefreshButton'
|
||||||
import UploadBox from '@/components/UploadBox'
|
import { useMemo, useState, type FC } from 'react'
|
||||||
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 { toast } from '@/shared/toast'
|
import { toast } from '@/shared/toast'
|
||||||
import { extractErrorMessage } from '@/config/func'
|
import { extractErrorMessage } from '@/config/func'
|
||||||
|
import { useGetAdminMe } from '@/pages/admin/hooks/useAdminData'
|
||||||
|
import { getChatSenderName, type ChatMessageType } from '../type/Types'
|
||||||
|
import {
|
||||||
|
useAddChatMessage,
|
||||||
|
useDeleteChatMessage,
|
||||||
|
useGetChatMessages,
|
||||||
|
} from '../hooks/useChatData'
|
||||||
|
import ChatComposer, {
|
||||||
|
type ChatComposerSubmitPayload,
|
||||||
|
} from './ChatComposer'
|
||||||
import ChatMessage from './ChatMessage'
|
import ChatMessage from './ChatMessage'
|
||||||
|
|
||||||
export type ChatSectionProps = {
|
export type ChatSectionProps = {
|
||||||
@@ -23,10 +25,15 @@ export type ChatSectionProps = {
|
|||||||
/** Optional fallback when message.user has no name */
|
/** Optional fallback when message.user has no name */
|
||||||
customerName?: string
|
customerName?: string
|
||||||
submitLabel?: string
|
submitLabel?: string
|
||||||
uploadLabel?: string
|
|
||||||
showRefresh?: boolean
|
showRefresh?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReplyTarget = {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
senderName?: string
|
||||||
|
}
|
||||||
|
|
||||||
const ChatSection: FC<ChatSectionProps> = ({
|
const ChatSection: FC<ChatSectionProps> = ({
|
||||||
refId,
|
refId,
|
||||||
title = 'گفتگو',
|
title = 'گفتگو',
|
||||||
@@ -35,82 +42,60 @@ const ChatSection: FC<ChatSectionProps> = ({
|
|||||||
userLabel = 'مشتری',
|
userLabel = 'مشتری',
|
||||||
customerName,
|
customerName,
|
||||||
submitLabel = 'ارسال پیام',
|
submitLabel = 'ارسال پیام',
|
||||||
uploadLabel = 'فایلهای ضمیمه',
|
|
||||||
showRefresh = true,
|
showRefresh = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [message, setMessage] = useState('')
|
const [replyTo, setReplyTo] = useState<ReplyTarget | null>(null)
|
||||||
const [files, setFiles] = useState<File[]>([])
|
|
||||||
const addMessage = useAddChatMessage()
|
const addMessage = useAddChatMessage()
|
||||||
|
const deleteMessage = useDeleteChatMessage()
|
||||||
|
const { data: adminMe } = useGetAdminMe()
|
||||||
|
const currentAdminId = adminMe?.data?.id
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
refetch,
|
refetch,
|
||||||
isFetching,
|
isFetching,
|
||||||
isPending: isLoadingMessages,
|
isPending: isLoadingMessages,
|
||||||
} = useGetChatMessages(refId)
|
} = useGetChatMessages(refId)
|
||||||
const singleUpload = useSingleUpload()
|
|
||||||
const multiUpload = useMultiUpload()
|
|
||||||
|
|
||||||
const {
|
const messages = useMemo(() => {
|
||||||
isRecording,
|
const list = data?.data ?? []
|
||||||
isPlaying,
|
return [...list].reverse()
|
||||||
audioUrl,
|
}, [data?.data])
|
||||||
audioRef,
|
|
||||||
togglePlayPause,
|
|
||||||
startRecording,
|
|
||||||
stopRecording,
|
|
||||||
progress,
|
|
||||||
audioFile,
|
|
||||||
resetRecorder,
|
|
||||||
} = useVoiceRecorder()
|
|
||||||
|
|
||||||
const messages = data?.data ?? []
|
const buildReplyTarget = (item: ChatMessageType): ReplyTarget => {
|
||||||
const isSubmitting =
|
const participant = item.admin || item.user
|
||||||
singleUpload.isPending || multiUpload.isPending || addMessage.isPending
|
return {
|
||||||
|
id: String(item.id),
|
||||||
const handleSubmit = async () => {
|
content: item.content,
|
||||||
if (!message.trim() && !audioFile && files.length === 0) {
|
senderName: getChatSenderName(
|
||||||
toast('لطفاً پیام یا فایل ضمیمه وارد کنید', 'error')
|
participant,
|
||||||
return
|
item.user ? customerName : undefined,
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const params: AddChatMessageType = {
|
const handleSubmit = async (payload: ChatComposerSubmitPayload) => {
|
||||||
attachments: [],
|
await addMessage.mutateAsync({
|
||||||
content: message.trim(),
|
refId,
|
||||||
}
|
params: {
|
||||||
|
content: payload.content,
|
||||||
if (audioFile) {
|
attachments: payload.attachments,
|
||||||
const voiceResult = await singleUpload.mutateAsync(audioFile)
|
...(replyTo ? { parentId: replyTo.id } : {}),
|
||||||
params.attachments.push({
|
},
|
||||||
type: 'voice',
|
|
||||||
url: voiceResult.data.key,
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
if (files.length) {
|
|
||||||
const filesResult = await multiUpload.mutateAsync(files)
|
|
||||||
filesResult.data?.forEach((item) => {
|
|
||||||
params.attachments.push({
|
|
||||||
type: 'uploads_attach',
|
|
||||||
url: item.key,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
addMessage.mutate(
|
|
||||||
{ refId, params },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
toast('پیام شما با موفقیت ارسال شد')
|
toast('پیام شما با موفقیت ارسال شد')
|
||||||
|
setReplyTo(null)
|
||||||
refetch()
|
refetch()
|
||||||
setMessage('')
|
}
|
||||||
setFiles([])
|
|
||||||
resetRecorder()
|
const handleDelete = async (id: string) => {
|
||||||
},
|
try {
|
||||||
onError: (error) => {
|
await deleteMessage.mutateAsync(id)
|
||||||
|
toast('پیام حذف شد')
|
||||||
|
if (replyTo?.id === id) setReplyTo(null)
|
||||||
|
refetch()
|
||||||
|
} catch (error) {
|
||||||
toast(extractErrorMessage(error), 'error')
|
toast(extractErrorMessage(error), 'error')
|
||||||
},
|
}
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -167,11 +152,26 @@ const ChatSection: FC<ChatSectionProps> = ({
|
|||||||
avatarUrl={item.user.avatarUrl}
|
avatarUrl={item.user.avatarUrl}
|
||||||
firstName={item.user.firstName}
|
firstName={item.user.firstName}
|
||||||
lastName={item.user.lastName}
|
lastName={item.user.lastName}
|
||||||
|
parent={item.parent}
|
||||||
|
parentSenderLabel={
|
||||||
|
item.parent?.admin
|
||||||
|
? senderLabel
|
||||||
|
: item.parent?.user
|
||||||
|
? userLabel
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onReply={() =>
|
||||||
|
setReplyTo(buildReplyTarget(item))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.admin) {
|
if (item.admin) {
|
||||||
|
const isOwnMessage =
|
||||||
|
!!currentAdminId &&
|
||||||
|
item.admin.id === currentAdminId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -184,6 +184,26 @@ const ChatSection: FC<ChatSectionProps> = ({
|
|||||||
avatarUrl={item.admin.avatarUrl}
|
avatarUrl={item.admin.avatarUrl}
|
||||||
firstName={item.admin.firstName}
|
firstName={item.admin.firstName}
|
||||||
lastName={item.admin.lastName}
|
lastName={item.admin.lastName}
|
||||||
|
parent={item.parent}
|
||||||
|
parentSenderLabel={
|
||||||
|
item.parent?.admin
|
||||||
|
? senderLabel
|
||||||
|
: item.parent?.user
|
||||||
|
? userLabel
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onReply={() =>
|
||||||
|
setReplyTo(buildReplyTarget(item))
|
||||||
|
}
|
||||||
|
canDelete={isOwnMessage}
|
||||||
|
onDelete={() =>
|
||||||
|
handleDelete(String(item.id))
|
||||||
|
}
|
||||||
|
isDeleting={
|
||||||
|
deleteMessage.isPending &&
|
||||||
|
deleteMessage.variables ===
|
||||||
|
String(item.id)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -192,99 +212,13 @@ const ChatSection: FC<ChatSectionProps> = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 pt-6 border-t border-[#EBEDF5]">
|
<div className="mt-8 border-t border-[#EBEDF5] pt-6">
|
||||||
<div className="text-sm mb-2 text-black">پیام شما</div>
|
<ChatComposer
|
||||||
|
onSubmit={handleSubmit}
|
||||||
<div className="relative">
|
isSubmitting={addMessage.isPending}
|
||||||
<textarea
|
submitLabel={submitLabel}
|
||||||
value={message}
|
replyTo={replyTo}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onCancelReply={() => setReplyTo(null)}
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,3 +21,9 @@ export const useAddChatMessage = () => {
|
|||||||
}) => api.addChatMessage(refId, params),
|
}) => api.addChatMessage(refId, params),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useDeleteChatMessage = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.deleteChatMessage(id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
export { default as ChatSection } from './components/ChatSection'
|
export { default as ChatSection } from './components/ChatSection'
|
||||||
export { default as ChatMessage } from './components/ChatMessage'
|
export { default as ChatMessage } from './components/ChatMessage'
|
||||||
|
export { default as ChatComposer } from './components/ChatComposer'
|
||||||
export type { ChatSectionProps } from './components/ChatSection'
|
export type { ChatSectionProps } from './components/ChatSection'
|
||||||
|
export type {
|
||||||
|
ChatComposerAttachment,
|
||||||
|
ChatComposerSubmitPayload,
|
||||||
|
} from './components/ChatComposer'
|
||||||
export {
|
export {
|
||||||
useGetChatMessages,
|
useGetChatMessages,
|
||||||
useAddChatMessage,
|
useAddChatMessage,
|
||||||
@@ -10,6 +15,7 @@ export type {
|
|||||||
ChatAttachmentType,
|
ChatAttachmentType,
|
||||||
ChatMessageType,
|
ChatMessageType,
|
||||||
ChatMessagesResponseType,
|
ChatMessagesResponseType,
|
||||||
|
ChatParentMessageType,
|
||||||
ChatParticipantType,
|
ChatParticipantType,
|
||||||
} from './type/Types'
|
} from './type/Types'
|
||||||
export { getChatSenderName } from './type/Types'
|
export { getChatSenderName } from './type/Types'
|
||||||
|
|||||||
@@ -18,3 +18,8 @@ export const addChatMessage = async (
|
|||||||
const { data } = await axios.post(`/admin/chat/ref/${refId}`, params)
|
const { data } = await axios.post(`/admin/chat/ref/${refId}`, params)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const deleteChatMessage = async (id: string) => {
|
||||||
|
const { data } = await axios.delete(`/admin/chat/${id}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type ChatAttachmentType = {
|
|||||||
export type AddChatMessageType = {
|
export type AddChatMessageType = {
|
||||||
content: string
|
content: string
|
||||||
attachments: ChatAttachmentType[]
|
attachments: ChatAttachmentType[]
|
||||||
|
parentId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChatParticipantType = {
|
export type ChatParticipantType = {
|
||||||
@@ -18,13 +19,21 @@ export type ChatParticipantType = {
|
|||||||
avatarUrl?: string | null
|
avatarUrl?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ChatParentMessageType = {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
admin?: ChatParticipantType | null
|
||||||
|
user?: ChatParticipantType | null
|
||||||
|
}
|
||||||
|
|
||||||
export type ChatMessageType = {
|
export type ChatMessageType = {
|
||||||
admin?: ChatParticipantType | null
|
admin?: ChatParticipantType | null
|
||||||
user?: ChatParticipantType | null
|
user?: ChatParticipantType | null
|
||||||
attachments: ChatAttachmentType[]
|
attachments: ChatAttachmentType[]
|
||||||
content: string
|
content: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
id: string | number
|
id: string
|
||||||
|
parent?: ChatParentMessageType | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChatMessagesResponseType = BaseResponse<ChatMessageType[]>
|
export type ChatMessagesResponseType = BaseResponse<ChatMessageType[]>
|
||||||
|
|||||||
Reference in New Issue
Block a user