v0.12.30 add notifications for iOS
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-frontend",
|
||||
"version": "0.12.29",
|
||||
"version": "0.12.30",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function ProfileModal({ onClose }) {
|
||||
const [notifPermission, setNotifPermission] = useState(
|
||||
typeof Notification !== 'undefined' ? Notification.permission : 'unsupported'
|
||||
);
|
||||
const isIOS = /iphone|ipad/i.test(navigator.userAgent);
|
||||
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
|
||||
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={{ fontSize: 14, color: 'var(--text-secondary)' }}>
|
||||
{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.'}
|
||||
</div>
|
||||
{notifPermission === 'default' && (
|
||||
@@ -232,7 +235,12 @@ export default function ProfileModal({ onClose }) {
|
||||
<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 }}>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
|
||||
</p>
|
||||
</div>
|
||||
@@ -258,30 +266,34 @@ export default function ProfileModal({ onClose }) {
|
||||
>
|
||||
{pushTesting ? 'Sending…' : 'Test (via SW)'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={pushTesting}
|
||||
onClick={async () => {
|
||||
setPushTesting(true);
|
||||
setPushResult(null);
|
||||
try {
|
||||
const { results } = await api.testPush('browser');
|
||||
setPushResult({ ok: true, results, mode: 'browser' });
|
||||
} catch (e) {
|
||||
setPushResult({ ok: false, error: e.message });
|
||||
} finally {
|
||||
setPushTesting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pushTesting ? 'Sending…' : 'Test (via Browser)'}
|
||||
</button>
|
||||
</div>
|
||||
<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.
|
||||
{!isIOS && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={pushTesting}
|
||||
onClick={async () => {
|
||||
setPushTesting(true);
|
||||
setPushResult(null);
|
||||
try {
|
||||
const { results } = await api.testPush('browser');
|
||||
setPushResult({ ok: true, results, mode: 'browser' });
|
||||
} catch (e) {
|
||||
setPushResult({ ok: false, error: e.message });
|
||||
} finally {
|
||||
setPushTesting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pushTesting ? 'Sending…' : 'Test (via Browser)'}
|
||||
</button>
|
||||
)}
|
||||
</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 && (
|
||||
<div style={{
|
||||
|
||||
@@ -11,10 +11,41 @@ function useTheme() {
|
||||
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 }) {
|
||||
const { user, logout } = useAuth();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [dark, setDark] = useTheme();
|
||||
const { permitted: showPushToggle, enabled: pushEnabled, toggle: togglePush } = usePushToggle();
|
||||
const menuRef = 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)' }}
|
||||
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)' }}>
|
||||
<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>
|
||||
@@ -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>
|
||||
About
|
||||
</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' }} />
|
||||
<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>
|
||||
|
||||
@@ -97,14 +97,71 @@ export default function Chat() {
|
||||
return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures);
|
||||
}, [loadFeatures]);
|
||||
|
||||
// Register / refresh FCM push subscription
|
||||
// Register / refresh push subscription — FCM for Android/Chrome, Web Push for iOS
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
|
||||
const registerPush = async () => {
|
||||
try {
|
||||
if (Notification.permission === 'denied') return;
|
||||
// Convert a URL-safe base64 string to Uint8Array for the VAPID applicationServerKey
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
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)
|
||||
const configRes = await fetch('/api/push/firebase-config');
|
||||
if (!configRes.ok) return;
|
||||
@@ -121,10 +178,6 @@ export default function Chat() {
|
||||
|
||||
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
|
||||
// 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
|
||||
@@ -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();
|
||||
|
||||
const handleVisibility = () => {
|
||||
|
||||
@@ -163,7 +163,9 @@ export const api = {
|
||||
|
||||
// Push notifications (FCM)
|
||||
getFirebaseConfig: () => req('GET', '/push/firebase-config'),
|
||||
getVapidPublicKey: () => req('GET', '/push/vapid-public-key'),
|
||||
subscribePush: (fcmToken) => req('POST', '/push/subscribe', { fcmToken }),
|
||||
subscribeWebPush: (subscription) => req('POST', '/push/subscribe-webpush', subscription),
|
||||
unsubscribePush: () => req('POST', '/push/unsubscribe'),
|
||||
testPush: (mode = 'notification') => req('POST', `/push/test?mode=${mode}`),
|
||||
pushDebug: () => req('GET', '/push/debug'),
|
||||
|
||||
Reference in New Issue
Block a user