web: add firebase push token registration and sync
This commit is contained in:
@@ -12,3 +12,18 @@ export async function getNotifications(limit = 30): Promise<NotificationItem[]>
|
||||
const { data } = await http.get<NotificationItem[]>("/notifications", { params: { limit } });
|
||||
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 { useChatStore } from "../store/chatStore";
|
||||
import { useUiStore } from "../store/uiStore";
|
||||
import { ensureWebPushRegistration } from "../utils/firebasePush";
|
||||
import { applyAppearancePreferences, getAppPreferences } from "../utils/preferences";
|
||||
|
||||
const PENDING_INVITE_TOKEN_KEY = "bm_pending_invite_token";
|
||||
@@ -118,6 +119,13 @@ export function App() {
|
||||
})();
|
||||
}, [accessToken, me, joiningInvite, loadChats, setActiveChatId, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !me) {
|
||||
return;
|
||||
}
|
||||
void ensureWebPushRegistration();
|
||||
}, [accessToken, me]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !me || joiningInvite) {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { loginRequest, meRequest, refreshRequest } from "../api/auth";
|
||||
import type { AuthUser } from "../chat/types";
|
||||
import { unregisterWebPushToken } from "../utils/firebasePush";
|
||||
import { useChatStore } from "./chatStore";
|
||||
|
||||
interface AuthState {
|
||||
@@ -59,6 +60,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
get().setTokens(data.access_token, data.refresh_token);
|
||||
},
|
||||
logout: () => {
|
||||
void unregisterWebPushToken();
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
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" />
|
||||
|
||||
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