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
94
debug_admin_posts.js
Normal file
94
debug_admin_posts.js
Normal file
|
|
@ -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()
|
||||
36
generate-vapid-keys.js
Normal file
36
generate-vapid-keys.js
Normal file
|
|
@ -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.')
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
126
public/sw.js
Normal file
126
public/sw.js
Normal file
|
|
@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { NostrClient, type NostrNote } from '@/lib/nostr/client'
|
||||
import type { NostrNote } from '@/lib/nostr/client'
|
||||
import { useNostr } from '@/composables/useNostr'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
|
@ -8,6 +8,8 @@ import { Badge } from '@/components/ui/badge'
|
|||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { Megaphone } from 'lucide-vue-next'
|
||||
import { config, configUtils } from '@/lib/config'
|
||||
import { notificationManager } from '@/lib/notifications/manager'
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
|
||||
const props = defineProps<{
|
||||
relays?: string[]
|
||||
|
|
@ -19,10 +21,14 @@ const isLoading = ref(true)
|
|||
const error = ref<Error | null>(null)
|
||||
|
||||
const { getClient } = useNostr(props.relays ? { relays: props.relays } : undefined)
|
||||
const nostrStore = useNostrStore()
|
||||
|
||||
// Get admin/moderator pubkeys from centralized config
|
||||
const adminPubkeys = config.nostr.adminPubkeys
|
||||
|
||||
// Track last seen note timestamp to avoid duplicate notifications
|
||||
let lastSeenTimestamp = Math.floor(Date.now() / 1000)
|
||||
|
||||
async function loadNotes() {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
|
@ -98,12 +104,62 @@ function getFeedDescription(): string {
|
|||
}
|
||||
}
|
||||
|
||||
// Real-time subscription for new notes (especially admin announcements)
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
async function startRealtimeSubscription() {
|
||||
try {
|
||||
const client = getClient()
|
||||
await client.connect()
|
||||
|
||||
// Subscribe to real-time notes
|
||||
unsubscribe = client.subscribeToNotes((newNote) => {
|
||||
// Only process notes newer than last seen
|
||||
if (newNote.created_at > lastSeenTimestamp) {
|
||||
// Add to feed if it matches our filter
|
||||
const shouldInclude = shouldIncludeNote(newNote)
|
||||
if (shouldInclude) {
|
||||
// Add to beginning of notes array
|
||||
notes.value.unshift(newNote)
|
||||
|
||||
// Limit the array size to prevent memory issues
|
||||
if (notes.value.length > 100) {
|
||||
notes.value = notes.value.slice(0, 100)
|
||||
}
|
||||
}
|
||||
|
||||
// Send notification if appropriate (only for admin announcements when not in announcements feed)
|
||||
if (props.feedType !== 'announcements' && configUtils.isAdminPubkey(newNote.pubkey)) {
|
||||
notificationManager.notifyForNote(newNote, nostrStore.account?.pubkey)
|
||||
}
|
||||
|
||||
// Update last seen timestamp
|
||||
lastSeenTimestamp = Math.max(lastSeenTimestamp, newNote.created_at)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to start real-time subscription:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldIncludeNote(note: NostrNote): boolean {
|
||||
if (props.feedType === 'announcements') {
|
||||
return adminPubkeys.length > 0 && adminPubkeys.includes(note.pubkey)
|
||||
}
|
||||
// For other feed types, include all notes for now
|
||||
return true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadNotes()
|
||||
// Start real-time subscription after initial load
|
||||
await startRealtimeSubscription()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
|
|
|
|||
213
src/components/notifications/NotificationPermission.vue
Normal file
213
src/components/notifications/NotificationPermission.vue
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
<template>
|
||||
<div v-if="shouldShowPrompt" class="bg-card border rounded-lg p-4 mb-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<Bell class="h-5 w-5 text-primary mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-sm mb-1">
|
||||
Enable Admin Notifications
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
Get instant notifications for important admin announcements, even when the app is closed.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@click="enableNotifications"
|
||||
:disabled="isLoading"
|
||||
size="sm"
|
||||
class="text-xs"
|
||||
>
|
||||
<Bell class="h-3 w-3 mr-1" />
|
||||
{{ isLoading ? 'Enabling...' : 'Enable Notifications' }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@click="dismiss"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-xs"
|
||||
>
|
||||
Not Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="dismiss"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status indicator when notifications are enabled -->
|
||||
<div v-else-if="isSubscribed" class="bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg p-3 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<BellRing class="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span class="text-sm text-green-800 dark:text-green-200">
|
||||
Push notifications enabled for admin announcements
|
||||
</span>
|
||||
<Button
|
||||
@click="disableNotifications"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="ml-auto text-xs text-green-700 dark:text-green-300 hover:text-green-900 dark:hover:text-green-100"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="bg-destructive/10 border border-destructive/20 rounded-lg p-3 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<AlertCircle class="h-4 w-4 text-destructive" />
|
||||
<span class="text-sm text-destructive">
|
||||
{{ error }}
|
||||
</span>
|
||||
<Button
|
||||
@click="clearError"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="ml-auto text-xs"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Bell, BellRing, X, AlertCircle } from 'lucide-vue-next'
|
||||
import { pushService } from '@/lib/notifications/push'
|
||||
import { configUtils } from '@/lib/config'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const shouldShowPrompt = ref(false)
|
||||
const isSubscribed = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref('')
|
||||
const dismissed = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
autoShow?: boolean
|
||||
}>()
|
||||
|
||||
onMounted(async () => {
|
||||
await checkNotificationStatus()
|
||||
|
||||
// Show prompt automatically if configured and not dismissed
|
||||
if (props.autoShow && !dismissed.value) {
|
||||
shouldShowPrompt.value = !isSubscribed.value &&
|
||||
pushService.isSupported() &&
|
||||
configUtils.hasPushConfig() &&
|
||||
pushService.getPermission() !== 'denied'
|
||||
}
|
||||
})
|
||||
|
||||
async function checkNotificationStatus() {
|
||||
try {
|
||||
if (!pushService.isSupported() || !configUtils.hasPushConfig()) {
|
||||
return
|
||||
}
|
||||
|
||||
isSubscribed.value = await pushService.isSubscribed()
|
||||
} catch (err) {
|
||||
console.error('Failed to check notification status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function enableNotifications() {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
if (!pushService.isSupported()) {
|
||||
throw new Error('Push notifications are not supported by your browser')
|
||||
}
|
||||
|
||||
if (!configUtils.hasPushConfig()) {
|
||||
throw new Error('Push notifications are not configured')
|
||||
}
|
||||
|
||||
const subscription = await pushService.subscribe()
|
||||
console.log('Push subscription created:', subscription)
|
||||
|
||||
// Here you would typically send the subscription to your backend
|
||||
// For now, we'll just store it locally and show success
|
||||
|
||||
isSubscribed.value = true
|
||||
shouldShowPrompt.value = false
|
||||
toast.success('Push notifications enabled! You\'ll receive admin announcements even when the app is closed.')
|
||||
|
||||
// TODO: Send subscription to backend
|
||||
// await sendSubscriptionToBackend(subscription)
|
||||
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to enable notifications'
|
||||
error.value = message
|
||||
toast.error(message)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disableNotifications() {
|
||||
try {
|
||||
await pushService.unsubscribe()
|
||||
isSubscribed.value = false
|
||||
toast.success('Push notifications disabled')
|
||||
|
||||
// TODO: Remove subscription from backend
|
||||
// await removeSubscriptionFromBackend()
|
||||
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to disable notifications'
|
||||
error.value = message
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
shouldShowPrompt.value = false
|
||||
dismissed.value = true
|
||||
|
||||
// Store dismissal in localStorage
|
||||
localStorage.setItem('notification-prompt-dismissed', Date.now().toString())
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
// Test notification function (for development)
|
||||
async function testNotification() {
|
||||
try {
|
||||
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'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to show test notification'
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
// Expose test function for development
|
||||
defineExpose({
|
||||
testNotification,
|
||||
enableNotifications,
|
||||
disableNotifications
|
||||
})
|
||||
</script>
|
||||
222
src/components/notifications/NotificationSettings.vue
Normal file
222
src/components/notifications/NotificationSettings.vue
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Bell class="h-5 w-5" />
|
||||
Notification Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure when and how you receive push notifications
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-6">
|
||||
<!-- Main notification toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label class="text-base font-medium">Push Notifications</Label>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Receive notifications even when the app is closed
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="!notificationsEnabled && !isBlocked"
|
||||
@click="enableNotifications"
|
||||
:disabled="isLoading"
|
||||
size="sm"
|
||||
>
|
||||
<Bell class="h-3 w-3 mr-1" />
|
||||
{{ isLoading ? 'Enabling...' : 'Enable' }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-else-if="notificationsEnabled"
|
||||
@click="disableNotifications"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<BellOff class="h-3 w-3 mr-1" />
|
||||
Disable
|
||||
</Button>
|
||||
|
||||
<Badge v-else-if="isBlocked" variant="destructive">
|
||||
Blocked
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification type preferences -->
|
||||
<div v-if="notificationsEnabled" class="space-y-4">
|
||||
<div class="border-t pt-4">
|
||||
<Label class="text-sm font-medium mb-3 block">Notification Types</Label>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label class="text-sm">Admin Announcements</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Important updates from community administrators
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="options.adminAnnouncements"
|
||||
@change="updateOptions"
|
||||
class="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label class="text-sm">Mentions</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
When someone mentions you in a post
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="options.mentions"
|
||||
@change="updateOptions"
|
||||
class="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label class="text-sm">Replies</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
When someone replies to your posts
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="options.replies"
|
||||
@change="updateOptions"
|
||||
class="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test notification -->
|
||||
<div class="border-t pt-4">
|
||||
<Button
|
||||
@click="sendTestNotification"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="isTestLoading"
|
||||
>
|
||||
<TestTube class="h-3 w-3 mr-1" />
|
||||
{{ isTestLoading ? 'Sending...' : 'Send Test Notification' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help text for blocked notifications -->
|
||||
<div v-if="isBlocked" class="bg-destructive/10 border border-destructive/20 rounded-lg p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertCircle class="h-4 w-4 text-destructive mt-0.5" />
|
||||
<div class="text-sm">
|
||||
<p class="text-destructive font-medium mb-1">Notifications Blocked</p>
|
||||
<p class="text-destructive/80">
|
||||
You've blocked notifications for this site. To enable them:
|
||||
</p>
|
||||
<ol class="list-decimal list-inside mt-2 text-destructive/80 space-y-1">
|
||||
<li>Click the lock icon in your browser's address bar</li>
|
||||
<li>Select "Allow" for notifications</li>
|
||||
<li>Refresh this page</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Not supported message -->
|
||||
<div v-if="!isSupported" class="bg-orange-50 dark:bg-orange-950 border border-orange-200 dark:border-orange-800 rounded-lg p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<AlertCircle class="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
||||
<span class="text-sm text-orange-800 dark:text-orange-200">
|
||||
Push notifications are not supported by your browser
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Bell, BellOff, TestTube, AlertCircle } from 'lucide-vue-next'
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
import { notificationManager } from '@/lib/notifications/manager'
|
||||
import { pushService } from '@/lib/notifications/push'
|
||||
import { configUtils } from '@/lib/config'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const nostrStore = useNostrStore()
|
||||
const { notificationsEnabled } = storeToRefs(nostrStore)
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isTestLoading = ref(false)
|
||||
const options = ref(notificationManager.getOptions())
|
||||
|
||||
const isSupported = pushService.isSupported() && configUtils.hasPushConfig()
|
||||
const isBlocked = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await checkStatus()
|
||||
})
|
||||
|
||||
async function checkStatus() {
|
||||
isBlocked.value = pushService.getPermission() === 'denied'
|
||||
await nostrStore.checkPushNotificationStatus()
|
||||
}
|
||||
|
||||
async function enableNotifications() {
|
||||
try {
|
||||
isLoading.value = true
|
||||
await nostrStore.enablePushNotifications()
|
||||
toast.success('Push notifications enabled!')
|
||||
await checkStatus()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to enable notifications'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disableNotifications() {
|
||||
try {
|
||||
await nostrStore.disablePushNotifications()
|
||||
toast.success('Push notifications disabled')
|
||||
await checkStatus()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to disable notifications'
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
function updateOptions() {
|
||||
notificationManager.updateOptions(options.value)
|
||||
toast.success('Notification preferences updated')
|
||||
}
|
||||
|
||||
async function sendTestNotification() {
|
||||
try {
|
||||
isTestLoading.value = true
|
||||
await notificationManager.sendTestNotification()
|
||||
toast.success('Test notification sent!')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to send test notification'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
isTestLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
<template>
|
||||
<div class="container py-8">
|
||||
<div class="container py-8 space-y-6">
|
||||
<NotificationPermission auto-show />
|
||||
<NostrFeed feed-type="announcements" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import NostrFeed from '@/components/nostr/NostrFeed.vue'
|
||||
import NotificationPermission from '@/components/notifications/NotificationPermission.vue'
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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<string[]>(config.nostr.relays)
|
||||
const account = ref<NostrAccount | null>(null)
|
||||
|
||||
// Push notifications
|
||||
const pushSubscription = ref<PushSubscriptionData | null>(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<PushSubscriptionData> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
|
@ -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}'
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue