mirror of
https://github.com/xtr-dev/payload-notifications.git
synced 2025-12-10 10:53:23 +00:00
255 lines
6.8 KiB
TypeScript
255 lines
6.8 KiB
TypeScript
/**
|
|
* Client-side Push Notification Manager
|
|
* Handles subscription, permission requests, and communication with the server
|
|
*
|
|
* @description This module is designed to run in browser environments only
|
|
*/
|
|
|
|
export interface PushSubscriptionData {
|
|
endpoint: string
|
|
keys: {
|
|
p256dh: string
|
|
auth: string
|
|
}
|
|
}
|
|
|
|
// Check if we're in a browser environment
|
|
const isBrowser = typeof window !== 'undefined'
|
|
|
|
export class ClientPushManager {
|
|
private readonly vapidPublicKey: string
|
|
private readonly serviceWorkerPath: string
|
|
private readonly apiEndpoint: string
|
|
|
|
constructor(
|
|
vapidPublicKey: string,
|
|
options: {
|
|
serviceWorkerPath?: string
|
|
apiEndpoint?: string
|
|
} = {}
|
|
) {
|
|
this.vapidPublicKey = vapidPublicKey
|
|
this.serviceWorkerPath = options.serviceWorkerPath || '/sw.js'
|
|
this.apiEndpoint = options.apiEndpoint || '/api/push-notifications'
|
|
}
|
|
|
|
/**
|
|
* Check if push notifications are supported
|
|
*/
|
|
public isSupported(): boolean {
|
|
if (!isBrowser) return false
|
|
|
|
return (
|
|
'serviceWorker' in navigator &&
|
|
'PushManager' in window &&
|
|
'Notification' in window
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Check current notification permission status
|
|
*/
|
|
public getPermissionStatus(): NotificationPermission {
|
|
if (!isBrowser || typeof Notification === 'undefined') return 'default'
|
|
return Notification.permission
|
|
}
|
|
|
|
/**
|
|
* Request notification permission from user
|
|
*/
|
|
public async requestPermission(): Promise<NotificationPermission> {
|
|
if (!this.isSupported()) {
|
|
throw new Error('Push notifications are not supported')
|
|
}
|
|
|
|
return await Notification.requestPermission()
|
|
}
|
|
|
|
/**
|
|
* Register service worker
|
|
*/
|
|
public async registerServiceWorker(): Promise<ServiceWorkerRegistration> {
|
|
if (!isBrowser || !('serviceWorker' in navigator)) {
|
|
throw new Error('Service workers are not supported')
|
|
}
|
|
|
|
try {
|
|
const registration = await navigator.serviceWorker.register(this.serviceWorkerPath)
|
|
console.log('Service worker registered:', registration)
|
|
return registration
|
|
} catch (error) {
|
|
console.error('Service worker registration failed:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subscribe to push notifications
|
|
*/
|
|
public async subscribe(channels: string[]): Promise<PushSubscriptionData> {
|
|
// Check support
|
|
if (!this.isSupported()) {
|
|
throw new Error('Push notifications are not supported')
|
|
}
|
|
|
|
// Request permission
|
|
const permission = await this.requestPermission()
|
|
if (permission !== 'granted') {
|
|
throw new Error('Notification permission not granted')
|
|
}
|
|
|
|
// Register service worker
|
|
const registration = await this.registerServiceWorker()
|
|
|
|
// Wait for service worker to be ready
|
|
await navigator.serviceWorker.ready
|
|
|
|
// Subscribe to push notifications
|
|
const subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey),
|
|
})
|
|
|
|
const subscriptionData: PushSubscriptionData = {
|
|
endpoint: subscription.endpoint,
|
|
keys: {
|
|
p256dh: this.arrayBufferToBase64(subscription.getKey('p256dh')!),
|
|
auth: this.arrayBufferToBase64(subscription.getKey('auth')!),
|
|
},
|
|
}
|
|
|
|
// Send subscription to server
|
|
await this.sendSubscriptionToServer(subscriptionData, channels)
|
|
|
|
return subscriptionData
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe from push notifications
|
|
*/
|
|
public async unsubscribe(): Promise<void> {
|
|
const registration = await navigator.serviceWorker.getRegistration()
|
|
if (!registration) {
|
|
return
|
|
}
|
|
|
|
const subscription = await registration.pushManager.getSubscription()
|
|
if (!subscription) {
|
|
return
|
|
}
|
|
|
|
// Unsubscribe from push service
|
|
await subscription.unsubscribe()
|
|
|
|
// Notify server
|
|
await this.sendUnsubscribeToServer(subscription.endpoint)
|
|
}
|
|
|
|
/**
|
|
* Get current push subscription
|
|
*/
|
|
public async getSubscription(): Promise<Omit<PushSubscriptionData, 'channels'> | null> {
|
|
if (!isBrowser || !('serviceWorker' in navigator)) return null
|
|
|
|
const registration = await navigator.serviceWorker.getRegistration()
|
|
if (!registration) {
|
|
return null
|
|
}
|
|
|
|
const subscription = await registration.pushManager.getSubscription()
|
|
if (!subscription) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
endpoint: subscription.endpoint,
|
|
keys: {
|
|
p256dh: this.arrayBufferToBase64(subscription.getKey('p256dh')!),
|
|
auth: this.arrayBufferToBase64(subscription.getKey('auth')!),
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user is currently subscribed
|
|
*/
|
|
public async isSubscribed(): Promise<boolean> {
|
|
if (!isBrowser) return false
|
|
const subscription = await this.getSubscription()
|
|
return subscription !== null
|
|
}
|
|
|
|
/**
|
|
* Send subscription data to server
|
|
*/
|
|
private async sendSubscriptionToServer(subscription: PushSubscriptionData, channels: string[]): Promise<void> {
|
|
try {
|
|
const response = await fetch(`${this.apiEndpoint}/subscribe`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
subscription,
|
|
channels,
|
|
userAgent: navigator.userAgent,
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to subscribe: ${response.statusText}`)
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to send subscription to server:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send unsubscribe request to server
|
|
*/
|
|
private async sendUnsubscribeToServer(endpoint: string): Promise<void> {
|
|
try {
|
|
const response = await fetch(`${this.apiEndpoint}/unsubscribe`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ endpoint }),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to unsubscribe: ${response.statusText}`)
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to send unsubscribe to server:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert VAPID public key to Uint8Array
|
|
*/
|
|
private urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
|
|
|
const rawData = window.atob(base64)
|
|
const outputArray = new Uint8Array(rawData.length)
|
|
|
|
for (let i = 0; i < rawData.length; ++i) {
|
|
outputArray[i] = rawData.charCodeAt(i)
|
|
}
|
|
return outputArray
|
|
}
|
|
|
|
/**
|
|
* Convert ArrayBuffer to base64 string
|
|
*/
|
|
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
const bytes = new Uint8Array(buffer)
|
|
const binary = Array.from(bytes).map(b => String.fromCharCode(b)).join('')
|
|
return window.btoa(binary)
|
|
}
|
|
}
|