sozsoft-platform/ui/src/components/template/Notification.tsx
2026-10-04 22:17:16 +03:00

493 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
/** Satır başlığı: kaydın çevrilmiş konusu, yoksa tip adı. */
title: 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,
title: item.subject || 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 (
<div className={classNames('text-2xl', className)}>
{unreadCount > 0 ? (
<Badge
badgeStyle={{ top: '3px', right: '6px' }}
content={unreadCount}
innerClass="py-0 px-1"
>
<FcAdvertising />
</Badge>
) : (
<FcAdvertising />
)}
</div>
)
}
const NotificationDropdown = ({ className }: { className?: string }) => {
const { translate } = useLocalization()
const [notificationList, setNotificationList] = useState<NotificationListItem[]>([])
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<string>())
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(
<Notify type="success" duration={0} closable={true}>
{notification.message}
</Notify>,
{ placement: 'bottom-end' },
)
shown.push(notification.id)
continue
}
const title = notification.subject || 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 (
<Tooltip title={translate('::App.Notifications')}>
<Dropdown
renderTitle={
<NotificationToggle unreadCount={unreadNotificationCount} className={className} />
}
menuClass="p-0 min-w-[320px] md:min-w-[440px]"
placement={larger.md ? 'bottom-end' : 'bottom-center'}
onOpen={onNotificationOpen}
>
<Dropdown.Item variant="header">
<div className="border-b border-gray-200 dark:border-gray-600 px-4 py-2 flex items-center justify-between">
<h6>{translate('::App.Notifications')}</h6>
<Tooltip title={translate('::Abp.Identity.ActivityLogs.MarkAllRead')}>
<Button
variant="plain"
shape="circle"
size="sm"
icon={<FaEnvelopeOpen className="text-xl" />}
onClick={onMarkAllAsRead}
/>
</Tooltip>
</div>
</Dropdown.Item>
<div className={classNames('overflow-y-auto', notificationHeight)}>
<ScrollBar direction={direction}>
{notificationList.map((item, index) => (
<div
key={item.ids[0]}
className={`relative flex px-4 py-4 cursor-pointer hover:bg-gray-50 active:bg-gray-100 dark:hover:bg-black dark:hover:bg-opacity-20 ${
!isLastChild(notificationList, index)
? 'border-b border-gray-200 dark:border-gray-600'
: ''
}`}
onClick={() => onMarkAsRead(item)}
>
<div>
<Avatar shape="circle" src={AVATAR_URL(item.creatorId, item.tenantId)} />
</div>
<div className="ltr:ml-3 rtl:mr-3 ltr:mr-6 rtl:ml-6 min-w-0">
<div>
{item.title && (
<span className="font-semibold heading-text">{item.title} </span>
)}
<div>{item.message}</div>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1">
{item.channels.map((channel) => (
<Tag
key={channel}
prefix
className="text-xs"
prefixClass={NotificationChannelColors[channel] ?? 'bg-gray-400'}
>
{translate(`::App.NotificationChannel.${channel}`)}
</Tag>
))}
</div>
<span className="text-xs">{dayjs(item.date).fromNow()}</span>
</div>
<Badge
className="absolute top-4 ltr:right-4 rtl:left-4 mt-1.5"
innerClass={`${item.readed ? 'bg-gray-300' : bgTheme} `}
/>
</div>
))}
{loading && (
<div className={classNames('flex items-center justify-center', notificationHeight)}>
<Spinner size={40} />
</div>
)}
{noResult && (
<div className={classNames('flex items-center justify-center', notificationHeight)}>
<div className="text-center">
<img
className="mx-auto mb-2 max-w-[150px]"
src="/img/others/no-notification.png"
alt="no-notification"
/>
<h6 className="font-semibold">{translate('::App.Notifications.Empty')}</h6>
<p className="mt-1">{translate('::App.Notifications.EmptyHint')}</p>
</div>
</div>
)}
</ScrollBar>
</div>
<Dropdown.Item variant="header">
<div className="flex justify-center border-t border-gray-200 dark:border-gray-600 px-4 py-2">
<Link
to={ROUTES_ENUM.protected.admin.activityLog}
className="font-semibold cursor-pointer p-2 px-3 text-gray-600 hover:text-gray-900 dark:text-gray-200 dark:hover:text-white"
>
{translate('::Abp.Identity.ActivityLogs.ViewAllActivity')}
</Link>
</div>
</Dropdown.Item>
</Dropdown>
</Tooltip>
)
}
const Notification = withHeaderItem(NotificationDropdown)
export default Notification