v0.12.0 codes for FCM and rebranded jama to RosterChirp

This commit is contained in:
2026-03-22 20:15:57 -04:00
parent 21dc788cd3
commit 819d60d693
40 changed files with 426 additions and 363 deletions

View File

@@ -42,7 +42,7 @@ export default function Chat() {
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager'
const [page, setPage] = useState('chat'); // 'chat' | 'schedule' | 'groupmessages'
const [drawerOpen, setDrawerOpen] = useState(false);
const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'JAMA-Chat', teamToolManagers: [], isHostDomain: false });
const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'RosterChirp-Chat', teamToolManagers: [], isHostDomain: false });
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [showSidebar, setShowSidebar] = useState(true);
@@ -81,7 +81,7 @@ export default function Chat() {
branding: settings.feature_branding === 'true',
groupManager: settings.feature_group_manager === 'true',
scheduleManager: settings.feature_schedule_manager === 'true',
appType: settings.app_type || 'JAMA-Chat',
appType: settings.app_type || 'RosterChirp-Chat',
teamToolManagers: JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'),
isHostDomain: settings.is_host_domain === 'true',
}));
@@ -93,53 +93,59 @@ export default function Chat() {
useEffect(() => {
loadFeatures();
window.addEventListener('jama:settings-changed', loadFeatures);
return () => window.removeEventListener('jama:settings-changed', loadFeatures);
window.addEventListener('rosterchirp:settings-changed', loadFeatures);
return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures);
}, [loadFeatures]);
// Register / refresh push subscription
// Register / refresh FCM push subscription
useEffect(() => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
if (!('serviceWorker' in navigator)) return;
const registerPush = async () => {
try {
const permission = Notification.permission;
if (permission === 'denied') return;
if (Notification.permission === 'denied') return;
// Fetch Firebase config from backend (returns 503 if FCM not configured)
const configRes = await fetch('/api/push/firebase-config');
if (!configRes.ok) return;
const { apiKey, projectId, messagingSenderId, appId, vapidKey } = await configRes.json();
// Dynamically import the Firebase SDK (tree-shaken, only loaded when needed)
const { initializeApp, getApps } = await import('firebase/app');
const { getMessaging, getToken } = await import('firebase/messaging');
const firebaseApp = getApps().length
? getApps()[0]
: initializeApp({ apiKey, projectId, messagingSenderId, appId });
const firebaseMessaging = getMessaging(firebaseApp);
const reg = await navigator.serviceWorker.ready;
const { publicKey } = await fetch('/api/push/vapid-public').then(r => r.json());
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
let sub = await reg.pushManager.getSubscription();
if (!sub) {
// First time or subscription was lost — request permission then subscribe
const granted = permission === 'granted'
? 'granted'
: await Notification.requestPermission();
if (Notification.permission !== 'granted') {
const granted = await Notification.requestPermission();
if (granted !== 'granted') return;
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
}
// Always re-register subscription with the server (keeps it fresh on mobile)
const fcmToken = await getToken(firebaseMessaging, {
vapidKey,
serviceWorkerRegistration: reg,
});
if (!fcmToken) return;
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify(sub.toJSON()),
body: JSON.stringify({ fcmToken }),
});
console.log('[Push] Subscription registered');
console.log('[Push] FCM subscription registered');
} catch (e) {
console.warn('[Push] Subscription failed:', e.message);
console.warn('[Push] FCM subscription failed:', e.message);
}
};
registerPush();
// Bug A fix: re-register push subscription when app returns to foreground
// Mobile browsers can drop push subscriptions when the app is backgrounded
const handleVisibility = () => {
if (document.visibilityState === 'visible') registerPush();
};
@@ -263,7 +269,7 @@ export default function Chat() {
// so we force logout unconditionally — the new session will reconnect cleanly)
localStorage.removeItem('tc_token');
sessionStorage.removeItem('tc_token');
window.dispatchEvent(new CustomEvent('jama:session-displaced'));
window.dispatchEvent(new CustomEvent('rosterchirp:session-displaced'));
};
// Online presence
@@ -320,7 +326,7 @@ export default function Chat() {
if (isMobile) {
setShowSidebar(false);
// Push a history entry so swipe-back returns to sidebar instead of exiting the app
window.history.pushState({ jamaChatOpen: true }, '');
window.history.pushState({ rosterchirpChatOpen: true }, '');
}
// Clear notifications and unread count for this group
setNotifications(prev => prev.filter(n => n.groupId !== id));
@@ -334,7 +340,7 @@ export default function Chat() {
setShowSidebar(true);
setActiveGroupId(null);
// Push another entry so subsequent back gestures are also intercepted
window.history.pushState({ jamaChatOpen: true }, '');
window.history.pushState({ rosterchirpChatOpen: true }, '');
}
};
window.addEventListener('popstate', handlePopState);

View File

