web: add firebase push token registration and sync

This commit is contained in:
Codex
2026-03-09 23:12:40 +03:00
parent b1b54896a7
commit ef28c165e6
8 changed files with 1104 additions and 3 deletions

View File

@@ -4,3 +4,10 @@ VITE_GIF_PROVIDER=
VITE_TENOR_API_KEY= VITE_TENOR_API_KEY=
VITE_TENOR_CLIENT_KEY=benya_messenger_web VITE_TENOR_CLIENT_KEY=benya_messenger_web
VITE_GIPHY_API_KEY= VITE_GIPHY_API_KEY=
VITE_FIREBASE_API_KEY=
VITE_FIREBASE_AUTH_DOMAIN=
VITE_FIREBASE_PROJECT_ID=
VITE_FIREBASE_STORAGE_BUCKET=
VITE_FIREBASE_MESSAGING_SENDER_ID=
VITE_FIREBASE_APP_ID=
VITE_FIREBASE_VAPID_KEY=

949
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"axios": "1.11.0", "axios": "1.11.0",
"firebase": "^12.1.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",

View File

@@ -12,3 +12,18 @@ export async function getNotifications(limit = 30): Promise<NotificationItem[]>
const { data } = await http.get<NotificationItem[]>("/notifications", { params: { limit } }); const { data } = await http.get<NotificationItem[]>("/notifications", { params: { limit } });
return data; return data;
} }
interface PushTokenPayload {
platform: "android" | "web" | "ios";
token: string;
device_id?: string;
app_version?: string;
}
export async function upsertPushToken(payload: PushTokenPayload): Promise<void> {
await http.post("/notifications/push-token", payload);
}
export async function deletePushToken(payload: Pick<PushTokenPayload, "platform" | "token">): Promise<void> {
await http.delete("/notifications/push-token", { data: payload });
}

View File

@@ -7,6 +7,7 @@ import { ChatsPage } from "../pages/ChatsPage";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { useUiStore } from "../store/uiStore"; import { useUiStore } from "../store/uiStore";
import { ensureWebPushRegistration } from "../utils/firebasePush";
import { applyAppearancePreferences, getAppPreferences } from "../utils/preferences"; import { applyAppearancePreferences, getAppPreferences } from "../utils/preferences";
const PENDING_INVITE_TOKEN_KEY = "bm_pending_invite_token"; const PENDING_INVITE_TOKEN_KEY = "bm_pending_invite_token";
@@ -118,6 +119,13 @@ export function App() {
})(); })();
}, [accessToken, me, joiningInvite, loadChats, setActiveChatId, showToast]); }, [accessToken, me, joiningInvite, loadChats, setActiveChatId, showToast]);
useEffect(() => {
if (!accessToken || !me) {
return;
}
void ensureWebPushRegistration();
}, [accessToken, me]);
useEffect(() => { useEffect(() => {
if (!accessToken || !me || joiningInvite) { if (!accessToken || !me || joiningInvite) {
return; return;

View File

@@ -1,6 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { loginRequest, meRequest, refreshRequest } from "../api/auth"; import { loginRequest, meRequest, refreshRequest } from "../api/auth";
import type { AuthUser } from "../chat/types"; import type { AuthUser } from "../chat/types";
import { unregisterWebPushToken } from "../utils/firebasePush";
import { useChatStore } from "./chatStore"; import { useChatStore } from "./chatStore";
interface AuthState { interface AuthState {
@@ -59,6 +60,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
get().setTokens(data.access_token, data.refresh_token); get().setTokens(data.access_token, data.refresh_token);
}, },
logout: () => { logout: () => {
void unregisterWebPushToken();
localStorage.removeItem(ACCESS_KEY); localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY); localStorage.removeItem(REFRESH_KEY);
useChatStore.getState().reset(); useChatStore.getState().reset();

View File

@@ -0,0 +1,109 @@
import { initializeApp, getApps } from "firebase/app";
import { getMessaging, getToken, isSupported, onMessage, type MessagePayload } from "firebase/messaging";
import { deletePushToken, upsertPushToken } from "../api/notifications";
import { showNotificationViaServiceWorker } from "./webNotifications";
const WEB_PUSH_TOKEN_KEY = "bm_web_push_token";
let foregroundListenerAttached = false;
export async function ensureWebPushRegistration(): Promise<void> {
const config = getFirebaseConfig();
const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY?.trim();
if (!config || !vapidKey) {
return;
}
if (!(await isSupported())) {
return;
}
if (!("Notification" in window)) {
return;
}
if (Notification.permission === "default") {
await Notification.requestPermission();
}
if (Notification.permission !== "granted") {
return;
}
if (!("serviceWorker" in navigator)) {
return;
}
const registration = await navigator.serviceWorker.ready;
const app = getApps()[0] ?? initializeApp(config);
const messaging = getMessaging(app);
const token = await getToken(messaging, {
vapidKey,
serviceWorkerRegistration: registration,
});
if (!token) {
return;
}
const previous = window.localStorage.getItem(WEB_PUSH_TOKEN_KEY);
if (previous && previous !== token) {
await deletePushToken({ platform: "web", token: previous }).catch(() => undefined);
}
await upsertPushToken({ platform: "web", token, app_version: "web" });
window.localStorage.setItem(WEB_PUSH_TOKEN_KEY, token);
if (!foregroundListenerAttached) {
foregroundListenerAttached = true;
onMessage(messaging, (payload) => {
void showForegroundNotification(payload);
});
}
}
export async function unregisterWebPushToken(): Promise<void> {
const token = window.localStorage.getItem(WEB_PUSH_TOKEN_KEY);
if (!token) {
return;
}
await deletePushToken({ platform: "web", token }).catch(() => undefined);
window.localStorage.removeItem(WEB_PUSH_TOKEN_KEY);
}
async function showForegroundNotification(payload: MessagePayload): Promise<void> {
const data = payload.data ?? {};
const chatId = Number(data.chat_id);
const messageId = Number(data.message_id);
const title = payload.notification?.title ?? "New message";
const body = payload.notification?.body ?? "Open chat";
if (Number.isFinite(chatId) && Number.isFinite(messageId)) {
await showNotificationViaServiceWorker({
chatId,
messageId,
title,
body,
});
}
}
function getFirebaseConfig():
| {
apiKey: string;
authDomain?: string;
projectId: string;
storageBucket?: string;
messagingSenderId: string;
appId: string;
}
| null {
const apiKey = import.meta.env.VITE_FIREBASE_API_KEY?.trim();
const projectId = import.meta.env.VITE_FIREBASE_PROJECT_ID?.trim();
const messagingSenderId = import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID?.trim();
const appId = import.meta.env.VITE_FIREBASE_APP_ID?.trim();
if (!apiKey || !projectId || !messagingSenderId || !appId) {
return null;
}
return {
apiKey,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN?.trim() || undefined,
projectId,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET?.trim() || undefined,
messagingSenderId,
appId,
};
}

16
web/src/vite-env.d.ts vendored
View File

@@ -1 +1,17 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_WS_URL?: string;
readonly VITE_FIREBASE_API_KEY?: string;
readonly VITE_FIREBASE_AUTH_DOMAIN?: string;
readonly VITE_FIREBASE_PROJECT_ID?: string;
readonly VITE_FIREBASE_STORAGE_BUCKET?: string;
readonly VITE_FIREBASE_MESSAGING_SENDER_ID?: string;
readonly VITE_FIREBASE_APP_ID?: string;
readonly VITE_FIREBASE_VAPID_KEY?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}