diff --git a/src/App.tsx b/src/App.tsx index a687551..bf8ffff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { LanguageProvider } from "./components/LanguageProvider" import { Toaster } from "./components/ui/toaster" import { Navbar } from "./components/Navbar" import { Sidebar } from './components/Sidebar'; +import { NotificationsProvider } from "./context/NotificationsContext" import { WorkspaceProvider } from "./context/WorkspaceContext" import Auth from "./pages/Auth" import Profile from "./pages/Profile" @@ -75,7 +76,9 @@ function App() { return ( - + + + diff --git a/src/api/client.ts b/src/api/client.ts index 7fa5fb6..28c2bd7 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,10 @@ import { API_BASE_URL } from "../config/constants" +import { + clearSessionTokens, + emitSessionChanged, + getAccessToken, + getRefreshToken, +} from "../lib/session" let refreshRequest: Promise | null = null @@ -40,8 +46,7 @@ const normalizeJsonResponse = (response: Response) => { } const clearSessionAndRedirect = () => { - localStorage.removeItem("accessToken") - localStorage.removeItem("refreshToken") + clearSessionTokens() if (window.location.pathname !== "/auth") { window.location.href = "/auth" } @@ -58,7 +63,7 @@ const shouldAttemptRefresh = (endpoint: string) => { } const refreshAccessToken = async () => { - const refreshToken = localStorage.getItem("refreshToken") + const refreshToken = getRefreshToken() if (!refreshToken) return null if (!refreshRequest) { @@ -87,6 +92,7 @@ const refreshAccessToken = async () => { if (nextRefreshToken) { localStorage.setItem("refreshToken", nextRefreshToken) } + emitSessionChanged() return nextAccessToken })().finally(() => { @@ -98,7 +104,7 @@ const refreshAccessToken = async () => { } export const authFetch = async (endpoint: string, options: RequestInit = {}, allowRetry = true): Promise => { - const token = localStorage.getItem("accessToken") + const token = getAccessToken() const isFormData = options.body instanceof FormData const headers: HeadersInit = { diff --git a/src/api/notifications.ts b/src/api/notifications.ts new file mode 100644 index 0000000..2bb2868 --- /dev/null +++ b/src/api/notifications.ts @@ -0,0 +1,108 @@ +import { API_BASE_URL } from "../config/constants" +import { authFetch } from "./client" + +export type NotificationLevel = "info" | "success" | "warning" | "error" + +export interface NotificationItem { + id: string + type: string + title: string + message: string + level: NotificationLevel + created_at: string + is_seen: boolean + delete_on_seen: boolean + action_url?: string | null + entity_type?: string | null + entity_id?: string | null + meta?: Record +} + +export interface NotificationsResponse { + count: number + unread_count: number + notifications: NotificationItem[] +} + +export interface NotificationStreamTokenResponse { + token: string + expires_in: number +} + +export interface NotificationFilters { + limit?: number + offset?: number + type?: string +} + +const buildSearchParams = (filters: NotificationFilters = {}) => { + const searchParams = new URLSearchParams() + if (typeof filters.limit === "number") { + searchParams.set("limit", String(filters.limit)) + } + if (typeof filters.offset === "number") { + searchParams.set("offset", String(filters.offset)) + } + if (filters.type) { + searchParams.set("type", filters.type) + } + const query = searchParams.toString() + return query ? `?${query}` : "" +} + +export const getNotifications = async ( + filters: NotificationFilters = {}, +): Promise => { + const response = await authFetch(`/api/notifications/list/${buildSearchParams(filters)}`) + if (!response.ok) { + throw new Error("Failed to load notifications") + } + return response.json() +} + +export const markNotificationSeen = async (id: string) => { + const response = await authFetch("/api/notifications/seen/", { + method: "POST", + body: JSON.stringify({ id }), + }) + if (!response.ok) { + throw new Error("Failed to mark notification as read") + } + return response.json() +} + +export const deleteNotification = async (id: string) => { + const response = await authFetch(`/api/notifications/${id}/`, { + method: "DELETE", + }) + if (!response.ok) { + throw new Error("Failed to delete notification") + } + return response.json() +} + +export const markAllNotificationsRead = async (type?: string) => { + const response = await authFetch(`/api/notifications/seen/all/${buildSearchParams({ type })}`, { + method: "POST", + body: JSON.stringify(type ? { type } : {}), + }) + if (!response.ok) { + throw new Error("Failed to mark all notifications as read") + } + return response.json() +} + +export const issueNotificationStreamToken = async (): Promise => { + const response = await authFetch("/api/notifications/stream-token/", { + method: "POST", + }) + if (!response.ok) { + throw new Error("Failed to issue notification stream token") + } + return response.json() +} + +export const buildNotificationStreamUrl = (token: string) => { + const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "") + return `${cleanBaseUrl}/api/notifications/stream/?token=${encodeURIComponent(token)}` +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 28a0b93..46df4f4 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,228 +1,244 @@ -import { useState, useEffect, useRef } from "react" -import { useNavigate } from "react-router-dom" -import { useTranslation } from "../hooks/useTranslation" -import { Button } from "./ui/button" -import { SettingsMenu } from "./SettingsMenu" -import { LogOut, User, Moon, Sun, Globe, Command } from "lucide-react" -import { logoutUser, getUserProfile } from "../api/users" -import { WorkspaceSelector } from "./WorkspaceSelector" -import { toast } from "sonner" - -export function Navbar() { - const { t, lang, setLanguage } = useTranslation() - const navigate = useNavigate() - const [showLogoutModal, setShowLogoutModal] = useState(false) - const [isDropdownOpen, setIsDropdownOpen] = useState(false) - const [user, setUser] = useState(null) - const dropdownRef = useRef(null) - const isFa = lang === 'fa'; - - const [isDarkMode, setIsDarkMode] = useState(() => { - const savedTheme = localStorage.getItem('theme'); - if (savedTheme) return savedTheme === 'dark'; - return document.documentElement.classList.contains('dark'); - }); - - useEffect(() => { - const handleProfileUpdated = ((e: CustomEvent) => { - if (e.detail) { - setUser((prev: any) => prev ? { ...prev, ...e.detail } : e.detail); - } - }) as EventListener; - - window.addEventListener('profile_updated', handleProfileUpdated); - return () => window.removeEventListener('profile_updated', handleProfileUpdated); - }, []); - - useEffect(() => { - if (isDarkMode) { - document.documentElement.classList.add('dark'); - } else { - document.documentElement.classList.remove('dark'); - } - }, [isDarkMode]); - - useEffect(() => { - const fetchUser = async () => { - const token = localStorage.getItem("accessToken") - if (!token) return - - try { - const userData = await getUserProfile() - setUser(userData) - } catch (error) { - console.error("Failed to fetch user profile:", error) - } - } - - fetchUser() - }, []) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsDropdownOpen(false) - } - } - document.addEventListener("mousedown", handleClickOutside) - return () => document.removeEventListener("mousedown", handleClickOutside) - }, []) - - const handleLogout = async () => { - try { - const refreshToken = localStorage.getItem("refreshToken") - if (refreshToken) { - await logoutUser(refreshToken) - } - } catch (error) { - console.error("Logout API failed:", error) - } finally { - localStorage.removeItem("accessToken") - localStorage.removeItem("refreshToken") - setUser(null) - setShowLogoutModal(false) - toast.success(t.logoutToast || "Successfully logged out!") - navigate("/auth") - } - } - - const toggleTheme = () => { - const newThemeState = !isDarkMode; - setIsDarkMode(newThemeState); - localStorage.setItem('theme', newThemeState ? 'dark' : 'light'); - }; - - const toggleLanguage = () => { - const newLang = isFa ? 'en' : 'fa' - if (setLanguage) { - setLanguage(newLang) - } else { - localStorage.setItem('language', newLang) - window.location.reload() - } - } - - return ( - <> -
-
navigate("/")} - > - - - {t.title || "Qlockify"} - -
- -
- {user && } - {user ? ( -
- - - {isDropdownOpen && ( -
-
-

- {user.first_name || user.last_name - ? `${user.first_name || ''} ${user.last_name || ''}`.trim() - : user.email} -

-
- - - - - - -
- - -
- )} -
- ) : ( - <> - - - - )} -
-
- - {showLogoutModal && ( -
setShowLogoutModal(false)}> -
e.stopPropagation()}> -

- {t.confirmLogoutTitle || "Confirm Logout"} -

-

- {t.confirmLogoutMessage || "Are you sure you want to log out of your account?"} -

-
- - -
-
-
- )} - - ) -} +import { useState, useEffect, useRef } from "react" +import { useNavigate } from "react-router-dom" +import { useTranslation } from "../hooks/useTranslation" +import { Button } from "./ui/button" +import { SettingsMenu } from "./SettingsMenu" +import { LogOut, User, Moon, Sun, Globe, Command } from "lucide-react" +import { logoutUser, getUserProfile } from "../api/users" +import { WorkspaceSelector } from "./WorkspaceSelector" +import { toast } from "sonner" +import { NotificationBell } from "./notifications/NotificationBell" +import { clearSessionTokens, getAccessToken, getRefreshToken } from "../lib/session" + +export function Navbar() { + const { t, lang, setLanguage } = useTranslation() + const navigate = useNavigate() + const [showLogoutModal, setShowLogoutModal] = useState(false) + const [isDropdownOpen, setIsDropdownOpen] = useState(false) + const [user, setUser] = useState(null) + const dropdownRef = useRef(null) + const isFa = lang === "fa" + + const [isDarkMode, setIsDarkMode] = useState(() => { + const savedTheme = localStorage.getItem("theme") + if (savedTheme) return savedTheme === "dark" + return document.documentElement.classList.contains("dark") + }) + + useEffect(() => { + const handleProfileUpdated = ((e: CustomEvent) => { + if (e.detail) { + setUser((prev: any) => (prev ? { ...prev, ...e.detail } : e.detail)) + } + }) as EventListener + + window.addEventListener("profile_updated", handleProfileUpdated) + return () => window.removeEventListener("profile_updated", handleProfileUpdated) + }, []) + + useEffect(() => { + if (isDarkMode) { + document.documentElement.classList.add("dark") + } else { + document.documentElement.classList.remove("dark") + } + }, [isDarkMode]) + + useEffect(() => { + const fetchUser = async () => { + const token = getAccessToken() + if (!token) return + + try { + const userData = await getUserProfile() + setUser(userData) + } catch (error) { + console.error("Failed to fetch user profile:", error) + } + } + + void fetchUser() + }, []) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false) + } + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + const handleLogout = async () => { + try { + const refreshToken = getRefreshToken() + if (refreshToken) { + await logoutUser(refreshToken) + } + } catch (error) { + console.error("Logout API failed:", error) + } finally { + clearSessionTokens() + setUser(null) + setShowLogoutModal(false) + toast.success(t.logoutToast || "Successfully logged out!") + navigate("/auth") + } + } + + const toggleTheme = () => { + const newThemeState = !isDarkMode + setIsDarkMode(newThemeState) + localStorage.setItem("theme", newThemeState ? "dark" : "light") + } + + const toggleLanguage = () => { + const newLang = isFa ? "en" : "fa" + if (setLanguage) { + setLanguage(newLang) + } else { + localStorage.setItem("language", newLang) + window.location.reload() + } + } + + return ( + <> +
+
navigate("/")}> + + + {t.title || "Qlockify"} + +
+ +
+ {user && } + {user ? ( + <> + +
+ + + {isDropdownOpen && ( +
+
+

+ {user.first_name || user.last_name + ? `${user.first_name || ""} ${user.last_name || ""}`.trim() + : user.email} +

+
+ + + + + + +
+ + +
+ )} +
+ + ) : ( + <> + + + + )} +
+
+ + {showLogoutModal && ( +
setShowLogoutModal(false)} + > +
e.stopPropagation()} + > +