@@ -3,9 +3,9 @@ import { useState, useEffect, useCallback } from 'react';
// ── Constants ─────────────────────────────────────────────────────────────────
const PLANS = [
{ value: 'chat', label: 'JAMA-Chat', desc: 'Chat only' },
{ value: 'brand', label: 'JAMA-Brand', desc: 'Chat + Branding' },
{ value: 'team', label: 'JAMA-Team', desc: 'Chat + Branding + Groups + Schedule' },
{ value: 'chat', label: 'RosterChirp-Chat', desc: 'Chat only' },
{ value: 'brand', label: 'RosterChirp-Brand', desc: 'Chat + Branding' },
{ value: 'team', label: 'RosterChirp-Team', desc: 'Chat + Branding + Groups + Schedule' },
];
const PLAN_BADGE = {
@@ -393,7 +393,7 @@ function KeyEntry({ onSubmit }) {
headers: { 'X-Host-Admin-Key': key.trim() },
});
if (res.ok) {
sessionStorage.setItem('jama-host-key', key.trim());
sessionStorage.setItem('rosterchirp-host-key', key.trim());
onSubmit(key.trim());
} else {
setError('Invalid admin key');
@@ -406,7 +406,7 @@ function KeyEntry({ onSubmit }) {
<div style={{ background: '#fff', borderRadius: 12, padding: 40, width: '100%', maxWidth: 380,
boxShadow: '0 2px 16px rgba(0,0,0,0.12)', textAlign: 'center' }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>🏠</div>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: '0 0 4px' }}>JAMA-HOST</h1>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: '0 0 4px' }}>RosterChirp-Host</h1>
<p style={{ color: '#5f6368', fontSize: 13, margin: '0 0 24px' }}>Host Administration Panel</p>
{error && <div style={{ padding: '8px 12px', background: '#fce8e6', color: '#d93025',
borderRadius: 6, fontSize: 13, marginBottom: 16 }}>{error}</div>}
@@ -428,7 +428,7 @@ function KeyEntry({ onSubmit }) {
// ── Main host admin panel ─────────────────────────────────────────────────────
export default function HostAdmin() {
const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('jama-host-key') || '');
const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('rosterchirp-host-key') || '');
const [status, setStatus] = useState(null);
const [tenants, setTenants] = useState([]);
const [loading, setLoading] = useState(false);
@@ -454,7 +454,7 @@ export default function HostAdmin() {
} catch (e) {
toast(e.message, 'error');
if (e.message.includes('Invalid') || e.message.includes('401')) {
sessionStorage.removeItem('jama-host-key');
sessionStorage.removeItem('rosterchirp-host-key');
setAdminKey('');
}
} finally { setLoading(false); }
@@ -479,7 +479,7 @@ export default function HostAdmin() {
!search || t.name.toLowerCase().includes(search.toLowerCase()) ||
t.slug.toLowerCase().includes(search.toLowerCase())
);
const baseDomain = status?.baseDomain || 'jamachat.com';
const baseDomain = status?.baseDomain || 'rosterchirp.com';
return (
<div style={{ minHeight: '100vh', background: '#f1f3f4', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' }}>
@@ -489,7 +489,7 @@ export default function HostAdmin() {
justifyContent: 'space-between', height: 56 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 20 }}>🏠</span>
<span style={{ fontWeight: 700, fontSize: 16 }}>JAMA-HOST</span>
<span style={{ fontWeight: 700, fontSize: 16 }}>RosterChirp-Host</span>
<span style={{ opacity: 0.7, fontSize: 13 }}>/ {baseDomain}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
@@ -498,7 +498,7 @@ export default function HostAdmin() {
{status.tenants.active} active · {status.tenants.total} total
</span>
)}
<Btn size="sm" variant="secondary" onClick={() => { sessionStorage.removeItem('jama-host-key'); setAdminKey(''); }}>
<Btn size="sm" variant="secondary" onClick={() => { sessionStorage.removeItem('rosterchirp-host-key'); setAdminKey(''); }}>
Sign Out
</Btn>
</div>
@@ -581,7 +581,7 @@ export default function HostAdmin() {
{/* Footer */}
<div style={{ textAlign: 'center', marginTop: 24, fontSize: 12, color: '#9aa0a6' }}>
JAMA-HOST Control Plane · {baseDomain}
RosterChirp-Host Control Plane · {baseDomain}
</div>
</div>

View File

@@ -67,7 +67,7 @@ export default function Login() {
}
};
const appName = settings.app_name || 'jama';
const appName = settings.app_name || 'rosterchirp';
const logoUrl = settings.logo_url;
return (
@@ -77,7 +77,7 @@ export default function Login() {
{logoUrl ? (
<img src={logoUrl} alt={appName} className="logo-img" />
) : (
<img src="/icons/jama.png" alt="jama" className="logo-img" />
<img src="/icons/rosterchirp.png" alt="rosterchirp" className="logo-img" />
)}
<h1>{appName}</h1>
<p>Sign in to continue</p>