atozas-push-notification
Version:
Real-time push notifications across platforms using socket.io
477 lines (408 loc) • 13.6 kB
text/typescript
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">×</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);
}
(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);
});
}
}
}