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": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview --host",
|
||||||
"analyze": "vite build --mode analyze",
|
"analyze": "vite build --mode analyze",
|
||||||
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
|
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
|
||||||
"electron:build": "vue-tsc -b && vite build && electron-builder",
|
"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 { useNostr } from '@/composables/useNostr'
|
||||||
import { identity } from '@/composables/useIdentity'
|
import { identity } from '@/composables/useIdentity'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { config } from '@/lib/config'
|
import { useNostrStore } from '@/stores/nostr'
|
||||||
|
|
||||||
const { isConnected, isConnecting, error, connect, disconnect } = useNostr()
|
const { isConnected, isConnecting, error, connect, disconnect } = useNostr()
|
||||||
|
const nostrStore = useNostrStore()
|
||||||
|
|
||||||
const showPasswordDialog = ref(false)
|
const showPasswordDialog = ref(false)
|
||||||
|
|
||||||
|
|
@ -43,6 +44,9 @@ onMounted(async () => {
|
||||||
|
|
||||||
// Connect to Nostr relays
|
// Connect to Nostr relays
|
||||||
await connect()
|
await connect()
|
||||||
|
|
||||||
|
// Check push notification status
|
||||||
|
await nostrStore.checkPushNotificationStatus()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
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 { useNostr } from '@/composables/useNostr'
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
|
@ -8,6 +8,8 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { Megaphone } from 'lucide-vue-next'
|
import { Megaphone } from 'lucide-vue-next'
|
||||||
import { config, configUtils } from '@/lib/config'
|
import { config, configUtils } from '@/lib/config'
|
||||||
|
import { notificationManager } from '@/lib/notifications/manager'
|
||||||
|
import { useNostrStore } from '@/stores/nostr'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
|
|
@ -19,10 +21,14 @@ const isLoading = ref(true)
|
||||||
const error = ref<Error | null>(null)
|
const error = ref<Error | null>(null)
|
||||||
|
|
||||||
const { getClient } = useNostr(props.relays ? { relays: props.relays } : undefined)
|
const { getClient } = useNostr(props.relays ? { relays: props.relays } : undefined)
|
||||||
|
const nostrStore = useNostrStore()
|
||||||
|
|
||||||
// Get admin/moderator pubkeys from centralized config
|
// Get admin/moderator pubkeys from centralized config
|
||||||
const adminPubkeys = config.nostr.adminPubkeys
|
const adminPubkeys = config.nostr.adminPubkeys
|
||||||
|
|
||||||
|
// Track last seen note timestamp to avoid duplicate notifications
|
||||||
|
let lastSeenTimestamp = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
async function loadNotes() {
|
async function loadNotes() {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
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 () => {
|
onMounted(async () => {
|
||||||
await loadNotes()
|
await loadNotes()
|
||||||
|
// Start real-time subscription after initial load
|
||||||
|
await startRealtimeSubscription()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
disconnect()
|
if (unsubscribe) {
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatDate(timestamp: number): string {
|
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 { 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 { createTextNote, createReaction, createProfileMetadata } from '@/lib/nostr/events'
|
||||||
import { identity } from '@/composables/useIdentity'
|
import { identity } from '@/composables/useIdentity'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,15 @@ interface ApiConfig {
|
||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PushConfig {
|
||||||
|
vapidPublicKey: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
nostr: NostrConfig
|
nostr: NostrConfig
|
||||||
api: ApiConfig
|
api: ApiConfig
|
||||||
|
push: PushConfig
|
||||||
support: {
|
support: {
|
||||||
npub: string
|
npub: string
|
||||||
}
|
}
|
||||||
|
|
@ -41,6 +47,10 @@ export const config: AppConfig = {
|
||||||
baseUrl: import.meta.env.VITE_API_BASE_URL || '',
|
baseUrl: import.meta.env.VITE_API_BASE_URL || '',
|
||||||
key: import.meta.env.VITE_API_KEY || ''
|
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: {
|
support: {
|
||||||
npub: import.meta.env.VITE_SUPPORT_NPUB || ''
|
npub: import.meta.env.VITE_SUPPORT_NPUB || ''
|
||||||
}
|
}
|
||||||
|
|
@ -60,6 +70,10 @@ export const configUtils = {
|
||||||
return Boolean(config.api.baseUrl && config.api.key)
|
return Boolean(config.api.baseUrl && config.api.key)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
hasPushConfig: (): boolean => {
|
||||||
|
return Boolean(config.push.vapidPublicKey && config.push.enabled)
|
||||||
|
},
|
||||||
|
|
||||||
getDefaultRelays: (): string[] => {
|
getDefaultRelays: (): string[] => {
|
||||||
return config.nostr.relays
|
return config.nostr.relays
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { SimplePool, type Filter, type Event, type UnsignedEvent } from 'nostr-tools'
|
import { SimplePool, type Filter, type Event } from 'nostr-tools'
|
||||||
import { extractReactions, extractReplyCounts, getReplyInfo, EventKinds } from './events'
|
import { getReplyInfo, EventKinds } from './events'
|
||||||
|
|
||||||
export interface NostrClientConfig {
|
export interface NostrClientConfig {
|
||||||
relays: string[]
|
relays: string[]
|
||||||
|
|
@ -61,7 +61,6 @@ export class NostrClient {
|
||||||
} = {}): Promise<NostrNote[]> {
|
} = {}): Promise<NostrNote[]> {
|
||||||
const {
|
const {
|
||||||
limit = 20,
|
limit = 20,
|
||||||
since = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000), // Last 7 days
|
|
||||||
authors,
|
authors,
|
||||||
includeReplies = false
|
includeReplies = false
|
||||||
} = options
|
} = options
|
||||||
|
|
@ -135,7 +134,7 @@ export class NostrClient {
|
||||||
/**
|
/**
|
||||||
* Publish an event to all connected relays
|
* Publish an event to all connected relays
|
||||||
*/
|
*/
|
||||||
async publishEvent(event: UnsignedEvent): Promise<void> {
|
async publishEvent(event: Event): Promise<void> {
|
||||||
if (!this._isConnected) {
|
if (!this._isConnected) {
|
||||||
throw new Error('Not connected to any relays')
|
throw new Error('Not connected to any relays')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'
|
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'
|
import { bytesToHex, hexToBytes } from '@/lib/utils/crypto'
|
||||||
|
|
||||||
export interface NostrIdentity {
|
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>
|
<template>
|
||||||
<div class="container py-8">
|
<div class="container py-8 space-y-6">
|
||||||
|
<NotificationPermission auto-show />
|
||||||
<NostrFeed feed-type="announcements" />
|
<NostrFeed feed-type="announcements" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import NostrFeed from '@/components/nostr/NostrFeed.vue'
|
import NostrFeed from '@/components/nostr/NostrFeed.vue'
|
||||||
|
import NotificationPermission from '@/components/notifications/NotificationPermission.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { NostrClient } from '@/lib/nostr/client'
|
import { NostrClient } from '@/lib/nostr/client'
|
||||||
import { config } from '@/lib/config'
|
import { config } from '@/lib/config'
|
||||||
|
import { pushService, type PushSubscriptionData } from '@/lib/notifications/push'
|
||||||
|
|
||||||
// Define an interface for the account object
|
// Define an interface for the account object
|
||||||
interface NostrAccount {
|
interface NostrAccount {
|
||||||
|
|
@ -19,6 +20,10 @@ export const useNostrStore = defineStore('nostr', () => {
|
||||||
const relayUrls = ref<string[]>(config.nostr.relays)
|
const relayUrls = ref<string[]>(config.nostr.relays)
|
||||||
const account = ref<NostrAccount | null>(null)
|
const account = ref<NostrAccount | null>(null)
|
||||||
|
|
||||||
|
// Push notifications
|
||||||
|
const pushSubscription = ref<PushSubscriptionData | null>(null)
|
||||||
|
const notificationsEnabled = ref(false)
|
||||||
|
|
||||||
// Singleton client instance
|
// Singleton client instance
|
||||||
let client: NostrClient | null = null
|
let client: NostrClient | null = null
|
||||||
|
|
||||||
|
|
@ -77,6 +82,78 @@ export const useNostrStore = defineStore('nostr', () => {
|
||||||
account.value = nostrAccount
|
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 {
|
return {
|
||||||
// State
|
// State
|
||||||
isConnected,
|
isConnected,
|
||||||
|
|
@ -84,6 +161,8 @@ export const useNostrStore = defineStore('nostr', () => {
|
||||||
error,
|
error,
|
||||||
relayUrls,
|
relayUrls,
|
||||||
account,
|
account,
|
||||||
|
pushSubscription,
|
||||||
|
notificationsEnabled,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
connect,
|
connect,
|
||||||
|
|
@ -92,5 +171,9 @@ export const useNostrStore = defineStore('nostr', () => {
|
||||||
setConnected,
|
setConnected,
|
||||||
setRelayUrls,
|
setRelayUrls,
|
||||||
setAccount,
|
setAccount,
|
||||||
|
enablePushNotifications,
|
||||||
|
disablePushNotifications,
|
||||||
|
checkPushNotificationStatus,
|
||||||
|
sendTestNotification,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -17,9 +17,10 @@ export default defineConfig(({ mode }) => ({
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true
|
enabled: true
|
||||||
},
|
},
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'public',
|
||||||
|
filename: 'sw.js',
|
||||||
workbox: {
|
workbox: {
|
||||||
clientsClaim: true,
|
|
||||||
skipWaiting: true,
|
|
||||||
globPatterns: [
|
globPatterns: [
|
||||||
'**/*.{js,css,html,ico,png,svg}'
|
'**/*.{js,css,html,ico,png,svg}'
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue