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

@@ -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={{

View File

@@ -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>