web: add firebase push token registration and sync
This commit is contained in:
@@ -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
949
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
109
web/src/utils/firebasePush.ts
Normal file
109
web/src/utils/firebasePush.ts
Normal 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
16
web/src/vite-env.d.ts
vendored
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user