feat: Add emoji reaction feature and improve reply formatting
- Add emoji-picker-react package - Create EmojiReaction component for quick emoji responses - Replace EmojiHappy icon with interactive EmojiReaction component - Implement emoji selection as structured reply messages - Improve reply HTML formatting with better styling and Persian date - Add contextual information to both emoji reactions and text replies - Update success messages to be more concise
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"emoji-picker-react": "^4.13.2",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"i18next": "^25.0.1",
|
"i18next": "^25.0.1",
|
||||||
"iconsax-react": "^0.0.8",
|
"iconsax-react": "^0.0.8",
|
||||||
|
|||||||
Generated
+19
@@ -41,6 +41,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
emoji-picker-react:
|
||||||
|
specifier: ^4.13.2
|
||||||
|
version: 4.13.2(react@19.1.0)
|
||||||
formik:
|
formik:
|
||||||
specifier: ^2.4.6
|
specifier: ^2.4.6
|
||||||
version: 2.4.6(react@19.1.0)
|
version: 2.4.6(react@19.1.0)
|
||||||
@@ -1579,6 +1582,12 @@ packages:
|
|||||||
electron-to-chromium@1.5.158:
|
electron-to-chromium@1.5.158:
|
||||||
resolution: {integrity: sha512-9vcp2xHhkvraY6AHw2WMi+GDSLPX42qe2xjYaVoZqFRJiOcilVQFq9mZmpuHEQpzlgGDelKlV7ZiGcmMsc8WxQ==}
|
resolution: {integrity: sha512-9vcp2xHhkvraY6AHw2WMi+GDSLPX42qe2xjYaVoZqFRJiOcilVQFq9mZmpuHEQpzlgGDelKlV7ZiGcmMsc8WxQ==}
|
||||||
|
|
||||||
|
emoji-picker-react@4.13.2:
|
||||||
|
resolution: {integrity: sha512-azaJQLTshEOZVhksgU136izJWJyZ4Clx6xQ6Vctzk1gOdPPAUbTa/JYDwZJ8rh97QxnjpyeftXl99eRlYr3vNA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16'
|
||||||
|
|
||||||
emoji-regex@8.0.0:
|
emoji-regex@8.0.0:
|
||||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||||
|
|
||||||
@@ -1776,6 +1785,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
flairup@1.0.0:
|
||||||
|
resolution: {integrity: sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==}
|
||||||
|
|
||||||
flat-cache@4.0.1:
|
flat-cache@4.0.1:
|
||||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -4817,6 +4829,11 @@ snapshots:
|
|||||||
|
|
||||||
electron-to-chromium@1.5.158: {}
|
electron-to-chromium@1.5.158: {}
|
||||||
|
|
||||||
|
emoji-picker-react@4.13.2(react@19.1.0):
|
||||||
|
dependencies:
|
||||||
|
flairup: 1.0.0
|
||||||
|
react: 19.1.0
|
||||||
|
|
||||||
emoji-regex@8.0.0: {}
|
emoji-regex@8.0.0: {}
|
||||||
|
|
||||||
engine.io-client@6.6.3:
|
engine.io-client@6.6.3:
|
||||||
@@ -5118,6 +5135,8 @@ snapshots:
|
|||||||
locate-path: 6.0.0
|
locate-path: 6.0.0
|
||||||
path-exists: 4.0.0
|
path-exists: 4.0.0
|
||||||
|
|
||||||
|
flairup@1.0.0: {}
|
||||||
|
|
||||||
flat-cache@4.0.1:
|
flat-cache@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
flatted: 3.3.3
|
flatted: 3.3.3
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { FC, useState } from 'react'
|
||||||
|
import EmojiPicker, { EmojiClickData } from 'emoji-picker-react'
|
||||||
|
import { EmojiHappy } from 'iconsax-react'
|
||||||
|
import { useOutsideClick } from '@/hooks/useOutSideClick'
|
||||||
|
|
||||||
|
interface EmojiReactionProps {
|
||||||
|
onEmojiSelect: (emoji: string) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmojiReaction: FC<EmojiReactionProps> = ({ onEmojiSelect, className = '' }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
// استفاده از hook موجود برای تشخیص کلیک خارج از المان
|
||||||
|
const containerRef = useOutsideClick(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleEmojiClick = (emojiData: EmojiClickData) => {
|
||||||
|
onEmojiSelect(emojiData.emoji)
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePicker = () => {
|
||||||
|
setIsOpen(!isOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={`relative ${className}`}>
|
||||||
|
<EmojiHappy
|
||||||
|
size={24}
|
||||||
|
color='black'
|
||||||
|
className='cursor-pointer hover:opacity-70 transition-opacity'
|
||||||
|
onClick={togglePicker}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className='absolute bottom-10 right-0 z-50'>
|
||||||
|
<div className='bg-white rounded-lg shadow-lg border border-gray-200'>
|
||||||
|
<EmojiPicker
|
||||||
|
onEmojiClick={handleEmojiClick}
|
||||||
|
width={300}
|
||||||
|
height={400}
|
||||||
|
|
||||||
|
searchDisabled
|
||||||
|
skinTonesDisabled
|
||||||
|
previewConfig={{
|
||||||
|
defaultCaption: 'یک ایموجی انتخاب کنید',
|
||||||
|
defaultEmoji: '1f60a'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmojiReaction
|
||||||
@@ -50,12 +50,25 @@ export const useReply = ({
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
}).format(originalDate);
|
}).format(originalDate);
|
||||||
|
|
||||||
const htmlContent =
|
const htmlContent = `
|
||||||
content +
|
<div dir="rtl" style="font-family: 'Irancell', Arial, sans-serif; line-height: 1.6;">
|
||||||
"<br /><br />" +
|
<div style="margin-bottom: 10px; padding: 15px;">
|
||||||
`<div dir="rtl">در تاریخ ${persianDate} ${originalMessage.from.name} <${originalMessage.from.address}> نوشت:</div>` +
|
${content}
|
||||||
"<br /><br />" +
|
</div>
|
||||||
`<div dir="rtl">${html}</div>`;
|
|
||||||
|
<div style="border-top: 2px solid #e9ecef; padding-top: 10px;">
|
||||||
|
<div style=" font-size: 12px; margin-bottom: 10px; padding: 8px; border-radius: 4px;">
|
||||||
|
در پاسخ به پیام ارسالی در تاریخ ${persianDate}
|
||||||
|
<br />
|
||||||
|
از: ${originalMessage.from.name} <${originalMessage.from.address}>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div >
|
||||||
|
${html}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
const replyData: SendEmailDto = {
|
const replyData: SendEmailDto = {
|
||||||
from: { address: userEmail || "sender@example.com" },
|
from: { address: userEmail || "sender@example.com" },
|
||||||
@@ -73,7 +86,19 @@ export const useReply = ({
|
|||||||
? originalMessage.subject
|
? originalMessage.subject
|
||||||
: `Re: ${originalMessage.subject}`,
|
: `Re: ${originalMessage.subject}`,
|
||||||
html: htmlContent,
|
html: htmlContent,
|
||||||
text: content.replace(/<[^>]*>/g, ""),
|
text: `${content.replace(/<[^>]*>/g, "")}
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
📩 در پاسخ به پیام ارسالی در تاریخ ${persianDate}
|
||||||
|
📧 از: ${originalMessage.from.name} <${originalMessage.from.address}>
|
||||||
|
|
||||||
|
متن پیام اصلی:
|
||||||
|
${
|
||||||
|
originalMessage.text ||
|
||||||
|
originalMessage.html?.join("").replace(/<[^>]*>/g, "") ||
|
||||||
|
""
|
||||||
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { FC, useEffect, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import AvatarImage from '@/assets/images/avatar.svg'
|
import AvatarImage from '@/assets/images/avatar.svg'
|
||||||
import { DocumentDownload, EmojiHappy } from 'iconsax-react'
|
import { DocumentDownload } from 'iconsax-react'
|
||||||
import Header from './Components/Header'
|
import Header from './Components/Header'
|
||||||
import { useGetMessageDetail, useDownloadAttachment } from './hooks/useEmailData'
|
import { useGetMessageDetail, useDownloadAttachment, useSendEmail } from './hooks/useEmailData'
|
||||||
import { toast } from '@/components/Toast'
|
import { toast } from '@/components/Toast'
|
||||||
import { formatDate, sanitizeEmailHTML, detectTextDirection } from '@/config/func'
|
import { formatDate, sanitizeEmailHTML, detectTextDirection } from '@/config/func'
|
||||||
import SkeletonDetail from './Components/SkeletonDetail'
|
import SkeletonDetail from './Components/SkeletonDetail'
|
||||||
@@ -13,17 +13,22 @@ import { useEmailActions } from '@/hooks/useEmailActions'
|
|||||||
import { MailboxEnum } from './enum/Enum'
|
import { MailboxEnum } from './enum/Enum'
|
||||||
import Reply from '@/components/newMessage/Reply'
|
import Reply from '@/components/newMessage/Reply'
|
||||||
import Forward from '@/components/newMessage/Forward'
|
import Forward from '@/components/newMessage/Forward'
|
||||||
|
import EmojiReaction from '@/components/EmojiReaction'
|
||||||
|
import { useAuthStore } from '@/pages/auth/store/AuthStore'
|
||||||
|
import { SendEmailDto } from './types/Types'
|
||||||
|
|
||||||
const DetailEmail: FC = () => {
|
const DetailEmail: FC = () => {
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const emailActions = useEmailActions()
|
const emailActions = useEmailActions()
|
||||||
|
const { email: userEmail } = useAuthStore()
|
||||||
const { id, mailbox } = useParams<{ id: string, mailbox: string }>()
|
const { id, mailbox } = useParams<{ id: string, mailbox: string }>()
|
||||||
const [activeAction, setActiveAction] = useState<'reply' | 'forward' | null>(null)
|
const [activeAction, setActiveAction] = useState<'reply' | 'forward' | null>(null)
|
||||||
|
|
||||||
// API hooks
|
// API hooks
|
||||||
const { data: messageDetail, isLoading, error } = useGetMessageDetail(id || '', mailbox || '')
|
const { data: messageDetail, isLoading, error } = useGetMessageDetail(id || '', mailbox || '')
|
||||||
const downloadAttachmentMutation = useDownloadAttachment()
|
const downloadAttachmentMutation = useDownloadAttachment()
|
||||||
|
const sendEmailMutation = useSendEmail()
|
||||||
|
|
||||||
const handleDownloadAttachment = async (attachmentId: string, filename: string) => {
|
const handleDownloadAttachment = async (attachmentId: string, filename: string) => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -55,6 +60,61 @@ const DetailEmail: FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEmojiSelect = async (emoji: string) => {
|
||||||
|
if (!messageDetail) {
|
||||||
|
toast('اطلاعات پیام یافت نشد', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date to Persian for reply context
|
||||||
|
const originalDate = new Date(messageDetail.date)
|
||||||
|
const persianDate = new Intl.DateTimeFormat('fa-IR', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(originalDate)
|
||||||
|
|
||||||
|
const reactionHtml = `
|
||||||
|
<div style="font-size: 24px; text-align: center; margin: 20px 0;">
|
||||||
|
${emoji}
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div dir="rtl" style="color: #666; font-size: 12px; border-top: 1px solid #ddd; padding-top: 10px;">
|
||||||
|
در پاسخ به پیام ${persianDate} از ${messageDetail.from.name} <${messageDetail.from.address}>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
const replyData: SendEmailDto = {
|
||||||
|
from: { address: userEmail || 'sender@example.com' },
|
||||||
|
to: [
|
||||||
|
{
|
||||||
|
address: messageDetail.from.address,
|
||||||
|
name: messageDetail.from.name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
replyTo: {
|
||||||
|
name: messageDetail.from.name,
|
||||||
|
address: messageDetail.from.address,
|
||||||
|
},
|
||||||
|
subject: messageDetail.subject.startsWith('Re:')
|
||||||
|
? messageDetail.subject
|
||||||
|
: `Re: ${messageDetail.subject}`,
|
||||||
|
html: reactionHtml,
|
||||||
|
text: `واکنش: ${emoji}\n\nدر پاسخ به پیام ${persianDate} از ${messageDetail.from.name}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmailMutation.mutateAsync(replyData)
|
||||||
|
toast('واکنش نشان داد', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Send error:', error)
|
||||||
|
toast('خطا در نشان دادن واکنش', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mark message as seen when it's loaded and not already seen
|
// Mark message as seen when it's loaded and not already seen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messageDetail && !messageDetail.seen && id) {
|
if (messageDetail && !messageDetail.seen && id) {
|
||||||
@@ -163,7 +223,7 @@ const DetailEmail: FC = () => {
|
|||||||
{activeAction === null && (
|
{activeAction === null && (
|
||||||
<div className='flex mt-9 justify-end'>
|
<div className='flex mt-9 justify-end'>
|
||||||
<div className='flex gap-5 text-sm'>
|
<div className='flex gap-5 text-sm'>
|
||||||
<EmojiHappy size={24} color='black' />
|
<EmojiReaction onEmojiSelect={handleEmojiSelect} />
|
||||||
|
|
||||||
<Reply
|
<Reply
|
||||||
originalMessage={messageDetail}
|
originalMessage={messageDetail}
|
||||||
|
|||||||
Reference in New Issue
Block a user