feat: fix push notifications and enhance PWA experience

- Fix InvalidCharacterError in push notifications with proper VAPID key validation
- Add attractive PWA install prompt component with cross-browser support
- Make notification settings always visible regardless of queue status
- Implement PWA install manager with user engagement detection
- Use SvelteKit navigation APIs instead of browser history API
- Add comprehensive error handling and logging
- Include cross-browser compatibility and responsive design
- Add development tooling improvements

Fixes push notification bugs and significantly improves PWA user experience
with modern, accessible interface components and proper error handling.
This commit is contained in:
Giancarmine Salucci
2025-12-22 15:18:03 +01:00
parent 621e113537
commit e49dbfae41
11 changed files with 760 additions and 33 deletions

View File

@@ -0,0 +1,201 @@
/**
* PWA Installation Manager
*
* Handles PWA installation flow with cross-browser support.
* Provides beforeinstallprompt event handling, user engagement detection,
* and dismissal state management for the install prompt.
*/
import { browser } from '$app/environment';
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export class PWAInstallManager {
private deferredPrompt: BeforeInstallPromptEvent | null = null;
private listeners: Array<(canInstall: boolean) => void> = [];
private installable = false;
constructor() {
if (browser) {
this.initializeInstallPrompt();
}
}
/**
* Initialize PWA install prompt event listeners
*/
private initializeInstallPrompt(): void {
// Listen for beforeinstallprompt event (Chrome, Edge)
window.addEventListener('beforeinstallprompt', (e: Event) => {
e.preventDefault();
this.deferredPrompt = e as BeforeInstallPromptEvent;
this.installable = true;
this.notifyListeners(true);
console.log('[PWA] Install prompt available');
});
// Listen for app installation completion
window.addEventListener('appinstalled', () => {
console.log('[PWA] App was installed');
this.installable = false;
this.deferredPrompt = null;
this.notifyListeners(false);
// Clear dismissal state since user installed
this.clearDismissed();
});
// Check if already installed
if (this.isStandalone()) {
console.log('[PWA] App is already running in standalone mode');
}
}
/**
* Check if PWA can be installed
*/
public canInstall(): boolean {
return this.installable && this.deferredPrompt !== null;
}
/**
* Show the browser's install prompt
*
* @returns Promise resolving to user's choice or 'unavailable' if no prompt available
*/
public async showInstallPrompt(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
if (!this.deferredPrompt) {
console.warn('[PWA] Install prompt not available');
return 'unavailable';
}
try {
await this.deferredPrompt.prompt();
const { outcome } = await this.deferredPrompt.userChoice;
this.deferredPrompt = null;
this.installable = false;
this.notifyListeners(false);
console.log(`[PWA] Install prompt ${outcome}`);
return outcome;
} catch (error) {
console.error('[PWA] Install prompt failed:', error);
return 'dismissed';
}
}
/**
* Register a callback for install state changes
*
* @param callback Function to call when install state changes
* @returns Unsubscribe function
*/
public onInstallStateChange(callback: (canInstall: boolean) => void): () => void {
this.listeners.push(callback);
// Call immediately with current state
callback(this.canInstall());
return () => {
this.listeners = this.listeners.filter(cb => cb !== callback);
};
}
/**
* Notify all listeners of state change
*/
private notifyListeners(canInstall: boolean): void {
this.listeners.forEach(callback => {
try {
callback(canInstall);
} catch (error) {
console.error('[PWA] Error in install state listener:', error);
}
});
}
/**
* Check if app is running in standalone mode (already installed)
*/
public isStandalone(): boolean {
if (!browser) return false;
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true ||
document.referrer.includes('android-app://')
);
}
/**
* Check if user has dismissed the install prompt
*/
public isDismissed(): boolean {
if (!browser) return false;
return localStorage.getItem('pwa-install-dismissed') === 'true';
}
/**
* Mark install prompt as dismissed by user
*/
public setDismissed(): void {
if (browser) {
localStorage.setItem('pwa-install-dismissed', 'true');
console.log('[PWA] Install prompt dismissed by user');
}
}
/**
* Clear dismissal state (called when app is installed)
*/
public clearDismissed(): void {
if (browser) {
localStorage.removeItem('pwa-install-dismissed');
}
}
/**
* Get browser-specific installation instructions
*/
public getInstallInstructions(): string {
if (!browser) return 'Install instructions not available';
const userAgent = navigator.userAgent.toLowerCase();
const isMobile = /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
if (isMobile && userAgent.includes('safari') && !userAgent.includes('chrome')) {
return 'Tap the Share button and select "Add to Home Screen"';
} else if (userAgent.includes('chrome') && !userAgent.includes('edg')) {
return 'Look for the install button in your browser address bar';
} else if (userAgent.includes('edg')) {
return 'Look for the install button in your browser address bar';
} else if (userAgent.includes('firefox')) {
return 'Firefox has limited PWA support. Try Chrome or Edge for the best experience';
}
return 'Check your browser menu for "Install App" or "Add to Home Screen" options';
}
/**
* Get current browser name for UI customization
*/
public getBrowserName(): string {
if (!browser) return 'unknown';
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes('chrome') && !userAgent.includes('edg')) return 'chrome';
if (userAgent.includes('safari') && !userAgent.includes('chrome')) return 'safari';
if (userAgent.includes('firefox')) return 'firefox';
if (userAgent.includes('edg')) return 'edge';
return 'unknown';
}
}
// Singleton instance for application-wide use
export const pwaInstallManager = new PWAInstallManager();

