v0.12.7 FCM bug fixes
This commit is contained in:
311
FCM_IMPLEMENTATION_NOTES.md
Normal file
311
FCM_IMPLEMENTATION_NOTES.md
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
# FCM PWA Implementation Notes
|
||||||
|
_Reference for applying FCM fixes to other projects_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Guide Key Points (fcm_details.txt)
|
||||||
|
|
||||||
|
### How FCM works (the correct flow)
|
||||||
|
1. User grants notification permission
|
||||||
|
2. Firebase generates a unique FCM token for the device
|
||||||
|
3. Token is stored on your server for targeting
|
||||||
|
4. Server sends push requests to Firebase
|
||||||
|
5. Firebase delivers notifications to the device
|
||||||
|
6. Service worker handles display and click interactions
|
||||||
|
|
||||||
|
### Common vibe-coding failures with FCM
|
||||||
|
|
||||||
|
**1. Service worker confusion**
|
||||||
|
Auto-generated setups often register multiple service workers or put Firebase logic in the wrong file. The dedicated `firebase-messaging-sw.js` must be served from root scope. Splitting logic across a redirect stub (`importScripts('/sw.js')`) causes background notifications to silently fail.
|
||||||
|
|
||||||
|
**2. Deprecated API usage**
|
||||||
|
Using `messaging.usePublicVapidKey()` and `messaging.useServiceWorker()` instead of passing options directly to `getToken()`. The correct modern pattern is:
|
||||||
|
```javascript
|
||||||
|
const token = await messaging.getToken({
|
||||||
|
vapidKey: VAPID_KEY,
|
||||||
|
serviceWorkerRegistration: registration
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Token generation without durable storage**
|
||||||
|
Tokens disappear when users switch devices, clear storage, or the server restarts. Without a persistent store (file, database) and proper Docker volume mounts, tokens are lost on every restart.
|
||||||
|
|
||||||
|
**4. Poor permission flow**
|
||||||
|
Requesting notification permission immediately on page load gets denied by users. Permission should be requested on a meaningful user action (e.g. login), not on first visit.
|
||||||
|
|
||||||
|
**5. Missing notificationclick handler**
|
||||||
|
Without a `notificationclick` handler in the service worker, clicking a notification does nothing. Users expect it to open or focus the app.
|
||||||
|
|
||||||
|
**6. Silent failures**
|
||||||
|
Tokens can be null, service workers can fail to register, VAPID keys can be wrong — and nothing surfaces in the UI. Every layer needs explicit error checking and user-visible feedback.
|
||||||
|
|
||||||
|
**7. iOS blind spots**
|
||||||
|
iOS requires the PWA to be added to the home screen, strict HTTPS, and a correctly structured manifest. Test on real iOS devices, not just Chrome on Android/desktop.
|
||||||
|
|
||||||
|
### Correct `getToken()` pattern (from guide)
|
||||||
|
```javascript
|
||||||
|
// Register SW first, then pass it directly to getToken
|
||||||
|
const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js');
|
||||||
|
const token = await getToken(messaging, {
|
||||||
|
vapidKey: VAPID_KEY,
|
||||||
|
serviceWorkerRegistration: registration
|
||||||
|
});
|
||||||
|
if (!token) throw new Error('getToken() returned empty — check VAPID key and SW');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Correct `firebase-messaging-sw.js` pattern (from guide)
|
||||||
|
```javascript
|
||||||
|
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
|
||||||
|
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
|
||||||
|
|
||||||
|
firebase.initializeApp({ /* config */ });
|
||||||
|
const messaging = firebase.messaging();
|
||||||
|
|
||||||
|
messaging.onBackgroundMessage((payload) => {
|
||||||
|
self.registration.showNotification(payload.notification.title, {
|
||||||
|
body: payload.notification.body,
|
||||||
|
icon: '/icon-192.png',
|
||||||
|
badge: '/icon-192.png',
|
||||||
|
tag: 'fcm-notification',
|
||||||
|
data: payload.data
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
if (event.action === 'close') return;
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url === '/' && 'focus' in client) return client.focus();
|
||||||
|
}
|
||||||
|
if (clients.openWindow) return clients.openWindow('/');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Code Fixes Applied to fcm-app
|
||||||
|
|
||||||
|
### app.js fixes
|
||||||
|
|
||||||
|
**Fix: `showUserInfo()` missing**
|
||||||
|
Function was called on login and session restore but never defined — crashed immediately on login.
|
||||||
|
```javascript
|
||||||
|
function showUserInfo() {
|
||||||
|
document.getElementById('loginForm').style.display = 'none';
|
||||||
|
document.getElementById('userInfo').style.display = 'block';
|
||||||
|
document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix: `setupApp()` wrong element IDs**
|
||||||
|
`getElementById('sendNotification')` and `getElementById('logoutBtn')` returned null — no element with those IDs existed in the HTML.
|
||||||
|
```javascript
|
||||||
|
// Wrong
|
||||||
|
document.getElementById('sendNotification').addEventListener('click', sendNotification);
|
||||||
|
// Fixed
|
||||||
|
document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification);
|
||||||
|
// Also added id="logoutBtn" to the logout button in index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix: `logout()` not clearing localStorage**
|
||||||
|
Session was restored on next page load even after logout.
|
||||||
|
```javascript
|
||||||
|
function logout() {
|
||||||
|
currentUser = null;
|
||||||
|
fcmToken = null;
|
||||||
|
localStorage.removeItem('currentUser'); // was missing
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix: Race condition in messaging initialization**
|
||||||
|
`initializeFirebase()` was fire-and-forget. When called again from `login()`, it returned early setting `messaging = firebase.messaging()` without the VAPID key or SW being configured. Now returns and caches a promise:
|
||||||
|
```javascript
|
||||||
|
let initPromise = null;
|
||||||
|
function initializeFirebase() {
|
||||||
|
if (initPromise) return initPromise;
|
||||||
|
initPromise = navigator.serviceWorker.register('/sw.js')
|
||||||
|
.then((registration) => {
|
||||||
|
swRegistration = registration;
|
||||||
|
messaging = firebase.messaging();
|
||||||
|
})
|
||||||
|
.catch((error) => { initPromise = null; throw error; });
|
||||||
|
return initPromise;
|
||||||
|
}
|
||||||
|
// In login():
|
||||||
|
await initializeFirebase(); // ensures messaging is ready before getToken()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix: `deleteToken()` invalidating tokens on every page load**
|
||||||
|
`deleteToken()` was called on every page load, invalidating the push subscription. The server still held the old (now invalid) token. When another device sent, the stale token failed and `recipients` stayed 0.
|
||||||
|
Solution: removed `deleteToken()` entirely — it's not needed when `serviceWorkerRegistration` is passed directly to `getToken()`.
|
||||||
|
|
||||||
|
**Fix: Session restore without re-registering token**
|
||||||
|
When a user's session was restored from localStorage, `showUserInfo()` was called but no new FCM token was generated or sent to the server. After a server restart the server had no record of the token.
|
||||||
|
```javascript
|
||||||
|
// In setupApp(), after restoring session:
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
initializeFirebase()
|
||||||
|
.then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }))
|
||||||
|
.then(token => { if (token) return registerToken(currentUser, token); })
|
||||||
|
.catch(err => console.error('Token refresh on session restore failed:', err));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix: Deprecated VAPID/SW API replaced**
|
||||||
|
```javascript
|
||||||
|
// Removed (deprecated):
|
||||||
|
messaging.usePublicVapidKey(VAPID_KEY);
|
||||||
|
messaging.useServiceWorker(registration);
|
||||||
|
const token = await messaging.getToken();
|
||||||
|
|
||||||
|
// Replaced with:
|
||||||
|
const VAPID_KEY = 'your-vapid-key';
|
||||||
|
let swRegistration = null;
|
||||||
|
// swRegistration set inside initializeFirebase() .then()
|
||||||
|
const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix: Null token guard**
|
||||||
|
`getToken()` can return null — passing null to the server produced a confusing 400 error.
|
||||||
|
```javascript
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('getToken() returned empty — check VAPID key and service worker');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix: Error message included server response**
|
||||||
|
```javascript
|
||||||
|
// Before: throw new Error('Failed to register token');
|
||||||
|
// After:
|
||||||
|
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix: Duplicate foreground message handlers**
|
||||||
|
`handleForegroundMessages()` was called on every login, stacking up `onMessage` listeners.
|
||||||
|
```javascript
|
||||||
|
let foregroundHandlerSetup = false;
|
||||||
|
function handleForegroundMessages() {
|
||||||
|
if (foregroundHandlerSetup) return;
|
||||||
|
foregroundHandlerSetup = true;
|
||||||
|
messaging.onMessage(/* ... */);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix: `login()` event.preventDefault() crash**
|
||||||
|
Button called `login()` with no argument, so `event.preventDefault()` threw on undefined.
|
||||||
|
```javascript
|
||||||
|
async function login(event) {
|
||||||
|
if (event) event.preventDefault(); // guard added
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix: `firebase-messaging-sw.js` redirect stub replaced**
|
||||||
|
File was `importScripts('/sw.js')` — a vibe-code anti-pattern. Replaced with full Firebase messaging setup including `onBackgroundMessage` and `notificationclick` handler (see Part 1 pattern above).
|
||||||
|
|
||||||
|
**Fix: `notificationclick` handler added to `sw.js`**
|
||||||
|
Clicking a background notification did nothing. Handler added to focus existing window or open a new one.
|
||||||
|
|
||||||
|
**Fix: CDN URLs removed from `urlsToCache` in `sw.js`**
|
||||||
|
External CDN URLs in `cache.addAll()` can fail on opaque responses, breaking the entire SW install.
|
||||||
|
```javascript
|
||||||
|
// Removed from urlsToCache:
|
||||||
|
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js',
|
||||||
|
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js'
|
||||||
|
```
|
||||||
|
|
||||||
|
### server.js fixes
|
||||||
|
|
||||||
|
**Fix: `icon`/`badge`/`tag` in wrong notification object**
|
||||||
|
These fields are only valid in `webpush.notification`, not the top-level `notification` (which only accepts `title`, `body`, `imageUrl`).
|
||||||
|
```javascript
|
||||||
|
// Wrong:
|
||||||
|
notification: { title, body, icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' }
|
||||||
|
// Fixed:
|
||||||
|
notification: { title, body },
|
||||||
|
webpush: {
|
||||||
|
notification: { icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' },
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix: `saveTokens()` in route handler not crash-safe**
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
saveTokens();
|
||||||
|
} catch (saveError) {
|
||||||
|
console.error('Failed to persist tokens to disk:', saveError);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix: `setInterval(saveTokens)` uncaught exception crashed the server**
|
||||||
|
An unhandled throw inside `setInterval` exits the Node.js process. Docker restarts it with empty state.
|
||||||
|
```javascript
|
||||||
|
setInterval(() => {
|
||||||
|
try { saveTokens(); }
|
||||||
|
catch (error) { console.error('Auto-save tokens failed:', error); }
|
||||||
|
}, 30000);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3 — Docker / Infrastructure Fixes
|
||||||
|
|
||||||
|
### Root cause of "no other users" bug
|
||||||
|
The server was crashing every ~30 seconds, wiping all registered tokens from memory. The crash chain:
|
||||||
|
1. `saveTokens()` threw `EACCES: permission denied` (nodejs user can't write to root-owned `/app`)
|
||||||
|
2. This propagated out of `setInterval` as an uncaught exception
|
||||||
|
3. Node.js exited the process
|
||||||
|
4. Docker restarted the container with empty state
|
||||||
|
5. Tokens were never on disk, so restart = all tokens lost
|
||||||
|
|
||||||
|
### Dockerfile fix
|
||||||
|
```dockerfile
|
||||||
|
# Create non-root user AND a writable data directory (while still root)
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nodejs -u 1001 && \
|
||||||
|
mkdir -p /app/data && \
|
||||||
|
chown nodejs:nodejs /app/data
|
||||||
|
```
|
||||||
|
`WORKDIR /app` is root-owned — the `nodejs` user can only write to subdirectories explicitly granted to it.
|
||||||
|
|
||||||
|
### docker-compose.yml fix
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
your-app:
|
||||||
|
volumes:
|
||||||
|
- app_data:/app/data # named volume survives container rebuilds
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app_data:
|
||||||
|
```
|
||||||
|
Without this, `tokens.json` lives in the container's ephemeral layer and is deleted on every `docker-compose up --build`.
|
||||||
|
|
||||||
|
### server.js path fix
|
||||||
|
```javascript
|
||||||
|
// Changed from:
|
||||||
|
const TOKENS_FILE = './tokens.json';
|
||||||
|
// To:
|
||||||
|
const TOKENS_FILE = './data/tokens.json';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist for applying to another project
|
||||||
|
|
||||||
|
- [ ] `firebase-messaging-sw.js` contains real FCM logic (not a redirect stub)
|
||||||
|
- [ ] `notificationclick` handler present in service worker
|
||||||
|
- [ ] CDN URLs NOT in `urlsToCache` in any service worker
|
||||||
|
- [ ] `initializeFirebase()` returns a promise; login awaits it before calling `getToken()`
|
||||||
|
- [ ] `getToken()` receives `{ vapidKey, serviceWorkerRegistration }` directly — no deprecated `usePublicVapidKey` / `useServiceWorker`
|
||||||
|
- [ ] `deleteToken()` is NOT called on page load
|
||||||
|
- [ ] Session restore re-registers FCM token if `Notification.permission === 'granted'`
|
||||||
|
- [ ] Null/empty token check before sending to server
|
||||||
|
- [ ] `icon`/`badge`/`tag` are in `webpush.notification`, not top-level `notification`
|
||||||
|
- [ ] `saveTokens()` (or equivalent) wrapped in try-catch everywhere it's called including `setInterval`
|
||||||
|
- [ ] Docker: data directory created with correct user ownership in Dockerfile
|
||||||
|
- [ ] Docker: named volume mounted for data directory in docker-compose.yml
|
||||||
|
- [ ] Duplicate foreground message handler registration is guarded
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-backend",
|
"name": "rosterchirp-backend",
|
||||||
"version": "0.12.6",
|
"version": "0.12.7",
|
||||||
"description": "RosterChirp backend server",
|
"description": "RosterChirp backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ async function sendPushToUser(schema, userId, payload) {
|
|||||||
groupId: payload.groupId ? String(payload.groupId) : '',
|
groupId: payload.groupId ? String(payload.groupId) : '',
|
||||||
},
|
},
|
||||||
android: { priority: 'high' },
|
android: { priority: 'high' },
|
||||||
|
apns: {
|
||||||
|
headers: { 'apns-priority': '10' },
|
||||||
|
payload: { aps: { contentAvailable: true } },
|
||||||
|
},
|
||||||
webpush: { headers: { Urgency: 'high' } },
|
webpush: { headers: { Urgency: 'high' } },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -141,6 +145,10 @@ router.post('/test', authMiddleware, async (req, res) => {
|
|||||||
const message = {
|
const message = {
|
||||||
token: sub.fcm_token,
|
token: sub.fcm_token,
|
||||||
android: { priority: 'high' },
|
android: { priority: 'high' },
|
||||||
|
apns: {
|
||||||
|
headers: { 'apns-priority': '10' },
|
||||||
|
payload: { aps: { contentAvailable: true } },
|
||||||
|
},
|
||||||
webpush: { headers: { Urgency: 'high' } },
|
webpush: { headers: { Urgency: 'high' } },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.12.6}"
|
VERSION="${1:-0.12.7}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="rosterchirp"
|
IMAGE_NAME="rosterchirp"
|
||||||
|
|||||||
1013
fcm_details.txt
Normal file
1013
fcm_details.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-frontend",
|
"name": "rosterchirp-frontend",
|
||||||
"version": "0.12.6",
|
"version": "0.12.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function AuthProvider({ children }) {
|
|||||||
try { await api.logout(); } catch {}
|
try { await api.logout(); } catch {}
|
||||||
localStorage.removeItem('tc_token');
|
localStorage.removeItem('tc_token');
|
||||||
sessionStorage.removeItem('tc_token');
|
sessionStorage.removeItem('tc_token');
|
||||||
|
localStorage.removeItem('rc_fcm_token');
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setMustChangePassword(false);
|
setMustChangePassword(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export default function Chat() {
|
|||||||
|
|
||||||
// Dynamically import the Firebase SDK (tree-shaken, only loaded when needed)
|
// Dynamically import the Firebase SDK (tree-shaken, only loaded when needed)
|
||||||
const { initializeApp, getApps } = await import('firebase/app');
|
const { initializeApp, getApps } = await import('firebase/app');
|
||||||
const { getMessaging, getToken, deleteToken } = await import('firebase/messaging');
|
const { getMessaging, getToken } = await import('firebase/messaging');
|
||||||
|
|
||||||
const firebaseApp = getApps().length
|
const firebaseApp = getApps().length
|
||||||
? getApps()[0]
|
? getApps()[0]
|
||||||
@@ -126,13 +126,12 @@ export default function Chat() {
|
|||||||
if (granted !== 'granted') return;
|
if (granted !== 'granted') return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always delete any cached token first so we get a fresh Web Push subscription.
|
// Do NOT call deleteToken() here. Deleting the token on every page load (or
|
||||||
// Stale tokens (e.g. from a previous install or a Chrome push-subscription reset)
|
// every visibility-change) forces Chrome to create a new Web Push subscription
|
||||||
// still look valid to Firebase but silently fail to deliver — deleteToken forces
|
// each time. During the brief window between delete and re-register the server
|
||||||
// Chrome to create a brand-new subscription and register it with FCM.
|
// still holds the old (now invalid) token, so any in-flight message fails to
|
||||||
console.log('[Push] Clearing any cached FCM token...');
|
// deliver. Passing serviceWorkerRegistration directly to getToken() is enough
|
||||||
await deleteToken(firebaseMessaging).catch(() => {});
|
// for Firebase to return the existing valid token without needing a refresh.
|
||||||
|
|
||||||
console.log('[Push] Requesting FCM token...');
|
console.log('[Push] Requesting FCM token...');
|
||||||
const fcmToken = await getToken(firebaseMessaging, {
|
const fcmToken = await getToken(firebaseMessaging, {
|
||||||
vapidKey,
|
vapidKey,
|
||||||
@@ -144,6 +143,14 @@ export default function Chat() {
|
|||||||
}
|
}
|
||||||
console.log('[Push] FCM token obtained:', fcmToken.slice(0, 30) + '...');
|
console.log('[Push] FCM token obtained:', fcmToken.slice(0, 30) + '...');
|
||||||
|
|
||||||
|
// Skip the server round-trip if this token is already registered.
|
||||||
|
// Avoids a redundant DB write on every tab-focus / visibility change.
|
||||||
|
const cachedToken = localStorage.getItem('rc_fcm_token');
|
||||||
|
if (cachedToken === fcmToken) {
|
||||||
|
console.log('[Push] Token unchanged — skipping subscribe');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
||||||
const subRes = await fetch('/api/push/subscribe', {
|
const subRes = await fetch('/api/push/subscribe', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -154,6 +161,7 @@ export default function Chat() {
|
|||||||
const err = await subRes.json().catch(() => ({}));
|
const err = await subRes.json().catch(() => ({}));
|
||||||
console.warn('[Push] Subscribe failed:', err.error || subRes.status);
|
console.warn('[Push] Subscribe failed:', err.error || subRes.status);
|
||||||
} else {
|
} else {
|
||||||
|
localStorage.setItem('rc_fcm_token', fcmToken);
|
||||||
console.log('[Push] FCM subscription registered successfully');
|
console.log('[Push] FCM subscription registered successfully');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user