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:
padreug 2025-07-03 08:34:05 +02:00
parent 6c1caac84b
commit c05f40f1ec
17 changed files with 1316 additions and 13 deletions

View file

@ -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
}

View file

@ -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')
}

View file

@ -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 {

View 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()

View 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()