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'
|
2026-08-11 10:00:43 +00:00
|
|
|
|
import { APP_NAME, AVATAR_URL } from '@/constants/app.constant'
|
2026-08-11 19:55:20 +00:00
|
|
|
|
import Tag from '@/components/ui/Tag'
|
|
|
|
|
|
import NotificationChannels, {
|
|
|
|
|
|
NotificationChannelColors,
|
|
|
|
|
|
} from '@/constants/notification-channel.enum'
|
|
|
|
|
|
import { NotificationDto } from '@/proxy/notification/models'
|
2026-02-24 20:44:16 +00:00
|
|
|
|
import { ROUTES_ENUM } from '@/routes/route.constant'
|
2026-07-11 20:35:32 +00:00
|
|
|
|
import {
|
|
|
|
|
|
getList,
|
|
|
|
|
|
updateReadAll,
|
|
|
|
|
|
updateReadMany,
|
|
|
|
|
|
updateSentMany,
|
|
|
|
|
|
} from '@/services/notification.service'
|
2026-09-20 20:23:39 +00:00
|
|
|
|
import { notificationSignalR } from '@/services/notification.signalr'
|
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'
|
2026-08-11 10:00:43 +00:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-08-11 19:55:20 +00:00
|
|
|
|
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[]
|
2026-02-24 20:44:16 +00:00
|
|
|
|
creatorId: string
|
|
|
|
|
|
tenantId?: string
|
|
|
|
|
|
notificationType: string
|
|
|
|
|
|
message: string
|
|
|
|
|
|
date: string
|
|
|
|
|
|
readed: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 19:55:20 +00:00
|
|
|
|
const notificationHeight = 'h-[26rem]'
|
2026-09-20 20:23:39 +00:00
|
|
|
|
/** SignalR duyurusu kaybolursa devreye giren yedek yoklama aralığı. */
|
|
|
|
|
|
const notificationInterval = 300000 // 5 minutes
|
2026-08-11 19:55:20 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 20:23:39 +00:00
|
|
|
|
const NotificationDropdown = ({ className }: { className?: string }) => {
|
2026-02-24 20:44:16 +00:00
|
|
|
|
const { translate } = useLocalization()
|
|
|
|
|
|
|
2026-08-11 19:55:20 +00:00
|
|
|
|
const [notificationList, setNotificationList] = useState<NotificationListItem[]>([])
|
2026-02-24 20:44:16 +00:00
|
|
|
|
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0)
|
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
|
|
2026-08-11 10:00:43 +00:00
|
|
|
|
// Aynı bildirimin sekme ömrü boyunca ikinci kez toast/desktop olarak
|
|
|
|
|
|
// gösterilmesini engeller.
|
|
|
|
|
|
const pushedIds = useRef(new Set<string>())
|
|
|
|
|
|
const listLoaded = useRef(false)
|
2026-08-11 19:55:20 +00:00
|
|
|
|
const lastPollAt = useRef(0)
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
|
|
|
|
|
const { bgTheme } = useThemeClass()
|
|
|
|
|
|
const { larger } = useResponsive()
|
|
|
|
|
|
|
|
|
|
|
|
const direction = useStoreState((state) => state.theme.direction)
|
|
|
|
|
|
|
2026-08-11 19:55:20 +00:00
|
|
|
|
/**
|
2026-09-20 20:23:39 +00:00
|
|
|
|
* 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.
|
2026-08-11 19:55:20 +00:00
|
|
|
|
*/
|
|
|
|
|
|
const getInbox = useCallback(async () => {
|
2026-09-20 20:23:39 +00:00
|
|
|
|
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)
|
2026-08-11 19:55:20 +00:00
|
|
|
|
listLoaded.current = true
|
|
|
|
|
|
|
|
|
|
|
|
return items
|
2026-08-11 10:00:43 +00:00
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-08-11 19:55:20 +00:00
|
|
|
|
sorting: 'CreationTime desc',
|
2026-08-11 10:00:43 +00:00
|
|
|
|
maxResultCount: 1000,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const pending = (resp.data.items ?? []).filter(
|
|
|
|
|
|
(a) => !a.isSent && !pushedIds.current.has(a.id),
|
|
|
|
|
|
)
|
|
|
|
|
|
if (pending.length === 0) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-20 20:23:39 +00:00
|
|
|
|
|
|
|
|
|
|
// 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[] = []
|
2026-08-11 10:00:43 +00:00
|
|
|
|
|
2026-08-11 19:55:20 +00:00
|
|
|
|
for (const notification of pending.slice(0, maxPushBurst)) {
|
2026-09-20 20:23:39 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-11 10:00:43 +00:00
|
|
|
|
|
2026-09-20 20:23:39 +00:00
|
|
|
|
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 },
|
|
|
|
|
|
}
|
2026-08-11 10:00:43 +00:00
|
|
|
|
|
2026-09-20 20:23:39 +00:00
|
|
|
|
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.
|
2026-08-11 10:00:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 20:23:39 +00:00
|
|
|
|
if (shown.length === 0) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
shown.forEach((id) => pushedIds.current.add(id))
|
|
|
|
|
|
|
2026-08-11 19:55:20 +00:00
|
|
|
|
// 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.
|
2026-09-20 20:23:39 +00:00
|
|
|
|
await updateSentMany(shown, true)
|
|
|
|
|
|
}, [])
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-08-11 10:00:43 +00:00
|
|
|
|
if ('Notification' in window && window.Notification.permission === 'default') {
|
|
|
|
|
|
window.Notification.requestPermission()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 20:23:39 +00:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 10:00:43 +00:00
|
|
|
|
const poll = () => {
|
2026-08-11 19:55:20 +00:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
2026-09-20 20:23:39 +00:00
|
|
|
|
refresh()
|
2026-08-11 10:00:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 20:23:39 +00:00
|
|
|
|
// 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()
|
|
|
|
|
|
|
2026-08-11 10:00:43 +00:00
|
|
|
|
poll()
|
2026-08-11 19:55:20 +00:00
|
|
|
|
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
|
2026-05-15 12:04:47 +00:00
|
|
|
|
}
|
2026-08-11 19:55:20 +00:00
|
|
|
|
if (performance.now() - lastPollAt.current < foregroundPollThrottle) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
poll()
|
|
|
|
|
|
}
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-08-11 19:55:20 +00:00
|
|
|
|
document.addEventListener('visibilitychange', onForeground)
|
|
|
|
|
|
window.addEventListener('focus', onForeground)
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-08-11 19:55:20 +00:00
|
|
|
|
return () => {
|
|
|
|
|
|
clearInterval(intervalId)
|
|
|
|
|
|
document.removeEventListener('visibilitychange', onForeground)
|
|
|
|
|
|
window.removeEventListener('focus', onForeground)
|
2026-09-20 20:23:39 +00:00
|
|
|
|
unsubscribeReceived()
|
|
|
|
|
|
notificationSignalR.stop()
|
2026-08-11 10:00:43 +00:00
|
|
|
|
}
|
2026-08-11 19:55:20 +00:00
|
|
|
|
}, [getInbox, getPushNotifications])
|
2026-08-11 10:00:43 +00:00
|
|
|
|
|
2026-08-11 19:55:20 +00:00
|
|
|
|
const onNotificationOpen = useCallback(async () => {
|
|
|
|
|
|
setLoading(!listLoaded.current)
|
2026-08-11 10:00:43 +00:00
|
|
|
|
try {
|
2026-08-11 19:55:20 +00:00
|
|
|
|
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.
|
2026-09-20 20:23:39 +00:00
|
|
|
|
// 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'
|
2026-08-11 19:55:20 +00:00
|
|
|
|
const unsentIds = items
|
2026-09-20 20:23:39 +00:00
|
|
|
|
.filter(
|
|
|
|
|
|
(a) =>
|
|
|
|
|
|
!a.isSent &&
|
|
|
|
|
|
pushChannels.includes(a.notificationChannel) &&
|
|
|
|
|
|
(desktopGranted || a.notificationChannel !== NotificationChannels.Desktop),
|
|
|
|
|
|
)
|
2026-08-11 19:55:20 +00:00
|
|
|
|
.map((a) => a.id)
|
|
|
|
|
|
if (unsentIds.length > 0) {
|
|
|
|
|
|
unsentIds.forEach((id) => pushedIds.current.add(id))
|
|
|
|
|
|
await updateSentMany(unsentIds, true)
|
2026-02-24 20:44:16 +00:00
|
|
|
|
}
|
2026-08-11 10:00:43 +00:00
|
|
|
|
} finally {
|
2026-02-24 20:44:16 +00:00
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
2026-08-11 19:55:20 +00:00
|
|
|
|
}, [getInbox])
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-09-20 20:23:39 +00:00
|
|
|
|
// 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.
|
2026-02-24 20:44:16 +00:00
|
|
|
|
const onMarkAllAsRead = useCallback(async () => {
|
2026-08-11 19:55:20 +00:00
|
|
|
|
await Promise.all(inboxChannels.map((channel) => updateReadAll(channel, true)))
|
2026-09-20 20:23:39 +00:00
|
|
|
|
await getInbox()
|
|
|
|
|
|
}, [getInbox])
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-09-20 20:23:39 +00:00
|
|
|
|
const onMarkAsRead = useCallback(
|
|
|
|
|
|
async (item: NotificationListItem) => {
|
|
|
|
|
|
if (item.readed) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-09-20 20:23:39 +00:00
|
|
|
|
await updateReadMany(item.ids, true)
|
|
|
|
|
|
await getInbox()
|
|
|
|
|
|
},
|
|
|
|
|
|
[getInbox],
|
|
|
|
|
|
)
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-08-11 10:00:43 +00:00
|
|
|
|
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} />
|
|
|
|
|
|
}
|
2026-08-11 19:55:20 +00:00
|
|
|
|
menuClass="p-0 min-w-[320px] md:min-w-[440px]"
|
2026-02-24 20:44:16 +00:00
|
|
|
|
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">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
<h6>{translate('::App.Notifications')}</h6>
|
2026-02-24 20:44:16 +00:00
|
|
|
|
<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}>
|
2026-08-11 10:00:43 +00:00
|
|
|
|
{notificationList.map((item, index) => (
|
|
|
|
|
|
<div
|
2026-08-11 19:55:20 +00:00
|
|
|
|
key={item.ids[0]}
|
2026-08-11 10:00:43 +00:00
|
|
|
|
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'
|
|
|
|
|
|
: ''
|
|
|
|
|
|
}`}
|
2026-08-11 19:55:20 +00:00
|
|
|
|
onClick={() => onMarkAsRead(item)}
|
2026-08-11 10:00:43 +00:00
|
|
|
|
>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Avatar shape="circle" src={AVATAR_URL(item.creatorId, item.tenantId)} />
|
|
|
|
|
|
</div>
|
2026-08-11 19:55:20 +00:00
|
|
|
|
<div className="ltr:ml-3 rtl:mr-3 ltr:mr-6 rtl:ml-6 min-w-0">
|
2026-08-11 10:00:43 +00:00
|
|
|
|
<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>
|
2026-08-11 19:55:20 +00:00
|
|
|
|
<div className="mt-1 flex flex-wrap items-center gap-1">
|
|
|
|
|
|
{item.channels.map((channel) => (
|
|
|
|
|
|
<Tag
|
|
|
|
|
|
key={channel}
|
|
|
|
|
|
prefix
|
|
|
|
|
|
className="text-xs"
|
2026-09-20 20:23:39 +00:00
|
|
|
|
prefixClass={NotificationChannelColors[channel] ?? 'bg-gray-400'}
|
2026-08-11 19:55:20 +00:00
|
|
|
|
>
|
2026-09-20 20:23:39 +00:00
|
|
|
|
{translate(`::App.NotificationChannel.${channel}`)}
|
2026-08-11 19:55:20 +00:00
|
|
|
|
</Tag>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-08-11 10:00:43 +00:00
|
|
|
|
<span className="text-xs">{dayjs(item.date).fromNow()}</span>
|
2026-02-24 20:44:16 +00:00
|
|
|
|
</div>
|
2026-08-11 10:00:43 +00:00
|
|
|
|
<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"
|
|
|
|
|
|
/>
|
2026-09-20 20:23:39 +00:00
|
|
|
|
<h6 className="font-semibold">{translate('::App.Notifications.Empty')}</h6>
|
|
|
|
|
|
<p className="mt-1">{translate('::App.Notifications.EmptyHint')}</p>
|
2026-02-24 20:44:16 +00:00
|
|
|
|
</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 20:23:39 +00:00
|
|
|
|
const Notification = withHeaderItem(NotificationDropdown)
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
|
|
|
|
|
export default Notification
|