import { useCallback, useEffect, useState } from 'react'; import { toast } from '../components/ui/sonner'; import type { Message } from '../types'; import { getStateKey } from '../utils/conversationState'; const STORAGE_KEY = 'meshcore_browser_notifications_enabled_by_conversation'; const NOTIFICATION_ICON_PATH = '/favicon-256x256.png'; type NotificationPermissionState = NotificationPermission | 'unsupported'; type ConversationNotificationMap = Record; interface NotificationEnableToastInfo { level: 'success' | 'warning'; title: string; description?: string; } interface NotificationEnvironment { protocol: string; isSecureContext: boolean; } function getConversationNotificationKey(type: 'channel' | 'contact', id: string): string { return getStateKey(type, id); } function readStoredEnabledMap(): ConversationNotificationMap { if (typeof window === 'undefined') { return {}; } try { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) { return {}; } const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== 'object') { return {}; } return Object.fromEntries( Object.entries(parsed).filter(([key, value]) => typeof key === 'string' && value === true) ); } catch { return {}; } } function writeStoredEnabledMap(enabledByConversation: ConversationNotificationMap) { if (typeof window === 'undefined') { return; } window.localStorage.setItem(STORAGE_KEY, JSON.stringify(enabledByConversation)); } function getInitialPermission(): NotificationPermissionState { if (typeof window === 'undefined' || !('Notification' in window)) { return 'unsupported'; } return window.Notification.permission; } function shouldShowDesktopNotification(): boolean { if (typeof document === 'undefined') { return false; } return document.visibilityState !== 'visible' || !document.hasFocus(); } function getMessageConversationNotificationKey(message: Message): string | null { if (message.type === 'PRIV' && message.conversation_key) { return getConversationNotificationKey('contact', message.conversation_key); } if (message.type === 'CHAN' && message.conversation_key) { return getConversationNotificationKey('channel', message.conversation_key); } return null; } function buildNotificationTitle(message: Message): string { if (message.type === 'PRIV') { return message.sender_name ? `New message from ${message.sender_name}` : `New message from ${message.conversation_key.slice(0, 12)}`; } const roomName = message.channel_name || message.conversation_key.slice(0, 8); return `New message in ${roomName}`; } function buildPreviewNotificationTitle(type: 'channel' | 'contact', label: string): string { return type === 'contact' ? `New message from ${label}` : `New message in ${label}`; } function buildMessageNotificationHash(message: Message): string | null { if (message.type === 'PRIV' && message.conversation_key) { const label = message.sender_name || message.conversation_key.slice(0, 12); return `#contact/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`; } if (message.type === 'CHAN' && message.conversation_key) { const label = message.channel_name || message.conversation_key.slice(0, 8); return `#channel/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`; } return null; } export function getNotificationEnableToastInfo( environment?: Partial ): NotificationEnableToastInfo { if (typeof window === 'undefined') { return { level: 'success', title: 'Notifications enabled' }; } const protocol = environment?.protocol ?? window.location.protocol; const isSecureContext = environment?.isSecureContext ?? window.isSecureContext; if (protocol === 'http:') { return { level: 'warning', title: 'Notifications enabled with warning', description: 'Desktop notifications are on for this conversation, but you are using HTTP instead of HTTPS. Notifications will likely not work reliably.', }; } // Best-effort heuristic only. Browsers do not expose certificate trust details // directly to page JS, so an HTTPS page that is not a secure context is the // closest signal we have for an untrusted/self-signed setup. if (protocol === 'https:' && !isSecureContext) { return { level: 'warning', title: 'Notifications enabled with warning', description: 'Desktop notifications are on for this conversation, but your HTTPS connection is untrusted, such as a self-signed certificate. Notification delivery may be inconsistent depending on your browser.', }; } return { level: 'success', title: 'Notifications enabled' }; } export function useBrowserNotifications() { const [permission, setPermission] = useState(getInitialPermission); const [enabledByConversation, setEnabledByConversation] = useState(readStoredEnabledMap); useEffect(() => { setPermission(getInitialPermission()); }, []); const isConversationNotificationsEnabled = useCallback( (type: 'channel' | 'contact', id: string) => permission === 'granted' && enabledByConversation[getConversationNotificationKey(type, id)] === true, [enabledByConversation, permission] ); const toggleConversationNotifications = useCallback( async (type: 'channel' | 'contact', id: string, label: string) => { const conversationKey = getConversationNotificationKey(type, id); if (enabledByConversation[conversationKey]) { setEnabledByConversation((prev) => { const next = { ...prev }; delete next[conversationKey]; writeStoredEnabledMap(next); return next; }); toast.success('Notifications disabled', { description: `Desktop notifications are off for ${label}.`, }); return; } if (permission === 'unsupported') { toast.error('Notifications unavailable', { description: 'This browser does not support desktop notifications.', }); return; } if (permission === 'denied') { toast.error('Notifications blocked', { description: 'Desktop notifications are blocked by your browser. Allow notifications in browser settings, then try again. Non-HTTPS or untrusted HTTPS origins may also prevent notifications from working reliably.', }); return; } const nextPermission = await window.Notification.requestPermission(); setPermission(nextPermission); if (nextPermission === 'granted') { setEnabledByConversation((prev) => { const next = { ...prev, [conversationKey]: true, }; writeStoredEnabledMap(next); return next; }); new window.Notification(buildPreviewNotificationTitle(type, label), { body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.', icon: NOTIFICATION_ICON_PATH, tag: `meshcore-notification-preview-${conversationKey}`, }); const toastInfo = getNotificationEnableToastInfo(); if (toastInfo.level === 'warning') { toast.warning(toastInfo.title, { description: toastInfo.description, }); } else { toast.success(toastInfo.title, { description: `Desktop notifications are on for ${label}.`, }); } return; } toast.error('Notifications not enabled', { description: nextPermission === 'denied' ? 'Desktop notifications were denied by your browser. Allow notifications in browser settings, then try again.' : 'The browser permission request was dismissed.', }); }, [enabledByConversation, permission] ); const notifyIncomingMessage = useCallback( (message: Message) => { const conversationKey = getMessageConversationNotificationKey(message); if ( permission !== 'granted' || !conversationKey || enabledByConversation[conversationKey] !== true || !shouldShowDesktopNotification() ) { return; } const notification = new window.Notification(buildNotificationTitle(message), { body: message.text, icon: NOTIFICATION_ICON_PATH, tag: `meshcore-message-${message.id}`, }); notification.onclick = () => { const hash = buildMessageNotificationHash(message); if (hash) { window.open(`${window.location.origin}${window.location.pathname}${hash}`, '_self'); } window.focus(); notification.close(); }; }, [enabledByConversation, permission] ); return { notificationsSupported: permission !== 'unsupported', notificationsPermission: permission, isConversationNotificationsEnabled, toggleConversationNotifications, notifyIncomingMessage, }; }