From 137d02567b29dbefce02de5daa371b5e3da59155 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 21 Sep 2025 15:23:27 +0300 Subject: [PATCH] Implement notifications for Electron --- frontend/electron.d.ts | 13 +- frontend/electron/main.ts | 47 ++- frontend/electron/preload.ts | 8 +- .../ui/components/settings/SettingsDialog.tsx | 38 ++- frontend/src/ui/screen/LoginScreen.tsx | 30 +- frontend/src/utils/notifications.ts | 267 ++++++++++++++++++ frontend/src/utils/pushNotifications.ts | 148 ---------- 7 files changed, 368 insertions(+), 183 deletions(-) create mode 100644 frontend/src/utils/notifications.ts delete mode 100644 frontend/src/utils/pushNotifications.ts diff --git a/frontend/electron.d.ts b/frontend/electron.d.ts index ae8b74e..a825785 100644 --- a/frontend/electron.d.ts +++ b/frontend/electron.d.ts @@ -1,8 +1,19 @@ export type Platform = "win32" | "darwin" | "linux" +export interface ElectronNotifications { + requestPermission: () => Promise; + show: (options: { + title: string; + body: string; + icon?: string; + tag?: string; + }) => Promise; +} + export interface ElectronInterface { desktop: true, - platform: Platform + platform: Platform, + notifications: ElectronNotifications } declare global { diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index 46c4322..cc9b44b 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -1,8 +1,10 @@ -import { app, BrowserWindow } from 'electron'; +import { app, BrowserWindow, Notification, ipcMain } from 'electron'; import path from "node:path"; +let mainWindow: BrowserWindow | null = null; + app.whenReady().then(() => { - const win = new BrowserWindow({ + mainWindow = new BrowserWindow({ title: 'Main window', minWidth: 800, minHeight: 420, @@ -19,8 +21,45 @@ app.whenReady().then(() => { }); if (process.env.VITE_DEV_SERVER_URL) { - win.loadURL(process.env.VITE_DEV_SERVER_URL); + mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL); } else { - win.loadFile('frontend/build/electron/dist/index.html'); + mainWindow.loadFile('frontend/build/electron/dist/index.html'); } + + // Handle notification permission requests + ipcMain.handle('request-notification-permission', async () => { + if (Notification.isSupported()) { + return 'granted'; + } + return 'denied'; + }); + + // Handle showing notifications + ipcMain.handle('show-notification', async (event, options) => { + if (Notification.isSupported()) { + try { + const notification = new Notification({ + title: options.title, + body: options.body, + icon: options.icon, + silent: false, + urgency: 'normal' + }); + + notification.on('click', () => { + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + } + }); + + notification.show(); + return true; + } catch (error) { + console.error('Error creating notification:', error); + return false; + } + } + return false; + }); }); \ No newline at end of file diff --git a/frontend/electron/preload.ts b/frontend/electron/preload.ts index 4d99711..e1ad02c 100644 --- a/frontend/electron/preload.ts +++ b/frontend/electron/preload.ts @@ -1,9 +1,13 @@ -import { contextBridge } from "electron"; +import { contextBridge, ipcRenderer } from "electron"; import type { ElectronInterface, Platform } from "../electron"; const electronInterface: ElectronInterface = { desktop: true, - platform: process.platform as Platform + platform: process.platform as Platform, + notifications: { + requestPermission: () => ipcRenderer.invoke('request-notification-permission'), + show: (options: any) => ipcRenderer.invoke('show-notification', options) + } } contextBridge.exposeInMainWorld("electronInterface", electronInterface); \ No newline at end of file diff --git a/frontend/src/ui/components/settings/SettingsDialog.tsx b/frontend/src/ui/components/settings/SettingsDialog.tsx index 033ee78..64471bc 100644 --- a/frontend/src/ui/components/settings/SettingsDialog.tsx +++ b/frontend/src/ui/components/settings/SettingsDialog.tsx @@ -2,7 +2,8 @@ import { useState, useEffect } from "react"; import { PRODUCT_NAME, API_BASE_URL } from "../../../core/config"; import type { DialogProps } from "../../../core/types"; import { MaterialDialog } from "../core/Dialog"; -import { pushNotificationManager } from "../../../utils/pushNotifications"; +import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "../../../utils/notifications"; +import { isElectron } from "../../../electron/electron"; import { useAppState } from "../../state"; import type { Switch } from "mdui/components/switch"; import { getAuthHeaders } from "../../../auth/api"; @@ -14,8 +15,10 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { const user = useAppState(state => state.user); useEffect(() => { - setPushSupported(pushNotificationManager.isSupported()); - setPushNotificationsEnabled(!!pushNotificationManager.getSubscription()); + setPushSupported(isSupported()); + // For Electron, we assume notifications are enabled if supported + // For web browsers, we check if there's a subscription + setPushNotificationsEnabled(isSupported()); }, []); const handlePanelChange = (panelId: string) => { @@ -27,19 +30,26 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { try { if (enabled) { - await pushNotificationManager.initialize(); - const permission = await pushNotificationManager.requestPermission(); - - if (permission === "granted") { - const subscription = await pushNotificationManager.subscribe(); - if (subscription) { - await pushNotificationManager.sendSubscriptionToServer(user.authToken); - setPushNotificationsEnabled(true); + const initialized = await initialize(); + if (initialized) { + await subscribe(user.authToken); + + // For Electron, start the notification receiver + if (isElectron) { + await startElectronReceiver(); } + + setPushNotificationsEnabled(true); } } else { - await pushNotificationManager.unsubscribe(); - // Call API to unsubscribe on server + await unsubscribe(); + + // For Electron, stop the notification receiver + if (isElectron) { + stopElectronReceiver(); + } + + // Call API to unsubscribe on server (for web browsers) await fetch(`${API_BASE_URL}/push/unsubscribe`, { method: "DELETE", headers: getAuthHeaders(user.authToken) @@ -47,7 +57,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { setPushNotificationsEnabled(false); } } catch (error) { - console.error("Failed to toggle push notifications:", error); + console.error("Failed to toggle notifications:", error); } }; diff --git a/frontend/src/ui/screen/LoginScreen.tsx b/frontend/src/ui/screen/LoginScreen.tsx index a0d81ee..40ba22b 100644 --- a/frontend/src/ui/screen/LoginScreen.tsx +++ b/frontend/src/ui/screen/LoginScreen.tsx @@ -8,7 +8,8 @@ import { useRef } from "react"; import type { TextField } from "mdui/components/text-field"; import { useAppState } from "../state"; import { MaterialTextField } from "../components/core/TextField"; -import { pushNotificationManager } from "../../utils/pushNotifications"; +import { initialize, isSupported, startElectronReceiver, subscribe } from "../../utils/notifications"; +import { isElectron } from "../../electron/electron"; export default function LoginScreen() { const [alerts, updateAlerts] = useImmer([]); @@ -68,26 +69,27 @@ export default function LoginScreen() { setCurrentPage("chat"); - // Initialize push notifications + // Initialize notifications try { - if (pushNotificationManager.isSupported()) { - await pushNotificationManager.initialize(); - const permission = await pushNotificationManager.requestPermission(); - - if (permission === "granted") { - const subscription = await pushNotificationManager.subscribe(); - if (subscription) { - await pushNotificationManager.sendSubscriptionToServer(data.token); - console.log("Push notifications enabled"); + if (isSupported()) { + const initialized = await initialize(); + if (initialized) { + await subscribe(data.token); + + // For Electron, start the notification receiver + if (isElectron) { + await startElectronReceiver(); } + + console.log("Notifications enabled"); } else { - console.log("Push notification permission denied"); + console.log("Notification permission denied"); } } else { - console.log("Not supported"); + console.log("Notifications not supported"); } } catch (e) { - console.error("Push notification setup failed:", e); + console.error("Notification setup failed:", e); } } else { const data: ErrorResponse = await response.json(); diff --git a/frontend/src/utils/notifications.ts b/frontend/src/utils/notifications.ts new file mode 100644 index 0000000..00ec50b --- /dev/null +++ b/frontend/src/utils/notifications.ts @@ -0,0 +1,267 @@ +import { API_BASE_URL } from "../core/config"; +import { isElectron } from "../electron/electron"; +import { websocket } from "../core/websocket"; +import type { WebSocketMessage } from "../core/types"; + +export interface PushSubscriptionData { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; +} + +export interface NotificationPayload { + title: string; + body: string; + icon?: string; + image?: string; + tag?: string; + data?: any; +} + +// Global state +let isInitialized = false; +let registration: ServiceWorkerRegistration | null = null; +let subscription: PushSubscription | null = null; +let isElectronReceiverRunning = false; +let messageListener: ((event: MessageEvent) => void) | null = null; + +// Helper functions +function 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; +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); +} + +async function subscribeToWebPush(): Promise { + if (!registration) { + throw new Error("Service Worker not initialized"); + } + + try { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array( + "BPFs0EYyE2XqAuY8vQ8B_ZggkJVhf9NmtKqSPtIKqy7lU0yGcM5qfpBz2ESRxNmC_CPbzoLbhKfF8fkKCFUwIjo" + ).slice().buffer + }); + + console.log("Push subscription successful"); + return subscription; + } catch (error) { + console.error("Push subscription failed:", error); + return null; + } +} + +async function sendSubscriptionToServer(token: string): Promise { + if (!subscription) { + throw new Error("No push subscription available"); + } + + const subscriptionData: PushSubscriptionData = { + endpoint: subscription.endpoint, + keys: { + p256dh: arrayBufferToBase64(subscription.getKey("p256dh")!), + auth: arrayBufferToBase64(subscription.getKey("auth")!) + } + }; + + try { + const response = await fetch(`${API_BASE_URL}/push/subscribe`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(subscriptionData) + }); + + return response.ok; + } catch (error) { + console.error("Failed to send subscription to server:", error); + return false; + } +} + +async function showMessageNotification(message: any): Promise { + try { + await showNotification({ + title: `New message from ${message.username}`, + body: message.content.length > 100 + ? message.content.substring(0, 100) + "..." + : message.content, + icon: message.profile_picture || "/logo.png", + tag: `message_${message.id}`, + data: { + type: "public_message", + message_id: message.id, + sender_id: message.user_id, + sender_username: message.username + } + }); + } catch (error) { + console.error("Failed to show message notification:", error); + } +} + +async function handleWebSocketMessage(response: WebSocketMessage): Promise { + // Handle notifications for new messages + if (response.type === "newMessage" && response.data) { + await showMessageNotification(response.data); + } +} + +// Public API functions +export async function initialize(): Promise { + if (isInitialized) { + return true; + } + + try { + if (isElectron) { + // For Electron, we just need to request permission + const permission = await window.electronInterface.notifications.requestPermission(); + isInitialized = permission === "granted"; + return isInitialized; + } else { + // For web browsers, initialize service worker and push manager + if (!("serviceWorker" in navigator) || !("PushManager" in window)) { + console.log("Push messaging is not supported"); + return false; + } + + try { + registration = await navigator.serviceWorker.register("/assets/serviceWorker.js"); + console.log("Service Worker registered successfully"); + + const permission = await Notification.requestPermission(); + if (permission === "granted") { + await subscribeToWebPush(); + isInitialized = true; + } + return isInitialized; + } catch (error) { + console.error("Service Worker registration failed:", error); + return false; + } + } + } catch (error) { + console.error("Failed to initialize notification service:", error); + return false; + } +} + +export async function subscribe(token: string): Promise { + if (!isInitialized) { + return false; + } + + if (isElectron) { + // In Electron, we don't need server-side subscription + return true; + } + + return await sendSubscriptionToServer(token); +} + +export async function showNotification(payload: NotificationPayload): Promise { + if (isElectron) { + try { + return await window.electronInterface.notifications.show({ + title: payload.title, + body: payload.body, + icon: payload.icon, + tag: payload.tag + }); + } catch (error) { + console.error("Failed to show Electron notification:", error); + return false; + } + } + + // For web browsers, notifications are handled by the service worker + // when push messages are received from the server + return false; +} + +export async function unsubscribe(): Promise { + if (isElectron) { + // In Electron, we don't need to unsubscribe from server + return true; + } + + if (!subscription) { + return true; + } + + try { + const result = await subscription.unsubscribe(); + subscription = null; + return result; + } catch (error) { + console.error("Failed to unsubscribe:", error); + return false; + } +} + +export function isSupported(): boolean { + if (isElectron) { + return true; // Electron always supports notifications + } + return "serviceWorker" in navigator && "PushManager" in window; +} + +// Electron-specific functions +export async function startElectronReceiver(): Promise { + if (!isElectron || isElectronReceiverRunning) { + return; + } + + isElectronReceiverRunning = true; + + // Add our own message listener to the existing WebSocket + messageListener = (event: MessageEvent) => { + try { + const response: WebSocketMessage = JSON.parse(event.data); + handleWebSocketMessage(response); + } catch (error) { + console.error('Failed to parse WebSocket message:', error); + } + }; + + websocket.addEventListener('message', messageListener); +} + +export function stopElectronReceiver(): void { + if (!isElectron) { + return; + } + + isElectronReceiverRunning = false; + + // Remove our message listener + if (messageListener) { + websocket.removeEventListener('message', messageListener); + messageListener = null; + } +} \ No newline at end of file diff --git a/frontend/src/utils/pushNotifications.ts b/frontend/src/utils/pushNotifications.ts deleted file mode 100644 index 15fa511..0000000 --- a/frontend/src/utils/pushNotifications.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { API_BASE_URL } from "../core/config"; - -export interface PushSubscriptionData { - endpoint: string; - keys: { - p256dh: string; - auth: string; - }; -} - -export interface NotificationPayload { - title: string; - body: string; - icon?: string; - image?: string; - tag?: string; - data?: any; -} - -class PushNotificationManager { - private registration: ServiceWorkerRegistration | null = null; - private subscription: PushSubscription | null = null; - - async initialize(): Promise { - if (!("serviceWorker" in navigator) || !("PushManager" in window)) { - console.log("Push messaging is not supported"); - return false; - } - - try { - this.registration = await navigator.serviceWorker.register("/assets/serviceWorker.js"); - console.log("Service Worker registered successfully"); - return true; - } catch (error) { - console.error("Service Worker registration failed:", error); - return false; - } - } - - async requestPermission(): Promise { - if (!this.registration) { - throw new Error("Service Worker not initialized"); - } - - const permission = await Notification.requestPermission(); - return permission; - } - - async subscribe(): Promise { - if (!this.registration) { - throw new Error("Service Worker not initialized"); - } - - try { - this.subscription = await this.registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: this.urlBase64ToUint8Array( - "BPFs0EYyE2XqAuY8vQ8B_ZggkJVhf9NmtKqSPtIKqy7lU0yGcM5qfpBz2ESRxNmC_CPbzoLbhKfF8fkKCFUwIjo" - ).slice().buffer - }); - - console.log("Push subscription successful"); - return this.subscription; - } catch (error) { - console.error("Push subscription failed:", error); - return null; - } - } - - async sendSubscriptionToServer(token: string): Promise { - if (!this.subscription) { - throw new Error("No push subscription available"); - } - - const subscriptionData: PushSubscriptionData = { - endpoint: this.subscription.endpoint, - keys: { - p256dh: this.arrayBufferToBase64(this.subscription.getKey("p256dh")!), - auth: this.arrayBufferToBase64(this.subscription.getKey("auth")!) - } - }; - - try { - const response = await fetch(`${API_BASE_URL}/push/subscribe`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${token}` - }, - body: JSON.stringify(subscriptionData) - }); - - return response.ok; - } catch (error) { - console.error("Failed to send subscription to server:", error); - return false; - } - } - - async unsubscribe(): Promise { - if (!this.subscription) { - return true; - } - - try { - const result = await this.subscription.unsubscribe(); - this.subscription = null; - return result; - } catch (error) { - console.error("Failed to unsubscribe:", error); - return false; - } - } - - 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; - } - - private arrayBufferToBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - return window.btoa(binary); - } - - getSubscription(): PushSubscription | null { - return this.subscription; - } - - isSupported(): boolean { - return "serviceWorker" in navigator && "PushManager" in window; - } -} - -export const pushNotificationManager = new PushNotificationManager(); \ No newline at end of file