From 059631e47cb74e8f20cb7cd2e748548a5c7e400a Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Fri, 17 Jul 2026 23:58:08 +0330 Subject: [PATCH] chat --- src/hooks/useVoiceRecorder.ts | 63 +-- src/pages/chat/components/ChatComposer.tsx | 541 +++++++++++++++++++++ src/pages/chat/components/ChatMessage.tsx | 51 +- src/pages/chat/components/ChatSection.tsx | 216 +++----- src/pages/chat/index.ts | 6 + src/pages/chat/type/Types.ts | 17 +- 6 files changed, 706 insertions(+), 188 deletions(-) create mode 100644 src/pages/chat/components/ChatComposer.tsx diff --git a/src/hooks/useVoiceRecorder.ts b/src/hooks/useVoiceRecorder.ts index a991c7c..4fb46e9 100644 --- a/src/hooks/useVoiceRecorder.ts +++ b/src/hooks/useVoiceRecorder.ts @@ -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(null); const chunksRef = useRef([]); const animationFrameRef = useRef(null); + const audioUrlRef = useRef(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, }; }; diff --git a/src/pages/chat/components/ChatComposer.tsx b/src/pages/chat/components/ChatComposer.tsx new file mode 100644 index 0000000..4003710 --- /dev/null +++ b/src/pages/chat/components/ChatComposer.tsx @@ -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 + 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(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 ( +
+ + +
+ {Array.from({ length: 12 }).map((_, i) => { + const passed = i / 12 <= progress + return ( +
+ ) + })} +
+ + {voice.status === 'uploading' && ( +
+ +
+ )} + + {voice.status === 'error' && ( +
+ خطا +
+ )} + + + +
+ ) +} + +const ChatComposer: FC = ({ + onSubmit, + isSubmitting = false, + submitLabel = 'ارسال پیام', + label = 'پیام شما', + placeholder = 'متن پیام خود را بنویسید...', + replyTo = null, + onCancelReply, +}) => { + const inputId = useId() + const fileInputRef = useRef(null) + const [message, setMessage] = useState('') + const [pendingFiles, setPendingFiles] = useState([]) + const [pendingVoices, setPendingVoices] = useState([]) + 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, + ) => { + 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 ( +
+ {label &&
{label}
} + + {replyTo && ( +
+
+
+ در پاسخ به + {replyTo.senderName ? ` ${replyTo.senderName}` : ''} +
+

+ {replyTo.content || 'پیام ضمیمه‌دار'} +

+
+ {onCancelReply && ( + + )} +
+ )} + +
+ {hasAttachments && ( +
+ {pendingFiles.map((item) => ( +
+
+ {item.previewUrl ? ( + {item.file.name} + ) : ( +
+ + + {item.file.name} + +
+ )} + + {item.status === 'uploading' && ( +
+ +
+ )} + + {item.status === 'error' && ( +
+ + خطا + +
+ )} +
+ + +
+ ))} + + {pendingVoices.map((voice) => ( + handleRemoveVoice(voice.id)} + /> + ))} + + {isRecording && ( +
+ + + در حال ضبط... + +
+ )} +
+ )} + +
+