From 27fc6b765c5ee03756ffc53844a5e2f8751d3651 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Sat, 19 Jul 2025 16:36:51 +0330 Subject: [PATCH] spam list + socket events --- .env | 2 +- package.json | 1 + pnpm-lock.yaml | 88 ++++++++++++ src/App.tsx | 15 +- src/components/EmailNotifications.tsx | 159 ++++++++++++++++++++++ src/components/SoundSettings.tsx | 91 +++++++++++++ src/config/func.ts | 47 ++++++- src/contexts/EmailWebSocketContext.tsx | 38 ++++++ src/hooks/useEmailEvents.ts | 92 +++++++++++++ src/hooks/useEmailWebSocket.ts | 66 +++++++++ src/hooks/useNotificationSound.ts | 72 ++++++++++ src/index.css | 31 +++++ src/pages/received/Detail.tsx | 24 ++-- src/pages/received/List.tsx | 46 ++++++- src/pages/received/hooks/useEmailData.tsx | 5 +- src/pages/sent/List.tsx | 9 +- src/pages/spam/List.tsx | 3 +- src/pages/spam/types/SpamTypes.ts | 2 +- src/router/AppRouter.tsx | 1 + src/shared/Header.tsx | 15 +- 20 files changed, 777 insertions(+), 30 deletions(-) create mode 100644 src/components/EmailNotifications.tsx create mode 100644 src/components/SoundSettings.tsx create mode 100644 src/contexts/EmailWebSocketContext.tsx create mode 100644 src/hooks/useEmailEvents.ts create mode 100644 src/hooks/useEmailWebSocket.ts create mode 100644 src/hooks/useNotificationSound.ts diff --git a/.env b/.env index 39c58cc..24d57e5 100644 --- a/.env +++ b/.env @@ -1,6 +1,6 @@ VITE_TOKEN_NAME = 'dmail_token' VITE_REFRESH_TOKEN_NAME = 'dmail_refresh_token' VITE_DANAK_BASE_URL ='https://api.danakcorp.com' -# VITE_BASE_URL = 'http://192.168.1.113:4000' +# VITE_BASE_URL = 'http://192.168.1.118:4000' VITE_BASE_URL = 'https://dmail-api.danakcorp.com' VITE_SERVICE_ID = 'e51afdc3-ea0b-49cf-8f49-2a6f131b024e' diff --git a/package.json b/package.json index 469c4ad..746eae4 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "react-quill-new": "^3.4.6", "react-router-dom": "^7.5.2", "react-spinners": "^0.17.0", + "socket.io-client": "^4.8.1", "swiper": "^11.2.6", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 230dbc0..27545cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: react-spinners: specifier: ^0.17.0 version: 0.17.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + socket.io-client: + specifier: ^4.8.1 + version: 4.8.1 swiper: specifier: ^11.2.6 version: 11.2.8 @@ -887,6 +890,9 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -1424,6 +1430,15 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -1484,6 +1499,13 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + engine.io-client@6.6.3: + resolution: {integrity: sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + enhanced-resolve@5.18.1: resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} engines: {node: '>=10.13.0'} @@ -2745,6 +2767,14 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + socket.io-client@4.8.1: + resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3094,6 +3124,22 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3874,6 +3920,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@socket.io/component-emitter@3.1.2': {} + '@standard-schema/utils@0.3.0': {} '@stoplight/better-ajv-errors@1.0.3(ajv@8.17.1)': @@ -4521,6 +4569,10 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + debug@4.3.7: + dependencies: + ms: 2.1.3 + debug@4.4.1: dependencies: ms: 2.1.3 @@ -4576,6 +4628,20 @@ snapshots: emoji-regex@8.0.0: {} + engine.io-client@6.6.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + engine.io-parser: 5.2.3 + ws: 8.17.1 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + enhanced-resolve@5.18.1: dependencies: graceful-fs: 4.2.11 @@ -5937,6 +6003,24 @@ snapshots: slash@3.0.0: {} + socket.io-client@4.8.1: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + engine.io-client: 6.6.3 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + source-map-js@1.2.1: {} stats-gl@2.4.2(@types/three@0.177.0)(three@0.177.0): @@ -6295,6 +6379,10 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + ws@8.17.1: {} + + xmlhttprequest-ssl@2.1.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/src/App.tsx b/src/App.tsx index 31dfca1..5b4a31b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import { getRefreshToken, getToken, removeRefreshToken, removeToken, setRefreshT import { refreshToken } from './pages/auth/service/AuthService'; import { Paths } from './utils/Paths'; import Login from './pages/auth/Login'; +import { EmailWebSocketProvider } from './contexts/EmailWebSocketContext'; declare global { interface Window { @@ -107,11 +108,14 @@ const queryClient = new QueryClient({ const App: FC = () => { const [isLogin, setIsLogin] = useState<'checking' | 'isLogin' | 'isNotLogin'>('checking') + const [userToken, setUserToken] = useState('') + useEffect(() => { const checkLogin = async () => { const token = await getToken(); if (token) { setIsLogin('isLogin') + setUserToken(token) } else { setIsLogin('isNotLogin') if (window.location.href.split('auth').length === 1) { @@ -126,10 +130,13 @@ const App: FC = () => { - - { - isLogin === 'isLogin' ?
: isLogin === 'isNotLogin' ? : null - } + {isLogin === 'isLogin' && userToken ? ( + +
+ + ) : isLogin === 'isNotLogin' ? ( + + ) : null} diff --git a/src/components/EmailNotifications.tsx b/src/components/EmailNotifications.tsx new file mode 100644 index 0000000..f88e2fb --- /dev/null +++ b/src/components/EmailNotifications.tsx @@ -0,0 +1,159 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { useEmailWebSocket } from '@/hooks/useEmailWebSocket'; +import { useEmailEvents, EmailPayload, UnreadCountPayload } from '@/hooks/useEmailEvents'; +import { useNotificationSound } from '@/hooks/useNotificationSound'; +import { Notification as NotificationIcon, VolumeHigh } from 'iconsax-react'; + +interface EmailNotificationsProps { + userToken: string; + onEmailClick?: (email: EmailPayload) => void; +} + +export const EmailNotifications: React.FC = ({ + userToken, + onEmailClick +}) => { + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [isVisible, setIsVisible] = useState(false); + + const { socket, isConnected } = useEmailWebSocket({ + token: userToken, + onConnect: () => console.log('نوتیفیکیشن‌های ایمیل متصل شد'), + onError: (error) => console.error('خطای WebSocket:', error), + }); + + // Sound settings state + const [soundEnabled, setSoundEnabled] = useState(true); + const [soundVolume, setSoundVolume] = useState(0.7); + + // Load sound settings from localStorage + useEffect(() => { + const savedEnabled = localStorage.getItem('notificationSoundEnabled'); + const savedVolume = localStorage.getItem('notificationSoundVolume'); + + if (savedEnabled !== null) { + setSoundEnabled(savedEnabled === 'true'); + } + if (savedVolume !== null) { + setSoundVolume(parseFloat(savedVolume)); + } + }, []); + + // Notification sound hook + const { playSound } = useNotificationSound({ + enabled: soundEnabled, + volume: soundVolume, + }); + + const handleNewEmail = useCallback((email: EmailPayload) => { + console.log('📧 ایمیل جدید دریافت شد:', email); + + // Add to notifications list + setNotifications(prev => [email, ...prev.slice(0, 9)]); // Keep only 10 latest + + // Show browser notification if permission granted + if (Notification.permission === 'granted') { + new Notification(`📧 ایمیل جدید: ${email.subject}`, { + body: `از: ${email.from.name || email.from.address}\n${email.preview}`, + tag: `email-${email.messageId}`, // Prevent duplicates + }); + } + + // Play notification sound + playSound(); + }, [playSound]); + + const handleUnreadCountUpdate = useCallback((data: UnreadCountPayload) => { + setUnreadCount(data.count); + }, []); + + const handleEmailRead = useCallback((data: { messageId: number }) => { + // Remove from notifications if it was there + setNotifications(prev => + prev.filter(notification => notification.messageId !== data.messageId) + ); + }, []); + + useEmailEvents(socket, { + onNewEmail: handleNewEmail, + onUnreadCountUpdated: handleUnreadCountUpdate, + onEmailRead: handleEmailRead, + }); + + // Request notification permission on mount + React.useEffect(() => { + if (Notification.permission === 'default') { + Notification.requestPermission(); + } + }, []); + + return ( +
+ {/* Connection Status */} +
+ + {/* Test Sound Button */} + + + {/* Notification Bell */} +
setIsVisible(!isVisible)} + > + + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} +
+ + {/* Notifications Dropdown */} + {isVisible && ( +
+
+

📧 نوتیفیکیشن‌های ایمیل

+ {unreadCount} خوانده نشده +
+ +
+ {notifications.length === 0 ? ( +
ایمیل جدیدی وجود ندارد
+ ) : ( + notifications.map((email) => ( +
onEmailClick?.(email)} + > +
+ + {email.from.name || email.from.address} + + {email.hasAttachments && 📎} +
+
{email.subject}
+
{email.preview}
+
+ {new Date(email.timestamp).toLocaleString('fa-IR')} +
+
+ )) + )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/SoundSettings.tsx b/src/components/SoundSettings.tsx new file mode 100644 index 0000000..543aaa9 --- /dev/null +++ b/src/components/SoundSettings.tsx @@ -0,0 +1,91 @@ +import React, { useState, useEffect } from 'react'; +import { VolumeHigh, VolumeLow, VolumeSlash } from 'iconsax-react'; +import { useNotificationSound } from '@/hooks/useNotificationSound'; + +interface SoundSettingsProps { + className?: string; +} + +export const SoundSettings: React.FC = ({ className = '' }) => { + const [isEnabled, setIsEnabled] = useState(true); + const [volume, setVolume] = useState(0.7); + + const { playSound } = useNotificationSound({ + enabled: isEnabled, + volume: volume, + }); + + // Load settings from localStorage on mount + useEffect(() => { + const savedEnabled = localStorage.getItem('notificationSoundEnabled'); + const savedVolume = localStorage.getItem('notificationSoundVolume'); + + if (savedEnabled !== null) { + setIsEnabled(savedEnabled === 'true'); + } + if (savedVolume !== null) { + setVolume(parseFloat(savedVolume)); + } + }, []); + + // Save settings to localStorage when changed + useEffect(() => { + localStorage.setItem('notificationSoundEnabled', isEnabled.toString()); + localStorage.setItem('notificationSoundVolume', volume.toString()); + }, [isEnabled, volume]); + + const handleVolumeChange = (newVolume: number) => { + setVolume(newVolume); + if (isEnabled) { + // Test sound with new volume + setTimeout(() => playSound(), 100); + } + }; + + const toggleSound = () => { + const newEnabled = !isEnabled; + setIsEnabled(newEnabled); + if (newEnabled) { + // Test sound when enabling + setTimeout(() => playSound(), 100); + } + }; + + const getVolumeIcon = () => { + if (!isEnabled) return ; + if (volume < 0.3) return ; + return ; + }; + + return ( +
+ {/* Sound Toggle */} + + + {/* Volume Slider */} + {isEnabled && ( +
+ handleVolumeChange(parseFloat(e.target.value))} + className="w-20 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider" + title={`حجم صدا: ${Math.round(volume * 100)}%`} + /> + + {Math.round(volume * 100)}% + +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/config/func.ts b/src/config/func.ts index bc96867..cc068fc 100644 --- a/src/config/func.ts +++ b/src/config/func.ts @@ -81,6 +81,30 @@ export const formatDate = (dateString: string) => { }).format(date); }; +export const detectTextDirection = (text: string): "ltr" | "rtl" => { + // حذف تگ‌های HTML برای تحلیل فقط متن + const plainText = text.replace(/<[^>]*>/g, " ").trim(); + + // اگر متن خالی است، از RTL استفاده کن + if (!plainText) return "rtl"; + + // الگوی کاراکترهای انگلیسی و اعداد + const englishPattern = /[a-zA-Z0-9\s.,!?;:'"()\-@#$%^&*+=<>{}[\]\\|`~_]/g; + const englishMatches = plainText.match(englishPattern) || []; + + // الگوی کاراکترهای فارسی و عربی (برای آینده ممکن است نیاز باشد) + // const persianArabicPattern = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/g; + + // محاسبه درصد کاراکترهای انگلیسی + const totalChars = plainText.replace(/\s/g, "").length; + const englishChars = englishMatches.join("").replace(/\s/g, "").length; + const englishPercentage = + totalChars > 0 ? (englishChars / totalChars) * 100 : 0; + + // اگر بیش از 70% متن انگلیسی است، LTR استفاده کن + return englishPercentage > 70 ? "ltr" : "rtl"; +}; + export const sanitizeEmailHTML = (html: string): string => { try { // Remove style tags and their content @@ -95,15 +119,30 @@ export const sanitizeEmailHTML = (html: string): string => { // Remove potentially harmful attributes sanitized = sanitized.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, ""); - // فقط font-family و font-size رو از inline styles حذف کن، بقیه رو حفظ کن + // تشخیص جهت متن اصلی + const detectedDirection = detectTextDirection(sanitized); + + // فقط font-family، font-size و font-weight رو از inline styles حذف کن + // text-align و direction رو فقط اگر با جهت تشخیص داده شده مطابقت نداشته باشد حذف کن sanitized = sanitized.replace( /style\s*=\s*["']([^"']*?)["']/gi, (_, styles) => { - // فقط font-family و font-size رو حذف کن - const cleanedStyles = styles + let cleanedStyles = styles .replace(/font-family\s*:\s*[^;]*;?/gi, "") .replace(/font-size\s*:\s*[^;]*;?/gi, "") - .replace(/font-weight\s*:\s*[^;]*;?/gi, "") + .replace(/font-weight\s*:\s*[^;]*;?/gi, ""); + + // اگر جهت تشخیص داده شده LTR است، direction و text-align را حفظ کن + if (detectedDirection === "ltr") { + // direction و text-align را حفظ کن + } else { + // اگر RTL است، این استایل‌ها را حذف کن تا استایل سایت اعمال شود + cleanedStyles = cleanedStyles + .replace(/text-align\s*:\s*[^;]*;?/gi, "") + .replace(/direction\s*:\s*[^;]*;?/gi, ""); + } + + cleanedStyles = cleanedStyles .replace(/;\s*;/g, ";") // حذف سمیکالن‌های اضافی .replace(/^\s*;\s*|\s*;\s*$/g, ""); // حذف سمیکالن‌های ابتدا و انتها diff --git a/src/contexts/EmailWebSocketContext.tsx b/src/contexts/EmailWebSocketContext.tsx new file mode 100644 index 0000000..cba1c13 --- /dev/null +++ b/src/contexts/EmailWebSocketContext.tsx @@ -0,0 +1,38 @@ +import React, { createContext, useContext, ReactNode } from 'react'; +import { useEmailWebSocket } from '@/hooks/useEmailWebSocket'; +import { Socket } from 'socket.io-client'; + +interface EmailWebSocketContextValue { + socket: Socket | null; + isConnected: boolean; +} + +const EmailWebSocketContext = createContext(null); + +interface EmailWebSocketProviderProps { + children: ReactNode; + token: string; + serverUrl?: string; +} + +export const EmailWebSocketProvider: React.FC = ({ + children, + token, + serverUrl, +}) => { + const { socket, isConnected } = useEmailWebSocket({ token, serverUrl }); + + return ( + + {children} + + ); +}; + +export const useEmailWebSocketContext = () => { + const context = useContext(EmailWebSocketContext); + if (!context) { + throw new Error('useEmailWebSocketContext must be used within EmailWebSocketProvider'); + } + return context; +}; \ No newline at end of file diff --git a/src/hooks/useEmailEvents.ts b/src/hooks/useEmailEvents.ts new file mode 100644 index 0000000..46967a6 --- /dev/null +++ b/src/hooks/useEmailEvents.ts @@ -0,0 +1,92 @@ +import { useEffect } from "react"; +import { Socket } from "socket.io-client"; + +export interface EmailPayload { + messageId: number; + userId: string; + from: { + name?: string; + address: string; + }; + to: Array<{ + name?: string; + address: string; + }>; + subject: string; + preview?: string; + hasAttachments: boolean; + timestamp: string; + mailboxId: string; + mailboxName: string; + isRead: boolean; + isFlagged: boolean; + size: number; +} + +export interface EmailStatusPayload { + messageId: number; + userId: string; + mailboxId: string; + timestamp: string; +} + +export interface UnreadCountPayload { + userId: string; + totalUnread: number; + mailboxCounts: Record; + timestamp: string; + count: number; +} + +interface EmailEventHandlers { + onNewEmail?: (email: EmailPayload) => void; + onEmailRead?: (data: EmailStatusPayload) => void; + onEmailDeleted?: (data: EmailStatusPayload) => void; + onEmailSent?: (data: EmailStatusPayload) => void; + onUnreadCountUpdated?: (data: UnreadCountPayload) => void; + onMailboxUpdated?: (data: EmailStatusPayload) => void; +} + +export const useEmailEvents = ( + socket: Socket | null, + handlers: EmailEventHandlers +) => { + useEffect(() => { + if (!socket) return; + + // Register event handlers + if (handlers.onNewEmail) { + socket.on("new_email", handlers.onNewEmail); + } + + if (handlers.onEmailRead) { + socket.on("email_read", handlers.onEmailRead); + } + + if (handlers.onEmailDeleted) { + socket.on("email_deleted", handlers.onEmailDeleted); + } + + if (handlers.onEmailSent) { + socket.on("email_sent", handlers.onEmailSent); + } + + if (handlers.onUnreadCountUpdated) { + socket.on("unread_count_updated", handlers.onUnreadCountUpdated); + } + + if (handlers.onMailboxUpdated) { + socket.on("mailbox_updated", handlers.onMailboxUpdated); + } + + // Cleanup on unmount + return () => { + socket.off("new_email"); + socket.off("email_read"); + socket.off("email_deleted"); + socket.off("email_sent"); + socket.off("unread_count_updated"); + socket.off("mailbox_updated"); + }; + }, [socket, handlers]); +}; diff --git a/src/hooks/useEmailWebSocket.ts b/src/hooks/useEmailWebSocket.ts new file mode 100644 index 0000000..7f75896 --- /dev/null +++ b/src/hooks/useEmailWebSocket.ts @@ -0,0 +1,66 @@ +import { useEffect, useState } from "react"; +import io, { Socket } from "socket.io-client"; + +interface UseEmailWebSocketOptions { + token: string; + serverUrl?: string; + onConnect?: () => void; + onDisconnect?: () => void; + onError?: (error: string) => void; +} + +export const useEmailWebSocket = ({ + token, + serverUrl = `ws://${import.meta.env.VITE_BASE_URL}/email`, + onConnect, + onDisconnect, + onError, +}: UseEmailWebSocketOptions) => { + const [socket, setSocket] = useState(null); + const [isConnected, setIsConnected] = useState(false); + + useEffect(() => { + if (!token) return; + + const newSocket = io(serverUrl, { + auth: { token }, + transports: ["websocket", "polling"], + }); + + // Connection events + newSocket.on("connect", () => { + console.log("✅ اتصال به WebSocket ایمیل برقرار شد"); + setIsConnected(true); + onConnect?.(); + }); + + newSocket.on("disconnect", () => { + console.log("❌ اتصال به WebSocket ایمیل قطع شد"); + setIsConnected(false); + onDisconnect?.(); + }); + + newSocket.on("connection_established", (data) => { + console.log("🎉 اتصال WebSocket ایمیل تأیید شد:", data); + }); + + newSocket.on("connection_error", (error) => { + console.error("💥 خطای اتصال WebSocket ایمیل:", error); + onError?.(error.error); + }); + + // Health monitoring + newSocket.on("ping", () => { + newSocket.emit("pong", { timestamp: new Date().toISOString() }); + }); + + setSocket(newSocket); + + return () => { + newSocket.close(); + setSocket(null); + }; + }, [token, serverUrl]); + + return { socket, isConnected }; +}; diff --git a/src/hooks/useNotificationSound.ts b/src/hooks/useNotificationSound.ts new file mode 100644 index 0000000..0d2c07a --- /dev/null +++ b/src/hooks/useNotificationSound.ts @@ -0,0 +1,72 @@ +import { useCallback, useState } from "react"; + +interface UseNotificationSoundOptions { + enabled?: boolean; + volume?: number; + soundUrl?: string; +} + +export const useNotificationSound = ({ + enabled = true, + volume = 0.5, + soundUrl = "/notification.mp3", +}: UseNotificationSoundOptions = {}) => { + const [isPlaying, setIsPlaying] = useState(false); + + const playSound = useCallback(async () => { + if (!enabled || isPlaying) { + console.log("صدا غیرفعال است یا در حال پخش"); + return; + } + + try { + setIsPlaying(true); + console.log("شروع پخش صدا...", { soundUrl, volume, enabled }); + + const audio = new Audio(); + audio.preload = "auto"; + audio.src = soundUrl; + audio.volume = Math.max(0, Math.min(1, volume)); + + // Add event listeners + audio.addEventListener("ended", () => { + console.log("پخش صدا تمام شد"); + setIsPlaying(false); + }); + + audio.addEventListener("error", (e) => { + console.error("خطا در پخش صدا:", e); + setIsPlaying(false); + }); + + // Wait for audio to be ready + if (audio.readyState >= 2) { + await audio.play(); + console.log("🔊 صدای نوتیفیکیشن پخش شد"); + } else { + audio.addEventListener("canplay", async () => { + try { + await audio.play(); + console.log("🔊 صدای نوتیفیکیشن پخش شد"); + } catch (playError) { + console.error("خطا در پخش بعد از آماده شدن:", playError); + setIsPlaying(false); + } + }); + } + } catch (error) { + console.error("خطا در ایجاد audio element:", error); + setIsPlaying(false); + } + }, [enabled, isPlaying, soundUrl, volume]); + + const stopSound = useCallback(() => { + setIsPlaying(false); + }, []); + + return { + playSound, + stopSound, + isPlaying, + }; +}; diff --git a/src/index.css b/src/index.css index a2d4313..6d306d6 100644 --- a/src/index.css +++ b/src/index.css @@ -48,6 +48,37 @@ body, } * { outline: none; + + /* Custom slider styles for volume control */ + .slider { + -webkit-appearance: none; + appearance: none; + background: #e5e7eb; + border-radius: 5px; + height: 6px; + outline: none; + } + + .slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #374151; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } + + .slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #374151; + cursor: pointer; + border: none; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } } input::placeholder, diff --git a/src/pages/received/Detail.tsx b/src/pages/received/Detail.tsx index e3ea700..a601e18 100644 --- a/src/pages/received/Detail.tsx +++ b/src/pages/received/Detail.tsx @@ -9,17 +9,18 @@ import AnswerIcon from '@/assets/images/answer.svg' import Header from './Components/Header' import ReactQuill from 'react-quill-new'; import Select from '@/components/Select' -import { useGetInbox, useGetMessageDetail, useSendEmail } from './hooks/useEmailData' +import { useGetMessageDetail, useSendEmail } from './hooks/useEmailData' import { useAuthStore } from '../auth/store/AuthStore' import { SendEmailDto } from './types/Types' import { toast } from '@/components/Toast' -import { formatDate, sanitizeEmailHTML } from '@/config/func' +import { formatDate, sanitizeEmailHTML, detectTextDirection } from '@/config/func' import SkeletonDetail from './Components/SkeletonDetail' +import { useEmailActions } from '@/hooks/useEmailActions' const DetailEmail: FC = () => { const { t } = useTranslation() - const { refetch } = useGetInbox({ page: 1 }) + const emailActions = useEmailActions() const { id } = useParams<{ id: string }>() const { email: userEmail } = useAuthStore() const [showAnswer, setShowAnswer] = useState(false) @@ -27,7 +28,7 @@ const DetailEmail: FC = () => { const [priority, setPriority] = useState('') // API hooks - const { data: messageDetail, isLoading, error, isSuccess } = useGetMessageDetail(id || '', !!id) + const { data: messageDetail, isLoading, error } = useGetMessageDetail(id || '', !!id) const sendEmailMutation = useSendEmail() // const saveDraftMutation = useSaveDraft() @@ -108,11 +109,13 @@ const DetailEmail: FC = () => { toast('قابلیت ارسال به دیگران به زودی اضافه خواهد شد', 'info') } + // Mark message as seen when it's loaded and not already seen useEffect(() => { - if (isSuccess) { - refetch() + if (messageDetail && !messageDetail.seen && id) { + console.log('Marking message as seen:', id, 'Current seen status:', messageDetail.seen) + emailActions.markAsSeen(Number(id)) } - }, [isSuccess]) + }, [messageDetail, emailActions, id]) if (isLoading) { return ( @@ -166,10 +169,15 @@ const DetailEmail: FC = () => { {messageDetail.html && messageDetail.html.length > 0 ? (
) : ( -

{messageDetail.text || 'محتوای پیام'}

+

{messageDetail.text || 'محتوای پیام'}

)}
diff --git a/src/pages/received/List.tsx b/src/pages/received/List.tsx index 2277480..24af334 100644 --- a/src/pages/received/List.tsx +++ b/src/pages/received/List.tsx @@ -1,19 +1,22 @@ import Filters, { FilterValues } from '../../components/Filters'; -import { FC, useState } from 'react' +import { FC, useState, useEffect, useCallback } from 'react' import { useTranslation } from 'react-i18next' import Table from '../../components/Table'; import { ColumnType } from '../../components/types/TableTypes'; import { InfoCircle, More, Refresh2, Trash, Edit, Archive, ArchiveTick, Star1 } from 'iconsax-react'; import { RowActionItem } from '../../components/RowActionsDropdown'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { useGetInbox } from './hooks/useEmailData'; import { InboxMessage } from './types/Types'; import { formatDate } from '@/config/func'; import MarkAsRead from '@/assets/images/mark_as_read.svg' import { useEmailActions } from '@/hooks/useEmailActions'; +import { useEmailWebSocketContext } from '@/contexts/EmailWebSocketContext'; +import { useEmailEvents } from '@/hooks/useEmailEvents'; const List: FC = () => { const navigate = useNavigate(); + const location = useLocation(); const { t } = useTranslation(); const [currentPage, setCurrentPage] = useState(1); const [filters, setFilters] = useState({}); @@ -21,10 +24,38 @@ const List: FC = () => { const { data: inboxData, isLoading, refetch, isFetching } = useGetInbox({ page: currentPage, - limit: 10, + limit: 25, ...filters }); + // WebSocket integration + const { socket } = useEmailWebSocketContext(); + + const handleNewEmailEvent = useCallback(() => { + // Refresh inbox when new email arrives + refetch(); + }, [refetch]); + + const handleEmailActionEvent = useCallback(() => { + // Refresh inbox when email actions occur + refetch(); + }, [refetch]); + + useEmailEvents(socket, { + onNewEmail: handleNewEmailEvent, + onEmailRead: handleEmailActionEvent, + onEmailDeleted: handleEmailActionEvent, + // onMailboxUpdated: handleEmailActionEvent, + }); + + // Force refresh when component mounts or location changes + useEffect(() => { + // Refetch when coming back to inbox from other pages + if (location.pathname === '/') { + refetch(); + } + }, [location.pathname, refetch]); + const [selectedMessages, setSelectedMessages] = useState([]); const columns: ColumnType[] = [ @@ -32,7 +63,14 @@ const List: FC = () => { key: 'subject', title: 'عنوان پیام', render: (item) => ( - {item.subject || 'بدون موضوع'} +
{item.subject || 'بدون موضوع'}
+ ) + }, + { + key: 'intro', + title: 'پیش نمایش', + render: (item) => ( +
{item.intro || 'بدون موضوع'}
) }, { diff --git a/src/pages/received/hooks/useEmailData.tsx b/src/pages/received/hooks/useEmailData.tsx index 49cc447..031ff24 100644 --- a/src/pages/received/hooks/useEmailData.tsx +++ b/src/pages/received/hooks/useEmailData.tsx @@ -26,7 +26,8 @@ export const useGetInbox = (query: MessageListQueryDto) => { return useQuery({ queryKey: ['inbox', query], queryFn: () => api.getInbox(query), - staleTime: 1000 * 60 * 5, // 5 minutes + staleTime: 0, // Always fetch fresh data + refetchOnWindowFocus: true, }); }; @@ -35,7 +36,7 @@ export const useGetMessageDetail = (messageId: string, enabled: boolean = true) queryKey: ['message', messageId], queryFn: () => api.getMessageDetail(messageId), enabled: !!messageId && enabled, - staleTime: 1000 * 60 * 10, // 10 minutes + staleTime: 0, // Always fetch fresh data for message details }); }; diff --git a/src/pages/sent/List.tsx b/src/pages/sent/List.tsx index 5180226..e074334 100644 --- a/src/pages/sent/List.tsx +++ b/src/pages/sent/List.tsx @@ -38,6 +38,13 @@ const List: FC = () => { ) }, + { + key: 'intro', + title: 'پیش نمایش', + render: (item) => ( +
{item.intro || 'بدون موضوع'}
+ ) + }, { key: 'to', title: 'گیرنده', @@ -55,7 +62,7 @@ const List: FC = () => { title: 'تاریخ ارسال', align: 'center', render: (item) => ( - + {formatDate(item.date)} ) diff --git a/src/pages/spam/List.tsx b/src/pages/spam/List.tsx index 436d039..600085b 100644 --- a/src/pages/spam/List.tsx +++ b/src/pages/spam/List.tsx @@ -37,7 +37,6 @@ const List: FC = () => { title: 'فرستنده', render: (item) => (
- {!item.seen &&
} {item.from.name || item.from.address}
) @@ -109,7 +108,7 @@ const List: FC = () => { setCurrentPage(page); }; - const messages = spamData?.data?.results || []; + const messages = spamData?.data?.junkMails || []; const pager = spamData?.data?.pager; return ( diff --git a/src/pages/spam/types/SpamTypes.ts b/src/pages/spam/types/SpamTypes.ts index 9512591..4d308d4 100644 --- a/src/pages/spam/types/SpamTypes.ts +++ b/src/pages/spam/types/SpamTypes.ts @@ -47,7 +47,7 @@ export interface MessageListQueryDto { export interface SpamResponse { success: boolean; data: { - results: SpamMessage[]; + junkMails: SpamMessage[]; pager: { page: number; totalPages: number; diff --git a/src/router/AppRouter.tsx b/src/router/AppRouter.tsx index 69216ba..9c111ea 100644 --- a/src/router/AppRouter.tsx +++ b/src/router/AppRouter.tsx @@ -15,6 +15,7 @@ import Login from '@/pages/auth/Login' const AppRouter: FC = () => { return ( + } /> } /> } /> } /> diff --git a/src/shared/Header.tsx b/src/shared/Header.tsx index a10310a..545bbe1 100644 --- a/src/shared/Header.tsx +++ b/src/shared/Header.tsx @@ -2,9 +2,9 @@ import { FC, useEffect, useState } from 'react' import Input from '../components/Input' import { Element3, HambergerMenu, Wallet } from 'iconsax-react' import { useTranslation } from 'react-i18next' -import { Link, useLocation } from 'react-router-dom' +import { Link, useLocation, useNavigate } from 'react-router-dom' import { Paths } from '@/utils/Paths' -// import Notifications from '../pages/notification/Notification' +import { EmailNotifications } from '@/components/EmailNotifications' import { useSharedStore } from './store/sharedStore' // import { useGetProfile } from '../pages/profile/hooks/useProfileData' // import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react' @@ -14,12 +14,16 @@ import { useSharedStore } from './store/sharedStore' const Header: FC = () => { const location = useLocation(); + const navigate = useNavigate(); const [popoverKey, setPopoverKey] = useState(0); const { t } = useTranslation('global') const { setOpenSidebar, openSidebar } = useSharedStore() // const { data } = useGetProfile() // const getWalletBalance = useGetWalletBalance() + // فرض می‌کنیم که token از localStorage گرفته می‌شود + const userToken = localStorage.getItem('jwt-token') || ''; + console.log(popoverKey); useEffect(() => { @@ -57,7 +61,12 @@ const Header: FC = () => {
- {/* */} + {userToken && ( + navigate(`/mail/${email.messageId}`)} + /> + )} {/* { data && (