Implement browser push notifications

This commit is contained in:
2025-09-20 20:13:35 +03:00
Unverified
parent 2f30399607
commit 9a25bdcf62
13 changed files with 590 additions and 6 deletions
@@ -1,15 +1,56 @@
import { useState } from "react";
import { PRODUCT_NAME } from "../../../core/config";
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 { useAppState } from "../../state";
import type { Switch } from "mdui/components/switch";
import { getAuthHeaders } from "../../../auth/api";
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const [activePanel, setActivePanel] = useState("notifications-settings");
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false);
const [pushSupported, setPushSupported] = useState(false);
const user = useAppState(state => state.user);
useEffect(() => {
setPushSupported(pushNotificationManager.isSupported());
setPushNotificationsEnabled(!!pushNotificationManager.getSubscription());
}, []);
const handlePanelChange = (panelId: string) => {
setActivePanel(panelId);
};
const handlePushNotificationToggle = async (enabled: boolean) => {
if (!user.authToken) return;
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);
}
}
} else {
await pushNotificationManager.unsubscribe();
// Call API to unsubscribe on server
await fetch(`${API_BASE_URL}/push/unsubscribe`, {
method: "DELETE",
headers: getAuthHeaders(user.authToken)
});
setPushNotificationsEnabled(false);
}
} catch (error) {
console.error("Failed to toggle push notifications:", error);
}
};
return (
<MaterialDialog close-on-overlay-click close-on-esc fullscreen open={isOpen} onOpenChange={onOpenChange} id="settings-dialog">
<div className="fullscreen-wrapper">
@@ -87,6 +128,14 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<div className="screen">
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
<h3>Уведомления</h3>
{pushSupported && (
<mdui-switch
checked={pushNotificationsEnabled}
onInput={(e) => handlePushNotificationToggle((e.target as Switch).checked)}
>
Push уведомления
</mdui-switch>
)}
<mdui-switch checked>Новые сообщения</mdui-switch>
<mdui-switch checked>Звуковые уведомления</mdui-switch>
<mdui-switch>Уведомления о статусе</mdui-switch>
+21
View File
@@ -8,6 +8,7 @@ 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";
export default function LoginScreen() {
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
@@ -65,6 +66,26 @@ export default function LoginScreen() {
console.error("Key setup failed:", e);
}
// Initialize push 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");
}
} else {
console.log("Push notification permission denied");
}
}
} catch (e) {
console.error("Push notification setup failed:", e);
}
setCurrentPage("chat");
// initializeProfile(); // Initialize profile after login
} else {
+1
View File
@@ -19,6 +19,7 @@ import 'mdui/components/text-field';
import 'mdui/components/button-icon';
import 'mdui/components/top-app-bar';
import 'mdui/components/top-app-bar-title';
import 'mdui/components/switch';
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
+148
View File
@@ -0,0 +1,148 @@
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<boolean> {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
console.log("Push messaging is not supported");
return false;
}
try {
this.registration = await navigator.serviceWorker.register("/sw.js");
console.log("Service Worker registered successfully");
return true;
} catch (error) {
console.error("Service Worker registration failed:", error);
return false;
}
}
async requestPermission(): Promise<NotificationPermission> {
if (!this.registration) {
throw new Error("Service Worker not initialized");
}
const permission = await Notification.requestPermission();
return permission;
}
async subscribe(): Promise<PushSubscription | null> {
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<boolean> {
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<boolean> {
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();