spam list + socket events
This commit is contained in:
@@ -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'
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+88
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
@@ -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, ""); // حذف سمیکالنهای ابتدا و انتها
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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]);
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user