+ {t.confirmLogoutTitle || "Confirm Logout"} +

+

+ {t.confirmLogoutMessage || "Are you sure you want to log out of your account?"} +

+
+ + +
+
+
+ )} + + ) +} diff --git a/src/components/notifications/NotificationBell.tsx b/src/components/notifications/NotificationBell.tsx new file mode 100644 index 0000000..597a0ae --- /dev/null +++ b/src/components/notifications/NotificationBell.tsx @@ -0,0 +1,203 @@ +import { useEffect, useMemo, useRef, useState } from "react" +import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react" +import { useTranslation } from "../../hooks/useTranslation" +import { cn } from "../../lib/utils" +import { useNotifications } from "../../context/NotificationsContext" +import type { NotificationItem } from "../../api/notifications" +import { Button } from "../ui/button" + +const formatNotificationTimestamp = (value: string, locale: string) => { + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return value + } + return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date) +} + +function NotificationRow({ + notification, + locale, + onClick, + onDelete, +}: { + notification: NotificationItem + locale: string + onClick: (notification: NotificationItem) => void + onDelete: (notification: NotificationItem) => void +}) { + return ( +
+
+ + +
+
+ ) +} + +export function NotificationBell() { + const { t, lang } = useTranslation() + const { + notifications, + unreadCount, + totalCount, + hasMore, + isLoading, + isLoadingMore, + loadMore, + markAllAsSeen, + deleteOne, + handleNotificationClick, + } = useNotifications() + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false) + } + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + return ( +
+ + + {isOpen ? ( +
+
+
+

+ {t.notifications?.title || "Notifications"} +

+

+ {t.notifications?.summary?.(totalCount, unreadCount) || + `${totalCount} total, ${unreadCount} unread`} +

+
+ +
+ +
+ {isLoading ? ( +
+ + {t.notifications?.loading || "Loading notifications..."} +
+ ) : notifications.length === 0 ? ( +
+ {t.notifications?.empty || "No notifications yet."} +
+ ) : ( + notifications.map((notification) => ( + void handleNotificationClick(item)} + onDelete={(item) => void deleteOne(item)} + /> + )) + )} +
+ + {hasMore ? ( +
+ +
+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/src/context/NotificationsContext.tsx b/src/context/NotificationsContext.tsx new file mode 100644 index 0000000..555ff50 --- /dev/null +++ b/src/context/NotificationsContext.tsx @@ -0,0 +1,467 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react" +import { toast } from "sonner" +import { + buildNotificationStreamUrl, + deleteNotification, + getNotifications, + issueNotificationStreamToken, + markAllNotificationsRead, + markNotificationSeen, + type NotificationItem, + type NotificationLevel, +} from "../api/notifications" +import { useTranslation } from "../hooks/useTranslation" +import { + getAccessToken, + SESSION_CHANGED_EVENT, +} from "../lib/session" + +type NotificationConnectionStatus = + | "idle" + | "connecting" + | "connected" + | "disconnected" + +interface NotificationsContextValue { + notifications: NotificationItem[] + unreadCount: number + totalCount: number + hasMore: boolean + isLoading: boolean + isLoadingMore: boolean + connectionStatus: NotificationConnectionStatus + loadMore: () => Promise + markAsSeen: (notification: NotificationItem) => Promise + deleteOne: (notification: NotificationItem) => Promise + markAllAsSeen: () => Promise + handleNotificationClick: (notification: NotificationItem) => Promise + refreshNotifications: () => Promise +} + +const NotificationsContext = createContext(undefined) + +const PAGE_SIZE = 20 + +const mergeNotifications = ( + current: NotificationItem[], + incoming: NotificationItem[], +) => { + const notificationsById = new Map() + for (const notification of current) { + notificationsById.set(notification.id, notification) + } + for (const notification of incoming) { + const existing = notificationsById.get(notification.id) + notificationsById.set(notification.id, existing ? { ...existing, ...notification } : notification) + } + return Array.from(notificationsById.values()).sort((left, right) => { + return new Date(right.created_at).getTime() - new Date(left.created_at).getTime() + }) +} + +const getToastMethod = (level: NotificationLevel) => { + switch (level) { + case "success": + return toast.success + case "warning": + return toast.warning + case "error": + return toast.error + default: + return toast.info + } +} + +export function NotificationsProvider({ children }: { children: ReactNode }) { + const { t } = useTranslation() + const [notifications, setNotifications] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) + const [totalCount, setTotalCount] = useState(0) + const [isLoading, setIsLoading] = useState(false) + const [isLoadingMore, setIsLoadingMore] = useState(false) + const [connectionStatus, setConnectionStatus] = useState("idle") + + const eventSourceRef = useRef(null) + const reconnectTimeoutRef = useRef(null) + const reconnectAttemptRef = useRef(0) + const toastedNotificationIdsRef = useRef>(new Set()) + + const hasMore = notifications.length < totalCount + + const closeEventSource = useCallback(() => { + if (reconnectTimeoutRef.current !== null) { + window.clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + eventSourceRef.current?.close() + eventSourceRef.current = null + }, []) + + const applyUnreadCount = useCallback((count: number | undefined) => { + if (typeof count === "number") { + setUnreadCount(count) + } + }, []) + + const updateNotification = useCallback((notification: NotificationItem) => { + setNotifications((current) => mergeNotifications(current, [notification])) + }, []) + + const removeNotification = useCallback((notificationId: string) => { + setNotifications((current) => current.filter((notification) => notification.id !== notificationId)) + }, []) + + const openNotificationTarget = useCallback((notification: NotificationItem) => { + if (!notification.action_url) { + return + } + window.location.assign(notification.action_url) + }, []) + + const showIncomingToast = useCallback( + (notification: NotificationItem) => { + if ( + notification.is_seen || + toastedNotificationIdsRef.current.has(notification.id) || + document.visibilityState !== "visible" || + !document.hasFocus() + ) { + return + } + + toastedNotificationIdsRef.current.add(notification.id) + const notify = getToastMethod(notification.level) + notify(notification.title || (t.notifications?.newTitle || "New notification"), { + description: notification.message || undefined, + action: notification.action_url + ? { + label: t.notifications?.openAction || "Open", + onClick: () => openNotificationTarget(notification), + } + : undefined, + }) + }, + [openNotificationTarget, t.notifications], + ) + + const refreshNotifications = useCallback(async () => { + if (!getAccessToken()) { + setNotifications([]) + setUnreadCount(0) + setTotalCount(0) + setConnectionStatus("idle") + return + } + + setIsLoading(true) + try { + const response = await getNotifications({ limit: PAGE_SIZE, offset: 0 }) + setNotifications(response.notifications) + setUnreadCount(response.unread_count) + setTotalCount(response.count) + } catch { + toast.error(t.notifications?.loadError || "Failed to load notifications") + } finally { + setIsLoading(false) + } + }, [t.notifications]) + + const loadMore = useCallback(async () => { + if (isLoadingMore || !hasMore) { + return + } + + setIsLoadingMore(true) + try { + const response = await getNotifications({ + limit: PAGE_SIZE, + offset: notifications.length, + }) + setNotifications((current) => mergeNotifications(current, response.notifications)) + setUnreadCount(response.unread_count) + setTotalCount(response.count) + } catch { + toast.error(t.notifications?.loadError || "Failed to load notifications") + } finally { + setIsLoadingMore(false) + } + }, [hasMore, isLoadingMore, notifications.length, t.notifications]) + + const markAsSeen = useCallback(async (notification: NotificationItem) => { + if (notification.is_seen) { + return + } + + try { + const response = await markNotificationSeen(notification.id) + setUnreadCount((current) => { + if (typeof response?.unread_count === "number") { + return response.unread_count + } + return Math.max(current - 1, 0) + }) + if (response?.deleted) { + removeNotification(notification.id) + setTotalCount((current) => Math.max(current - 1, 0)) + } else { + setNotifications((current) => + current.map((item) => + item.id === notification.id ? { ...item, is_seen: true } : item, + ), + ) + } + } catch { + toast.error(t.notifications?.markSeenError || "Failed to update notification") + } + }, [removeNotification, t.notifications]) + + const markAllAsSeen = useCallback(async () => { + try { + await markAllNotificationsRead() + setUnreadCount(0) + setNotifications((current) => + current.map((notification) => ({ ...notification, is_seen: true })), + ) + } catch { + toast.error(t.notifications?.markAllError || "Failed to update notifications") + } + }, [t.notifications]) + + const deleteOne = useCallback(async (notification: NotificationItem) => { + try { + const response = await deleteNotification(notification.id) + removeNotification(notification.id) + setTotalCount((current) => Math.max(current - 1, 0)) + setUnreadCount((current) => { + if (typeof response?.unread_count === "number") { + return response.unread_count + } + return notification.is_seen ? current : Math.max(current - 1, 0) + }) + } catch { + toast.error(t.notifications?.deleteError || "Failed to delete notification") + } + }, [removeNotification, t.notifications]) + + const handleNotificationClick = useCallback(async (notification: NotificationItem) => { + await markAsSeen(notification) + openNotificationTarget(notification) + }, [markAsSeen, openNotificationTarget]) + + const connectToStream = useCallback(async () => { + if (!getAccessToken()) { + closeEventSource() + setConnectionStatus("idle") + return + } + + closeEventSource() + setConnectionStatus("connecting") + + try { + const tokenResponse = await issueNotificationStreamToken() + const stream = new EventSource(buildNotificationStreamUrl(tokenResponse.token)) + eventSourceRef.current = stream + + stream.onopen = () => { + reconnectAttemptRef.current = 0 + setConnectionStatus("connected") + } + + stream.addEventListener("connected", (event) => { + const payload = JSON.parse((event as MessageEvent).data) as { + notifications?: NotificationItem[] + unread_count?: number + } + if (Array.isArray(payload.notifications)) { + setNotifications((current) => mergeNotifications(current, payload.notifications || [])) + setTotalCount((current) => Math.max(current, payload.notifications?.length || 0)) + } + applyUnreadCount(payload.unread_count) + }) + + stream.addEventListener("notification", (event) => { + const payload = JSON.parse((event as MessageEvent).data) as { + notification?: NotificationItem + unread_count?: number + } + if (!payload.notification) { + return + } + const incomingNotification = payload.notification + setNotifications((current) => { + const isExisting = current.some( + (notification) => notification.id === incomingNotification.id, + ) + if (!isExisting) { + setTotalCount((count) => count + 1) + } + return mergeNotifications(current, [incomingNotification]) + }) + applyUnreadCount(payload.unread_count) + showIncomingToast(incomingNotification) + }) + + stream.addEventListener("notification_seen", (event) => { + const payload = JSON.parse((event as MessageEvent).data) as { + notification_id?: string + deleted?: boolean + notification?: NotificationItem | null + unread_count?: number + } + if (payload.deleted && payload.notification_id) { + removeNotification(payload.notification_id) + setTotalCount((current) => Math.max(current - 1, 0)) + } else if (payload.notification) { + updateNotification(payload.notification) + } else if (payload.notification_id) { + setNotifications((current) => + current.map((notification) => + notification.id === payload.notification_id + ? { ...notification, is_seen: true } + : notification, + ), + ) + } + applyUnreadCount(payload.unread_count) + }) + + stream.addEventListener("notification_mark_all_read", (event) => { + const payload = JSON.parse((event as MessageEvent).data) as { + type?: string | null + unread_count?: number + } + setNotifications((current) => + current.map((notification) => + payload.type && notification.type !== payload.type + ? notification + : { ...notification, is_seen: true }, + ), + ) + applyUnreadCount(payload.unread_count) + }) + + stream.addEventListener("unread_count", (event) => { + const payload = JSON.parse((event as MessageEvent).data) as { + unread_count?: number + } + applyUnreadCount(payload.unread_count) + }) + + stream.onerror = async () => { + stream.close() + eventSourceRef.current = null + setConnectionStatus("disconnected") + + reconnectAttemptRef.current += 1 + const reconnectDelay = Math.min( + 1000 * 2 ** reconnectAttemptRef.current, + 30000, + ) + + reconnectTimeoutRef.current = window.setTimeout(async () => { + if (!getAccessToken()) { + return + } + try { + await connectToStream() + } catch { + setConnectionStatus("disconnected") + } + }, reconnectDelay) + } + } catch { + setConnectionStatus("disconnected") + } + }, [ + applyUnreadCount, + closeEventSource, + showIncomingToast, + removeNotification, + updateNotification, + ]) + + useEffect(() => { + const startNotifications = async () => { + if (!getAccessToken()) { + closeEventSource() + setNotifications([]) + setUnreadCount(0) + setTotalCount(0) + setConnectionStatus("idle") + return + } + + await refreshNotifications() + await connectToStream() + } + + void startNotifications() + + const handleSessionChange = () => { + void startNotifications() + } + + window.addEventListener(SESSION_CHANGED_EVENT, handleSessionChange) + + return () => { + window.removeEventListener(SESSION_CHANGED_EVENT, handleSessionChange) + closeEventSource() + } + }, [closeEventSource, connectToStream, refreshNotifications]) + + const value = useMemo(() => { + return { + notifications, + unreadCount, + totalCount, + hasMore, + isLoading, + isLoadingMore, + connectionStatus, + loadMore, + markAsSeen, + deleteOne, + markAllAsSeen, + handleNotificationClick, + refreshNotifications, + } + }, [ + connectionStatus, + handleNotificationClick, + hasMore, + isLoading, + isLoadingMore, + loadMore, + markAllAsSeen, + markAsSeen, + deleteOne, + notifications, + refreshNotifications, + totalCount, + unreadCount, + ]) + + return ( + + {children} + + ) +} + +export const useNotifications = () => { + const context = useContext(NotificationsContext) + if (!context) { + throw new Error("useNotifications must be used inside NotificationsProvider") + } + return context +} diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..55e1181 --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,21 @@ +export const SESSION_CHANGED_EVENT = "auth_session_changed" + +export const getAccessToken = () => localStorage.getItem("accessToken") + +export const getRefreshToken = () => localStorage.getItem("refreshToken") + +export const emitSessionChanged = () => { + window.dispatchEvent(new Event(SESSION_CHANGED_EVENT)) +} + +export const setSessionTokens = (accessToken: string, refreshToken: string) => { + localStorage.setItem("accessToken", accessToken) + localStorage.setItem("refreshToken", refreshToken) + emitSessionChanged() +} + +export const clearSessionTokens = () => { + localStorage.removeItem("accessToken") + localStorage.removeItem("refreshToken") + emitSessionChanged() +} diff --git a/src/locales/en.ts b/src/locales/en.ts index 109b630..dfd5f2e 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -391,4 +391,20 @@ export const en = { toFilterPrefix: "To", }, + notifications: { + title: "Notifications", + open: "Open notifications", + empty: "No notifications yet.", + loading: "Loading notifications...", + loadingMore: "Loading more...", + loadMore: "Load more", + markAllRead: "Mark all as read", + markSeenError: "Failed to update notification", + markAllError: "Failed to update notifications", + deleteError: "Failed to delete notification", + loadError: "Failed to load notifications", + newTitle: "New notification", + openAction: "Open", + summary: (total: number, unread: number) => `${total} total, ${unread} unread`, + }, } diff --git a/src/locales/fa.ts b/src/locales/fa.ts index ccd6c38..a97677c 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -387,4 +387,20 @@ export const fa = { fromFilterPrefix: "از", toFilterPrefix: "تا", }, + notifications: { + title: "اعلان‌ها", + open: "باز کردن اعلان‌ها", + empty: "هنوز اعلانی وجود ندارد.", + loading: "در حال بارگذاری اعلان‌ها...", + loadingMore: "در حال بارگذاری بیشتر...", + loadMore: "بارگذاری بیشتر", + markAllRead: "خواندن همه", + markSeenError: "به‌روزرسانی اعلان با خطا مواجه شد.", + markAllError: "به‌روزرسانی اعلان‌ها با خطا مواجه شد.", + deleteError: "حذف اعلان با خطا مواجه شد.", + loadError: "دریافت اعلان‌ها با خطا مواجه شد.", + newTitle: "اعلان جدید", + openAction: "باز کردن", + summary: (total: number, unread: number) => `${total} کل، ${unread} خوانده‌نشده`, + }, } diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx index 333c6a3..ecafb7f 100644 --- a/src/pages/Auth.tsx +++ b/src/pages/Auth.tsx @@ -4,9 +4,10 @@ import { Button } from "../components/ui/button" import { Input } from "../components/ui/input" import { SettingsMenu } from "../components/SettingsMenu" import { ArrowLeft, ArrowRight, Command, Loader2, Eye, EyeOff } from "lucide-react" -import { toast } from "sonner" -import { useTranslation } from "../hooks/useTranslation" -import { loginWithPassword, sendOtp, loginWithOtp } from "../api/users" +import { toast } from "sonner" +import { useTranslation } from "../hooks/useTranslation" +import { loginWithPassword, sendOtp, loginWithOtp } from "../api/users" +import { setSessionTokens } from "../lib/session" type AuthStep = "mobile" | "password" | "otp" type AuthMode = "login" | "register" @@ -24,12 +25,11 @@ export default function Auth() { const [loading, setLoading] = useState(false) const [showPassword, setShowPassword] = useState(false) // Added state for password visibility - const handleTokenResponse = (access: string, refresh: string) => { - localStorage.setItem("accessToken", access) - localStorage.setItem("refreshToken", refresh) - toast.success(t.login.toasts.successLogin) - navigate("/profile") - } + const handleTokenResponse = (access: string, refresh: string) => { + setSessionTokens(access, refresh) + toast.success(t.login.toasts.successLogin) + navigate("/profile") + } const handleSendOtp = async (selectedMode: AuthMode) => { if (!mobile) return toast.error(t.login.toasts.enterMobile)