sozsoft-platform/ui/src/components/template/Notification.tsx

333 lines
11 KiB
TypeScript
Raw Normal View History

2026-02-24 20:44:16 +00:00
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'
2026-02-24 20:44:16 +00:00
import NotificationChannels from '@/constants/notification-channel.enum'
import { ROUTES_ENUM } from '@/routes/route.constant'
2026-07-11 20:35:32 +00:00
import {
getList,
updateRead,
updateReadAll,
updateReadMany,
updateSentMany,
} from '@/services/notification.service'
2026-02-24 20:44:16 +00:00
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'
2026-02-24 20:44:16 +00:00
import { Link } from 'react-router-dom'
import { Notification as Notify, toast } from '../ui'
dayjs.extend(relativeTime)
type NotificationList = {
id: string
creatorId: string
tenantId?: string
notificationType: string
message: string
date: string
readed: boolean
}
const notificationHeight = 'h-72'
const notificationInterval = 120000 // 2 minutes
2026-02-24 20:44:16 +00:00
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 _Notification = ({ className }: { className?: string }) => {
const { translate } = useLocalization()
const [notificationList, setNotificationList] = useState<NotificationList[]>([])
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)
2026-02-24 20:44:16 +00:00
const { bgTheme } = useThemeClass()
const { larger } = useResponsive()
const direction = useStoreState((state) => state.theme.direction)
const tabHasFocus = useStoreState((a) => a.base.common.tabHasFocus)
const tabHasFocusRef = useRef(tabHasFocus)
useEffect(() => {
tabHasFocusRef.current = tabHasFocus
}, [tabHasFocus])
2026-02-24 20:44:16 +00:00
const getUnreadCount = useCallback(async () => {
2026-02-24 20:44:16 +00:00
const resp = await getList({
channels: [NotificationChannels.UiActivity],
isListRequest: false,
isRead: false,
maxResultCount: 1,
})
setUnreadNotificationCount(resp.data?.totalCount ?? 0)
}, [])
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,
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) {
if (notification.notificationChannel === NotificationChannels.UiToast) {
toast.push(
<Notify type="success" duration={0} closable={true}>
{notification.message}
</Notify>,
{ 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)
}
}
const processedIds = pending.map((a) => a.id)
await updateSentMany(processedIds, true)
await updateReadMany(processedIds, true)
}, [])
2026-02-24 20:44:16 +00:00
useEffect(() => {
if ('Notification' in window && window.Notification.permission === 'default') {
window.Notification.requestPermission()
}
const poll = () => {
getUnreadCount()
getPushNotifications()
}
poll()
const intervalId = setInterval(() => {
2026-05-15 12:04:47 +00:00
if (tabHasFocusRef.current) {
poll()
2026-05-15 12:04:47 +00:00
}
2026-02-24 20:44:16 +00:00
}, notificationInterval)
return () => clearInterval(intervalId)
}, [getUnreadCount, getPushNotifications])
2026-02-24 20:44:16 +00:00
const onNotificationOpen = useCallback(async () => {
const currentUnread = notificationList.filter((a) => !a.readed).length
if (listLoaded.current && currentUnread === unreadNotificationCount) {
return
}
setLoading(true)
try {
2026-02-24 20:44:16 +00:00
const resp = await getList({
channels: [NotificationChannels.UiActivity],
isListRequest: false,
maxResultCount: 1000,
})
const items = resp.data.items ?? []
2026-07-11 20:35:32 +00:00
if (items.length > 0) {
await updateSentMany(
items.map((notification) => notification.id),
true,
)
2026-02-24 20:44:16 +00:00
}
setNotificationList(
items.map((a) => ({
id: a.id,
notificationType: a.notificationType,
date: a.creationTime.toLocaleString(),
message: a.message,
creatorId: a.creatorId,
tenantId: a.tenantId,
readed: a.isRead,
})) as NotificationList[],
)
listLoaded.current = true
} finally {
2026-02-24 20:44:16 +00:00
setLoading(false)
}
}, [notificationList, unreadNotificationCount])
2026-02-24 20:44:16 +00:00
const onMarkAllAsRead = useCallback(async () => {
await updateReadAll(NotificationChannels.UiActivity, true)
2026-02-24 20:44:16 +00:00
setNotificationList((list) => list.map((item) => ({ ...item, readed: true })))
2026-02-24 20:44:16 +00:00
setUnreadNotificationCount(0)
}, [])
2026-02-24 20:44:16 +00:00
const onMarkAsRead = useCallback(
async (id: string) => {
const target = notificationList.find((item) => item.id === id)
if (!target || target.readed) {
return
}
2026-02-24 20:44:16 +00:00
await updateRead(id, true)
2026-02-24 20:44:16 +00:00
setNotificationList((list) =>
list.map((item) => (item.id === id ? { ...item, readed: true } : item)),
)
setUnreadNotificationCount((count) => Math.max(0, count - 1))
2026-02-24 20:44:16 +00:00
},
[notificationList],
)
const noResult = !loading && listLoaded.current && notificationList.length === 0
2026-02-24 20:44:16 +00:00
return (
<Tooltip title={translate('::App.Notifications')}>
<Dropdown
renderTitle={
<NotificationToggle unreadCount={unreadNotificationCount} className={className} />
}
menuClass="p-0 min-w-[280px] md:min-w-[340px]"
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('::Abp.Identity.ActivityLogs.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.id}
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.id)}
>
<div>
<Avatar shape="circle" src={AVATAR_URL(item.creatorId, item.tenantId)} />
</div>
<div className="ltr:ml-3 rtl:mr-3">
<div>
{item.notificationType && (
<span className="font-semibold heading-text">{item.notificationType} </span>
)}
<div>{item.message}</div>
2026-02-24 20:44:16 +00:00
</div>
<span className="text-xs">{dayjs(item.date).fromNow()}</span>
2026-02-24 20:44:16 +00:00
</div>
<Badge
className="absolute top-4 ltr:right-4 rtl:left-4 mt-1.5"
innerClass={`${item.readed ? 'bg-gray-300' : bgTheme} `}
/>
</div>
))}
2026-02-24 20:44:16 +00:00
{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">No notifications!</h6>
<p className="mt-1">Please Try again later</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(_Notification)
export default Notification