import { globalState } from '../../app/global-state'; import { notify } from './vendor/notify'; /** * Type of the notification determined by the bootstrap theme */ export enum NotificationType { Primary = 'primary', Secondary = 'secondary', Info = 'info', Success = 'success', Warning = 'warning', Danger = 'danger', } export enum NotificationIcon { Bell = 'now-ui-icons ui-1_bell-53', Exclamation = 'fas fa-exclamation-triangle', Checkmark = 'fas fa-check', Cross = 'now-ui-icons ui-1_simple-remove', Like = 'now-ui-icons ui-2_like', Gear = 'now-ui-icons ui-1_settings-gear-63', Smiley = 'now-ui-icons emoticons_satisfied', } /** * Notification placement */ export enum NotificationPlacement { TopLeft = 0, TopCenter = 1, TopRight = 2, BottomLeft = 20, BottomCenter = 21, BottomRight = 22, } /** * Notification animation */ export enum NotificationAnimation { FadeInDown = 'animate__animated animate__fadeInDown', FadeOutDown = 'animate__animated animate__fadeOutDown', FadeInUp = 'animate__animated animate__fadeInUp', FadeOutUp = 'animate__animated animate__fadeOutUp', FadeInRight = 'animate__animated animate__fadeInRight', FadeOutRight = 'animate__animated animate__fadeOutRight', FadeInLeft = 'animate__animated animate__fadeInLeft', FadeOutLeft = 'animate__animated animate__fadeOutLeft', } export class NotificationProviderConfig { static defaultTimeout = 1000; static defaultDelay = 4000; /** * Minimum interval [ms] between two notifications OF THE SAME SEVERITY. A burst within * this window is collapsed to the first (leading edge); distinct messages of the same * severity are also throttled (rate-limited), not just identical ones. */ static throttleWindowMs = 300; } interface NotificationDisplayArgs { /** * Notification message HTML, will not be escaped, ensure proper escaping if needed */ messageHtml: string; /** * Icon of the notification, might be NotificationIcon type, or now-ui, font awesome, or simple line icons */ icon?: NotificationIcon | string; /** * Optional title of the notification */ title?: string; /** * Display type of the notification [based on bootstrap theme colors] */ type?: NotificationType; /** * Notification placement */ placement?: NotificationPlacement; /** * Determines if the newest notification should be on top. */ newestTop?: boolean; /** * If not closed manually, after this timeout [in ms] ellapses, notification automatically hides (default 1000ms) */ timeout?: number; /** * After this delay [in ms], timeout starts. (default 4000ms) (0 to disable) */ delay?: number; /** * Notification animation */ animation?: { /** * CSS class for enter animation. Default is animate__animated animate__fadeInDown */ enter?: NotificationAnimation; /** * CSS class for exit animation. Default is animate__animated animate__fadeOutUp */ exit?: NotificationAnimation; }; /** * Custom css classes for the notification component */ cssClasses?: { /** * Custom css class for the notification container */ containerClass?: string; }; /** * Optional custom template passed directly to bootstrap-notify. * When not provided, a default template is used. */ template?: string | ((containerClass: string) => string); } const isMobileViewport = (): boolean => typeof window !== 'undefined' && window.innerWidth < 768; const applyMobileCenterOffset = (element: HTMLElement | null | undefined, offsetX: number): void => { if (element == null) { return; } element.style.left = `${offsetX}px`; element.style.right = `${offsetX}px`; }; const NotificationUtils = (() => { /** * Tracks the leading edge of the per-severity rate throttle. * Key = severity only (see buildThrottleKey); value = monotonic expiry timestamp * (performance.now() ms) until which the next notification of that severity is * suppressed. Module-scoped, only ever touched in the client show() path, so it is * request-agnostic and SSR-inert. */ const lastShownBySeverity = new Map(); /** * Throttle bucket for a notification: severity ONLY. All notifications of one severity * share a single bucket, so a burst of DISTINCT messages of the same severity is * rate-limited to one per window, not just identical ones. Text slots (title/messageHtml) * and presentation fields (placement/icon/template) are deliberately excluded so the * throttle is purely per-severity — an error and a success fall into different buckets * and never throttle each other. */ const buildThrottleKey = (args: NotificationDisplayArgs): string => args.type ?? ''; /** * Per-severity leading-edge rate throttle: returns true when a notification of the same * severity was already shown within NotificationProviderConfig.throttleWindowMs (the * caller then drops it). The FIRST notification of a severity shows and arms the window * (expiry = now + throttleWindowMs); any further notification of that severity within the * window is suppressed regardless of its text, collapsing a burst to its leading edge. * Uses performance.now() — monotonic and not subject to wall-clock skew; Date / Date.now() * are ESLint-forbidden in powerduck. SSR-inert: when no window exists it never suppresses * (and notify() is itself a no-op server-side). Expired keys are purged on each call so * the map stays bounded. */ const shouldThrottle = (args: NotificationDisplayArgs): boolean => { if (!globalState.windowExists) { return false; } const now = performance.now(); lastShownBySeverity.forEach((expiry, existingKey) => { if (expiry <= now) { lastShownBySeverity.delete(existingKey); } }); const key = buildThrottleKey(args); const liveExpiry = lastShownBySeverity.get(key); if (liveExpiry != null && liveExpiry > now) { return true; } lastShownBySeverity.set(key, now + NotificationProviderConfig.throttleWindowMs); return false; }; const getArgs = ( messageOrArgs: string | NotificationDisplayArgs, icon?: string, title?: string, ): NotificationDisplayArgs => { const args = messageOrArgs as NotificationDisplayArgs; if (args.messageHtml != null) { return args; } return { messageHtml: messageOrArgs, icon, title, }; }; const getPlacement = (args: NotificationDisplayArgs): any => { const placement = typeof args.placement === 'number' ? args.placement : NotificationPlacement.TopCenter; const placementEnd = (placement).toString(); const lastNumber = Number(placementEnd.substring(placementEnd.length - 1)); const from = placement >= NotificationPlacement.BottomLeft ? 'bottom' : 'top'; let align; if (lastNumber == 0) { align = 'left'; } else if (lastNumber == 1) { align = 'center'; } else { align = 'right'; } if (isMobileViewport() && (from === 'bottom' || from === 'top')) { align = 'center'; } return { from, align, }; }; const buildTemplate = (containerClass: string): string => ``.replace(/\s{2,}/g, ' ').trim(); const resolveTemplate = (args: NotificationDisplayArgs, containerClass: string): string => { if (typeof args.template === 'string') { return args.template; } if (typeof args.template === 'function') { return args.template(containerClass); } return buildTemplate(containerClass); }; const show = ( messageOrArgs: string | NotificationDisplayArgs, icon?: string, title?: string, ): void => { const args = getArgs( messageOrArgs, icon, title, ); if (shouldThrottle(args)) { return; } const containerClass = args.cssClasses?.containerClass || 'col-xs-11 col-sm-4'; const timerValue = typeof args.timeout === 'number' ? args.timeout : NotificationProviderConfig.defaultTimeout; const delayValue = typeof args.delay === 'number' ? args.delay : NotificationProviderConfig.defaultDelay; const placementOptions = getPlacement(args); const defaultEnterAnimation = placementOptions.from === 'bottom' ? NotificationAnimation.FadeInUp : NotificationAnimation.FadeInDown; const defaultExitAnimation = placementOptions.from === 'bottom' ? NotificationAnimation.FadeOutDown : NotificationAnimation.FadeOutUp; const offsetOptions = { x: 20, y: 20, }; const shouldForceMobileCenter = isMobileViewport() && placementOptions.align === 'center'; const onShowHandler = shouldForceMobileCenter ? function (this: HTMLElement) { applyMobileCenterOffset(this, offsetOptions.x); } : undefined; const template = resolveTemplate(args, containerClass); notify({ icon: args.icon, message: args.messageHtml, title: args.title, }, { type: args.type || NotificationType.Primary, timer: timerValue, delay: delayValue, placement: placementOptions, z_index: 99999, newest_on_top: args.newestTop ?? false, icon_type: 'class', animate: { enter: args.animation?.enter || defaultEnterAnimation, exit: args.animation?.exit || defaultExitAnimation, }, offset: offsetOptions, template, onShow: onShowHandler, }); }; return { show, }; })(); export default class NotificationProvider { /** * Displays given message top-center * @param message */ static show(message: string): void; /** * Displays message based on given args * * @param args */ static show(args: NotificationDisplayArgs): void; /** * Displays given message * @param message Message to-be shown * @param icon Accompanying icon */ static show(message: string, icon?: NotificationIcon | string): void; /** * Displays given message * * @param message Message to-be shown * @param icon Accompanying icon * @param title Message title */ static show( message: string | NotificationDisplayArgs, icon?: NotificationIcon | string, title?: string, ): void { NotificationUtils.show( message, icon, title, ); } /** * Displays danger\error message * * @param message Danger\Error message */ static showErrorMessage(message: string, placement?: NotificationPlacement): void; static showErrorMessage(message: string, title?: string, placement?: NotificationPlacement): void; /** * Displays danger\error message * * @param messageHtml Danger\Error message * @param titleOrPlacement Title string OR a NotificationPlacement value (overload-collapsed signature) * @param placement Placement of the message (when titleOrPlacement is a title string) */ static showErrorMessage( messageHtml: string, titleOrPlacement?: string | NotificationPlacement, placement?: NotificationPlacement, ): void { const resolvedTitle = typeof titleOrPlacement === 'string' ? titleOrPlacement : undefined; const resolvedPlacement = typeof titleOrPlacement === 'number' ? titleOrPlacement : placement; NotificationUtils.show({ messageHtml, title: resolvedTitle, icon: NotificationIcon.Exclamation, type: NotificationType.Danger, placement: resolvedPlacement ?? NotificationPlacement.TopCenter, }); } /** * Displays success message * * @param message Success message // */ static showSuccessMessage(message: string, placement?: NotificationPlacement): void; static showSuccessMessage(message: string, title?: string, placement?: NotificationPlacement): void; /** * Displays success message * * @param messageHtml Success message * @param titleOrPlacement Title string OR a NotificationPlacement value (overload-collapsed signature) * @param placement Placement of the message (when titleOrPlacement is a title string) */ static showSuccessMessage( messageHtml: string, titleOrPlacement?: string | NotificationPlacement, placement?: NotificationPlacement, ): void { const resolvedTitle = typeof titleOrPlacement === 'string' ? titleOrPlacement : undefined; const resolvedPlacement = typeof titleOrPlacement === 'number' ? titleOrPlacement : placement; NotificationUtils.show({ messageHtml, title: resolvedTitle, icon: NotificationIcon.Checkmark, type: NotificationType.Success, placement: resolvedPlacement ?? NotificationPlacement.TopCenter, }); } /** * Displays warning message * * @param messageHtml Success message * @param title Title of the message */ static showWarningMessage(message: string, placement?: NotificationPlacement): void; static showWarningMessage(message: string, title?: string, placement?: NotificationPlacement): void; static showWarningMessage( messageHtml: string, titleOrPlacement?: string | NotificationPlacement, placement?: NotificationPlacement, ): void { const resolvedTitle = typeof titleOrPlacement === 'string' ? titleOrPlacement : undefined; const resolvedPlacement = typeof titleOrPlacement === 'number' ? titleOrPlacement : placement; NotificationUtils.show({ messageHtml, title: resolvedTitle, icon: NotificationIcon.Exclamation, type: NotificationType.Warning, placement: resolvedPlacement ?? NotificationPlacement.TopRight, }); } }