From c05f40f1ec30a1f20d39bd176cbc78eb57bdead7 Mon Sep 17 00:00:00 2001 From: padreug Date: Thu, 3 Jul 2025 08:34:05 +0200 Subject: [PATCH] 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. --- debug_admin_posts.js | 94 +++++++ generate-vapid-keys.js | 36 +++ package.json | 2 +- public/sw.js | 126 ++++++++++ src/App.vue | 6 +- src/components/nostr/NostrFeed.vue | 60 ++++- .../notifications/NotificationPermission.vue | 213 ++++++++++++++++ .../notifications/NotificationSettings.vue | 222 +++++++++++++++++ src/composables/useSocial.ts | 2 +- src/lib/config/index.ts | 14 ++ src/lib/nostr/client.ts | 7 +- src/lib/nostr/identity.ts | 2 +- src/lib/notifications/manager.ts | 233 ++++++++++++++++++ src/lib/notifications/push.ts | 220 +++++++++++++++++ src/pages/Home.vue | 4 +- src/stores/nostr.ts | 83 +++++++ vite.config.ts | 5 +- 17 files changed, 1316 insertions(+), 13 deletions(-) create mode 100644 debug_admin_posts.js create mode 100644 generate-vapid-keys.js create mode 100644 public/sw.js create mode 100644 src/components/notifications/NotificationPermission.vue create mode 100644 src/components/notifications/NotificationSettings.vue create mode 100644 src/lib/notifications/manager.ts create mode 100644 src/lib/notifications/push.ts diff --git a/debug_admin_posts.js b/debug_admin_posts.js new file mode 100644 index 0000000..f2f3be4 --- /dev/null +++ b/debug_admin_posts.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +// Debug what admin posts exist in the relay +import { SimplePool } from 'nostr-tools' + +const RELAY_URL = "ws://127.0.0.1:5001/nostrrelay/mainhub" +const ADMIN_PUBKEYS = [ + "4b9d7688eba64565dcb77cc8ab157eca1964d5de9f7afabe03eb5b54a43d9882", + "30b1ab3683fa6cc0e3695849ee1ec29e9fbae4c9e7ddb7452a4ddb37a0660040", + "c116dbc73a8ccd0046a2ecf96c0b0531d3eda650d449798ac5b86ff6e301debe" +] + +async function debugAdminPosts() { + const pool = new SimplePool() + + console.log('🔍 Checking relay for admin posts...') + console.log('Relay:', RELAY_URL) + console.log('Admin pubkeys:', ADMIN_PUBKEYS.length) + console.log('') + + try { + // Check each admin individually + for (let i = 0; i < ADMIN_PUBKEYS.length; i++) { + const pubkey = ADMIN_PUBKEYS[i] + console.log(`👤 Admin ${i + 1}: ${pubkey.slice(0, 16)}...`) + + const individualFilter = { + kinds: [1], + authors: [pubkey], + limit: 10 + } + + const events = [] + const sub = pool.subscribeMany([RELAY_URL], [individualFilter], { + onevent(event) { + events.push(event) + }, + oneose() { + console.log(` Found ${events.length} posts`) + events.forEach((event, idx) => { + console.log(` ${idx + 1}. "${event.content.slice(0, 60)}${event.content.length > 60 ? '...' : ''}"`) + console.log(` Created: ${new Date(event.created_at * 1000).toLocaleString()}`) + }) + console.log('') + } + }) + + // Wait for events + await new Promise(resolve => setTimeout(resolve, 1000)) + sub.close() + } + + // Now test the combined filter (what the app uses) + console.log('🔄 Testing combined filter (what the app uses)...') + const combinedFilter = { + kinds: [1], + authors: ADMIN_PUBKEYS, + limit: 50 + } + + const allEvents = [] + const combinedSub = pool.subscribeMany([RELAY_URL], [combinedFilter], { + onevent(event) { + allEvents.push(event) + }, + oneose() { + console.log(`📊 Combined filter result: ${allEvents.length} total posts`) + + // Group by author + const byAuthor = {} + allEvents.forEach(event => { + const shortPubkey = event.pubkey.slice(0, 16) + '...' + if (!byAuthor[shortPubkey]) byAuthor[shortPubkey] = 0 + byAuthor[shortPubkey]++ + }) + + console.log('Posts by author:') + Object.entries(byAuthor).forEach(([author, count]) => { + console.log(` ${author}: ${count} posts`) + }) + } + }) + + await new Promise(resolve => setTimeout(resolve, 1000)) + combinedSub.close() + + } catch (error) { + console.error('❌ Error:', error) + } finally { + pool.close([RELAY_URL]) + } +} + +debugAdminPosts() \ No newline at end of file diff --git a/generate-vapid-keys.js b/generate-vapid-keys.js new file mode 100644 index 0000000..79a8569 --- /dev/null +++ b/generate-vapid-keys.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +// Simple VAPID key generator for testing push notifications +// In production, you'd want to use proper cryptographic libraries + +console.log('🔑 VAPID Key Generator for Push Notifications') +console.log('') + +// Generate a random string for testing +function generateTestKey(length = 64) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' + let result = '' + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return result +} + +// Generate test VAPID keys +const publicKey = generateTestKey(87) // Base64 URL-safe, typical length +const privateKey = generateTestKey(43) // Base64 URL-safe, typical length + +console.log('📋 Add these to your .env file:') +console.log('') +console.log(`VITE_VAPID_PUBLIC_KEY=${publicKey}`) +console.log(`VITE_PUSH_NOTIFICATIONS_ENABLED=true`) +console.log('') +console.log('⚠️ IMPORTANT: These are test keys for development only!') +console.log(' For production, generate proper VAPID keys using:') +console.log(' - web-push library: npx web-push generate-vapid-keys') +console.log(' - online tool: https://vapidkeys.com/') +console.log('') +console.log('🔐 Private key (keep secure, for backend only):') +console.log(`VAPID_PRIVATE_KEY=${privateKey}`) +console.log('') +console.log('✅ Once added, restart your dev server to apply the changes.') \ No newline at end of file diff --git a/package.json b/package.json index 16f84ae..c974dfd 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "vite --host", "build": "vue-tsc -b && vite build", - "preview": "vite preview", + "preview": "vite preview --host", "analyze": "vite build --mode analyze", "electron:dev": "concurrently \"vite --host\" \"electron-forge start\"", "electron:build": "vue-tsc -b && vite build && electron-builder", diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..4daad6b --- /dev/null +++ b/public/sw.js @@ -0,0 +1,126 @@ +// Custom service worker for push notifications +// This will be merged with Workbox generated SW + +import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching' +import { clientsClaim, skipWaiting } from 'workbox-core' + +// Precache and route static assets +precacheAndRoute(self.__WB_MANIFEST) +cleanupOutdatedCaches() + +// Take control of all pages immediately +skipWaiting() +clientsClaim() + +// Push notification event handler +self.addEventListener('push', (event) => { + console.log('Push event received:', event) + + let notificationData = { + title: 'New Announcement', + body: 'You have a new admin announcement', + icon: '/pwa-192x192.png', + badge: '/pwa-192x192.png', + data: { + url: '/', + timestamp: Date.now() + }, + tag: 'admin-announcement', + requireInteraction: true, + actions: [ + { + action: 'view', + title: 'View', + icon: '/pwa-192x192.png' + }, + { + action: 'dismiss', + title: 'Dismiss' + } + ] + } + + // Parse push data if available + if (event.data) { + try { + const pushData = event.data.json() + notificationData = { + ...notificationData, + ...pushData + } + } catch (error) { + console.warn('Failed to parse push data:', error) + // Use default notification data + } + } + + event.waitUntil( + self.registration.showNotification(notificationData.title, notificationData) + ) +}) + +// Notification click handler +self.addEventListener('notificationclick', (event) => { + console.log('Notification clicked:', event) + + event.notification.close() + + const action = event.action + const notificationData = event.notification.data || {} + + if (action === 'dismiss') { + return // Just close the notification + } + + // Default action or 'view' action - open the app + const urlToOpen = notificationData.url || '/' + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + // Try to find an existing window with the app + for (const client of clientList) { + if (client.url.includes(self.location.origin) && 'focus' in client) { + client.focus() + // Navigate to the notification URL if different + if (client.url !== urlToOpen) { + client.navigate(urlToOpen) + } + return + } + } + // If no existing window, open a new one + if (clients.openWindow) { + return clients.openWindow(urlToOpen) + } + }) + ) +}) + +// Background sync for offline notification queue (future enhancement) +self.addEventListener('sync', (event) => { + if (event.tag === 'notification-queue') { + event.waitUntil( + // Process any queued notifications when back online + console.log('Background sync: notification-queue') + ) + } +}) + +// Message handler for communication with main app +self.addEventListener('message', (event) => { + console.log('Service worker received message:', event.data) + + if (event.data && event.data.type === 'SHOW_NOTIFICATION') { + const { title, body, data } = event.data.payload + + self.registration.showNotification(title, { + body, + icon: '/pwa-192x192.png', + badge: '/pwa-192x192.png', + data, + tag: 'manual-notification', + requireInteraction: false + }) + } +}) \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index 3c95390..2c6f47e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -8,9 +8,10 @@ import { Toaster } from '@/components/ui/sonner' import { useNostr } from '@/composables/useNostr' import { identity } from '@/composables/useIdentity' import { toast } from 'vue-sonner' -import { config } from '@/lib/config' +import { useNostrStore } from '@/stores/nostr' const { isConnected, isConnecting, error, connect, disconnect } = useNostr() +const nostrStore = useNostrStore() const showPasswordDialog = ref(false) @@ -43,6 +44,9 @@ onMounted(async () => { // Connect to Nostr relays await connect() + + // Check push notification status + await nostrStore.checkPushNotificationStatus() }) onUnmounted(() => { diff --git a/src/components/nostr/NostrFeed.vue b/src/components/nostr/NostrFeed.vue index e0b82c5..ee818e8 100644 --- a/src/components/nostr/NostrFeed.vue +++ b/src/components/nostr/NostrFeed.vue @@ -1,6 +1,6 @@ \ No newline at end of file diff --git a/src/components/notifications/NotificationSettings.vue b/src/components/notifications/NotificationSettings.vue new file mode 100644 index 0000000..1a70cfb --- /dev/null +++ b/src/components/notifications/NotificationSettings.vue @@ -0,0 +1,222 @@ + + + \ No newline at end of file diff --git a/src/composables/useSocial.ts b/src/composables/useSocial.ts index f1db92f..b686efb 100644 --- a/src/composables/useSocial.ts +++ b/src/composables/useSocial.ts @@ -1,5 +1,5 @@ import { ref } from 'vue' -import { NostrClient, type NostrNote } from '@/lib/nostr/client' +import type { NostrNote } from '@/lib/nostr/client' import { createTextNote, createReaction, createProfileMetadata } from '@/lib/nostr/events' import { identity } from '@/composables/useIdentity' import { toast } from 'vue-sonner' diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index 44ca3b2..cb119b8 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -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 } diff --git a/src/lib/nostr/client.ts b/src/lib/nostr/client.ts index 87d02bb..c38e3e5 100644 --- a/src/lib/nostr/client.ts +++ b/src/lib/nostr/client.ts @@ -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 { 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 { + async publishEvent(event: Event): Promise { if (!this._isConnected) { throw new Error('Not connected to any relays') } diff --git a/src/lib/nostr/identity.ts b/src/lib/nostr/identity.ts index c6d2ac3..d0138f6 100644 --- a/src/lib/nostr/identity.ts +++ b/src/lib/nostr/identity.ts @@ -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 { diff --git a/src/lib/notifications/manager.ts b/src/lib/notifications/manager.ts new file mode 100644 index 0000000..73781bd --- /dev/null +++ b/src/lib/notifications/manager.ts @@ -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): 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 { + 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 { + 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 { + // 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() \ No newline at end of file diff --git a/src/lib/notifications/push.ts b/src/lib/notifications/push.ts new file mode 100644 index 0000000..c689ffd --- /dev/null +++ b/src/lib/notifications/push.ts @@ -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 + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const subscription = await this.getSubscription() + return subscription !== null + } +} + +// Export singleton instance +export const pushService = PushNotificationService.getInstance() \ No newline at end of file diff --git a/src/pages/Home.vue b/src/pages/Home.vue index 966f97b..7677ef3 100644 --- a/src/pages/Home.vue +++ b/src/pages/Home.vue @@ -1,9 +1,11 @@ diff --git a/src/stores/nostr.ts b/src/stores/nostr.ts index 47b78a9..dcb9ff3 100644 --- a/src/stores/nostr.ts +++ b/src/stores/nostr.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { NostrClient } from '@/lib/nostr/client' import { config } from '@/lib/config' +import { pushService, type PushSubscriptionData } from '@/lib/notifications/push' // Define an interface for the account object interface NostrAccount { @@ -19,6 +20,10 @@ export const useNostrStore = defineStore('nostr', () => { const relayUrls = ref(config.nostr.relays) const account = ref(null) + // Push notifications + const pushSubscription = ref(null) + const notificationsEnabled = ref(false) + // Singleton client instance let client: NostrClient | null = null @@ -77,6 +82,78 @@ export const useNostrStore = defineStore('nostr', () => { account.value = nostrAccount } + // Push notification management + async function enablePushNotifications(): Promise { + try { + const subscription = await pushService.subscribe() + pushSubscription.value = subscription + notificationsEnabled.value = true + + // Store subscription in localStorage for persistence + localStorage.setItem('push-subscription', JSON.stringify(subscription)) + localStorage.setItem('notifications-enabled', 'true') + + return subscription + } catch (error) { + console.error('Failed to enable push notifications:', error) + throw error + } + } + + async function disablePushNotifications(): Promise { + try { + await pushService.unsubscribe() + pushSubscription.value = null + notificationsEnabled.value = false + + // Remove from localStorage + localStorage.removeItem('push-subscription') + localStorage.removeItem('notifications-enabled') + } catch (error) { + console.error('Failed to disable push notifications:', error) + throw error + } + } + + async function checkPushNotificationStatus(): Promise { + try { + // Check localStorage first + const storedEnabled = localStorage.getItem('notifications-enabled') === 'true' + const storedSubscription = localStorage.getItem('push-subscription') + + if (storedEnabled && storedSubscription) { + pushSubscription.value = JSON.parse(storedSubscription) + notificationsEnabled.value = true + } + + // Verify with push service + const currentSubscription = await pushService.getSubscription() + if (currentSubscription) { + pushSubscription.value = currentSubscription + notificationsEnabled.value = true + } else if (storedEnabled) { + // Stored state says enabled but no actual subscription - clear stored state + await disablePushNotifications() + } + } catch (error) { + console.error('Failed to check push notification status:', error) + } + } + + // Send test notification + async function sendTestNotification(): Promise { + await pushService.showLocalNotification({ + title: '🚨 Test Admin Announcement', + body: 'This is a test notification to verify push notifications are working correctly.', + icon: '/pwa-192x192.png', + tag: 'test-notification', + data: { + url: '/', + type: 'admin-announcement' + } + }) + } + return { // State isConnected, @@ -84,6 +161,8 @@ export const useNostrStore = defineStore('nostr', () => { error, relayUrls, account, + pushSubscription, + notificationsEnabled, // Actions connect, @@ -92,5 +171,9 @@ export const useNostrStore = defineStore('nostr', () => { setConnected, setRelayUrls, setAccount, + enablePushNotifications, + disablePushNotifications, + checkPushNotificationStatus, + sendTestNotification, } }) \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index adbdb71..4834ab1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,9 +17,10 @@ export default defineConfig(({ mode }) => ({ devOptions: { enabled: true }, + strategies: 'injectManifest', + srcDir: 'public', + filename: 'sw.js', workbox: { - clientsClaim: true, - skipWaiting: true, globPatterns: [ '**/*.{js,css,html,ico,png,svg}' ]