diff --git a/api/modules/Sozsoft.Notifications/Sozsoft.Notifications.Application/NotificationApplicationMappers.cs b/api/modules/Sozsoft.Notifications/Sozsoft.Notifications.Application/NotificationApplicationMappers.cs index f14431b9..f487a4ea 100644 --- a/api/modules/Sozsoft.Notifications/Sozsoft.Notifications.Application/NotificationApplicationMappers.cs +++ b/api/modules/Sozsoft.Notifications/Sozsoft.Notifications.Application/NotificationApplicationMappers.cs @@ -41,10 +41,15 @@ public override partial NotificationRuleDto Map(NotificationRule source); [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] public partial class NotificationToNotificationDtoMapper : MapperBase { -[MapperIgnoreTarget(nameof(NotificationDto.CreatorFullname))] + // NotificationType hedefte string. Kaynaktaki navigation entity'si doğrudan + // eşlenirse ToString() devreye girip "[ENTITY: NotificationType] Id = ..." + // yazısı UI'a düşüyor; bu yüzden açıkça Name alanından beslenir. + [MapProperty("NotificationType.Name", nameof(NotificationDto.NotificationType))] + [MapperIgnoreTarget(nameof(NotificationDto.CreatorFullname))] [MapperIgnoreTarget(nameof(NotificationDto.TenantId))] public override partial NotificationDto Map(Notification source); + [MapProperty("NotificationType.Name", nameof(NotificationDto.NotificationType))] [MapperIgnoreTarget(nameof(NotificationDto.CreatorFullname))] [MapperIgnoreTarget(nameof(NotificationDto.TenantId))] public override partial void Map(Notification source, NotificationDto destination); diff --git a/api/src/Sozsoft.Platform.Application.Contracts/LookUpQueryValues.cs b/api/src/Sozsoft.Platform.Application.Contracts/LookUpQueryValues.cs index d44b5adf..0ce11bc9 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/LookUpQueryValues.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/LookUpQueryValues.cs @@ -279,4 +279,20 @@ public static class LookupQueryValues $"WHERE \"SkillTypeId\" IN (SELECT \"Id\" FROM \"{FullNameTable(TableNameEnum.SkillType)}\" WHERE \"Name\"='Technical Skills' ) " + $"ORDER BY \"Name\""; + public static string NotificationIdentityValues = + $"SELECT " + + $"\"Id\" AS \"Key\", " + + $"\"Name\" AS \"Name\" " + + $"FROM \"AbpRoles\"" + + $"UNION ALL " + + $"SELECT " + + $"\"Id\" AS \"Key\", " + + $"\"UserName\" AS \"Name\" " + + $"FROM \"AbpUsers\"" + + $"UNION ALL " + + $"SELECT " + + $"\"Id\" AS \"Key\", " + + $"\"DisplayName\" AS \"Name\" " + + $"FROM \"AbpOrganizationUnits\"" + + $"ORDER BY \"Name\""; } diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs index b4a87ba4..815ed676 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs @@ -5585,7 +5585,12 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency ListOrderNo = 5, Visible = true, IsActive = true, - + LookupJson = JsonSerializer.Serialize(new LookupDto { + DataSourceType = UiLookupDataSourceTypeEnum.Query, + DisplayExpr = "Name", + ValueExpr = "Key", + LookupQuery = LookupQueryValues.NotificationIdentityValues + }), AllowSearch = true, ColumnCustomizationJson = DefaultColumnCustomizationJson, PermissionJson = DefaultFieldPermissionJson(listForm.Name), diff --git a/ui/src/components/template/Notification.tsx b/ui/src/components/template/Notification.tsx index 79ba45b7..81932b71 100644 --- a/ui/src/components/template/Notification.tsx +++ b/ui/src/components/template/Notification.tsx @@ -5,7 +5,7 @@ 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 { AVATAR_URL } from '@/constants/app.constant' +import { APP_NAME, AVATAR_URL } from '@/constants/app.constant' import NotificationChannels from '@/constants/notification-channel.enum' import { ROUTES_ENUM } from '@/routes/route.constant' import { @@ -26,9 +26,9 @@ 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' -import { FcAdvertising } from 'react-icons/fc' dayjs.extend(relativeTime) @@ -45,10 +45,6 @@ type NotificationList = { const notificationHeight = 'h-72' const notificationInterval = 120000 // 2 minutes -const notificationTypeAvatar = (creatorId: string, tenantId?: string) => { - return -} - const NotificationToggle = ({ className, unreadCount, @@ -78,12 +74,12 @@ const _Notification = ({ className }: { className?: string }) => { const [notificationList, setNotificationList] = useState([]) const [unreadNotificationCount, setUnreadNotificationCount] = useState(0) - const [noResult, setNoResult] = useState(false) const [loading, setLoading] = useState(false) - const toastNotificationList = useRef([]) - const desktopNotificationList = useRef([]) - const pushNotificationInterval = useRef>() + // Aynı bildirimin sekme ömrü boyunca ikinci kez toast/desktop olarak + // gösterilmesini engeller. + const pushedIds = useRef(new Set()) + const listLoaded = useRef(false) const { bgTheme } = useThemeClass() const { larger } = useResponsive() @@ -95,7 +91,7 @@ const _Notification = ({ className }: { className?: string }) => { tabHasFocusRef.current = tabHasFocus }, [tabHasFocus]) - const getReactNotificationCount = useCallback(async () => { + const getUnreadCount = useCallback(async () => { const resp = await getList({ channels: [NotificationChannels.UiActivity], isListRequest: false, @@ -103,85 +99,9 @@ const _Notification = ({ className }: { className?: string }) => { maxResultCount: 1, }) setUnreadNotificationCount(resp.data?.totalCount ?? 0) - }, [setUnreadNotificationCount]) + }, []) - useEffect(() => { - getReactNotificationCount() - var intervalId = setInterval(() => { - if (tabHasFocusRef.current) { - getReactNotificationCount() - } - }, notificationInterval) - - return () => { - clearInterval(intervalId) - } - }, [getReactNotificationCount]) - - const onNotificationOpen = useCallback(async () => { - const currentUnread = notificationList.filter((a) => !a.readed).length - if (currentUnread !== unreadNotificationCount) { - setLoading(true) - const resp = await getList({ - channels: [NotificationChannels.UiActivity], - isListRequest: false, - maxResultCount: 1000, - }) - const items = resp.data.items ?? [] - if (items.length > 0) { - await updateSentMany(items.map((notification) => notification.id), true) - } - const newNotificationList = 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, - ) - - setLoading(false) - setNotificationList(newNotificationList) - setNoResult(newNotificationList.length == 0) - } - }, [notificationList, setLoading, unreadNotificationCount]) - - const onMarkAllAsRead = useCallback(async () => { - await updateReadAll(NotificationChannels.UiToast, true) - - const list = notificationList.map((item: NotificationList) => { - if (!item.readed) { - item.readed = true - } - return item - }) - setNotificationList(list) - setUnreadNotificationCount(0) - }, [notificationList]) - - const onMarkAsRead = useCallback( - async (id: string) => { - await updateRead(id, true) - - const list = notificationList.map((item) => { - if (item.id === id) { - item.readed = true - } - return item - }) - setNotificationList(list) - - const unreadCount = notificationList.filter((item) => !item.readed).length - setUnreadNotificationCount(unreadCount) - }, - [notificationList], - ) - - const getPushNotifications = async () => { + const getPushNotifications = useCallback(async () => { const desktopGranted = 'Notification' in window && window.Notification.permission === 'granted' @@ -194,84 +114,130 @@ const _Notification = ({ className }: { className?: string }) => { isSent: false, maxResultCount: 1000, }) - const items = resp.data.items ?? [] - // Toast - const newToastList = items.filter( - (a) => - a.notificationChannel === NotificationChannels.UiToast && - !toastNotificationList.current.includes(a.id) && - !a.isSent, + const pending = (resp.data.items ?? []).filter( + (a) => !a.isSent && !pushedIds.current.has(a.id), ) - toastNotificationList.current = [ - ...toastNotificationList.current, - ...newToastList.map((a) => a.id), - ] - for (const notification of newToastList) { - toast.push( - - {notification.message} - , - { placement: 'bottom-end' }, - ) + if (pending.length === 0) { + return } + pending.forEach((a) => pushedIds.current.add(a.id)) - // Desktop - const newDesktopList = desktopGranted - ? items.filter( - (a) => - a.notificationChannel === NotificationChannels.Desktop && - !desktopNotificationList.current.includes(a.id) && - !a.isSent, - ) - : [] - if (desktopGranted) { - desktopNotificationList.current = [ - ...desktopNotificationList.current, - ...newDesktopList.map((a) => a.id), - ] - for (const notification of newDesktopList) { - const title = notification.notificationType || 'Bildirim' - const options = { - body: notification.message, - dir: 'ltr', - requireInteraction: true, - } as NotificationOptions + for (const notification of pending) { + if (notification.notificationChannel === NotificationChannels.UiToast) { + toast.push( + + {notification.message} + , + { placement: 'bottom-end' }, + ) + continue + } - 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 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 = [...newToastList, ...newDesktopList].map((notification) => notification.id) - if (processedIds.length > 0) { - await updateSentMany(processedIds, true) - await updateReadMany(processedIds, true) - } - } + const processedIds = pending.map((a) => a.id) + await updateSentMany(processedIds, true) + await updateReadMany(processedIds, true) + }, []) useEffect(() => { if ('Notification' in window && window.Notification.permission === 'default') { window.Notification.requestPermission() } - getPushNotifications() + const poll = () => { + getUnreadCount() + getPushNotifications() + } - pushNotificationInterval.current = setInterval(() => { + poll() + const intervalId = setInterval(() => { if (tabHasFocusRef.current) { - getPushNotifications() + poll() } }, notificationInterval) - return () => { - clearInterval(pushNotificationInterval.current) + return () => clearInterval(intervalId) + }, [getUnreadCount, getPushNotifications]) + + const onNotificationOpen = useCallback(async () => { + const currentUnread = notificationList.filter((a) => !a.readed).length + if (listLoaded.current && currentUnread === unreadNotificationCount) { + return } + + setLoading(true) + try { + const resp = await getList({ + channels: [NotificationChannels.UiActivity], + isListRequest: false, + maxResultCount: 1000, + }) + const items = resp.data.items ?? [] + if (items.length > 0) { + await updateSentMany( + items.map((notification) => notification.id), + true, + ) + } + + 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 { + setLoading(false) + } + }, [notificationList, unreadNotificationCount]) + + const onMarkAllAsRead = useCallback(async () => { + await updateReadAll(NotificationChannels.UiActivity, true) + + setNotificationList((list) => list.map((item) => ({ ...item, readed: true }))) + setUnreadNotificationCount(0) }, []) + const onMarkAsRead = useCallback( + async (id: string) => { + const target = notificationList.find((item) => item.id === id) + if (!target || target.readed) { + return + } + + await updateRead(id, true) + + setNotificationList((list) => + list.map((item) => (item.id === id ? { ...item, readed: true } : item)), + ) + setUnreadNotificationCount((count) => Math.max(0, count - 1)) + }, + [notificationList], + ) + + const noResult = !loading && listLoaded.current && notificationList.length === 0 + return ( {
- {notificationList.length > 0 && - notificationList.map((item, index) => ( -
onMarkAsRead(item.id)} - > -
{notificationTypeAvatar(item.creatorId, item.tenantId)}
-
-
- {item.notificationType && ( - {item.notificationType} - )} -
{item.message}
-
- {dayjs(item.date).fromNow()} -
- + {notificationList.map((item, index) => ( +
onMarkAsRead(item.id)} + > +
+
- ))} +
+
+ {item.notificationType && ( + {item.notificationType} + )} +
{item.message}
+
+ {dayjs(item.date).fromNow()} +
+ +
+ ))} {loading && (
diff --git a/ui/src/components/template/Theme.tsx b/ui/src/components/template/Theme.tsx index 226c5aae..a471631f 100644 --- a/ui/src/components/template/Theme.tsx +++ b/ui/src/components/template/Theme.tsx @@ -3,7 +3,6 @@ import ConfigProvider from '@/components/ui/ConfigProvider' import { themeConfig } from '@/proxy/theme/theme.config' import { useStoreActions, useStoreState } from '@/store' import useDarkMode from '@/utils/hooks/useDarkmode' -import useNotification from '@/utils/hooks/useNotification' import { useSetting } from '@/utils/hooks/useSetting' import useTabFocus from '@/utils/hooks/useTabFocus' import { ComponentProps, useEffect } from 'react' @@ -74,7 +73,6 @@ const Theme = (props: CommonProps) => { 'en' useDarkMode() useTabFocus() - useNotification() useAppVersionNotice() type ConfigProviderValue = NonNullable['value']> diff --git a/ui/src/components/ui/Notification/Notification.tsx b/ui/src/components/ui/Notification/Notification.tsx index 0a202b10..cd9938c0 100644 --- a/ui/src/components/ui/Notification/Notification.tsx +++ b/ui/src/components/ui/Notification/Notification.tsx @@ -34,7 +34,9 @@ const Notification = forwardRef( ...rest } = props - const [display, setDisplay] = useState('show') + // Toast tarafından yönetilmeyen bildirimler kapatma animasyonu + // tamamlandıktan sonra kendilerini DOM'dan kaldırır. + const [hidden, setHidden] = useState(false) const { clear } = useTimeout( onClose as () => void, @@ -44,12 +46,11 @@ const Notification = forwardRef( const handleClose = useCallback( (e: MouseEvent) => { - setDisplay('hiding') onClose?.(e) clear() if (!triggerByToast) { setTimeout(() => { - setDisplay('hide') + setHidden(true) }, 400) } }, @@ -58,7 +59,7 @@ const Notification = forwardRef( const notificationClass = classNames('notification', className) - if (display === 'hide') { + if (hidden) { return null } diff --git a/ui/src/services/notification.service.ts b/ui/src/services/notification.service.ts index 5a5ab61a..b524839b 100644 --- a/ui/src/services/notification.service.ts +++ b/ui/src/services/notification.service.ts @@ -29,13 +29,6 @@ export const updateReadAll = (notificationChannel: string, isRead: boolean) => params: { notificationChannel, isRead }, }) -export const updateSent = (notificationId: string, isSent: boolean) => - apiService.fetchData({ - method: 'PUT', - url: `/api/app/notification/sent/${notificationId}`, - params: { isSent }, - }) - export const updateReadMany = (notificationIds: string[], isRead: boolean) => apiService.fetchData({ method: 'PUT', diff --git a/ui/src/utils/hooks/useNotification.ts b/ui/src/utils/hooks/useNotification.ts deleted file mode 100644 index 7b450c62..00000000 --- a/ui/src/utils/hooks/useNotification.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useEffect } from 'react' - -function useNotification() { - useEffect(() => { - if (window === undefined) { - return - } - - if (!('Notification' in window)) { - console.log('This browser does not support desktop notification') - } else { - Notification.requestPermission() - } - }, []) -} - -export default useNotification diff --git a/ui/src/views/admin/notification/CreateNotification.tsx b/ui/src/views/admin/notification/CreateNotification.tsx index d6f11ed8..718b0235 100644 --- a/ui/src/views/admin/notification/CreateNotification.tsx +++ b/ui/src/views/admin/notification/CreateNotification.tsx @@ -12,7 +12,7 @@ import { useLocalization } from '@/utils/hooks/useLocalization' import { Field, Form, Formik, FormikHelpers } from 'formik' import * as Yup from 'yup' -export interface NotificationDto { +interface CreateNotificationValues { id: string message: string } @@ -34,34 +34,28 @@ function CreateNotification({ const { translate } = useLocalization() const handleSubmit = async ( - values: NotificationDto, - { setSubmitting }: FormikHelpers, + values: CreateNotificationValues, + { setSubmitting }: FormikHelpers, ) => { if (!id) { return } - setSubmitting(true) - try { await postMyNotificationByNotificationRuleId({ id, message: values.message }) toast.push( - {translate('::Kaydet')} + {translate('::App.Platform.Success')} , - { - placement: 'bottom-end', - }, + { placement: 'bottom-end' }, ) onDialogClose() } catch { toast.push( - {'Hata'} + {translate('::App.Platform.Error')} , - { - placement: 'bottom-end', - }, + { placement: 'bottom-end' }, ) } finally { setSubmitting(false)