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')}
}
onClick={onMarkAllAsRead}
/>
{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 && (
{translate('::App.Notifications.Empty')}
{translate('::App.Notifications.EmptyHint')}
)}
{translate('::Abp.Identity.ActivityLogs.ViewAllActivity')}
)
}
const Notification = withHeaderItem(NotificationDropdown)
export default Notification