mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement browser push notifications
This commit is contained in:
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from routes import account, messaging, profile
|
from routes import account, messaging, profile, push
|
||||||
|
|
||||||
# Инициализация FastAPI
|
# Инициализация FastAPI
|
||||||
app = FastAPI(title="PixelChat")
|
app = FastAPI(title="PixelChat")
|
||||||
@@ -18,4 +18,5 @@ app.add_middleware(
|
|||||||
# Routes
|
# Routes
|
||||||
app.include_router(account.router)
|
app.include_router(account.router)
|
||||||
app.include_router(messaging.router)
|
app.include_router(messaging.router)
|
||||||
app.include_router(profile.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)
|
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 модели
|
# Pydantic модели
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
@@ -97,6 +109,11 @@ class UpdateBioRequest(BaseModel):
|
|||||||
bio: str
|
bio: str
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscriptionRequest(BaseModel):
|
||||||
|
endpoint: str
|
||||||
|
keys: dict
|
||||||
|
|
||||||
|
|
||||||
class UserProfileResponse(BaseModel):
|
class UserProfileResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
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()
|
||||||
@@ -5,4 +5,6 @@ sqlalchemy>=2.0.43
|
|||||||
bcrypt>=4.3.0
|
bcrypt>=4.3.0
|
||||||
websockets>=15.0.1
|
websockets>=15.0.1
|
||||||
Pillow>=10.0.0
|
Pillow>=10.0.0
|
||||||
python-multipart>=0.0.6
|
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 dependencies import get_current_user, get_db
|
||||||
from constants import OWNER_USERNAME
|
from constants import OWNER_USERNAME
|
||||||
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope
|
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope
|
||||||
|
from push_service import push_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
@@ -58,6 +59,12 @@ async def send_message(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_message)
|
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)}
|
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.add(env)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(env)
|
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}
|
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 self.send_to_user(env.recipient_id, payload);
|
||||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}});
|
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}});
|
||||||
await self.send_to_user(env.sender_id, payload);
|
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))
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Service Worker for Push Notifications
|
||||||
|
self.addEventListener("push", function(event) {
|
||||||
|
if (event.data) {
|
||||||
|
const data = event.data.json();
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
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) {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
if (event.action === "open" || !event.action) {
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: "window" }).then(function(clientList) {
|
||||||
|
// 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 (clients.openWindow) {
|
||||||
|
return clients.openWindow(self.location.origin);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclose", function(event) {
|
||||||
|
// Handle notification close if needed
|
||||||
|
});
|
||||||
@@ -1,15 +1,56 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { PRODUCT_NAME } from "../../../core/config";
|
import { PRODUCT_NAME, API_BASE_URL } from "../../../core/config";
|
||||||
import type { DialogProps } from "../../../core/types";
|
import type { DialogProps } from "../../../core/types";
|
||||||
import { MaterialDialog } from "../core/Dialog";
|
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) {
|
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||||
const [activePanel, setActivePanel] = useState("notifications-settings");
|
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) => {
|
const handlePanelChange = (panelId: string) => {
|
||||||
setActivePanel(panelId);
|
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 (
|
return (
|
||||||
<MaterialDialog close-on-overlay-click close-on-esc fullscreen open={isOpen} onOpenChange={onOpenChange} id="settings-dialog">
|
<MaterialDialog close-on-overlay-click close-on-esc fullscreen open={isOpen} onOpenChange={onOpenChange} id="settings-dialog">
|
||||||
<div className="fullscreen-wrapper">
|
<div className="fullscreen-wrapper">
|
||||||
@@ -87,6 +128,14 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
|
|||||||
<div className="screen">
|
<div className="screen">
|
||||||
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
|
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
|
||||||
<h3>Уведомления</h3>
|
<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 checked>Звуковые уведомления</mdui-switch>
|
<mdui-switch checked>Звуковые уведомления</mdui-switch>
|
||||||
<mdui-switch>Уведомления о статусе</mdui-switch>
|
<mdui-switch>Уведомления о статусе</mdui-switch>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useRef } from "react";
|
|||||||
import type { TextField } from "mdui/components/text-field";
|
import type { TextField } from "mdui/components/text-field";
|
||||||
import { useAppState } from "../state";
|
import { useAppState } from "../state";
|
||||||
import { MaterialTextField } from "../components/core/TextField";
|
import { MaterialTextField } from "../components/core/TextField";
|
||||||
|
import { pushNotificationManager } from "../../utils/pushNotifications";
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||||
@@ -65,6 +66,26 @@ export default function LoginScreen() {
|
|||||||
console.error("Key setup failed:", e);
|
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");
|
setCurrentPage("chat");
|
||||||
// initializeProfile(); // Initialize profile after login
|
// initializeProfile(); // Initialize profile after login
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import 'mdui/components/text-field';
|
|||||||
import 'mdui/components/button-icon';
|
import 'mdui/components/button-icon';
|
||||||
import 'mdui/components/top-app-bar';
|
import 'mdui/components/top-app-bar';
|
||||||
import 'mdui/components/top-app-bar-title';
|
import 'mdui/components/top-app-bar-title';
|
||||||
|
import 'mdui/components/switch';
|
||||||
|
|
||||||
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
|
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
"frontend:typecheck": "tsc --project frontend",
|
"frontend:typecheck": "tsc --project frontend",
|
||||||
"frontend:build": "npm run frontend:typecheck && vite build frontend",
|
"frontend:build": "npm run frontend:typecheck && vite build frontend",
|
||||||
"frontend:electron:dev": "VITE_ELECTRON=true npm run frontend:dev",
|
"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:preview": "vite preview frontend",
|
||||||
"frontend:dependencies": "npm install --ignore-scripts",
|
"frontend:dependencies": "npm install --ignore-scripts",
|
||||||
"frontend:clean": "rm -rf frontend/dist",
|
"frontend:clean": "rm -rf frontend/dist",
|
||||||
|
|||||||
Reference in New Issue
Block a user