Files
archived-vdo.ninja/notifications/notifications.js
steveseguin c9380616de merge fix
2025-05-12 21:45:08 -04:00

838 lines
27 KiB
JavaScript

const POLL_INTERVAL = 60000;
let sseConnection = null;
let pollingInterval = null;
let notificationHistory = [];
class NotificationManager {
constructor() {
this.subscribers = [];
this.isConnected = false;
this.currentTopic = null;
}
async initialize() {
if ('serviceWorker' in navigator) {
try {
let reg = await navigator.serviceWorker.register('./service-worker.js', { scope: './' });
console.log('Service Worker registered with scope:', reg.scope);
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
console.log('No controller, reloading page to activate service worker');
window.location.reload();
return false;
}
console.log('Service Worker ready and controlling the page');
this.setupServiceWorkerMessaging();
this.loadNotificationHistory();
const savedTopic = localStorage.getItem('notifyTopic');
if (savedTopic) {
console.log('Restoring saved subscription for:', savedTopic);
await this.startSubscription(savedTopic);
}
return true;
} catch (error) {
console.error('Service Worker registration failed:', error);
return false;
}
} else {
console.warn('Service Workers not supported');
return false;
}
}
async checkSubscriptionValidity() {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
console.log('No push subscription found');
return false;
}
try {
const response = await fetch(subscription.endpoint, {
method: 'HEAD',
headers: {
'Origin': 'https://vdo.ninja'
}
});
if (response.status === 404 || response.status === 410) {
console.log('Push subscription is invalid, unsubscribing...');
await subscription.unsubscribe();
localStorage.removeItem('pushSubscription');
localStorage.removeItem('pushTopic');
return false;
}
return true;
} catch (error) {
console.error('Error checking subscription validity:', error);
return false;
}
} catch (error) {
console.error('Error checking push subscription:', error);
return false;
}
}
async unsubscribeFromPush() {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await fetch('https://notify.vdo.ninja/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription: subscription,
topic: localStorage.getItem('pushTopic')
})
});
await new Promise(resolve => setTimeout(resolve, 500));
await subscription.unsubscribe();
}
localStorage.removeItem('pushSubscription');
localStorage.removeItem('pushTopic');
return true;
} catch (error) {
console.error('Error unsubscribing from push:', error);
return false;
}
}
async diagnoseNotificationIssues() {
console.group('Notification System Diagnostics');
// Check permission
console.log('Notification permission:', Notification.permission);
// Check service worker
console.log('Service worker controller:', !!navigator.serviceWorker.controller);
// Check push subscription
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
console.log('Push subscription exists:', !!subscription);
if (subscription) {
// Check endpoint validity
try {
const response = await fetch(subscription.endpoint, {
method: 'HEAD',
headers: { 'Origin': 'https://vdo.ninja' }
});
console.log('Push endpoint status:', response.status);
console.log('Push endpoint:', subscription.endpoint.slice(-20));
} catch (error) {
console.error('Error checking endpoint:', error);
}
// Check subscription data
console.log('Subscription has keys:', subscription.keys && !!subscription.keys.p256dh && !!subscription.keys.auth);
}
} catch (err) {
console.error('Error checking subscription:', err);
}
// Check saved topic
console.log('Saved topic:', localStorage.getItem('notifyTopic'));
console.log('Saved push topic:', localStorage.getItem('pushTopic'));
// Check manifest
fetch('./manifest.json')
.then(r => r.ok ? 'OK' : 'Failed')
.then(status => console.log('manifest.json check:', status))
.catch(e => console.log('manifest.json error:', e));
console.groupEnd();
return {
permission: Notification.permission,
hasServiceWorker: !!navigator.serviceWorker.controller,
hasSubscription: await this.testPushSubscription(),
topic: localStorage.getItem('notifyTopic')
};
}
async verifyPushEndpoint(endpoint) {
try {
const response = await fetch(endpoint, {
method: 'HEAD',
headers: { 'Origin': 'https://vdo.ninja' }
});
console.log('Push endpoint check:', endpoint.slice(-10), 'status:', response.status);
if (response.status === 404 || response.status === 410) {
console.warn('Push endpoint invalid (404/410), needs resubscription');
return false;
}
return true;
} catch (error) {
console.error('Error verifying push endpoint:', error);
return false;
}
}
urlBase64ToUint8Array(base64String) {
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;
}
async testPushSubscription() {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
console.warn('No push subscription found');
return {
success: false,
error: 'No push subscription found'
};
}
if (!subscription.endpoint || !subscription.keys || !subscription.keys.p256dh || !subscription.keys.auth) {
console.warn('Invalid push subscription format');
return {
success: false,
error: 'Invalid subscription format'
};
}
try {
const response = await fetch(subscription.endpoint, {
method: 'HEAD',
headers: {
'Origin': 'https://vdo.ninja'
}
});
return {
success: response.status !== 410,
status: response.status,
endpoint: subscription.endpoint.slice(-10)
};
} catch (fetchError) {
console.error('Error testing subscription endpoint:', fetchError);
return {
success: false,
error: fetchError.message,
endpoint: subscription.endpoint.slice(-10)
};
}
} catch (error) {
console.error('Error testing push subscription:', error);
return {
success: false,
error: error.message
};
}
}
isNotificationDuplicate(notification) {
if (!notification) return true;
const existingNotifications = this.getNotificationHistory();
if (notification.id) {
return existingNotifications.some(n => n.id === notification.id);
}
const fiveSecondsAgo = Date.now() - 5000;
return existingNotifications.some(n => {
return (n.timestamp || n.receivedAt || 0) >= fiveSecondsAgo &&
n.title === notification.title &&
n.body === notification.body;
});
}
async subscribeToPushNotifications(topic) {
if (!topic) {
console.warn("No topic");
return false;
}
if (!('PushManager' in window)) {
console.warn('Push notifications not supported');
return false;
}
try {
const permission = await this.requestPermission();
if (permission !== 'granted') {
console.warn('Notification permission denied');
return false;
}
const registration = await navigator.serviceWorker.ready;
let existingSubscription = await registration.pushManager.getSubscription();
// If existing subscription is found, try to use it
if (existingSubscription) {
console.log('Using existing push subscription');
const subscriptionObject = existingSubscription.toJSON ? existingSubscription.toJSON() : existingSubscription;
try {
const subscriptionResponse = await fetch('https://notify.vdo.ninja/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription: subscriptionObject,
topic: topic
})
});
if (subscriptionResponse.ok) {
localStorage.setItem('pushSubscription', JSON.stringify(subscriptionObject));
localStorage.setItem('pushTopic', topic);
return true;
}
} catch (error) {
console.error('Error updating subscription on server:', error);
// Continue to try creating a new subscription
}
}
try {
// Get the server public key for VAPID with proper JSON parsing
const response = await fetch('https://notify.vdo.ninja/vapidPublicKey');
if (!response.ok) {
throw new Error('Failed to fetch VAPID public key');
}
// Parse the response as JSON instead of text
const responseData = await response.json();
const vapidPublicKey = responseData.key;
if (!vapidPublicKey) {
throw new Error('Invalid VAPID key format (missing key property)');
}
console.log('Received VAPID key:', vapidPublicKey);
const convertedVapidKey = this.urlBase64ToUint8Array(vapidPublicKey);
const newSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey
});
if (newSubscription) {
console.log('Created new push subscription');
const subscriptionObject = newSubscription.toJSON ? newSubscription.toJSON() : newSubscription;
const subscriptionResponse = await fetch('https://notify.vdo.ninja/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription: subscriptionObject,
topic: topic
})
});
if (subscriptionResponse.ok) {
localStorage.setItem('pushSubscription', JSON.stringify(subscriptionObject));
localStorage.setItem('pushTopic', topic);
return true;
}
}
} catch (error) {
console.error('Error in push subscription process:', error);
}
// If push subscription failed but we have SSE, that's ok
return false;
} catch (error) {
console.error('Error subscribing to push:', error);
return false;
}
}
setupServiceWorkerMessaging() {
if (!navigator.serviceWorker) return;
navigator.serviceWorker.addEventListener('message', event => {
const data = event.data;
if (!data || !data.action) return;
console.log('Service worker message received:', data.action, data);
switch (data.action) {
case 'connectionStatus':
this.isConnected = data.connected;
this.notifySubscribers('connectionStatus', {
connected: data.connected,
topic: data.topic
});
break;
case 'subscriptionUpdated':
console.log('Subscription was updated by service worker', data);
if (data.topic) {
// Refresh our subscription data
this.checkSubscriptionValidity().then(isValid => {
if (!isValid) {
console.log('Subscription is invalid, attempting to resubscribe');
this.startSubscription(data.topic);
}
});
}
break;
case 'notificationSync':
if (data.notifications) {
this.updateNotificationList(data.notifications);
}
break;
case 'newNotification':
if (data.notification) {
this.handleNewNotification(data.notification);
}
break;
case 'sseStatus':
this.notifySubscribers('sseStatus', {
status: data.status,
attempt: data.attempt,
delay: data.delay
});
break;
case 'statusUpdate':
this.handleStatusUpdate(data);
break;
}
});
setTimeout(() => {
if (navigator.serviceWorker.controller) {
this.checkStatus();
}
}, 1000);
}
async sendToServiceWorker(message) {
return new Promise((resolve, reject) => {
if (!navigator.serviceWorker.controller) {
return reject(new Error('No active service worker found'));
}
const timeout = setTimeout(() => {
reject(new Error('Service worker response timeout'));
}, 10000);
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = event => {
clearTimeout(timeout);
if (event.data.error) {
reject(event.data.error);
} else {
resolve(event.data);
}
};
try {
navigator.serviceWorker.controller.postMessage(message, [messageChannel.port2]);
} catch (e) {
clearTimeout(timeout);
reject(e);
}
});
}
async sendToServiceWorkerWithRetry(message, maxRetries = 3) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await this.sendToServiceWorker(message);
} catch (error) {
console.warn(`Service worker communication attempt ${attempt} failed:`, error);
lastError = error;
if (attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1)));
}
}
}
throw lastError;
}
subscribe(callback) {
this.subscribers.push(callback);
return () => {
this.subscribers = this.subscribers.filter(cb => cb !== callback);
};
}
notifySubscribers(event, data) {
this.subscribers.forEach(callback => {
callback(event, data);
});
}
async startSubscription(topic) {
if (!topic) return false;
this.currentTopic = topic;
localStorage.setItem('notifyTopic', topic);
localStorage.setItem('lastPollTime', Date.now().toString());
localStorage.setItem('subscriptionCreatedAt', Date.now().toString());
const pushSuccess = await this.subscribeToPushNotifications(topic);
console.log('Push subscription', pushSuccess ? 'successful' : 'failed');
if (!navigator.serviceWorker.controller) {
try {
await new Promise(resolve => {
const timeout = setTimeout(() => resolve(false), 3000);
navigator.serviceWorker.addEventListener('controllerchange', () => {
clearTimeout(timeout);
resolve(true);
}, { once: true });
});
} catch (e) {
console.warn('Timed out waiting for service worker to activate');
}
}
if (navigator.serviceWorker.controller) {
try {
const response = await this.sendToServiceWorkerWithRetry({
action: 'connect',
topic: topic
});
this.notifySubscribers('connectionStatus', {
connected: response && response.connected,
topic: topic
});
return true;
} catch (error) {
console.error('Error connecting via service worker after retries:', error);
this.startFallbackPolling(topic);
return pushSuccess;
}
} else {
console.warn('Service worker not active, using fallback polling');
this.startFallbackPolling(topic);
return pushSuccess;
}
}
async checkSubscriptionRenewal() {
const subscriptionCreatedAt = parseInt(localStorage.getItem('subscriptionCreatedAt') || '0');
const currentTopic = localStorage.getItem('notifyTopic');
if (!subscriptionCreatedAt || !currentTopic) return;
const now = Date.now();
const monthInMs = 30 * 24 * 60 * 60 * 1000;
if (now - subscriptionCreatedAt > monthInMs) {
this.promptForRenewal(currentTopic);
}
}
promptForRenewal(topic) {
const lastRenewalPrompt = parseInt(localStorage.getItem('lastRenewalPrompt') || '0');
const now = Date.now();
const dayInMs = 24 * 60 * 60 * 1000;
if (now - lastRenewalPrompt < dayInMs) return;
const renewalElement = document.createElement('div');
renewalElement.className = 'notification-popup';
renewalElement.innerHTML = `
<div class="notification-popup-header">
<h3 class="notification-popup-title">🔔 Notification Renewal</h3>
<button class="notification-popup-close">&times;</button>
</div>
<div class="notification-popup-body">
<p>You've been subscribed to notifications for "${topic}" for a month.</p>
<p>Would you like to continue receiving these notifications?</p>
</div>
<div class="notification-popup-actions">
<button class="secondary unsubscribe-btn">Unsubscribe</button>
<button class="renew-btn">Renew Subscription</button>
</div>
`;
document.body.appendChild(renewalElement);
localStorage.setItem('lastRenewalPrompt', now.toString());
renewalElement.querySelector('.notification-popup-close').addEventListener('click', () => {
document.body.removeChild(renewalElement);
});
renewalElement.querySelector('.unsubscribe-btn').addEventListener('click', () => {
this.stopSubscription();
document.body.removeChild(renewalElement);
});
renewalElement.querySelector('.renew-btn').addEventListener('click', () => {
localStorage.setItem('subscriptionCreatedAt', Date.now().toString());
this.refreshSubscription(topic);
document.body.removeChild(renewalElement);
});
}
async refreshSubscription(topic) {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
console.warn('No subscription to refresh');
return false;
}
const response = await fetch('https://notify.vdo.ninja/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription: subscription.toJSON ? subscription.toJSON() : subscription,
topic: topic
})
});
if (response.ok) {
this.notifySubscribers('notification', {
notification: {
title: 'Subscription Renewed',
body: `You'll continue receiving notifications for "${topic}"`,
timestamp: Date.now()
}
});
return true;
}
return false;
} catch (error) {
console.error('Error refreshing subscription:', error);
return false;
}
}
async stopSubscription() {
this.currentTopic = null;
localStorage.removeItem('notifyTopic');
localStorage.removeItem('lastPollTime');
await this.unsubscribeFromPush();
if (navigator.serviceWorker.controller) {
try {
await this.sendToServiceWorker({ action: 'disconnect' });
} catch (error) {
console.error('Error disconnecting:', error);
}
}
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
return true;
}
startFallbackPolling(topic) {
if (pollingInterval) {
clearInterval(pollingInterval);
}
const pollForNotifications = async () => {
try {
const lastPollTime = parseInt(localStorage.getItem('lastPollTime') || '0');
const response = await fetch(`https://notify.vdo.ninja/poll?topic=${encodeURIComponent(topic)}&since=${lastPollTime}`);
const data = await response.json();
localStorage.setItem('lastPollTime', Date.now().toString());
if (data.notifications && Array.isArray(data.notifications) && data.notifications.length > 0) {
data.notifications.forEach(notification => {
this.showNotification(notification);
this.addNotificationToHistory(notification);
});
}
this.notifySubscribers('sseStatus', { status: 'polling' });
} catch (error) {
console.error('Error during fallback polling:', error);
}
};
pollForNotifications();
pollingInterval = setInterval(pollForNotifications, POLL_INTERVAL);
}
async requestPermission() {
if (!('Notification' in window)) {
return 'denied';
}
if (Notification.permission === 'granted') {
return 'granted';
}
const permission = await Notification.requestPermission();
return permission;
}
async checkStatus() {
if (navigator.serviceWorker.controller) {
try {
const response = await this.sendToServiceWorkerWithRetry({ action: 'checkStatus' });
if (response) {
this.notifySubscribers('statusUpdate', {
topic: response.config?.topic,
connected: true,
sseConnected: response.sse?.connected,
isPolling: response.polling?.isPolling
});
this.notifySubscribers('connectionStatus', {
connected: response.sse?.connected || response.polling?.isPolling,
topic: response.config?.topic
});
if (response.sse?.connected) {
this.notifySubscribers('sseStatus', { status: 'connected' });
} else if (response.polling?.isPolling) {
this.notifySubscribers('sseStatus', { status: 'polling' });
} else {
this.notifySubscribers('sseStatus', { status: 'disconnected' });
}
}
return response && (response.sse?.connected || response.polling?.isPolling);
} catch (error) {
console.error('Error checking status:', error);
this.notifySubscribers('sseStatus', { status: 'disconnected' });
return false;
}
}
return false;
}
handleStatusUpdate(data) {
if (data.config && data.config.topic) {
this.currentTopic = data.config.topic;
this.notifySubscribers('statusUpdate', {
topic: data.config.topic,
connected: true,
sseConnected: data.sse && data.sse.connected,
isPolling: data.polling && data.polling.isPolling
});
}
}
showNotification(notification) {
return new Promise((resolve, reject) => {
if (!self.registration) {
return reject(new Error('No service worker registration available'));
}
try {
// Use relative paths to improve reliability
const options = {
body: notification.body || 'Someone joined your room',
icon: './media/logo2.png', // Use relative path
badge: './media/icon.png', // Use relative path
vibrate: [100, 50, 100],
data: {
url: notification.url || notification.originalUrl || self.registration.scope,
timestamp: notification.timestamp || Date.now(),
topic: notification.topic,
id: notification.id
}
};
// Simply try to show the notification
self.registration.showNotification(
notification.title || 'VDO.Ninja',
options
).then(() => {
console.log('[SW-DEBUG] Notification shown successfully:', notification.id);
resolve();
}).catch(error => {
console.error('[SW-DEBUG] Error showing notification:', error ? error.toString() : 'Unknown error');
reject(error);
});
} catch (error) {
console.error('[SW-DEBUG] Exception in showNotification:', error ? error.toString() : 'Unknown error');
reject(error);
}
});
}
playNotificationSound() {
const soundSetting = localStorage.getItem('notificationSound') || 'default';
if (soundSetting === 'none') return;
let soundFile;
switch (soundSetting) {
case 'chime':
soundFile = '/notifications/media/chime.wav';
break;
case 'bell':
soundFile = '/notifications/media/bell.wav';
break;
default:
soundFile = '/notifications/media/tone.mp3';
}
const audio = new Audio(soundFile);
audio.play().catch(e => console.warn('Could not play notification sound', e));
}
handleNewNotification(notification) {
this.showNotification(notification);
}
updateNotificationList(notifications) {
if (!notifications || !Array.isArray(notifications) || notifications.length === 0) return;
notificationHistory = notifications.map(n => ({
...n,
isNew: n.isNew || false
}));
localStorage.setItem('notificationHistory', JSON.stringify(notificationHistory));
this.notifySubscribers('historyUpdated', { notifications: notificationHistory });
}
addNotificationToHistory(notification) {
const historyItem = {
...notification,
receivedAt: Date.now(),
isNew: true
};
notificationHistory.unshift(historyItem);
if (notificationHistory.length > 50) {
notificationHistory = notificationHistory.slice(0, 50);
}
localStorage.setItem('notificationHistory', JSON.stringify(notificationHistory));
this.notifySubscribers('historyUpdated', { notifications: notificationHistory });
}
loadNotificationHistory() {
try {
const savedHistory = localStorage.getItem('notificationHistory');
if (savedHistory) {
notificationHistory = JSON.parse(savedHistory);
this.notifySubscribers('historyUpdated', { notifications: notificationHistory });
}
} catch (e) {
console.error('Error loading notification history', e);
notificationHistory = [];
}
if (navigator.serviceWorker.controller) {
this.sendToServiceWorker({ action: 'syncNotifications' })
.catch(error => console.error('Failed to sync notifications:', error));
}
}
clearNotificationHistory() {
notificationHistory = [];
localStorage.removeItem('notificationHistory');
this.notifySubscribers('historyUpdated', { notifications: [] });
}
getNotificationHistory() {
return [...notificationHistory];
}
async sendTestNotification(topic, customMessage = null) {
if (!topic) return Promise.resolve(false);
const message = customMessage || "Someone joined Test Room";
const notifyUrl = `https://notify.vdo.ninja/?notify=${encodeURIComponent(topic)}&message=${encodeURIComponent(message)}`;
console.log('Sending notification to:', notifyUrl);
try {
const response = await fetch(notifyUrl);
if (!response.ok) {
console.error('Server returned error:', response.status);
return false;
}
try {
const data = await response.json();
console.log('Notification response:', data);
return true;
} catch (err) {
console.warn('Could not parse JSON response:', err);
return true;
}
} catch (error) {
console.error('Network error when sending notification:', error);
return false;
}
}
getTimeAgo(timestamp) {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
if (seconds < 60) {
return `${seconds} second${seconds !== 1 ? 's' : ''} ago`;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
}
const days = Math.floor(hours / 24);
if (days < 30) {
return `${days} day${days !== 1 ? 's' : ''} ago`;
}
const months = Math.floor(days / 30);
if (months < 12) {
return `${months} month${months !== 1 ? 's' : ''} ago`;
}
const years = Math.floor(months / 12);
return `${years} year${years !== 1 ? 's' : ''} ago`;
}
}
const notificationManager = new NotificationManager();
window.notificationManager = notificationManager;
window.diagnosePush = () => window.notificationManager.diagnoseNotificationIssues();