v0.12.30 add notifications for iOS

This commit is contained in:
2026-03-26 14:49:17 -04:00
parent 6e5c39607c
commit d6a37d5948
11 changed files with 386 additions and 149 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "rosterchirp-backend", "name": "rosterchirp-backend",
"version": "0.12.29", "version": "0.12.30",
"description": "RosterChirp backend server", "description": "RosterChirp backend server",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
@@ -20,7 +20,8 @@
"sharp": "^0.33.2", "sharp": "^0.33.2",
"socket.io": "^4.6.1", "socket.io": "^4.6.1",
"csv-parse": "^5.5.6", "csv-parse": "^5.5.6",
"pg": "^8.11.3" "pg": "^8.11.3",
"web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2"

View File

@@ -253,10 +253,10 @@ io.on('connection', async (socket) => {
}).catch(() => {}); }).catch(() => {});
} }
} else if (group.type === 'public') { } else if (group.type === 'public') {
// Push to all users who have registered an FCM token — everyone is implicitly // Push to all users who have a push subscription — everyone is implicitly
// a member of every public group. Skip the sender. // a member of every public group. Skip the sender.
const subUsers = await query(schema, const subUsers = await query(schema,
'SELECT DISTINCT user_id FROM push_subscriptions WHERE fcm_token IS NOT NULL AND user_id != $1', 'SELECT DISTINCT user_id FROM push_subscriptions WHERE (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL) AND user_id != $1',
[userId] [userId]
); );
for (const sub of subUsers) { for (const sub of subUsers) {

View File

@@ -0,0 +1,9 @@
-- Migration 011: Add Web Push (VAPID) subscription columns for iOS PWA support
-- iOS uses the standard W3C Web Push protocol (not FCM). A subscription consists of
-- an endpoint URL (web.push.apple.com) plus two crypto keys (p256dh + auth).
-- Rows will have either fcm_token set (Android/Chrome) OR the three webpush_* columns
-- set (iOS/Firefox/Edge). Never both on the same row.
ALTER TABLE push_subscriptions
ADD COLUMN IF NOT EXISTS webpush_endpoint TEXT,
ADD COLUMN IF NOT EXISTS webpush_p256dh TEXT,
ADD COLUMN IF NOT EXISTS webpush_auth TEXT;

View File

@@ -118,7 +118,7 @@ module.exports = function(io) {
} }
} else if (group.type === 'public') { } else if (group.type === 'public') {
const subUsers = await query(req.schema, const subUsers = await query(req.schema,
'SELECT DISTINCT user_id FROM push_subscriptions WHERE fcm_token IS NOT NULL AND user_id != $1', 'SELECT DISTINCT user_id FROM push_subscriptions WHERE (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL) AND user_id != $1',
[req.user.id] [req.user.id]
); );
for (const sub of subUsers) { for (const sub of subUsers) {
@@ -166,7 +166,7 @@ module.exports = function(io) {
} }
} else if (group.type === 'public') { } else if (group.type === 'public') {
const subUsers = await query(req.schema, const subUsers = await query(req.schema,
'SELECT DISTINCT user_id FROM push_subscriptions WHERE fcm_token IS NOT NULL AND user_id != $1', 'SELECT DISTINCT user_id FROM push_subscriptions WHERE (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL) AND user_id != $1',
[req.user.id] [req.user.id]
); );
for (const sub of subUsers) { for (const sub of subUsers) {

View File

@@ -3,7 +3,7 @@ const router = express.Router();
const { query, queryOne, exec } = require('../models/db'); const { query, queryOne, exec } = require('../models/db');
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware } = require('../middleware/auth');
// ── Firebase Admin ───────────────────────────────────────────────────────────── // ── Firebase Admin (FCM — Android/Chrome) ──────────────────────────────────────
let firebaseAdmin = null; let firebaseAdmin = null;
let firebaseApp = null; let firebaseApp = null;
@@ -25,65 +25,118 @@ function getMessaging() {
} }
} }
// ── web-push (VAPID — iOS/Firefox/Edge) ────────────────────────────────────────
let webPushReady = false;
function getWebPush() {
if (webPushReady) return require('web-push');
const pub = process.env.VAPID_PUBLIC;
const priv = process.env.VAPID_PRIVATE;
if (!pub || !priv) return null;
try {
const wp = require('web-push');
// Subject must be mailto: or https:// — Apple returns 403 for any other format.
const subject = process.env.VAPID_SUBJECT || 'mailto:push@rosterchirp.app';
wp.setVapidDetails(subject, pub, priv);
webPushReady = true;
console.log('[Push] web-push (VAPID) initialised');
return wp;
} catch (e) {
console.error('[Push] web-push init failed:', e.message);
return null;
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────────
// Called from index.js socket push notifications // Called from messages.js (REST) and index.js (socket) for every outbound push.
// Dispatches to FCM (fcm_token rows) or web-push (webpush_endpoint rows) based on
// which columns are populated. Both paths run concurrently for a given user.
async function sendPushToUser(schema, userId, payload) { async function sendPushToUser(schema, userId, payload) {
const messaging = getMessaging();
if (!messaging) return;
try { try {
const subs = await query(schema, const subs = await query(schema,
'SELECT * FROM push_subscriptions WHERE user_id = $1 AND fcm_token IS NOT NULL', `SELECT * FROM push_subscriptions
WHERE user_id = $1
AND (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL)`,
[userId] [userId]
); );
if (subs.length === 0) { if (subs.length === 0) {
console.log(`[Push] No FCM token for user ${userId} (schema=${schema})`); console.log(`[Push] No subscription for user ${userId} (schema=${schema})`);
return; return;
} }
const messaging = getMessaging();
const wp = getWebPush();
for (const sub of subs) { for (const sub of subs) {
try { if (sub.fcm_token) {
await messaging.send({ // ── FCM path ──────────────────────────────────────────────────────────
token: sub.fcm_token, if (!messaging) continue;
// Top-level notification ensures FCM/Chrome can display even if the SW try {
// onBackgroundMessage handler has trouble — mirrors the working fcm-app pattern. await messaging.send({
notification: { token: sub.fcm_token,
title: payload.title || 'New Message',
body: payload.body || '',
},
// Extra fields for SW click-routing (url, groupId)
data: {
url: payload.url || '/',
groupId: payload.groupId ? String(payload.groupId) : '',
},
android: {
priority: 'high',
notification: { sound: 'default' },
},
apns: {
headers: { 'apns-priority': '10' },
payload: { aps: { sound: 'default', badge: 1, contentAvailable: true } },
},
webpush: {
headers: { Urgency: 'high' },
notification: { notification: {
icon: '/icons/icon-192.png', title: payload.title || 'New Message',
badge: '/icons/icon-192-maskable.png', body: payload.body || '',
tag: payload.groupId ? `rosterchirp-group-${payload.groupId}` : 'rosterchirp-message',
renotify: true,
}, },
fcm_options: { link: payload.url || '/' }, data: {
}, url: payload.url || '/',
groupId: payload.groupId ? String(payload.groupId) : '',
},
android: {
priority: 'high',
notification: { sound: 'default' },
},
apns: {
headers: { 'apns-priority': '10' },
payload: { aps: { sound: 'default', badge: 1, contentAvailable: true } },
},
webpush: {
headers: { Urgency: 'high' },
notification: {
icon: '/icons/icon-192.png',
badge: '/icons/icon-192-maskable.png',
tag: payload.groupId ? `rosterchirp-group-${payload.groupId}` : 'rosterchirp-message',
renotify: true,
},
fcm_options: { link: payload.url || '/' },
},
});
console.log(`[Push] FCM sent to user ${userId} device=${sub.device} schema=${schema}`);
} catch (err) {
const stale = [
'messaging/registration-token-not-registered',
'messaging/invalid-registration-token',
'messaging/invalid-argument',
];
if (stale.includes(err.code)) {
await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
console.log(`[Push] Removed stale FCM token for user ${userId} device=${sub.device}`);
}
}
} else if (sub.webpush_endpoint) {
// ── Web Push / VAPID path (iOS, Firefox, Edge) ────────────────────────
if (!wp) continue;
const subscription = {
endpoint: sub.webpush_endpoint,
keys: { p256dh: sub.webpush_p256dh, auth: sub.webpush_auth },
};
const body = JSON.stringify({
title: payload.title || 'New Message',
body: payload.body || '',
url: payload.url || '/',
groupId: payload.groupId ? String(payload.groupId) : '',
icon: '/icons/icon-192.png',
}); });
console.log(`[Push] Sent to user ${userId} device=${sub.device} schema=${schema}`); try {
} catch (err) { await wp.sendNotification(subscription, body, { TTL: 86400, urgency: 'high' });
// Remove stale tokens console.log(`[Push] WebPush sent to user ${userId} device=${sub.device} schema=${schema}`);
const stale = [ } catch (err) {
'messaging/registration-token-not-registered', // 404/410 = subscription expired or user unsubscribed — remove the stale row
'messaging/invalid-registration-token', if (err.statusCode === 404 || err.statusCode === 410) {
'messaging/invalid-argument', await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
]; console.log(`[Push] Removed stale WebPush sub for user ${userId} device=${sub.device}`);
if (stale.includes(err.code)) { }
await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
} }
} }
} }
@@ -108,7 +161,14 @@ router.get('/firebase-config', (req, res) => {
res.json({ apiKey, projectId, messagingSenderId, appId, vapidKey }); res.json({ apiKey, projectId, messagingSenderId, appId, vapidKey });
}); });
// Register / refresh an FCM token for the logged-in user // Public — iOS frontend fetches this to create a PushManager subscription
router.get('/vapid-public-key', (req, res) => {
const pub = process.env.VAPID_PUBLIC;
if (!pub) return res.status(503).json({ error: 'VAPID not configured' });
res.json({ vapidPublicKey: pub });
});
// Register / refresh an FCM token for the logged-in user (Android/Chrome)
router.post('/subscribe', authMiddleware, async (req, res) => { router.post('/subscribe', authMiddleware, async (req, res) => {
const { fcmToken } = req.body; const { fcmToken } = req.body;
if (!fcmToken) return res.status(400).json({ error: 'fcmToken required' }); if (!fcmToken) return res.status(400).json({ error: 'fcmToken required' });
@@ -126,7 +186,29 @@ router.post('/subscribe', authMiddleware, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// Remove the FCM token for the logged-in user / device // Register / refresh a Web Push subscription for the logged-in user (iOS/Firefox/Edge)
// Body: { endpoint, keys: { p256dh, auth } } — the PushSubscription JSON from the browser
router.post('/subscribe-webpush', authMiddleware, async (req, res) => {
const { endpoint, keys } = req.body;
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return res.status(400).json({ error: 'endpoint and keys.p256dh/auth required' });
}
try {
const device = req.device || 'mobile'; // iOS is always mobile
await exec(req.schema,
'DELETE FROM push_subscriptions WHERE user_id = $1 AND device = $2',
[req.user.id, device]
);
await exec(req.schema,
`INSERT INTO push_subscriptions (user_id, device, webpush_endpoint, webpush_p256dh, webpush_auth)
VALUES ($1, $2, $3, $4, $5)`,
[req.user.id, device, endpoint, keys.p256dh, keys.auth]
);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Remove the push subscription for the logged-in user / device
router.post('/unsubscribe', authMiddleware, async (req, res) => { router.post('/unsubscribe', authMiddleware, async (req, res) => {
try { try {
const device = req.device || 'desktop'; const device = req.device || 'desktop';
@@ -138,96 +220,105 @@ router.post('/unsubscribe', authMiddleware, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// Send a test push to the requesting user's own device — for diagnosing FCM setup. // Send a test push to the requesting user's own devices.
// mode=notification (default): notification+data message — same path as real messages. // Covers both FCM tokens and Web Push subscriptions in one call.
// mode=browser: webpush.notification only — Chrome shows it directly, SW not involved. // mode query param only applies to FCM test messages (notification vs browser).
// Use mode=browser to verify FCM delivery works independently of the service worker.
router.post('/test', authMiddleware, async (req, res) => { router.post('/test', authMiddleware, async (req, res) => {
try { try {
const subs = await query(req.schema, const subs = await query(req.schema,
'SELECT * FROM push_subscriptions WHERE user_id = $1 AND fcm_token IS NOT NULL', `SELECT * FROM push_subscriptions
WHERE user_id = $1
AND (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL)`,
[req.user.id] [req.user.id]
); );
if (subs.length === 0) { if (subs.length === 0) {
return res.status(404).json({ return res.status(404).json({
error: 'No push subscription found for your account. Grant notification permission and reload the app first.', error: 'No push subscription found. Grant notification permission and reload the app first.',
}); });
} }
const messaging = getMessaging(); const messaging = getMessaging();
if (!messaging) { const wp = getWebPush();
return res.status(503).json({ error: 'Firebase Admin not initialised on server — check FIREBASE_SERVICE_ACCOUNT in .env' }); const mode = req.query.mode === 'browser' ? 'browser' : 'notification';
} const results = [];
const mode = req.query.mode === 'browser' ? 'browser' : 'notification';
const results = [];
for (const sub of subs) { for (const sub of subs) {
try { if (sub.fcm_token) {
const message = { if (!messaging) {
token: sub.fcm_token, results.push({ device: sub.device, type: 'fcm', status: 'failed', error: 'Firebase Admin not initialised — check FIREBASE_SERVICE_ACCOUNT in .env' });
android: { continue;
priority: 'high', }
notification: { sound: 'default' }, try {
}, const message = {
apns: { token: sub.fcm_token,
headers: { 'apns-priority': '10' }, android: { priority: 'high', notification: { sound: 'default' } },
payload: { aps: { sound: 'default', badge: 1, contentAvailable: true } }, apns: {
}, headers: { 'apns-priority': '10' },
webpush: { payload: { aps: { sound: 'default', badge: 1, contentAvailable: true } },
headers: { Urgency: 'high' }, },
notification: { webpush: {
icon: '/icons/icon-192.png', headers: { Urgency: 'high' },
badge: '/icons/icon-192-maskable.png', notification: { icon: '/icons/icon-192.png', badge: '/icons/icon-192-maskable.png', tag: 'rosterchirp-test' },
tag: 'rosterchirp-test', },
}, };
}, if (mode === 'browser') {
}; message.webpush.notification.title = 'RosterChirp Test (browser)';
message.webpush.notification.body = 'FCM delivery confirmed — Chrome handled this directly.';
if (mode === 'browser') { message.webpush.fcm_options = { link: '/' };
// Chrome displays the notification directly — onBackgroundMessage does NOT fire. } else {
// Use this to verify FCM delivery works independently of the service worker. message.notification = { title: 'RosterChirp Test', body: 'Push notifications are working!' };
message.webpush.notification.title = 'RosterChirp Test (browser)'; message.data = { url: '/', groupId: '' };
message.webpush.notification.body = 'FCM delivery confirmed — Chrome handled this directly.'; message.webpush.fcm_options = { link: '/' };
message.webpush.fcm_options = { link: '/' }; }
} else { await messaging.send(message);
// notification+data — same structure as real messages. results.push({ device: sub.device, type: 'fcm', mode, status: 'sent' });
// SW onBackgroundMessage fires and shows the notification. } catch (err) {
message.notification = { results.push({ device: sub.device, type: 'fcm', mode, status: 'failed', error: err.message, code: err.code });
title: 'RosterChirp Test', }
body: 'Push notifications are working!', } else if (sub.webpush_endpoint) {
}; if (!wp) {
message.data = { url: '/', groupId: '' }; results.push({ device: sub.device, type: 'webpush', status: 'failed', error: 'VAPID keys not configured — check VAPID_PUBLIC/VAPID_PRIVATE in .env' });
message.webpush.fcm_options = { link: '/' }; continue;
}
const subscription = {
endpoint: sub.webpush_endpoint,
keys: { p256dh: sub.webpush_p256dh, auth: sub.webpush_auth },
};
try {
await wp.sendNotification(
subscription,
JSON.stringify({ title: 'RosterChirp Test', body: 'Push notifications are working!', url: '/', icon: '/icons/icon-192.png' }),
{ TTL: 300, urgency: 'high' }
);
results.push({ device: sub.device, type: 'webpush', status: 'sent' });
} catch (err) {
results.push({ device: sub.device, type: 'webpush', status: 'failed', error: err.message, statusCode: err.statusCode });
} }
await messaging.send(message);
results.push({ device: sub.device, mode, status: 'sent' });
console.log(`[Push] Test (${mode}) sent to user ${req.user.id} device=${sub.device}`);
} catch (err) {
results.push({ device: sub.device, mode, status: 'failed', error: err.message, code: err.code });
console.error(`[Push] Test (${mode}) failed for user ${req.user.id} device=${sub.device}:`, err.message);
} }
} }
res.json({ results }); res.json({ results });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// Debug endpoint (admin-only) — lists all FCM subscriptions for this schema // Debug endpoint (admin-only) — lists all push subscriptions for this schema
router.get('/debug', authMiddleware, async (req, res) => { router.get('/debug', authMiddleware, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' }); if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
try { try {
const subs = await query(req.schema, ` const subs = await query(req.schema, `
SELECT ps.id, ps.user_id, ps.device, ps.fcm_token, SELECT ps.id, ps.user_id, ps.device,
ps.fcm_token,
ps.webpush_endpoint,
u.name, u.email u.name, u.email
FROM push_subscriptions ps FROM push_subscriptions ps
JOIN users u ON u.id = ps.user_id JOIN users u ON u.id = ps.user_id
WHERE ps.fcm_token IS NOT NULL WHERE ps.fcm_token IS NOT NULL OR ps.webpush_endpoint IS NOT NULL
ORDER BY u.name, ps.device ORDER BY u.name, ps.device
`); `);
const fcmConfigured = !!(process.env.FIREBASE_API_KEY && process.env.FIREBASE_SERVICE_ACCOUNT && process.env.FIREBASE_VAPID_KEY); const fcmConfigured = !!(process.env.FIREBASE_API_KEY && process.env.FIREBASE_SERVICE_ACCOUNT && process.env.FIREBASE_VAPID_KEY);
const firebaseAdminReady = !!getMessaging(); const firebaseAdminReady = !!getMessaging();
res.json({ subscriptions: subs, fcmConfigured, firebaseAdminReady }); const vapidConfigured = !!(process.env.VAPID_PUBLIC && process.env.VAPID_PRIVATE);
res.json({ subscriptions: subs, fcmConfigured, firebaseAdminReady, vapidConfigured });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });

View File

@@ -13,7 +13,7 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
VERSION="${1:-0.12.29}" VERSION="${1:-0.12.30}"
ACTION="${2:-}" ACTION="${2:-}"
REGISTRY="${REGISTRY:-}" REGISTRY="${REGISTRY:-}"
IMAGE_NAME="rosterchirp" IMAGE_NAME="rosterchirp"

View File

@@ -1,6 +1,6 @@
{ {
"name": "rosterchirp-frontend", "name": "rosterchirp-frontend",
"version": "0.12.29", "version": "0.12.30",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -27,6 +27,7 @@ export default function ProfileModal({ onClose }) {
const [notifPermission, setNotifPermission] = useState( const [notifPermission, setNotifPermission] = useState(
typeof Notification !== 'undefined' ? Notification.permission : 'unsupported' typeof Notification !== 'undefined' ? Notification.permission : 'unsupported'
); );
const isIOS = /iphone|ipad/i.test(navigator.userAgent);
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag); const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0); const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0);
@@ -216,7 +217,9 @@ export default function ProfileModal({ onClose }) {
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', borderRadius: 8, background: 'var(--surface-variant)' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', borderRadius: 8, background: 'var(--surface-variant)' }}>
<div style={{ fontSize: 14, color: 'var(--text-secondary)' }}> <div style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
{notifPermission === 'denied' {notifPermission === 'denied'
? 'Notifications are blocked. Enable them in Android Settings → Apps → RosterChirp → Notifications.' ? isIOS
? 'Notifications are blocked. Enable them in iOS Settings → RosterChirp → Notifications.'
: 'Notifications are blocked. Enable them in Android Settings → Apps → RosterChirp → Notifications.'
: 'Push notifications are not yet enabled on this device.'} : 'Push notifications are not yet enabled on this device.'}
</div> </div>
{notifPermission === 'default' && ( {notifPermission === 'default' && (
@@ -232,7 +235,12 @@ export default function ProfileModal({ onClose }) {
<div style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.5 }}> <div style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
<p style={{ margin: '0 0 8px' }}>Tap <strong>Send Test Notification</strong> to trigger a push to this device. The notification will arrive shortly if everything is configured correctly.</p> <p style={{ margin: '0 0 8px' }}>Tap <strong>Send Test Notification</strong> to trigger a push to this device. The notification will arrive shortly if everything is configured correctly.</p>
<p style={{ margin: 0 }}>If it doesn't arrive, check:<br/> <p style={{ margin: 0 }}>If it doesn't arrive, check:<br/>
• Android Settings → Apps → RosterChirp → Notifications → Enabled<br/> {isIOS ? (
<>• iOS Settings → RosterChirp → Notifications → Allow<br/>
• App must be added to the Home Screen (not open in Safari)<br/></>
) : (
<>• Android Settings → Apps → RosterChirp → Notifications → Enabled<br/></>
)}
• App is backgrounded when the test fires • App is backgrounded when the test fires
</p> </p>
</div> </div>
@@ -258,30 +266,34 @@ export default function ProfileModal({ onClose }) {
> >
{pushTesting ? 'Sending' : 'Test (via SW)'} {pushTesting ? 'Sending' : 'Test (via SW)'}
</button> </button>
<button {!isIOS && (
className="btn btn-secondary" <button
style={{ flex: 1 }} className="btn btn-secondary"
disabled={pushTesting} style={{ flex: 1 }}
onClick={async () => { disabled={pushTesting}
setPushTesting(true); onClick={async () => {
setPushResult(null); setPushTesting(true);
try { setPushResult(null);
const { results } = await api.testPush('browser'); try {
setPushResult({ ok: true, results, mode: 'browser' }); const { results } = await api.testPush('browser');
} catch (e) { setPushResult({ ok: true, results, mode: 'browser' });
setPushResult({ ok: false, error: e.message }); } catch (e) {
} finally { setPushResult({ ok: false, error: e.message });
setPushTesting(false); } finally {
} setPushTesting(false);
}} }
> }}
{pushTesting ? 'Sending' : 'Test (via Browser)'} >
</button> {pushTesting ? 'Sending' : 'Test (via Browser)'}
</div> </button>
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', lineHeight: 1.4 }}> )}
<strong>Test (via SW)</strong> — normal production path, service worker shows notification.<br/>
<strong>Test (via Browser)</strong> — bypasses service worker; Chrome displays directly.
</div> </div>
{!isIOS && (
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', lineHeight: 1.4 }}>
<strong>Test (via SW)</strong> — normal production path, service worker shows notification.<br/>
<strong>Test (via Browser)</strong> — bypasses service worker; Chrome displays directly.
</div>
)}
</>)} </>)}
{pushResult && ( {pushResult && (
<div style={{ <div style={{

View File

@@ -11,10 +11,41 @@ function useTheme() {
return [dark, setDark]; return [dark, setDark];
} }
const PUSH_ENABLED_KEY = 'rc_push_enabled';
function usePushToggle() {
// Push toggle is only relevant when the user has already granted permission
const supported = 'serviceWorker' in navigator && typeof Notification !== 'undefined';
const permitted = supported && Notification.permission === 'granted';
const [enabled, setEnabled] = useState(() => localStorage.getItem(PUSH_ENABLED_KEY) !== 'false');
const toggle = async () => {
if (enabled) {
// Disable: remove the server subscription so no pushes are sent
try {
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
await fetch('/api/push/unsubscribe', { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
} catch (e) { /* best effort */ }
localStorage.removeItem('rc_fcm_token');
localStorage.removeItem('rc_webpush_endpoint');
localStorage.setItem(PUSH_ENABLED_KEY, 'false');
setEnabled(false);
} else {
// Enable: re-run the registration flow
localStorage.setItem(PUSH_ENABLED_KEY, 'true');
setEnabled(true);
window.dispatchEvent(new CustomEvent('rosterchirp:push-init'));
}
};
return { permitted, enabled, toggle };
}
export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=false }) { export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=false }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const [dark, setDark] = useTheme(); const [dark, setDark] = useTheme();
const { permitted: showPushToggle, enabled: pushEnabled, toggle: togglePush } = usePushToggle();
const menuRef = useRef(null); const menuRef = useRef(null);
const btnRef = useRef(null); const btnRef = useRef(null);
@@ -44,6 +75,13 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f
<button key={label} onClick={action} style={{ display:'block',width:'100%',padding:'11px 14px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)' }} <button key={label} onClick={action} style={{ display:'block',width:'100%',padding:'11px 14px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)' }}
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button> onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button>
))} ))}
{showPushToggle && (
<button onClick={() => { togglePush(); setShowMenu(false); }} style={{ display:'flex',alignItems:'center',justifyContent:'space-between',width:'100%',padding:'11px 14px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)' }}
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>
<span>Notifications</span>
<span style={{ fontSize:12,fontWeight:600,color: pushEnabled ? 'var(--primary)' : 'var(--text-secondary)' }}>{pushEnabled ? 'ON' : 'OFF'}</span>
</button>
)}
<div style={{ borderTop:'1px solid var(--border)' }}> <div style={{ borderTop:'1px solid var(--border)' }}>
<button onClick={handleLogout} style={{ display:'block',width:'100%',padding:'11px 14px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--error)' }}>Sign out</button> <button onClick={handleLogout} style={{ display:'block',width:'100%',padding:'11px 14px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--error)' }}>Sign out</button>
</div> </div>
@@ -96,6 +134,17 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
About About
</button> </button>
{showPushToggle && (
<button className="footer-menu-item" onClick={() => { togglePush(); setShowMenu(false); }}>
{pushEnabled ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
)}
<span style={{ flex: 1 }}>Notifications</span>
<span style={{ fontSize: 11, fontWeight: 600, color: pushEnabled ? 'var(--primary)' : 'var(--text-secondary)' }}>{pushEnabled ? 'ON' : 'OFF'}</span>
</button>
)}
<hr className="divider" style={{ margin: '4px 0' }} /> <hr className="divider" style={{ margin: '4px 0' }} />
<button className="footer-menu-item danger" onClick={handleLogout}> <button className="footer-menu-item danger" onClick={handleLogout}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>

View File

@@ -97,14 +97,71 @@ export default function Chat() {
return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures); return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures);
}, [loadFeatures]); }, [loadFeatures]);
// Register / refresh FCM push subscription // Register / refresh push subscription — FCM for Android/Chrome, Web Push for iOS
useEffect(() => { useEffect(() => {
if (!('serviceWorker' in navigator)) return; if (!('serviceWorker' in navigator)) return;
const registerPush = async () => { // Convert a URL-safe base64 string to Uint8Array for the VAPID applicationServerKey
try { function urlBase64ToUint8Array(base64String) {
if (Notification.permission === 'denied') return; const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return Uint8Array.from(raw, c => c.charCodeAt(0));
}
// ── iOS / Web Push path ───────────────────────────────────────────────────
// iOS 16.4+ PWAs use the standard W3C Web Push API via pushManager.subscribe().
// FCM tokens are Google-specific and are not accepted by Apple's push service.
const registerWebPush = async () => {
try {
const configRes = await fetch('/api/push/vapid-public-key');
if (!configRes.ok) { console.warn('[Push] VAPID key not available'); return; }
const { vapidPublicKey } = await configRes.json();
const reg = await navigator.serviceWorker.ready;
// Re-use any existing subscription so we don't lose it on every page load
let subscription = await reg.pushManager.getSubscription();
if (subscription) {
// Check if it's already registered with the server
const cachedEndpoint = localStorage.getItem('rc_webpush_endpoint');
if (cachedEndpoint === subscription.endpoint) {
console.log('[Push] WebPush subscription unchanged — skipping subscribe');
return;
}
} else {
subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
console.log('[Push] WebPush subscription obtained');
const subJson = subscription.toJSON();
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
const subRes = await fetch('/api/push/subscribe-webpush', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ endpoint: subJson.endpoint, keys: subJson.keys }),
});
if (!subRes.ok) {
const err = await subRes.json().catch(() => ({}));
console.warn('[Push] WebPush subscribe failed:', err.error || subRes.status);
localStorage.setItem('rc_fcm_error', `WebPush subscribe failed: ${err.error || subRes.status}`);
} else {
localStorage.setItem('rc_webpush_endpoint', subJson.endpoint);
localStorage.removeItem('rc_fcm_error');
console.log('[Push] WebPush subscription registered successfully');
}
} catch (e) {
console.warn('[Push] WebPush registration failed:', e.message);
localStorage.setItem('rc_fcm_error', e.message);
}
};
// ── Android / Chrome FCM path ─────────────────────────────────────────────
const registerFCM = async () => {
try {
// Fetch Firebase config from backend (returns 503 if FCM not configured) // Fetch Firebase config from backend (returns 503 if FCM not configured)
const configRes = await fetch('/api/push/firebase-config'); const configRes = await fetch('/api/push/firebase-config');
if (!configRes.ok) return; if (!configRes.ok) return;
@@ -121,10 +178,6 @@ export default function Chat() {
const reg = await navigator.serviceWorker.ready; const reg = await navigator.serviceWorker.ready;
// Never auto-request permission — that triggers a dialog on PWA launch.
// Permission is requested explicitly from the Notifications tab in the profile modal.
if (Notification.permission !== 'granted') return;
// Do NOT call deleteToken() here. Deleting the token on every page load (or // Do NOT call deleteToken() here. Deleting the token on every page load (or
// every visibility-change) forces Chrome to create a new Web Push subscription // every visibility-change) forces Chrome to create a new Web Push subscription
// each time. During the brief window between delete and re-register the server // each time. During the brief window between delete and re-register the server
@@ -183,6 +236,26 @@ export default function Chat() {
} }
}; };
const registerPush = async () => {
try {
if (Notification.permission === 'denied') return;
// Never auto-request permission — that triggers a dialog on PWA launch.
// Permission is requested explicitly from the Notifications tab in the profile modal.
if (Notification.permission !== 'granted') return;
// Respect the user's explicit opt-out from the user menu toggle
if (localStorage.getItem('rc_push_enabled') === 'false') return;
const isIOS = /iphone|ipad/i.test(navigator.userAgent);
if (isIOS) {
await registerWebPush();
} else {
await registerFCM();
}
} catch (e) {
console.warn('[Push] registerPush failed:', e.message);
}
};
registerPush(); registerPush();
const handleVisibility = () => { const handleVisibility = () => {

View File

@@ -163,7 +163,9 @@ export const api = {
// Push notifications (FCM) // Push notifications (FCM)
getFirebaseConfig: () => req('GET', '/push/firebase-config'), getFirebaseConfig: () => req('GET', '/push/firebase-config'),
getVapidPublicKey: () => req('GET', '/push/vapid-public-key'),
subscribePush: (fcmToken) => req('POST', '/push/subscribe', { fcmToken }), subscribePush: (fcmToken) => req('POST', '/push/subscribe', { fcmToken }),
subscribeWebPush: (subscription) => req('POST', '/push/subscribe-webpush', subscription),
unsubscribePush: () => req('POST', '/push/unsubscribe'), unsubscribePush: () => req('POST', '/push/unsubscribe'),
testPush: (mode = 'notification') => req('POST', `/push/test?mode=${mode}`), testPush: (mode = 'notification') => req('POST', `/push/test?mode=${mode}`),
pushDebug: () => req('GET', '/push/debug'), pushDebug: () => req('GET', '/push/debug'),