View File

@@ -302,26 +302,61 @@ class PushNotificationManager {
}
/**
* Convert VAPID key to Uint8Array
* Convert URL-safe base64 string to Uint8Array
* Enhanced with validation and error handling for VAPID keys
* SSR-safe: uses window.atob only in browser context
*/
private urlBase64ToUint8Array(base64String: string): Uint8Array {
if (!browser) {
return new Uint8Array(0);
}
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);
// Input validation
if (!base64String || typeof base64String !== 'string') {
console.error('[PushManager] Invalid VAPID key: empty or non-string');
return new Uint8Array(0);
}
// Remove whitespace and validate format
const cleanKey = base64String.trim();
if (cleanKey.length === 0) {
console.error('[PushManager] Invalid VAPID key: empty string');
return new Uint8Array(0);
}
// VAPID keys should be 65 characters (unpadded base64)
if (cleanKey.length !== 65) {
console.warn(`[PushManager] VAPID key length ${cleanKey.length}, expected 65`);
}
try {
// Add proper padding
const padding = '='.repeat((4 - cleanKey.length % 4) % 4);
const base64 = (cleanKey + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
// Validate base64 format before decoding
const base64Regex = /^[A-Za-z0-9+\/]*={0,2}$/;
if (!base64Regex.test(base64)) {
throw new Error('Invalid base64 characters');
}
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
console.log(`[PushManager] Successfully decoded VAPID key (${outputArray.length} bytes)`);
return outputArray;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[PushManager] Failed to decode VAPID key:', error, 'Key:', cleanKey);
throw new Error(`Invalid VAPID key format: ${errorMessage}`);
}
return outputArray;
}
/**

View File

@@ -5,6 +5,8 @@
* and coordinates with the main application.
*/
import { pushState } from "$app/navigation";
interface ServiceWorkerMessage {
type: string;
action?: string;
@@ -91,10 +93,10 @@ class ServiceWorkerMessageHandler {
// If not found, navigate to homepage with highlight
const url = new URL(window.location.href);
url.searchParams.set('highlight', itemId);
window.history.pushState({}, '', url.toString());
pushState(url, {});
// Refresh page to show the item
window.location.reload();
//window.location.reload();
}
}

View File

@@ -28,7 +28,7 @@ export const queueConfig = {
/** Web Push notification settings */
push: {
vapidPublicKey: env.VAPID_PUBLIC_KEY || 'BDummyPublicKeyForDevelopment',
vapidPrivateKey: env.VAPID_PRIVATE_KEY || 'DummyPrivateKeyForDevelopment'
vapidPublicKey: env.VAPID_PUBLIC_KEY || 'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
vapidPrivateKey: env.VAPID_PRIVATE_KEY || 'JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680'
}
};

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import InstallPrompt from './components/InstallPrompt.svelte';
import './layout.css';
let { children } = $props();
@@ -10,3 +11,6 @@
</svelte:head>
{@render children()}
<!-- PWA Install Prompt -->
<InstallPrompt />

View File

