spam list + socket events
This commit is contained in:
+11
-4
@@ -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<string>('')
|
||||
|
||||
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 = () => {
|
||||
<BrowserRouter>
|
||||
<I18nextProvider i18n={i18next}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
{
|
||||
isLogin === 'isLogin' ? <Main /> : isLogin === 'isNotLogin' ? <Login /> : null
|
||||
}
|
||||
{isLogin === 'isLogin' && userToken ? (
|
||||
<EmailWebSocketProvider token={userToken}>
|
||||
<Main />
|
||||
</EmailWebSocketProvider>
|
||||
) : isLogin === 'isNotLogin' ? (
|
||||
<Login />
|
||||
) : null}
|
||||
<NewMessage />
|
||||
<ToastContainer />
|
||||
</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);
|
||||
};
|
||||
|
||||
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, ""); // حذف سمیکالنهای ابتدا و انتها
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/* 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,
|
||||
|
||||
@@ -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<boolean>(false)
|
||||
@@ -27,7 +28,7 @@ const DetailEmail: FC = () => {
|
||||
const [priority, setPriority] = useState<string>('')
|
||||
|
||||
// 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 ? (
|
||||
<div
|
||||
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('')) }}
|
||||
/>
|
||||
) : (
|
||||
<p>{messageDetail.text || 'محتوای پیام'}</p>
|
||||
<p style={{
|
||||
direction: detectTextDirection(messageDetail.text || '')
|
||||
}}>{messageDetail.text || 'محتوای پیام'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<FilterValues>({});
|
||||
@@ -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<InboxMessage[]>([]);
|
||||
|
||||
const columns: ColumnType<InboxMessage>[] = [
|
||||
@@ -32,7 +63,14 @@ const List: FC = () => {
|
||||
key: 'subject',
|
||||
title: 'عنوان پیام',
|
||||
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({
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -38,6 +38,13 @@ const List: FC = () => {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'intro',
|
||||
title: 'پیش نمایش',
|
||||
render: (item) => (
|
||||
<div className="truncate max-w-[250px]">{item.intro || 'بدون موضوع'}</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'to',
|
||||
title: 'گیرنده',
|
||||
@@ -55,7 +62,7 @@ const List: FC = () => {
|
||||
title: 'تاریخ ارسال',
|
||||
align: 'center',
|
||||
render: (item) => (
|
||||
<span className="text-xs md:text-sm">
|
||||
<span className="">
|
||||
{formatDate(item.date)}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -37,7 +37,6 @@ const List: FC = () => {
|
||||
title: 'فرستنده',
|
||||
render: (item) => (
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
@@ -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 (
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface MessageListQueryDto {
|
||||
export interface SpamResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
results: SpamMessage[];
|
||||
junkMails: SpamMessage[];
|
||||
pager: {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
|
||||
@@ -15,6 +15,7 @@ import Login from '@/pages/auth/Login'
|
||||
const AppRouter: FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path={'/'} element={<ReceivedList />} />
|
||||
<Route path={Paths.received} element={<ReceivedList />} />
|
||||
<Route path={Paths.sent} element={<SentList />} />
|
||||
<Route path={Paths.draft} element={<DraftList />} />
|
||||
|
||||
+12
-3
@@ -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 = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{/* <Notifications /> */}
|
||||
{userToken && (
|
||||
<EmailNotifications
|
||||
userToken={userToken}
|
||||
onEmailClick={(email) => navigate(`/mail/${email.messageId}`)}
|
||||
/>
|
||||
)}
|
||||
{/* {
|
||||
data && (
|
||||
<Popover className="relative" key={popoverKey}>
|
||||
|
||||
Reference in New Issue
Block a user