import Avatar from '@/components/ui/Avatar' import Badge from '@/components/ui/Badge' import Button from '@/components/ui/Button' import Dropdown from '@/components/ui/Dropdown' import ScrollBar from '@/components/ui/ScrollBar' import Spinner from '@/components/ui/Spinner' import Tooltip from '@/components/ui/Tooltip' import { APP_NAME, AVATAR_URL } from '@/constants/app.constant' import Tag from '@/components/ui/Tag' import NotificationChannels, { NotificationChannelColors, } from '@/constants/notification-channel.enum' import { NotificationDto } from '@/proxy/notification/models' import { ROUTES_ENUM } from '@/routes/route.constant' import { getList, updateReadAll, updateReadMany, updateSentMany, } from '@/services/notification.service' import { useStoreState } from '@/store' import withHeaderItem from '@/utils/hoc/withHeaderItem' import { useLocalization } from '@/utils/hooks/useLocalization' import useResponsive from '@/utils/hooks/useResponsive' import useThemeClass from '@/utils/hooks/useThemeClass' import isLastChild from '@/utils/isLastChild' import classNames from 'classnames' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import { useCallback, useEffect, useRef, useState } from 'react' import { FaEnvelopeOpen } from 'react-icons/fa' import { FcAdvertising } from 'react-icons/fc' import { Link } from 'react-router-dom' import { Notification as Notify, toast } from '../ui' dayjs.extend(relativeTime) type NotificationListItem = { /** * Aynı mesaj birden fazla kanalda (UiActivity + UiToast + Mail …) satır * ürettiği için kayıtlar tek satırda birleştirilir; okundu işaretlemede * gruptaki tüm id'ler birlikte güncellenir. */ ids: string[] /** Satırın hangi kanallardan geldiği; rozet olarak gösterilir. */ channels: string[] creatorId: string tenantId?: string notificationType: string message: string date: string readed: boolean } const notificationHeight = 'h-[26rem]' const notificationInterval = 120000 // 2 minutes const foregroundPollThrottle = 30000 // 30 seconds /** * Dropdown, Activity Log ekranıyla aynı kapsamı gösterir: kullanıcıya giden her * kanal listelenir, satırda hangi kanallardan gittiği rozetle belirtilir. */ const inboxChannels = Object.values(NotificationChannels) /** Tarayıcıda gösterim üretebilen, dolayısıyla isSent takibi yapılan kanallar. */ const pushChannels: string[] = [NotificationChannels.UiToast, NotificationChannels.Desktop] /** Dropdown'da tutulan kayıt sayısı. Tamamı için "View all activity" ekranı var. */ const inboxSize = 50 /** * Uzun süre uzak kalınıp dönüldüğünde onlarca toast birden açılmasın; en yeniler * gösterilir, kalanı okunmamış olarak badge ve listede görünür. */ const maxPushBurst = 5 /** Aynı gönderimden doğan farklı kanal satırlarını eşleştirme toleransı. */ const duplicateWindowMs = 30000 const notificationDate = (value: NotificationDto['creationTime']) => value instanceof Date ? value.toISOString() : String(value ?? '') /** * Aynı mesajın kanal başına düşen kopyalarını tek satırda toplar. Liste * `CreationTime desc` sıralı geldiği için kopyalar ardışıktır. */ const groupNotifications = (items: NotificationDto[]): NotificationListItem[] => { const groups: NotificationListItem[] = [] for (const item of items) { const date = notificationDate(item.creationTime) const time = dayjs(date).valueOf() const target = groups.find( (group) => group.notificationType === item.notificationType && group.message === item.message && Math.abs(dayjs(group.date).valueOf() - time) <= duplicateWindowMs, ) if (target) { target.ids.push(item.id) if (!target.channels.includes(item.notificationChannel)) { target.channels.push(item.notificationChannel) } target.readed = target.readed && item.isRead continue } groups.push({ ids: [item.id], channels: [item.notificationChannel], notificationType: item.notificationType, message: item.message, date, creatorId: item.creatorId as string, tenantId: item.tenantId, readed: item.isRead, }) } return groups } const NotificationToggle = ({ className, unreadCount, }: { className?: string unreadCount: number }) => { return (
{unreadCount > 0 ? ( ) : ( )}
) } const _Notification = ({ className }: { className?: string }) => { const { translate } = useLocalization() const [notificationList, setNotificationList] = useState([]) const [unreadNotificationCount, setUnreadNotificationCount] = useState(0) const [loading, setLoading] = useState(false) // Aynı bildirimin sekme ömrü boyunca ikinci kez toast/desktop olarak // gösterilmesini engeller. const pushedIds = useRef(new Set()) const listLoaded = useRef(false) const lastPollAt = useRef(0) const { bgTheme } = useThemeClass() const { larger } = useResponsive() const direction = useStoreState((state) => state.theme.direction) /** * Badge ve dropdown içeriği tek istekten beslenir; böylece rozetteki sayı ile * listedeki okunmamış kayıt sayısı her zaman aynı kalır. */ const getInbox = useCallback(async () => { const resp = await getList({ channels: inboxChannels, // Kanallar arasında UiToast olduğu için servis, geçmişi son 24 saatle // sınırlayan push filtresini uygulamasın diye liste modunda çağrılır. isListRequest: true, sorting: 'CreationTime desc', maxResultCount: inboxSize, }) const items = resp.data.items ?? [] const groups = groupNotifications(items) setNotificationList(groups) setUnreadNotificationCount(groups.filter((a) => !a.readed).length) listLoaded.current = true return items }, []) const getPushNotifications = useCallback(async () => { const desktopGranted = 'Notification' in window && window.Notification.permission === 'granted' const channels = [NotificationChannels.UiToast] if (desktopGranted) channels.push(NotificationChannels.Desktop) const resp = await getList({ channels, isListRequest: false, isSent: false, sorting: 'CreationTime desc', maxResultCount: 1000, }) const pending = (resp.data.items ?? []).filter( (a) => !a.isSent && !pushedIds.current.has(a.id), ) if (pending.length === 0) { return } pending.forEach((a) => pushedIds.current.add(a.id)) for (const notification of pending.slice(0, maxPushBurst)) { if (notification.notificationChannel === NotificationChannels.UiToast) { toast.push( {notification.message} , { placement: 'bottom-end' }, ) continue } const title = notification.notificationType || APP_NAME const options: NotificationOptions = { body: notification.message, dir: 'ltr', requireInteraction: true, } if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { const reg = await navigator.serviceWorker.ready await reg.showNotification(title, options) } else { new window.Notification(title, options) } } // Yalnızca "gösterildi" işaretlenir; okundu bilgisi kullanıcının kendi // aksiyonuna bırakılır, aksi hâlde mesaj badge'e hiç düşmezdi. await updateSentMany( pending.map((a) => a.id), true, ) await getInbox() }, [getInbox]) useEffect(() => { if ('Notification' in window && window.Notification.permission === 'default') { window.Notification.requestPermission() } const poll = () => { // Sekme arka plandaysa istek atma. Pencerenin OS odağı yoksa (IDE'ye // geçmek, ikinci ekran, ayrık DevTools) sekme hâlâ görünür sayılır ve // yoklama devam eder. if (document.visibilityState === 'hidden') { return } lastPollAt.current = performance.now() getInbox() getPushNotifications() } poll() const intervalId = setInterval(poll, notificationInterval) // Sekme arka plandan öne geldiğinde bir sonraki tick'i beklemeden tazele; // hızlı sekme geçişlerinde istek yağmuru olmasın diye kısa aralıklar elenir. const onForeground = () => { if (document.visibilityState !== 'visible') { return } if (performance.now() - lastPollAt.current < foregroundPollThrottle) { return } poll() } document.addEventListener('visibilitychange', onForeground) window.addEventListener('focus', onForeground) return () => { clearInterval(intervalId) document.removeEventListener('visibilitychange', onForeground) window.removeEventListener('focus', onForeground) } }, [getInbox, getPushNotifications]) const onNotificationOpen = useCallback(async () => { setLoading(!listLoaded.current) try { const items = await getInbox() // Panelde görülen kayıtlar için ayrıca toast/desktop açılmasın. isSent // yalnızca tarayıcı kanallarında UI'nin sorumluluğunda; Mail/Sms gibi // kanalların gönderim durumu backend'e ait olduğu için dokunulmaz. const unsentIds = items .filter((a) => !a.isSent && pushChannels.includes(a.notificationChannel)) .map((a) => a.id) if (unsentIds.length > 0) { unsentIds.forEach((id) => pushedIds.current.add(id)) await updateSentMany(unsentIds, true) } } finally { setLoading(false) } }, [getInbox]) const onMarkAllAsRead = useCallback(async () => { await Promise.all(inboxChannels.map((channel) => updateReadAll(channel, true))) setNotificationList((list) => list.map((item) => ({ ...item, readed: true }))) setUnreadNotificationCount(0) }, []) const onMarkAsRead = useCallback(async (item: NotificationListItem) => { if (item.readed) { return } await updateReadMany(item.ids, true) setNotificationList((list) => list.map((a) => (a.ids[0] === item.ids[0] ? { ...a, readed: true } : a)), ) setUnreadNotificationCount((count) => Math.max(0, count - 1)) }, []) const noResult = !loading && listLoaded.current && notificationList.length === 0 return ( } menuClass="p-0 min-w-[320px] md:min-w-[440px]" placement={larger.md ? 'bottom-end' : 'bottom-center'} onOpen={onNotificationOpen} >
{translate('::App.Notifications')}
{notificationList.map((item, index) => (
onMarkAsRead(item)} >
{item.notificationType && ( {item.notificationType} )}
{item.message}
{item.channels.map((channel) => ( {channel} ))}
{dayjs(item.date).fromNow()}
))} {loading && (
)} {noResult && (
no-notification
No notifications!

Please Try again later

)}
{translate('::Abp.Identity.ActivityLogs.ViewAllActivity')}
) } const Notification = withHeaderItem(_Notification) export default Notification