mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Merge branch 'feature/push'
This commit is contained in:
+2
-1
@@ -1,7 +1,7 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from routes import account, messaging, profile
|
||||
from routes import account, messaging, profile, push
|
||||
|
||||
# Инициализация FastAPI
|
||||
app = FastAPI(title="PixelChat")
|
||||
@@ -19,3 +19,4 @@ app.add_middleware(
|
||||
app.include_router(account.router)
|
||||
app.include_router(messaging.router)
|
||||
app.include_router(profile.router)
|
||||
app.include_router(push.router, prefix="/push")
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate VAPID keys for push notifications
|
||||
Run this script to generate new VAPID keys for your application
|
||||
"""
|
||||
|
||||
from pywebpush import WebPushException
|
||||
import base64
|
||||
import json
|
||||
|
||||
def generate_vapid_keys():
|
||||
"""Generate VAPID keys for push notifications"""
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
# Generate private key
|
||||
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
|
||||
# Get public key
|
||||
public_key = private_key.public_key()
|
||||
|
||||
# Serialize keys
|
||||
private_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
public_pem = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
|
||||
# Convert to base64 for web push
|
||||
private_key_b64 = base64.urlsafe_b64encode(
|
||||
private_key.private_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
).decode('utf-8').rstrip('=')
|
||||
|
||||
# Get the raw uncompressed public key point (65 bytes: 0x04 + 32 bytes x + 32 bytes y)
|
||||
public_numbers = public_key.public_numbers()
|
||||
x_bytes = public_numbers.x.to_bytes(32, 'big')
|
||||
y_bytes = public_numbers.y.to_bytes(32, 'big')
|
||||
public_key_raw = b'\x04' + x_bytes + y_bytes
|
||||
|
||||
public_key_b64 = base64.urlsafe_b64encode(public_key_raw).decode('utf-8').rstrip('=')
|
||||
|
||||
print("VAPID Keys Generated:")
|
||||
print("=" * 50)
|
||||
print(f"Private Key: {private_key_b64}")
|
||||
print(f"Public Key: {public_key_b64}")
|
||||
print("=" * 50)
|
||||
print("\nAdd these to your environment variables:")
|
||||
print(f"VAPID_PRIVATE_KEY={private_key_b64}")
|
||||
print(f"VAPID_PUBLIC_KEY={public_key_b64}")
|
||||
|
||||
return private_key_b64, public_key_b64
|
||||
|
||||
except ImportError:
|
||||
print("Error: cryptography library not found.")
|
||||
print("Install it with: pip install cryptography")
|
||||
return None, None
|
||||
except Exception as e:
|
||||
print(f"Error generating VAPID keys: {e}")
|
||||
return None, None
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_vapid_keys()
|
||||
@@ -68,6 +68,18 @@ class DMEnvelope(Base):
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
|
||||
|
||||
class PushSubscription(Base):
|
||||
__tablename__ = "push_subscription"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
endpoint = Column(Text, nullable=False)
|
||||
p256dh_key = Column(Text, nullable=False)
|
||||
auth_key = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
# Pydantic модели
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
@@ -97,6 +109,11 @@ class UpdateBioRequest(BaseModel):
|
||||
bio: str
|
||||
|
||||
|
||||
class PushSubscriptionRequest(BaseModel):
|
||||
endpoint: str
|
||||
keys: dict
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from pywebpush import webpush, WebPushException
|
||||
from models import PushSubscription, User, Message, DMEnvelope
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
class PushNotificationService:
|
||||
def __init__(self):
|
||||
# VAPID keys - load from environment variables
|
||||
self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY", "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQghg2CSKiq0KsXXXImE75Z8UAphGBjkpYjUE87zPBmGqKhRANCAATxbNBGMhNl6gLmPL0PAf2YIJCVYX_TZrSqkj7SCqsu5VNMhnDOan6Qc9hEkcTZgvwj286C24SnxfH5CghVMCI6")
|
||||
self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY", "BPFs0EYyE2XqAuY8vQ8B_ZggkJVhf9NmtKqSPtIKqy7lU0yGcM5qfpBz2ESRxNmC_CPbzoLbhKfF8fkKCFUwIjo")
|
||||
self.vapid_claims = {
|
||||
"sub": "mailto:admin@fromchat.com",
|
||||
"aud": "https://fcm.googleapis.com"
|
||||
}
|
||||
|
||||
async def subscribe_user(self, db: Session, user_id: int, endpoint: str, p256dh_key: str, auth_key: str) -> bool:
|
||||
"""Subscribe a user to push notifications"""
|
||||
try:
|
||||
# Check if user already has a subscription
|
||||
existing_sub = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
||||
|
||||
if existing_sub:
|
||||
# Update existing subscription
|
||||
existing_sub.endpoint = endpoint
|
||||
existing_sub.p256dh_key = p256dh_key
|
||||
existing_sub.auth_key = auth_key
|
||||
else:
|
||||
# Create new subscription
|
||||
new_sub = PushSubscription(
|
||||
user_id=user_id,
|
||||
endpoint=endpoint,
|
||||
p256dh_key=p256dh_key,
|
||||
auth_key=auth_key
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Push subscription saved for user {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save push subscription for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
async def send_public_message_notification(self, db: Session, message: Message, exclude_user_id: Optional[int] = None):
|
||||
"""Send push notification for a new public chat message"""
|
||||
try:
|
||||
# Get all users except the sender
|
||||
users = db.query(User).filter(User.id != message.user_id)
|
||||
if exclude_user_id:
|
||||
users = users.filter(User.id != exclude_user_id)
|
||||
|
||||
for user in users:
|
||||
# Check if user has push subscription before trying to send
|
||||
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first()
|
||||
if not subscription:
|
||||
continue
|
||||
|
||||
await self._send_notification_to_user(
|
||||
db, user.id,
|
||||
f"New message from {message.author.username}",
|
||||
message.content[:100] + ("..." if len(message.content) > 100 else ""),
|
||||
message.author.profile_picture,
|
||||
{
|
||||
"type": "public_message",
|
||||
"message_id": message.id,
|
||||
"sender_id": message.user_id,
|
||||
"sender_username": message.author.username
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send public message notifications: {e}")
|
||||
|
||||
async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User):
|
||||
"""Send push notification for a new DM"""
|
||||
try:
|
||||
await self._send_notification_to_user(
|
||||
db, dm_envelope.recipient_id,
|
||||
f"New message from {sender.username}",
|
||||
"You have a new direct message",
|
||||
sender.profile_picture,
|
||||
{
|
||||
"type": "dm",
|
||||
"dm_id": dm_envelope.id,
|
||||
"sender_id": sender.id,
|
||||
"sender_username": sender.username
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send DM notification: {e}")
|
||||
|
||||
async def _send_notification_to_user(self, db: Session, user_id: int, title: str, body: str, icon: Optional[str], data: dict):
|
||||
"""Send a push notification to a specific user"""
|
||||
try:
|
||||
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"icon": icon or "/logo.png",
|
||||
"tag": f"message_{user_id}",
|
||||
"data": data
|
||||
}
|
||||
|
||||
subscription_info = {
|
||||
"endpoint": subscription.endpoint,
|
||||
"keys": {
|
||||
"p256dh": subscription.p256dh_key,
|
||||
"auth": subscription.auth_key
|
||||
}
|
||||
}
|
||||
|
||||
webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=json.dumps(payload),
|
||||
vapid_private_key=self.vapid_private_key,
|
||||
vapid_claims=self.vapid_claims
|
||||
)
|
||||
|
||||
except WebPushException as e:
|
||||
logger.error(f"WebPush error for user {user_id}: {e}")
|
||||
# If the subscription is invalid, remove it
|
||||
if hasattr(e, 'response') and e.response and e.response.status_code in [410, 404]:
|
||||
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification to user {user_id}: {e}")
|
||||
|
||||
async def unsubscribe_user(self, db: Session, user_id: int) -> bool:
|
||||
"""Unsubscribe a user from push notifications"""
|
||||
try:
|
||||
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
||||
db.commit()
|
||||
logger.info(f"Push subscription removed for user {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove push subscription for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
# Global instance
|
||||
push_service = PushNotificationService()
|
||||
@@ -6,3 +6,5 @@ bcrypt>=4.3.0
|
||||
websockets>=15.0.1
|
||||
Pillow>=10.0.0
|
||||
python-multipart>=0.0.6
|
||||
pywebpush>=1.14.0
|
||||
cryptography>=41.0.0
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
||||
from dependencies import get_current_user, get_db
|
||||
from constants import OWNER_USERNAME
|
||||
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope
|
||||
from push_service import push_service
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
@@ -58,6 +59,12 @@ async def send_message(
|
||||
db.commit()
|
||||
db.refresh(new_message)
|
||||
|
||||
# Send push notifications for public messages
|
||||
try:
|
||||
await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification for message {new_message.id}: {e}")
|
||||
|
||||
return {"status": "success", "message": convert_message(new_message)}
|
||||
|
||||
|
||||
@@ -93,6 +100,13 @@ async def dm_send(payload: dict, current_user: User = Depends(get_current_user),
|
||||
db.add(env)
|
||||
db.commit()
|
||||
db.refresh(env)
|
||||
|
||||
# Send push notification for DM
|
||||
try:
|
||||
await push_service.send_dm_notification(db, env, current_user)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
|
||||
|
||||
return {"status": "ok", "id": env.id}
|
||||
|
||||
|
||||
@@ -298,6 +312,12 @@ class MessaggingSocketManager:
|
||||
}
|
||||
}
|
||||
|
||||
# Send push notification for DM
|
||||
try:
|
||||
await push_service.send_dm_notification(db, env, current_user)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
|
||||
|
||||
await self.send_to_user(env.recipient_id, payload);
|
||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}});
|
||||
await self.send_to_user(env.sender_id, payload);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import User, PushSubscriptionRequest
|
||||
from push_service import push_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/subscribe")
|
||||
async def subscribe_to_push_notifications(
|
||||
request: PushSubscriptionRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Subscribe user to push notifications"""
|
||||
try:
|
||||
success = await push_service.subscribe_user(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
endpoint=request.endpoint,
|
||||
p256dh_key=request.keys["p256dh"],
|
||||
auth_key=request.keys["auth"]
|
||||
)
|
||||
|
||||
if success:
|
||||
return {"status": "success", "message": "Push notifications enabled"}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Failed to enable push notifications")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete("/unsubscribe")
|
||||
async def unsubscribe_from_push_notifications(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Unsubscribe user from push notifications"""
|
||||
try:
|
||||
success = await push_service.unsubscribe_user(db=db, user_id=current_user.id)
|
||||
|
||||
if success:
|
||||
return {"status": "success", "message": "Push notifications disabled"}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Failed to disable push notifications")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
Vendored
+12
-1
@@ -1,8 +1,19 @@
|
||||
export type Platform = "win32" | "darwin" | "linux"
|
||||
|
||||
export interface ElectronNotifications {
|
||||
requestPermission: () => Promise<NotificationPermission>;
|
||||
show: (options: {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
tag?: string;
|
||||
}) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ElectronInterface {
|
||||
desktop: true,
|
||||
platform: Platform
|
||||
platform: Platform,
|
||||
notifications: ElectronNotifications
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -0,0 +1,89 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
interface NotificationPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
image?: string;
|
||||
tag?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
interface NotificationAction {
|
||||
action: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface NotificationOptions {
|
||||
body: string;
|
||||
icon: string;
|
||||
badge: string;
|
||||
image?: string;
|
||||
tag: string;
|
||||
data?: any;
|
||||
actions: NotificationAction[];
|
||||
requireInteraction: boolean;
|
||||
silent: boolean;
|
||||
}
|
||||
|
||||
// Service Worker for Push Notifications
|
||||
self.addEventListener("push", function(event: ExtendableEvent) {
|
||||
const pushEvent = event as PushEvent;
|
||||
if (pushEvent.data) {
|
||||
const data: NotificationPayload = pushEvent.data.json();
|
||||
|
||||
const options: NotificationOptions = {
|
||||
body: data.body,
|
||||
icon: data.icon || "/logo.png",
|
||||
badge: "/logo.png",
|
||||
image: data.image,
|
||||
tag: data.tag || "message",
|
||||
data: data.data,
|
||||
actions: [
|
||||
{
|
||||
action: "open",
|
||||
title: "Open Chat"
|
||||
},
|
||||
{
|
||||
action: "close",
|
||||
title: "Close"
|
||||
}
|
||||
],
|
||||
requireInteraction: true,
|
||||
silent: false
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, options)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", function(event: ExtendableEvent) {
|
||||
const notificationEvent = event as NotificationEvent;
|
||||
notificationEvent.notification.close();
|
||||
|
||||
if (notificationEvent.action === "open" || !notificationEvent.action) {
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window" }).then(function(clientList: readonly WindowClient[]) {
|
||||
// If there's already a window open, focus it
|
||||
for (let i = 0; i < clientList.length; i++) {
|
||||
const client = clientList[i];
|
||||
if (client.url === self.location.origin && "focus" in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Otherwise, open a new window
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(self.location.origin);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclose", function(_event: ExtendableEvent) {
|
||||
// Handle notification close if needed
|
||||
});
|
||||
@@ -1,15 +1,66 @@
|
||||
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 { 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";
|
||||
|
||||
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(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) => {
|
||||
setActivePanel(panelId);
|
||||
};
|
||||
|
||||
const handlePushNotificationToggle = async (enabled: boolean) => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
if (enabled) {
|
||||
const initialized = await initialize();
|
||||
if (initialized) {
|
||||
await subscribe(user.authToken);
|
||||
|
||||
// For Electron, start the notification receiver
|
||||
if (isElectron) {
|
||||
await startElectronReceiver();
|
||||
}
|
||||
|
||||
setPushNotificationsEnabled(true);
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
});
|
||||
setPushNotificationsEnabled(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle 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 +138,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>
|
||||
|
||||
@@ -8,6 +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 { initialize, isSupported, startElectronReceiver, subscribe } from "../../utils/notifications";
|
||||
import { isElectron } from "../../electron/electron";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
@@ -66,7 +68,29 @@ export default function LoginScreen() {
|
||||
}
|
||||
|
||||
setCurrentPage("chat");
|
||||
// initializeProfile(); // Initialize profile after login
|
||||
|
||||
// Initialize notifications
|
||||
try {
|
||||
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("Notification permission denied");
|
||||
}
|
||||
} else {
|
||||
console.log("Notifications not supported");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Notification setup failed:", e);
|
||||
}
|
||||
} else {
|
||||
const data: ErrorResponse = await response.json();
|
||||
showAlert("danger", data.message || "Неверное имя пользователя или пароль");
|
||||
|
||||
@@ -7,6 +7,8 @@ import { DMPanel, type DMPanelData } from "./panels/DMPanel";
|
||||
import { getAuthHeaders } from "../auth/api";
|
||||
import { restoreKeys } from "../auth/crypto";
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { initialize, subscribe, startElectronReceiver, isSupported } from "../utils/notifications";
|
||||
import { isElectron } from "../electron/electron";
|
||||
|
||||
type Page = "login" | "register" | "chat"
|
||||
export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
|
||||
@@ -215,6 +217,23 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
},
|
||||
currentPage: "chat"
|
||||
}));
|
||||
|
||||
// Initialize notifications after successful credential restoration
|
||||
try {
|
||||
if (isSupported()) {
|
||||
const initialized = await initialize();
|
||||
if (initialized) {
|
||||
await subscribe(token);
|
||||
|
||||
// For Electron, start the notification receiver
|
||||
if (isElectron) {
|
||||
await startElectronReceiver();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Notification setup failed (restored):", e);
|
||||
}
|
||||
} else {
|
||||
throw new Error("Unable to authenticate");
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<PushSubscription | null> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// Handle notifications for new messages
|
||||
if (response.type === "newMessage" && response.data) {
|
||||
await showMessageNotification(response.data);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API functions
|
||||
export async function initialize(): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+45
-2
@@ -3,6 +3,9 @@ import { createHtmlPlugin } from "vite-plugin-html";
|
||||
import autoprefixer from "autoprefixer";
|
||||
import electron from "vite-plugin-electron/simple";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "path";
|
||||
|
||||
const serviceWorkerPath = resolve(__dirname, "src/service-worker/service-worker.ts");
|
||||
|
||||
const plugins: PluginOption[] = [
|
||||
react(),
|
||||
@@ -17,7 +20,31 @@ const plugins: PluginOption[] = [
|
||||
minifyCSS: true,
|
||||
minifyJS: true
|
||||
}
|
||||
})
|
||||
}),
|
||||
{
|
||||
name: 'service-worker-redirect',
|
||||
configureServer(server) {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
if (req.url === '/assets/serviceWorker.js') {
|
||||
try {
|
||||
const traspiled = await server.transformRequest(serviceWorkerPath);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': "application/javascript"
|
||||
});
|
||||
res.end(traspiled!.code);
|
||||
} catch (e) {
|
||||
try {
|
||||
res.writeHead(500);
|
||||
res.end();
|
||||
} catch (e) {}
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
if (process.env.VITE_ELECTRON) {
|
||||
@@ -79,6 +106,22 @@ export default defineConfig({
|
||||
},
|
||||
cssMinify: true,
|
||||
assetsInlineLimit: 0,
|
||||
outDir: process.env.VITE_ELECTRON ? "build/electron/dist" : "build/normal/dist"
|
||||
outDir: process.env.VITE_ELECTRON ? "build/electron/dist" : "build/normal/dist",
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, "index.html"),
|
||||
serviceWorker: serviceWorkerPath
|
||||
},
|
||||
output: {
|
||||
entryFileNames: (chunkInfo) => {
|
||||
// Проверяем имя чанка
|
||||
if (chunkInfo.name === 'serviceWorker') {
|
||||
return 'assets/serviceWorker.js'; // Указываем фиксированное имя для этого скрипта
|
||||
}
|
||||
// Для остальных файлов используем стандартное именование с хэшем
|
||||
return 'assets/[name]-[hash].js';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
"frontend:typecheck": "tsc --project frontend",
|
||||
"frontend:build": "npm run frontend:typecheck && vite build frontend",
|
||||
"frontend:electron:dev": "VITE_ELECTRON=true npm run frontend:dev",
|
||||
"frontend:electron:build": "VITE_ELECTRON=true npm run frontend:build && rm -rf out && electron-forge make --force",
|
||||
"frontend:electron:build": "VITE_ELECTRON=true npm run frontend:build && rm -rf out && electron-forge make --force --arch arm64,x64",
|
||||
"frontend:preview": "vite preview frontend",
|
||||
"frontend:dependencies": "npm install --ignore-scripts",
|
||||
"frontend:clean": "rm -rf frontend/dist",
|
||||
|
||||
Reference in New Issue
Block a user