UNPKG

atozas-push-notification

Version:

Real-time push notifications across platforms using socket.io

477 lines (408 loc) 13.6 kB
import { NotificationData, NotificationOptions, ClientConfig, NotificationPermission } from './types'; export class NotificationDisplayManager { private config: ClientConfig; private permissionStatus: NotificationPermission = 'default'; private customContainer: HTMLElement | null = null; private activeNotifications: Map<string, any> = new Map(); constructor(config: ClientConfig) { this.config = { requestPermission: true, fallbackToUI: true, showNotifications: true, enableVibration: true, ...config }; this.init(); } /** * Initialize notification system */ private async init(): Promise<void> { if (!this.config.showNotifications) return; // Check if we're in a browser environment if (typeof window !== 'undefined' && 'Notification' in window) { this.permissionStatus = (Notification.permission as NotificationPermission); if (this.config.requestPermission && this.permissionStatus === 'default') { await this.requestPermission(); } } // Create custom UI container if fallback is enabled if (this.config.fallbackToUI) { this.createCustomUIContainer(); } // Inject CSS for custom notifications this.injectNotificationStyles(); } /** * Request notification permission */ public async requestPermission(): Promise<NotificationPermission> { if (typeof window === 'undefined' || !('Notification' in window)) { return 'denied'; } if (Notification.permission !== 'denied') { this.permissionStatus = await Notification.requestPermission(); } return this.permissionStatus; } /** * Display notification */ public async displayNotification(notification: NotificationData, options?: NotificationOptions): Promise<void> { if (!this.config.showNotifications) return; const mergedOptions = { icon: this.config.defaultNotificationIcon, autoClose: 5000, position: 'top-right' as const, showInUI: false, ...options }; // Try browser notification first if (this.canUseBrowserNotifications()) { this.showBrowserNotification(notification, mergedOptions); } // Fallback to custom UI else if (this.config.fallbackToUI || mergedOptions.showInUI) { this.showCustomNotification(notification, mergedOptions); } // Handle vibration if (this.config.enableVibration && mergedOptions.vibrate && 'vibrate' in navigator) { navigator.vibrate(200); } // Play sound if specified if (mergedOptions.sound && this.config.defaultNotificationSound) { this.playNotificationSound(this.config.defaultNotificationSound); } } /** * Check if browser notifications can be used */ private canUseBrowserNotifications(): boolean { return typeof window !== 'undefined' && 'Notification' in window && this.permissionStatus === 'granted'; } /** * Show browser notification */ private showBrowserNotification(notification: NotificationData, options: NotificationOptions): void { const notificationOptions: any = { body: notification.message, icon: options.icon, image: options.image, badge: options.badge ? 'badge' : undefined, tag: options.tag || notification.id, renotify: options.renotify, silent: options.silent, requireInteraction: options.requireInteraction, dir: options.dir, lang: options.lang, timestamp: options.timestamp || notification.timestamp, data: notification.data }; const browserNotification = new Notification(notification.title, notificationOptions); // Store notification reference this.activeNotifications.set(notification.id, browserNotification); // Handle click events browserNotification.onclick = () => { window.focus(); browserNotification.close(); this.handleNotificationClick(notification); }; // Auto close if specified if (options.autoClose && options.autoClose > 0) { setTimeout(() => { browserNotification.close(); this.activeNotifications.delete(notification.id); }, options.autoClose); } // Clean up when closed browserNotification.onclose = () => { this.activeNotifications.delete(notification.id); }; } /** * Show custom UI notification */ private showCustomNotification(notification: NotificationData, options: NotificationOptions): void { if (!this.customContainer) return; const notificationEl = document.createElement('div'); notificationEl.className = `atozas-notification atozas-notification-${options.position}`; notificationEl.setAttribute('data-id', notification.id); // Priority class if (notification.priority) { notificationEl.classList.add(`atozas-notification-${notification.priority}`); } notificationEl.innerHTML = ` <div class="atozas-notification-content"> ${options.icon ? `<img class="atozas-notification-icon" src="${options.icon}" alt=""/>` : ''} <div class="atozas-notification-text"> <div class="atozas-notification-title">${this.escapeHtml(notification.title)}</div> <div class="atozas-notification-message">${this.escapeHtml(notification.message)}</div> </div> <button class="atozas-notification-close">&times;</button> </div> ${options.image ? `<img class="atozas-notification-image" src="${options.image}" alt=""/>` : ''} `; // Add click handler notificationEl.addEventListener('click', (e) => { if ((e.target as HTMLElement).classList.contains('atozas-notification-close')) { this.closeCustomNotification(notification.id); } else { this.handleNotificationClick(notification); this.closeCustomNotification(notification.id); } }); // Add to container this.customContainer.appendChild(notificationEl); // Store reference this.activeNotifications.set(notification.id, notificationEl); // Animate in setTimeout(() => { notificationEl.classList.add('atozas-notification-show'); }, 100); // Auto close if specified if (options.autoClose && options.autoClose > 0) { setTimeout(() => { this.closeCustomNotification(notification.id); }, options.autoClose); } } /** * Close custom notification */ private closeCustomNotification(notificationId: string): void { const notificationEl = this.activeNotifications.get(notificationId); if (notificationEl && notificationEl instanceof HTMLElement) { notificationEl.classList.remove('atozas-notification-show'); setTimeout(() => { if (notificationEl.parentNode) { notificationEl.parentNode.removeChild(notificationEl); } this.activeNotifications.delete(notificationId); }, 300); } } /** * Handle notification click */ private handleNotificationClick(notification: NotificationData): void { // Emit custom event for notification click if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('atozas-notification-click', { detail: notification })); } } /** * Play notification sound */ private playNotificationSound(soundUrl: string): void { if (typeof window !== 'undefined') { const audio = new Audio(soundUrl); audio.play().catch(() => { // Ignore audio play errors }); } } /** * Create custom UI container */ private createCustomUIContainer(): void { if (typeof window === 'undefined') return; this.customContainer = document.getElementById('atozas-notifications'); if (!this.customContainer) { this.customContainer = document.createElement('div'); this.customContainer.id = 'atozas-notifications'; this.customContainer.className = 'atozas-notifications-container'; document.body.appendChild(this.customContainer); } } /** * Inject notification styles */ private injectNotificationStyles(): void { if (typeof window === 'undefined') return; const styleId = 'atozas-notification-styles'; if (document.getElementById(styleId)) return; const style = document.createElement('style'); style.id = styleId; style.textContent = ` .atozas-notifications-container { position: fixed; z-index: 1000000; pointer-events: none; top: 0; left: 0; right: 0; bottom: 0; } .atozas-notification { position: absolute; max-width: 400px; min-width: 300px; background: #ffffff; border-radius: 8px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); pointer-events: auto; transform: translateX(100%); transition: transform 0.3s ease, opacity 0.3s ease; opacity: 0; margin: 16px; border-left: 4px solid #007bff; overflow: hidden; } .atozas-notification-show { transform: translateX(0) !important; opacity: 1 !important; } .atozas-notification-top-right { top: 0; right: 0; } .atozas-notification-top-left { top: 0; left: 0; transform: translateX(-100%); } .atozas-notification-top-left.atozas-notification-show { transform: translateX(0) !important; } .atozas-notification-bottom-right { bottom: 0; right: 0; } .atozas-notification-bottom-left { bottom: 0; left: 0; transform: translateX(-100%); } .atozas-notification-bottom-left.atozas-notification-show { transform: translateX(0) !important; } .atozas-notification-top-center { top: 0; left: 50%; transform: translateX(-50%) translateY(-100%); } .atozas-notification-top-center.atozas-notification-show { transform: translateX(-50%) translateY(0) !important; } .atozas-notification-bottom-center { bottom: 0; left: 50%; transform: translateX(-50%) translateY(100%); } .atozas-notification-bottom-center.atozas-notification-show { transform: translateX(-50%) translateY(0) !important; } .atozas-notification-high { border-left-color: #dc3545; } .atozas-notification-low { border-left-color: #6c757d; } .atozas-notification-content { display: flex; align-items: flex-start; padding: 16px; gap: 12px; } .atozas-notification-icon { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; } .atozas-notification-text { flex: 1; min-width: 0; } .atozas-notification-title { font-weight: 600; font-size: 14px; color: #333333; margin-bottom: 4px; line-height: 1.3; } .atozas-notification-message { font-size: 13px; color: #666666; line-height: 1.4; word-wrap: break-word; } .atozas-notification-close { background: none; border: none; font-size: 20px; color: #999999; cursor: pointer; padding: 0; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: background-color 0.2s ease; flex-shrink: 0; } .atozas-notification-close:hover { background-color: #f5f5f5; color: #333333; } .atozas-notification-image { width: 100%; max-height: 200px; object-fit: cover; } .atozas-notification:hover { box-shadow: 0 6px 25px rgba(0, 0, 0, 0.2); } @media (max-width: 480px) { .atozas-notification { max-width: calc(100vw - 32px); min-width: calc(100vw - 32px); } } `; document.head.appendChild(style); } /** * Escape HTML to prevent XSS */ private escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Close all notifications */ public closeAllNotifications(): void { this.activeNotifications.forEach((notification, id) => { if (notification instanceof Notification) { notification.close(); } else { this.closeCustomNotification(id); } }); this.activeNotifications.clear(); } /** * Get permission status */ public getPermissionStatus(): NotificationPermission { return this.permissionStatus; } /** * Set notification click handler */ public onNotificationClick(callback: (notification: NotificationData) => void): void { if (typeof window !== 'undefined') { window.addEventListener('atozas-notification-click', (event: any) => { callback(event.detail); }); } } }