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 { notificationSignalR } from '@/services/notification.signalr' 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]' /** SignalR duyurusu kaybolursa devreye giren yedek yoklama aralığı. */ const notificationInterval = 300000 // 5 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 NotificationDropdown = ({ 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) /** * Dropdown listesi son `inboxSize` kaydı gösterir; rozetteki sayı ise sunucudan okunan * toplam okunmamış kayıt sayısıdır. Sayıyı sayfadan hesaplamak 50'den fazla okunmamış * bildirimde rozeti sessizce tavanlıyordu. Listedeki satırlar aynı mesajın kanal * kopyalarını birleştirdiği için satır sayısı ile rozet birebir aynı olmak zorunda değildir. */ const getInbox = useCallback(async () => { const [listResponse, unreadResponse] = await Promise.all([ 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, }), getList({ channels: inboxChannels, isListRequest: true, isRead: false, maxResultCount: 1, }), ]) const items = listResponse.data.items ?? [] setNotificationList(groupNotifications(items)) setUnreadNotificationCount(unreadResponse.data.totalCount ?? 0) 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 } // Yalnızca gerçekten gösterilen kayıt "gönderildi" sayılır. Patlamayı sınırlamak için // kesilen kuyruk işaretlenseydi o bildirimler hiç görünmeden kaybolurdu; işaretlenmeyen // kayıtlar bir sonraki yoklamada yeniden denenir. const shown: string[] = [] for (const notification of pending.slice(0, maxPushBurst)) { try { if (notification.notificationChannel === NotificationChannels.UiToast) { toast.push( {notification.message} , { placement: 'bottom-end' }, ) shown.push(notification.id) continue } const title = notification.notificationType || APP_NAME const options: NotificationOptions = { body: notification.message, dir: 'ltr', requireInteraction: true, // Service worker tıklamada bu adrese götürür; yoksa etkinlik kaydı ekranı açılır. data: { url: ROUTES_ENUM.protected.admin.activityLog }, } if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { const reg = await navigator.serviceWorker.ready await reg.showNotification(title, options) } else { new window.Notification(title, options) } shown.push(notification.id) } catch { // Gösterim başarısızsa (izin kalkmış, service worker hazır değil) kayıt // işaretlenmez; sonraki yoklamada tekrar denenir. } } if (shown.length === 0) { return } shown.forEach((id) => pushedIds.current.add(id)) // 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(shown, true) }, []) useEffect(() => { if ('Notification' in window && window.Notification.permission === 'default') { window.Notification.requestPermission() } // Sıra korunur: önce gösterilmemiş kayıtlar gösterilip "gönderildi" yazılır, sonra // liste okunur. Eşzamanlı koşsalardı geç dönen istek erken dönenin sonucunu ezer ve // rozet sunucudaki gerçekle ayrışırdı. Hata kullanıcı aksiyonu değildir; sessizce // geçilir, bir sonraki tetikleme yeniden dener. const refresh = () => { lastPollAt.current = performance.now() return getPushNotifications() .then(() => getInbox()) .catch(() => undefined) } 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 } refresh() } // Bildirim anında SignalR ile düşer; yoklama yalnızca bağlantı kurulamadığında ya da // duyuru kaybolduğunda devreye giren yedektir. const unsubscribeReceived = notificationSignalR.onReceived(() => { refresh() }) notificationSignalR.start() 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) unsubscribeReceived() notificationSignalR.stop() } }, [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. // Masaüstü bildirimine izin verilmemişse o kayıt gösterilemez; "gönderildi" // yazılırsa izin sonradan verildiğinde de hiç gösterilmezdi. const desktopGranted = 'Notification' in window && window.Notification.permission === 'granted' const unsentIds = items .filter( (a) => !a.isSent && pushChannels.includes(a.notificationChannel) && (desktopGranted || a.notificationChannel !== NotificationChannels.Desktop), ) .map((a) => a.id) if (unsentIds.length > 0) { unsentIds.forEach((id) => pushedIds.current.add(id)) await updateSentMany(unsentIds, true) } } finally { setLoading(false) } }, [getInbox]) // Sunucu, kullaniciya henuz iletilmemis (isSent === false) kaydi okundu saymaz; bu yuzden // liste iyimser guncellenmez, isaretlemeden sonra sunucudan tazelenir. Aksi halde rozet // sifirlanir ama bir sonraki yoklamada geri gelirdi. const onMarkAllAsRead = useCallback(async () => { await Promise.all(inboxChannels.map((channel) => updateReadAll(channel, true))) await getInbox() }, [getInbox]) const onMarkAsRead = useCallback( async (item: NotificationListItem) => { if (item.readed) { return } await updateReadMany(item.ids, true) await getInbox() }, [getInbox], ) 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) => ( {translate(`::App.NotificationChannel.${channel}`)} ))}
{dayjs(item.date).fromNow()}
))} {loading && (
)} {noResult && (
no-notification
{translate('::App.Notifications.Empty')}

{translate('::App.Notifications.EmptyHint')}

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