feat: Implement push notification system for admin announcements

- Add a notification manager to handle push notifications and integrate with Nostr events.
- Create a push notification service to manage subscription and permission requests.
- Introduce components for notification settings and permission prompts in the UI.
- Update Nostr store to manage push notification state and enable/disable functionality.
- Enhance NostrFeed to send notifications for new admin announcements.
- Implement test notification functionality for development purposes.
This commit is contained in:
padreug 2025-07-03 08:34:05 +02:00
parent 6c1caac84b
commit c05f40f1ec
17 changed files with 1316 additions and 13 deletions

View file

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

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