@@ -5,6 +5,7 @@
import type { QueueItem, QueueStatusUpdate } from '$lib/server/queue/types';
import QueueItemCard from './components/QueueItemCard.svelte';
import NotificationSettings from './components/NotificationSettings.svelte';
import { replaceState } from '$app/navigation';
let items = $state<QueueItem[]>([]);
let loading = $state(true);
@@ -204,7 +205,7 @@
// Remove highlight parameter from URL without navigation
const url = new URL(window.location.href);
url.searchParams.delete('highlight');
window.history.replaceState({}, '', url.toString());
replaceState(url, {});
}
</script>
@@ -314,12 +315,10 @@
</div>
{/if}
<!-- Notification Settings -->
{#if filteredItems.length > 0 || filter !== 'all'}
<div class="mt-8">
<NotificationSettings />
</div>
{/if}
<!-- Notification Settings - Always visible -->
<div class="mt-8">
<NotificationSettings />
</div>
<!-- Connection Status -->
<div class="fixed bottom-4 right-4">

View File

@@ -0,0 +1,249 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { pwaInstallManager } from '$lib/client/PWAInstallManager';
let showPrompt = $state(false);
let showFallback = $state(false);
let canInstall = $state(false);
let installing = $state(false);
let userEngaged = $state(false);
let unsubscribe: (() => void) | null = null;
onMount(() => {
// Don't show if already dismissed or in standalone mode
if (pwaInstallManager.isDismissed() || pwaInstallManager.isStandalone()) {
return;
}
// Listen for install state changes
unsubscribe = pwaInstallManager.onInstallStateChange((installable) => {
canInstall = installable;
// Show prompt after user engagement and delay
if (installable && userEngaged && !pwaInstallManager.isDismissed()) {
setTimeout(() => {
showPrompt = true;
}, 2000);
} else if (!installable && userEngaged && !pwaInstallManager.isStandalone() && !pwaInstallManager.isDismissed()) {
// Show fallback instructions for browsers without beforeinstallprompt
setTimeout(() => {
showFallback = true;
}, 5000);
}
});
// Detect user engagement
const detectEngagement = () => {
userEngaged = true;
document.removeEventListener('scroll', detectEngagement);
document.removeEventListener('click', detectEngagement);
document.removeEventListener('keydown', detectEngagement);
};
document.addEventListener('scroll', detectEngagement, { once: true });
document.addEventListener('click', detectEngagement, { once: true });
document.addEventListener('keydown', detectEngagement, { once: true });
return () => {
unsubscribe?.();
document.removeEventListener('scroll', detectEngagement);
document.removeEventListener('click', detectEngagement);
document.removeEventListener('keydown', detectEngagement);
};
});
async function handleInstall() {
installing = true;
try {
const result = await pwaInstallManager.showInstallPrompt();
if (result === 'accepted') {
showPrompt = false;
showFallback = false;
} else if (result === 'dismissed') {
handleDismiss();
}
} catch (error) {
console.error('Install failed:', error);
} finally {
installing = false;
}
}
function handleDismiss() {
showPrompt = false;
showFallback = false;
pwaInstallManager.setDismissed();
}
</script>
<!-- Main Install Prompt (for browsers with beforeinstallprompt support) -->
{#if showPrompt && canInstall}
<div class="fixed bottom-0 left-0 right-0 z-50 transform transition-transform duration-300 ease-out animate-slide-up">
<div class="bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-2xl">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<!-- App Icon -->
<div class="flex-shrink-0">
<div class="w-12 h-12 bg-white rounded-xl flex items-center justify-center shadow-lg">
<svg class="w-8 h-8 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
</svg>
</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-white">Install InstaRecipe</h3>
<p class="text-blue-100 text-sm">
Get faster access and offline support. Works like a native app!
</p>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center space-x-2 ml-4">
<button
onclick={handleInstall}
disabled={installing}
class="bg-white text-blue-600 px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-50 transition-colors disabled:opacity-50 flex items-center space-x-2 shadow-lg"
>
{#if installing}
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Installing...</span>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span>Install</span>
{/if}
</button>
<button
onclick={handleDismiss}
class="text-blue-100 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
title="Dismiss"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<!-- Features List -->
<div class="mt-3 flex flex-wrap gap-3 text-xs text-blue-100">
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Offline access</span>
</div>
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Push notifications</span>
</div>
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Faster loading</span>
</div>
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Home screen access</span>
</div>
</div>
</div>
</div>
</div>
{/if}
<!-- Fallback Instructions (for browsers without beforeinstallprompt) -->
{#if showFallback && !canInstall && !pwaInstallManager.isStandalone()}
<div class="fixed bottom-4 right-4 max-w-sm bg-white border rounded-lg shadow-xl p-4 z-40 animate-fade-in">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
</div>
</div>
<div class="flex-1">
<h4 class="text-sm font-semibold text-gray-900 mb-1">Install InstaRecipe</h4>
<p class="text-xs text-gray-600 mb-3">
{pwaInstallManager.getInstallInstructions()}
</p>
<!-- Browser-specific hints -->
{#if pwaInstallManager.getBrowserName() === 'safari'}
<div class="flex items-center space-x-1 text-xs text-blue-600 bg-blue-50 rounded px-2 py-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z"></path>
</svg>
<span>Use the Share button</span>
</div>
{:else}
<div class="flex items-center space-x-1 text-xs text-green-600 bg-green-50 rounded px-2 py-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span>Look for install button</span>
</div>
{/if}
</div>
<button
onclick={handleDismiss}
class="text-gray-400 hover:text-gray-500 flex-shrink-0"
title="Dismiss"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
{/if}
<style>
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
</style>

View File

@@ -64,6 +64,12 @@
<p class="text-sm text-gray-600 mb-4">
Get notified when your recipe extractions complete, even when InstaRecipe is not open.
{#if typeof window !== 'undefined'}
<!-- Check if we're on the homepage and queue appears empty -->
{#if window.location.pathname === '/' && !document.querySelector('[data-queue-item]')}
Start by adding some Instagram recipe URLs to see notifications in action!
{/if}
{/if}
</p>
<!-- Status -->