chat composer
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-07-17 23:58:18 +03:30
parent 026b605943
commit c01f1a32dc
8 changed files with 764 additions and 199 deletions
+35 -28
View File
@@ -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,
};
};
+541
View File
@@ -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
+62 -5
View File
@@ -1,11 +1,13 @@
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 TrashWithConfrim from '@/components/TrashWithConfrim'
import type { ChatAttachmentType, ChatParentMessageType } from '../type/Types'
import { getChatSenderName } from '../type/Types'
type Props = {
content: string
@@ -17,6 +19,12 @@ type Props = {
avatarUrl?: string | null
firstName?: string | null
lastName?: string | null
parent?: ChatParentMessageType | null
parentSenderLabel?: string
onReply?: () => void
canDelete?: boolean
onDelete?: () => void
isDeleting?: boolean
}
const ChatMessage: FC<Props> = ({
@@ -29,6 +37,12 @@ const ChatMessage: FC<Props> = ({
avatarUrl,
firstName,
lastName,
parent,
parentSenderLabel,
onReply,
canDelete = false,
onDelete,
isDeleting = false,
}) => {
const fileAttachments = 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')
}
const parentName = parent
? getChatSenderName(parent.admin || parent.user)
: ''
const avatar = (
<UserAvatar
src={avatarUrl}
@@ -55,11 +73,50 @@ 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>
<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 && (
<p className="text-sm text-black leading-7 whitespace-pre-wrap">
{content}
+99 -165
View File
@@ -1,15 +1,17 @@
import Button from '@/components/Button'
import RefreshButton from '@/components/RefreshButton'
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 { useMemo, useState, type FC } from 'react'
import { toast } from '@/shared/toast'
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'
export type ChatSectionProps = {
@@ -23,10 +25,15 @@ export type ChatSectionProps = {
/** Optional fallback when message.user has no name */
customerName?: string
submitLabel?: string
uploadLabel?: string
showRefresh?: boolean
}
type ReplyTarget = {
id: string
content: string
senderName?: string
}
const ChatSection: FC<ChatSectionProps> = ({
refId,
title = 'گفتگو',
@@ -35,82 +42,60 @@ const ChatSection: FC<ChatSectionProps> = ({
userLabel = 'مشتری',
customerName,
submitLabel = 'ارسال پیام',
uploadLabel = 'فایل‌های ضمیمه',
showRefresh = true,
}) => {
const [message, setMessage] = useState('')
const [files, setFiles] = useState<File[]>([])
const [replyTo, setReplyTo] = useState<ReplyTarget | null>(null)
const addMessage = useAddChatMessage()
const deleteMessage = useDeleteChatMessage()
const { data: adminMe } = useGetAdminMe()
const currentAdminId = adminMe?.data?.id
const {
data,
refetch,
isFetching,
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,
item.user ? customerName : undefined,
),
}
}
const params: AddChatMessageType = {
attachments: [],
content: message.trim(),
}
if (audioFile) {
const voiceResult = await singleUpload.mutateAsync(audioFile)
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('پیام شما با موفقیت ارسال شد')
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()
}
const handleDelete = async (id: string) => {
try {
await deleteMessage.mutateAsync(id)
toast('پیام حذف شد')
if (replyTo?.id === id) setReplyTo(null)
refetch()
} catch (error) {
toast(extractErrorMessage(error), 'error')
}
}
return (
@@ -167,11 +152,26 @@ 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))
}
/>
)
}
if (item.admin) {
const isOwnMessage =
!!currentAdminId &&
item.admin.id === currentAdminId
return (
<ChatMessage
key={item.id}
@@ -184,6 +184,26 @@ 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))
}
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 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>
+6
View File
@@ -21,3 +21,9 @@ export const useAddChatMessage = () => {
}) => api.addChatMessage(refId, params),
})
}
export const useDeleteChatMessage = () => {
return useMutation({
mutationFn: (id: string) => api.deleteChatMessage(id),
})
}
+6
View File
@@ -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'
+5
View File
@@ -18,3 +18,8 @@ export const addChatMessage = async (
const { data } = await axios.post(`/admin/chat/ref/${refId}`, params)
return data
}
export const deleteChatMessage = async (id: string) => {
const { data } = await axios.delete(`/admin/chat/${id}`)
return data
}
+10 -1
View File
@@ -8,6 +8,7 @@ export type ChatAttachmentType = {
export type AddChatMessageType = {
content: string
attachments: ChatAttachmentType[]
parentId?: string
}
export type ChatParticipantType = {
@@ -18,13 +19,21 @@ 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
attachments: ChatAttachmentType[]
content: string
createdAt: string
id: string | number
id: string
parent?: ChatParentMessageType | null
}
export type ChatMessagesResponseType = BaseResponse<ChatMessageType[]>