feat: Implement push notification system for admin announcements
- Add a notification manager to handle push notifications and integrate with Nostr events. - Create a push notification service to manage subscription and permission requests. - Introduce components for notification settings and permission prompts in the UI. - Update Nostr store to manage push notification state and enable/disable functionality. - Enhance NostrFeed to send notifications for new admin announcements. - Implement test notification functionality for development purposes.
This commit is contained in:
parent
6c1caac84b
commit
c05f40f1ec
17 changed files with 1316 additions and 13 deletions
|
|
@ -11,9 +11,15 @@ interface ApiConfig {
|
|||
key: string
|
||||
}
|
||||
|
||||
interface PushConfig {
|
||||
vapidPublicKey: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface AppConfig {
|
||||
nostr: NostrConfig
|
||||
api: ApiConfig
|
||||
push: PushConfig
|
||||
support: {
|
||||
npub: string
|
||||
}
|
||||
|
|
@ -41,6 +47,10 @@ export const config: AppConfig = {
|
|||
baseUrl: import.meta.env.VITE_API_BASE_URL || '',
|
||||
key: import.meta.env.VITE_API_KEY || ''
|
||||
},
|
||||
push: {
|
||||
vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY || '',
|
||||
enabled: Boolean(import.meta.env.VITE_PUSH_NOTIFICATIONS_ENABLED)
|
||||
},
|
||||
support: {
|
||||
npub: import.meta.env.VITE_SUPPORT_NPUB || ''
|
||||
}
|
||||
|
|
@ -60,6 +70,10 @@ export const configUtils = {
|
|||
return Boolean(config.api.baseUrl && config.api.key)
|
||||
},
|
||||
|
||||
hasPushConfig: (): boolean => {
|
||||
return Boolean(config.push.vapidPublicKey && config.push.enabled)
|
||||
},
|
||||
|
||||
getDefaultRelays: (): string[] => {
|
||||
return config.nostr.relays
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { SimplePool, type Filter, type Event, type UnsignedEvent } from 'nostr-tools'
|
||||
import { extractReactions, extractReplyCounts, getReplyInfo, EventKinds } from './events'
|
||||
import { SimplePool, type Filter, type Event } from 'nostr-tools'
|
||||
import { getReplyInfo, EventKinds } from './events'
|
||||
|
||||
export interface NostrClientConfig {
|
||||
relays: string[]
|
||||
|
|
@ -61,7 +61,6 @@ export class NostrClient {
|
|||
} = {}): Promise<NostrNote[]> {
|
||||
const {
|
||||
limit = 20,
|
||||
since = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000), // Last 7 days
|
||||
authors,
|
||||
includeReplies = false
|
||||
} = options
|
||||
|
|
@ -135,7 +134,7 @@ export class NostrClient {
|
|||
/**
|
||||
* Publish an event to all connected relays
|
||||
*/
|
||||
async publishEvent(event: UnsignedEvent): Promise<void> {
|
||||
async publishEvent(event: Event): Promise<void> {
|
||||
if (!this._isConnected) {
|
||||
throw new Error('Not connected to any relays')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'
|
||||
import { SecureStorage, type EncryptedData } from '@/lib/crypto/encryption'
|
||||
import { SecureStorage } from '@/lib/crypto/encryption'
|
||||
import { bytesToHex, hexToBytes } from '@/lib/utils/crypto'
|
||||
|
||||
export interface NostrIdentity {
|
||||
|
|
|
|||
233
src/lib/notifications/manager.ts
Normal file
233
src/lib/notifications/manager.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// Notification manager that integrates Nostr events with push notifications
|
||||
import { pushService, type NotificationPayload } from './push'
|
||||
import { configUtils } from '@/lib/config'
|
||||
import type { NostrNote } from '@/lib/nostr/client'
|
||||
|
||||
export interface NotificationOptions {
|
||||
enabled: boolean
|
||||
adminAnnouncements: boolean
|
||||
mentions: boolean
|
||||
replies: boolean
|
||||
sound: boolean
|
||||
}
|
||||
|
||||
export class NotificationManager {
|
||||
private static instance: NotificationManager
|
||||
private options: NotificationOptions = {
|
||||
enabled: true,
|
||||
adminAnnouncements: true,
|
||||
mentions: true,
|
||||
replies: false,
|
||||
sound: true
|
||||
}
|
||||
|
||||
static getInstance(): NotificationManager {
|
||||
if (!this.instance) {
|
||||
this.instance = new NotificationManager()
|
||||
}
|
||||
return this.instance
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.loadOptions()
|
||||
}
|
||||
|
||||
// Load notification options from localStorage
|
||||
private loadOptions(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem('notification-options')
|
||||
if (stored) {
|
||||
this.options = { ...this.options, ...JSON.parse(stored) }
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load notification options:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Save notification options to localStorage
|
||||
private saveOptions(): void {
|
||||
try {
|
||||
localStorage.setItem('notification-options', JSON.stringify(this.options))
|
||||
} catch (error) {
|
||||
console.warn('Failed to save notification options:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Get current options
|
||||
getOptions(): NotificationOptions {
|
||||
return { ...this.options }
|
||||
}
|
||||
|
||||
// Update options
|
||||
updateOptions(newOptions: Partial<NotificationOptions>): void {
|
||||
this.options = { ...this.options, ...newOptions }
|
||||
this.saveOptions()
|
||||
}
|
||||
|
||||
// Check if notifications should be sent for a note
|
||||
shouldNotify(note: NostrNote, userPubkey?: string): boolean {
|
||||
if (!this.options.enabled) return false
|
||||
|
||||
// Admin announcements
|
||||
if (this.options.adminAnnouncements && configUtils.isAdminPubkey(note.pubkey)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Mentions (if user is mentioned in the note)
|
||||
if (this.options.mentions && userPubkey && note.mentions.includes(userPubkey)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Replies (if it's a reply to user's note)
|
||||
if (this.options.replies && userPubkey && note.isReply && note.replyTo) {
|
||||
// We'd need to check if the reply is to the user's note
|
||||
// This would require additional context about the user's notes
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Create notification payload from Nostr note
|
||||
private createNotificationPayload(note: NostrNote): NotificationPayload {
|
||||
const isAdmin = configUtils.isAdminPubkey(note.pubkey)
|
||||
|
||||
let title = 'New Note'
|
||||
let body = note.content
|
||||
let tag = 'nostr-note'
|
||||
|
||||
if (isAdmin) {
|
||||
title = '🚨 Admin Announcement'
|
||||
tag = 'admin-announcement'
|
||||
} else if (note.isReply) {
|
||||
title = 'Reply'
|
||||
tag = 'reply'
|
||||
} else if (note.mentions.length > 0) {
|
||||
title = 'Mention'
|
||||
tag = 'mention'
|
||||
}
|
||||
|
||||
// Truncate long content
|
||||
if (body.length > 100) {
|
||||
body = body.slice(0, 100) + '...'
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
body,
|
||||
icon: '/pwa-192x192.png',
|
||||
badge: '/pwa-192x192.png',
|
||||
tag,
|
||||
requireInteraction: isAdmin, // Admin announcements require interaction
|
||||
data: {
|
||||
noteId: note.id,
|
||||
pubkey: note.pubkey,
|
||||
isAdmin,
|
||||
url: '/',
|
||||
timestamp: note.created_at
|
||||
},
|
||||
actions: isAdmin ? [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View'
|
||||
},
|
||||
{
|
||||
action: 'dismiss',
|
||||
title: 'Dismiss'
|
||||
}
|
||||
] : [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Send notification for a Nostr note
|
||||
async notifyForNote(note: NostrNote, userPubkey?: string): Promise<void> {
|
||||
try {
|
||||
if (!this.shouldNotify(note, userPubkey)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!pushService.isSupported()) {
|
||||
console.warn('Push notifications not supported')
|
||||
return
|
||||
}
|
||||
|
||||
const isSubscribed = await pushService.isSubscribed()
|
||||
if (!isSubscribed) {
|
||||
console.log('User not subscribed to push notifications')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = this.createNotificationPayload(note)
|
||||
await pushService.showLocalNotification(payload)
|
||||
|
||||
console.log('Notification sent for note:', note.id)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to send notification for note:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Send test notification
|
||||
async sendTestNotification(): Promise<void> {
|
||||
const testPayload: NotificationPayload = {
|
||||
title: '🚨 Test Admin Announcement',
|
||||
body: 'This is a test notification to verify push notifications are working correctly.',
|
||||
icon: '/pwa-192x192.png',
|
||||
badge: '/pwa-192x192.png',
|
||||
tag: 'test-notification',
|
||||
requireInteraction: true,
|
||||
data: {
|
||||
url: '/',
|
||||
type: 'test',
|
||||
timestamp: Date.now()
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View App'
|
||||
},
|
||||
{
|
||||
action: 'dismiss',
|
||||
title: 'Dismiss'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
await pushService.showLocalNotification(testPayload)
|
||||
}
|
||||
|
||||
// Handle background notification processing
|
||||
async processBackgroundNote(noteData: any): Promise<void> {
|
||||
// This would be called from the service worker
|
||||
// when receiving push notifications from a backend
|
||||
try {
|
||||
const payload = this.createNotificationPayload(noteData)
|
||||
|
||||
// Show notification via service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
await registration.showNotification(payload.title, payload)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to process background notification:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user has denied notifications
|
||||
isBlocked(): boolean {
|
||||
return pushService.getPermission() === 'denied'
|
||||
}
|
||||
|
||||
// Check if notifications are enabled and configured
|
||||
isConfigured(): boolean {
|
||||
return pushService.isSupported() && configUtils.hasPushConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const notificationManager = NotificationManager.getInstance()
|
||||
220
src/lib/notifications/push.ts
Normal file
220
src/lib/notifications/push.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
// Push notification service for browser push API
|
||||
import { config, configUtils } from '@/lib/config'
|
||||
|
||||
export interface PushSubscriptionData {
|
||||
endpoint: string
|
||||
keys: {
|
||||
p256dh: string
|
||||
auth: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
title: string
|
||||
body: string
|
||||
icon?: string
|
||||
badge?: string
|
||||
data?: Record<string, any>
|
||||
tag?: string
|
||||
requireInteraction?: boolean
|
||||
actions?: Array<{
|
||||
action: string
|
||||
title: string
|
||||
icon?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export class PushNotificationService {
|
||||
private static instance: PushNotificationService
|
||||
|
||||
static getInstance(): PushNotificationService {
|
||||
if (!this.instance) {
|
||||
this.instance = new PushNotificationService()
|
||||
}
|
||||
return this.instance
|
||||
}
|
||||
|
||||
// Check if push notifications are supported
|
||||
isSupported(): boolean {
|
||||
return 'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
}
|
||||
|
||||
// Check if push notifications are configured
|
||||
isConfigured(): boolean {
|
||||
return configUtils.hasPushConfig()
|
||||
}
|
||||
|
||||
// Get current notification permission
|
||||
getPermission(): NotificationPermission {
|
||||
return Notification.permission
|
||||
}
|
||||
|
||||
// Request notification permission
|
||||
async requestPermission(): Promise<NotificationPermission> {
|
||||
if (!this.isSupported()) {
|
||||
throw new Error('Push notifications are not supported')
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission()
|
||||
console.log('Notification permission:', permission)
|
||||
return permission
|
||||
}
|
||||
|
||||
// Convert Uint8Array to base64 URL-safe string
|
||||
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
|
||||
}
|
||||
|
||||
// Subscribe to push notifications
|
||||
async subscribe(): Promise<PushSubscriptionData> {
|
||||
if (!this.isSupported()) {
|
||||
throw new Error('Push notifications are not supported')
|
||||
}
|
||||
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error('Push notifications are not configured. Missing VAPID key.')
|
||||
}
|
||||
|
||||
if (this.getPermission() !== 'granted') {
|
||||
const permission = await this.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('Push notification permission denied')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
|
||||
// Check if already subscribed
|
||||
const existingSubscription = await registration.pushManager.getSubscription()
|
||||
if (existingSubscription) {
|
||||
return this.subscriptionToData(existingSubscription)
|
||||
}
|
||||
|
||||
// Create new subscription
|
||||
const applicationServerKey = this.urlBase64ToUint8Array(config.push.vapidPublicKey)
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
})
|
||||
|
||||
const subscriptionData = this.subscriptionToData(subscription)
|
||||
|
||||
console.log('Push subscription created:', subscriptionData)
|
||||
return subscriptionData
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to subscribe to push notifications:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe from push notifications
|
||||
async unsubscribe(): Promise<void> {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
|
||||
if (subscription) {
|
||||
await subscription.unsubscribe()
|
||||
console.log('Push subscription removed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to unsubscribe from push notifications:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Get current subscription
|
||||
async getSubscription(): Promise<PushSubscriptionData | null> {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
|
||||
if (subscription) {
|
||||
return this.subscriptionToData(subscription)
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Failed to get push subscription:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Convert PushSubscription to our data format
|
||||
private subscriptionToData(subscription: PushSubscription): PushSubscriptionData {
|
||||
const keys = subscription.getKey('p256dh')
|
||||
const auth = subscription.getKey('auth')
|
||||
|
||||
if (!keys || !auth) {
|
||||
throw new Error('Failed to get subscription keys')
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: btoa(String.fromCharCode(...new Uint8Array(keys))),
|
||||
auth: btoa(String.fromCharCode(...new Uint8Array(auth)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show a local notification (for testing)
|
||||
async showLocalNotification(payload: NotificationPayload): Promise<void> {
|
||||
if (!this.isSupported()) {
|
||||
throw new Error('Notifications are not supported')
|
||||
}
|
||||
|
||||
if (this.getPermission() !== 'granted') {
|
||||
await this.requestPermission()
|
||||
}
|
||||
|
||||
if (this.getPermission() !== 'granted') {
|
||||
throw new Error('Notification permission denied')
|
||||
}
|
||||
|
||||
// Send message to service worker to show notification
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
|
||||
if (registration.active) {
|
||||
registration.active.postMessage({
|
||||
type: 'SHOW_NOTIFICATION',
|
||||
payload
|
||||
})
|
||||
} else {
|
||||
// Fallback: show browser notification directly
|
||||
new Notification(payload.title, {
|
||||
body: payload.body,
|
||||
icon: payload.icon || '/pwa-192x192.png',
|
||||
badge: payload.badge || '/pwa-192x192.png',
|
||||
tag: payload.tag,
|
||||
requireInteraction: payload.requireInteraction,
|
||||
data: payload.data
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is subscribed to push notifications
|
||||
async isSubscribed(): Promise<boolean> {
|
||||
const subscription = await this.getSubscription()
|
||||
return subscription !== null
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pushService = PushNotificationService.getInstance()
|
||||
Loading…
Add table
Add a link
Reference in a new issue