spam list + socket events

This commit is contained in:
hamid zarghami
2025-07-19 16:36:51 +03:30
parent f8af0af065
commit 27fc6b765c
20 changed files with 777 additions and 30 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
VITE_TOKEN_NAME = 'dmail_token' VITE_TOKEN_NAME = 'dmail_token'
VITE_REFRESH_TOKEN_NAME = 'dmail_refresh_token' VITE_REFRESH_TOKEN_NAME = 'dmail_refresh_token'
VITE_DANAK_BASE_URL ='https://api.danakcorp.com' 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_BASE_URL = 'https://dmail-api.danakcorp.com'
VITE_SERVICE_ID = 'e51afdc3-ea0b-49cf-8f49-2a6f131b024e' VITE_SERVICE_ID = 'e51afdc3-ea0b-49cf-8f49-2a6f131b024e'
+1
View File
@@ -42,6 +42,7 @@
"react-quill-new": "^3.4.6", "react-quill-new": "^3.4.6",
"react-router-dom": "^7.5.2", "react-router-dom": "^7.5.2",
"react-spinners": "^0.17.0", "react-spinners": "^0.17.0",
"socket.io-client": "^4.8.1",
"swiper": "^11.2.6", "swiper": "^11.2.6",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
+88
View File
@@ -101,6 +101,9 @@ importers:
react-spinners: react-spinners:
specifier: ^0.17.0 specifier: ^0.17.0
version: 0.17.0(react-dom@19.1.0(react@19.1.0))(react@19.1.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: swiper:
specifier: ^11.2.6 specifier: ^11.2.6
version: 11.2.8 version: 11.2.8
@@ -887,6 +890,9 @@ packages:
'@shikijs/vscode-textmate@10.0.2': '@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} 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': '@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
@@ -1424,6 +1430,15 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'} 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: debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -1484,6 +1499,13 @@ packages:
emoji-regex@8.0.0: emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} 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: enhanced-resolve@5.18.1:
resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@@ -2745,6 +2767,14 @@ packages:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'} 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: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3094,6 +3124,22 @@ packages:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'} 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: y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3874,6 +3920,8 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {} '@shikijs/vscode-textmate@10.0.2': {}
'@socket.io/component-emitter@3.1.2': {}
'@standard-schema/utils@0.3.0': {} '@standard-schema/utils@0.3.0': {}
'@stoplight/better-ajv-errors@1.0.3(ajv@8.17.1)': '@stoplight/better-ajv-errors@1.0.3(ajv@8.17.1)':
@@ -4521,6 +4569,10 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
is-data-view: 1.0.2 is-data-view: 1.0.2
debug@4.3.7:
dependencies:
ms: 2.1.3
debug@4.4.1: debug@4.4.1:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@@ -4576,6 +4628,20 @@ snapshots:
emoji-regex@8.0.0: {} 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: enhanced-resolve@5.18.1:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@@ -5937,6 +6003,24 @@ snapshots:
slash@3.0.0: {} 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: {} source-map-js@1.2.1: {}
stats-gl@2.4.2(@types/three@0.177.0)(three@0.177.0): stats-gl@2.4.2(@types/three@0.177.0)(three@0.177.0):
@@ -6295,6 +6379,10 @@ snapshots:
string-width: 4.2.3 string-width: 4.2.3
strip-ansi: 6.0.1 strip-ansi: 6.0.1
ws@8.17.1: {}
xmlhttprequest-ssl@2.1.2: {}
y18n@5.0.8: {} y18n@5.0.8: {}
yallist@3.1.1: {} yallist@3.1.1: {}
+11 -4
View File
@@ -16,6 +16,7 @@ import { getRefreshToken, getToken, removeRefreshToken, removeToken, setRefreshT
import { refreshToken } from './pages/auth/service/AuthService'; import { refreshToken } from './pages/auth/service/AuthService';
import { Paths } from './utils/Paths'; import { Paths } from './utils/Paths';
import Login from './pages/auth/Login'; import Login from './pages/auth/Login';
import { EmailWebSocketProvider } from './contexts/EmailWebSocketContext';
declare global { declare global {
interface Window { interface Window {
@@ -107,11 +108,14 @@ const queryClient = new QueryClient({
const App: FC = () => { const App: FC = () => {
const [isLogin, setIsLogin] = useState<'checking' | 'isLogin' | 'isNotLogin'>('checking') const [isLogin, setIsLogin] = useState<'checking' | 'isLogin' | 'isNotLogin'>('checking')
const [userToken, setUserToken] = useState<string>('')
useEffect(() => { useEffect(() => {
const checkLogin = async () => { const checkLogin = async () => {
const token = await getToken(); const token = await getToken();
if (token) { if (token) {
setIsLogin('isLogin') setIsLogin('isLogin')
setUserToken(token)
} else { } else {
setIsLogin('isNotLogin') setIsLogin('isNotLogin')
if (window.location.href.split('auth').length === 1) { if (window.location.href.split('auth').length === 1) {
@@ -126,10 +130,13 @@ const App: FC = () => {
<BrowserRouter> <BrowserRouter>
<I18nextProvider i18n={i18next}> <I18nextProvider i18n={i18next}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{isLogin === 'isLogin' && userToken ? (
{ <EmailWebSocketProvider token={userToken}>
isLogin === 'isLogin' ? <Main /> : isLogin === 'isNotLogin' ? <Login /> : null <Main />
} </EmailWebSocketProvider>
) : isLogin === 'isNotLogin' ? (
<Login />
) : null}
<NewMessage /> <NewMessage />
<ToastContainer /> <ToastContainer />
</QueryClientProvider> </QueryClientProvider>
+159
View File
@@ -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<EmailNotificationsProps> = ({
userToken,
onEmailClick
}) => {
const [notifications, setNotifications] = useState<EmailPayload[]>([]);
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 (
<div className="relative">
{/* Connection Status */}
<div className={`absolute -top-2 -right-2 w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'
}`}></div>
{/* Test Sound Button */}
<button
onClick={() => {
console.log("کلیک روی دکمه تست صدا");
playSound();
}}
className="p-2 rounded-full hover:bg-gray-100 transition-colors"
title="تست صدا"
>
<VolumeHigh size={20} color="black" />
</button>
{/* Notification Bell */}
<div
className="relative cursor-pointer p-2 rounded-full hover:bg-gray-100 transition-colors"
onClick={() => setIsVisible(!isVisible)}
>
<NotificationIcon size={24} color="black" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center font-bold">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</div>
{/* Notifications Dropdown */}
{isVisible && (
<div className="absolute top-full left-0 w-80 max-h-96 bg-white border border-gray-200 rounded-lg shadow-lg z-50 overflow-hidden">
<div className="p-3 bg-gray-50 border-b border-gray-200 flex justify-between items-center">
<h3 className="font-semibold">📧 نوتیفیکیشنهای ایمیل</h3>
<span className="text-sm text-gray-600">{unreadCount} خوانده نشده</span>
</div>
<div className="max-h-72 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-4 text-center text-gray-500">ایمیل جدیدی وجود ندارد</div>
) : (
notifications.map((email) => (
<div
key={email.messageId}
className="p-3 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => onEmailClick?.(email)}
>
<div className="flex items-center justify-between mb-1">
<strong className="text-sm truncate">
{email.from.name || email.from.address}
</strong>
{email.hasAttachments && <span>📎</span>}
</div>
<div className="font-medium text-sm mb-1 truncate">{email.subject}</div>
<div className="text-xs text-gray-600 mb-1 line-clamp-2">{email.preview}</div>
<div className="text-xs text-gray-400">
{new Date(email.timestamp).toLocaleString('fa-IR')}
</div>
</div>
))
)}
</div>
</div>
)}
</div>
);
};
+91
View File
@@ -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<SoundSettingsProps> = ({ 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 <VolumeSlash size={18} color="gray" />;
if (volume < 0.3) return <VolumeLow size={18} color="black" />;
return <VolumeHigh size={18} color="black" />;
};
return (
<div className={`flex items-center gap-3 ${className}`}>
{/* Sound Toggle */}
<button
onClick={toggleSound}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
title={isEnabled ? 'غیرفعال کردن صدا' : 'فعال کردن صدا'}
>
{getVolumeIcon()}
</button>
{/* Volume Slider */}
{isEnabled && (
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
onChange={(e) => 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)}%`}
/>
<span className="text-xs text-gray-500 min-w-[30px]">
{Math.round(volume * 100)}%
</span>
</div>
)}
</div>
);
};
+43 -4
View File
@@ -81,6 +81,30 @@ export const formatDate = (dateString: string) => {
}).format(date); }).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 => { export const sanitizeEmailHTML = (html: string): string => {
try { try {
// Remove style tags and their content // Remove style tags and their content
@@ -95,15 +119,30 @@ export const sanitizeEmailHTML = (html: string): string => {
// Remove potentially harmful attributes // Remove potentially harmful attributes
sanitized = sanitized.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, ""); 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( sanitized = sanitized.replace(
/style\s*=\s*["']([^"']*?)["']/gi, /style\s*=\s*["']([^"']*?)["']/gi,
(_, styles) => { (_, styles) => {
// فقط font-family و font-size رو حذف کن let cleanedStyles = styles
const cleanedStyles = styles
.replace(/font-family\s*:\s*[^;]*;?/gi, "") .replace(/font-family\s*:\s*[^;]*;?/gi, "")
.replace(/font-size\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*;/g, ";") // حذف سمیکالن‌های اضافی
.replace(/^\s*;\s*|\s*;\s*$/g, ""); // حذف سمیکالن‌های ابتدا و انتها .replace(/^\s*;\s*|\s*;\s*$/g, ""); // حذف سمیکالن‌های ابتدا و انتها
+38
View File
@@ -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<EmailWebSocketContextValue | null>(null);
interface EmailWebSocketProviderProps {
children: ReactNode;
token: string;
serverUrl?: string;
}
export const EmailWebSocketProvider: React.FC<EmailWebSocketProviderProps> = ({
children,
token,
serverUrl,
}) => {
const { socket, isConnected } = useEmailWebSocket({ token, serverUrl });
return (
<EmailWebSocketContext.Provider value={{ socket, isConnected }}>
{children}
</EmailWebSocketContext.Provider>
);
};
export const useEmailWebSocketContext = () => {
const context = useContext(EmailWebSocketContext);
if (!context) {
throw new Error('useEmailWebSocketContext must be used within EmailWebSocketProvider');
}
return context;
};
+92
View File
@@ -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<string, number>;
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]);
};
+66
View File
@@ -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<Socket | null>(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 };
};
+72
View File
@@ -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,
};
};
+31
View File
@@ -48,6 +48,37 @@ body,
} }
* { * {
outline: none; 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, input::placeholder,
+16 -8
View File
@@ -9,17 +9,18 @@ import AnswerIcon from '@/assets/images/answer.svg'
import Header from './Components/Header' import Header from './Components/Header'
import ReactQuill from 'react-quill-new'; import ReactQuill from 'react-quill-new';
import Select from '@/components/Select' 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 { useAuthStore } from '../auth/store/AuthStore'
import { SendEmailDto } from './types/Types' import { SendEmailDto } from './types/Types'
import { toast } from '@/components/Toast' import { toast } from '@/components/Toast'
import { formatDate, sanitizeEmailHTML } from '@/config/func' import { formatDate, sanitizeEmailHTML, detectTextDirection } from '@/config/func'
import SkeletonDetail from './Components/SkeletonDetail' import SkeletonDetail from './Components/SkeletonDetail'
import { useEmailActions } from '@/hooks/useEmailActions'
const DetailEmail: FC = () => { const DetailEmail: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { refetch } = useGetInbox({ page: 1 }) const emailActions = useEmailActions()
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const { email: userEmail } = useAuthStore() const { email: userEmail } = useAuthStore()
const [showAnswer, setShowAnswer] = useState<boolean>(false) const [showAnswer, setShowAnswer] = useState<boolean>(false)
@@ -27,7 +28,7 @@ const DetailEmail: FC = () => {
const [priority, setPriority] = useState<string>('') const [priority, setPriority] = useState<string>('')
// API hooks // API hooks
const { data: messageDetail, isLoading, error, isSuccess } = useGetMessageDetail(id || '', !!id) const { data: messageDetail, isLoading, error } = useGetMessageDetail(id || '', !!id)
const sendEmailMutation = useSendEmail() const sendEmailMutation = useSendEmail()
// const saveDraftMutation = useSaveDraft() // const saveDraftMutation = useSaveDraft()
@@ -108,11 +109,13 @@ const DetailEmail: FC = () => {
toast('قابلیت ارسال به دیگران به زودی اضافه خواهد شد', 'info') toast('قابلیت ارسال به دیگران به زودی اضافه خواهد شد', 'info')
} }
// Mark message as seen when it's loaded and not already seen
useEffect(() => { useEffect(() => {
if (isSuccess) { if (messageDetail && !messageDetail.seen && id) {
refetch() console.log('Marking message as seen:', id, 'Current seen status:', messageDetail.seen)
emailActions.markAsSeen(Number(id))
} }
}, [isSuccess]) }, [messageDetail, emailActions, id])
if (isLoading) { if (isLoading) {
return ( return (
@@ -166,10 +169,15 @@ const DetailEmail: FC = () => {
{messageDetail.html && messageDetail.html.length > 0 ? ( {messageDetail.html && messageDetail.html.length > 0 ? (
<div <div
className='email-content-container border border-gray-200 rounded-lg p-4 overflow-hidden' className='email-content-container border border-gray-200 rounded-lg p-4 overflow-hidden'
style={{
direction: detectTextDirection(messageDetail.html.join(''))
}}
dangerouslySetInnerHTML={{ __html: sanitizeEmailHTML(messageDetail.html.join('')) }} dangerouslySetInnerHTML={{ __html: sanitizeEmailHTML(messageDetail.html.join('')) }}
/> />
) : ( ) : (
<p>{messageDetail.text || 'محتوای پیام'}</p> <p style={{
direction: detectTextDirection(messageDetail.text || '')
}}>{messageDetail.text || 'محتوای پیام'}</p>
)} )}
</div> </div>
+42 -4
View File
@@ -1,19 +1,22 @@
import Filters, { FilterValues } from '../../components/Filters'; import Filters, { FilterValues } from '../../components/Filters';
import { FC, useState } from 'react' import { FC, useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Table from '../../components/Table'; import Table from '../../components/Table';
import { ColumnType } from '../../components/types/TableTypes'; import { ColumnType } from '../../components/types/TableTypes';
import { InfoCircle, More, Refresh2, Trash, Edit, Archive, ArchiveTick, Star1 } from 'iconsax-react'; import { InfoCircle, More, Refresh2, Trash, Edit, Archive, ArchiveTick, Star1 } from 'iconsax-react';
import { RowActionItem } from '../../components/RowActionsDropdown'; import { RowActionItem } from '../../components/RowActionsDropdown';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useGetInbox } from './hooks/useEmailData'; import { useGetInbox } from './hooks/useEmailData';
import { InboxMessage } from './types/Types'; import { InboxMessage } from './types/Types';
import { formatDate } from '@/config/func'; import { formatDate } from '@/config/func';
import MarkAsRead from '@/assets/images/mark_as_read.svg' import MarkAsRead from '@/assets/images/mark_as_read.svg'
import { useEmailActions } from '@/hooks/useEmailActions'; import { useEmailActions } from '@/hooks/useEmailActions';
import { useEmailWebSocketContext } from '@/contexts/EmailWebSocketContext';
import { useEmailEvents } from '@/hooks/useEmailEvents';
const List: FC = () => { const List: FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [filters, setFilters] = useState<FilterValues>({}); const [filters, setFilters] = useState<FilterValues>({});
@@ -21,10 +24,38 @@ const List: FC = () => {
const { data: inboxData, isLoading, refetch, isFetching } = useGetInbox({ const { data: inboxData, isLoading, refetch, isFetching } = useGetInbox({
page: currentPage, page: currentPage,
limit: 10, limit: 25,
...filters ...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<InboxMessage[]>([]); const [selectedMessages, setSelectedMessages] = useState<InboxMessage[]>([]);
const columns: ColumnType<InboxMessage>[] = [ const columns: ColumnType<InboxMessage>[] = [
@@ -32,7 +63,14 @@ const List: FC = () => {
key: 'subject', key: 'subject',
title: 'عنوان پیام', title: 'عنوان پیام',
render: (item) => ( render: (item) => (
<span className="truncate">{item.subject || 'بدون موضوع'}</span> <div className="truncate max-w-[250px]">{item.subject || 'بدون موضوع'}</div>
)
},
{
key: 'intro',
title: 'پیش نمایش',
render: (item) => (
<div className="truncate max-w-[250px]">{item.intro || 'بدون موضوع'}</div>
) )
}, },
{ {
+3 -2
View File
@@ -26,7 +26,8 @@ export const useGetInbox = (query: MessageListQueryDto) => {
return useQuery({ return useQuery({
queryKey: ['inbox', query], queryKey: ['inbox', query],
queryFn: () => api.getInbox(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], queryKey: ['message', messageId],
queryFn: () => api.getMessageDetail(messageId), queryFn: () => api.getMessageDetail(messageId),
enabled: !!messageId && enabled, enabled: !!messageId && enabled,
staleTime: 1000 * 60 * 10, // 10 minutes staleTime: 0, // Always fetch fresh data for message details
}); });
}; };
+8 -1
View File
@@ -38,6 +38,13 @@ const List: FC = () => {
</div> </div>
) )
}, },
{
key: 'intro',
title: 'پیش نمایش',
render: (item) => (
<div className="truncate max-w-[250px]">{item.intro || 'بدون موضوع'}</div>
)
},
{ {
key: 'to', key: 'to',
title: 'گیرنده', title: 'گیرنده',
@@ -55,7 +62,7 @@ const List: FC = () => {
title: 'تاریخ ارسال', title: 'تاریخ ارسال',
align: 'center', align: 'center',
render: (item) => ( render: (item) => (
<span className="text-xs md:text-sm"> <span className="">
{formatDate(item.date)} {formatDate(item.date)}
</span> </span>
) )
+1 -2
View File
@@ -37,7 +37,6 @@ const List: FC = () => {
title: 'فرستنده', title: 'فرستنده',
render: (item) => ( render: (item) => (
<div className="flex items-center"> <div className="flex items-center">
{!item.seen && <div className="w-1.5 h-1.5 md:w-2 md:h-2 rounded-full bg-blue-500 ml-2" />}
<span className="truncate">{item.from.name || item.from.address}</span> <span className="truncate">{item.from.name || item.from.address}</span>
</div> </div>
) )
@@ -109,7 +108,7 @@ const List: FC = () => {
setCurrentPage(page); setCurrentPage(page);
}; };
const messages = spamData?.data?.results || []; const messages = spamData?.data?.junkMails || [];
const pager = spamData?.data?.pager; const pager = spamData?.data?.pager;
return ( return (
+1 -1
View File
@@ -47,7 +47,7 @@ export interface MessageListQueryDto {
export interface SpamResponse { export interface SpamResponse {
success: boolean; success: boolean;
data: { data: {
results: SpamMessage[]; junkMails: SpamMessage[];
pager: { pager: {
page: number; page: number;
totalPages: number; totalPages: number;
+1
View File
@@ -15,6 +15,7 @@ import Login from '@/pages/auth/Login'
const AppRouter: FC = () => { const AppRouter: FC = () => {
return ( return (
<Routes> <Routes>
<Route path={'/'} element={<ReceivedList />} />
<Route path={Paths.received} element={<ReceivedList />} /> <Route path={Paths.received} element={<ReceivedList />} />
<Route path={Paths.sent} element={<SentList />} /> <Route path={Paths.sent} element={<SentList />} />
<Route path={Paths.draft} element={<DraftList />} /> <Route path={Paths.draft} element={<DraftList />} />
+12 -3
View File
@@ -2,9 +2,9 @@ import { FC, useEffect, useState } from 'react'
import Input from '../components/Input' import Input from '../components/Input'
import { Element3, HambergerMenu, Wallet } from 'iconsax-react' import { Element3, HambergerMenu, Wallet } from 'iconsax-react'
import { useTranslation } from 'react-i18next' 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 { Paths } from '@/utils/Paths'
// import Notifications from '../pages/notification/Notification' import { EmailNotifications } from '@/components/EmailNotifications'
import { useSharedStore } from './store/sharedStore' import { useSharedStore } from './store/sharedStore'
// import { useGetProfile } from '../pages/profile/hooks/useProfileData' // import { useGetProfile } from '../pages/profile/hooks/useProfileData'
// import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react' // import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
@@ -14,12 +14,16 @@ import { useSharedStore } from './store/sharedStore'
const Header: FC = () => { const Header: FC = () => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const [popoverKey, setPopoverKey] = useState(0); const [popoverKey, setPopoverKey] = useState(0);
const { t } = useTranslation('global') const { t } = useTranslation('global')
const { setOpenSidebar, openSidebar } = useSharedStore() const { setOpenSidebar, openSidebar } = useSharedStore()
// const { data } = useGetProfile() // const { data } = useGetProfile()
// const getWalletBalance = useGetWalletBalance() // const getWalletBalance = useGetWalletBalance()
// فرض می‌کنیم که token از localStorage گرفته می‌شود
const userToken = localStorage.getItem('jwt-token') || '';
console.log(popoverKey); console.log(popoverKey);
useEffect(() => { useEffect(() => {
@@ -57,7 +61,12 @@ const Header: FC = () => {
</div> </div>
</div> </div>
</Link> </Link>
{/* <Notifications /> */} {userToken && (
<EmailNotifications
userToken={userToken}
onEmailClick={(email) => navigate(`/mail/${email.messageId}`)}
/>
)}
{/* { {/* {
data && ( data && (
<Popover className="relative" key={popoverKey}> <Popover className="relative" key={popoverKey}>