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

2
.env
View File

@@ -28,3 +28,5 @@ HOST_ADMIN_KEY=VBGFHETSTTGRDDWAASJKH
#** Optional #** Optional
PORT=3144 PORT=3144
TZ=America/Toronto TZ=America/Toronto
{"type": "service_account","project_id": "rosterchirp-push", "private_key_id": "577d2e29044634a9a7efba8cc3c5b67cd98f1f61", "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCma+cTelvpLARK\n0B+Ok4901OJU3TEpnJHMc7N5mEg231Nn4XnXSuM4QPOSSJLGRTSHJrZ17kAJkShf\nP2V8mVWujtwhK/a2W1Jl43VkgqNnAunsqG0T12hCpPuRYkVZoDtPmJ4pYzD8TkV9\n1xmrHGP4JovSld5uDFWTQ91ZSsEqi1lS3A35Tqhl3jO81GPzRaR8CyY7/cyoplZ3\n49njHV9llh68pFeYYbfNDbZAli9tDDmP4rBQud8Q+Ix5syQ2zbmAErQFzSl8hiCO\n3FhsZYGBxj2cDXTJXfwqiLoVBL9WbTj5NMVnneDSZJ15ILjQA6zyqKvCsXYB4YrM\n3Vs1D3jnAgMBAAECggEAMh2JSwjMV8XNDxhogF94UlbvR14KuXywPTDUabgNexS6\ngaxZLBedoCmTD8iyBmn9vPtP8+iIuTjQvwoQzjpAnp3ftU+Pbm/Guu8JwXhDq7gp\naH55xoFWIMedCDVfK/PAGKKdclov/LK3Y4Ncc/ZLNoWpEoPWJS6qsHu90u9bhytN\n+TQ//K4ODvxzp8dwrVyEoalaTSubxctvyN43L2EkqJBWOfm5GOnfbfB6UENTVYii\nd98lsc/LFumepGyHWrXOGodjVqWqaW54po2KMGXUiYfdzg7vgcVpBIdG1gbKAmax\n1Ypst7l4VKosJM1cI5zykdRj8JuGlX4YM5MRLhGtLQKBgQDafa2tFU4OViWwMn4E\nowdpczZKjHCy/50NSS/UU2WyTCJo8cO6vjhkCGVINazAWABXIcLeuWHYOIhWyfRj\n9v7dShH2VA2mip7vFP1XEjiK98tFOV4FbS1Qg4m5zVvsECOSNZ21ozHEgZ9q0yEK\nDoXX3HOr3OV6az6GUhB45CzeWwKBgQDC/dtnSwJFpFyR1QAZX5lPeAYXlosO1jhO\nhCqPwKemOnH32j/UVGmE2t93ALo1sQD2YV2CsuHysQWEnH/mdC9iR9HF0g9nCrgc\nqbOD2MppnISYEp2DktcfyZMNujxMYYZ6e4rxXCrjkdk4z2xg5PsGYAJZQ+FA7XdN\nBViQmz1tZQKBgDLPsXkkEEADRsaAJ5BafZnHYmPZ30exbEuvroDZWDgrvoDbYKJo\nJGMXFL7DRMaCcKnSvyfewuNu2j4cv0oUIddCp4S6rWYCrM16+yOpqB6hW9NgcP4g\nEr67qGbeXDc81ZjmASRBrIw/fNxx9ygIkpXNvdTFDVT35dWE9jG3FrwrAoGADICO\nUr8idCinrsoDaZ0RjWDasyR54gemMJKU0AbAOQ5CRGv/77NB2LzX2x920P56W1G+\n1yR1DESBYBFQugv1Bc4pCw/+4NJ1H5FZ6zg5MjBQ6Bc5djgyBt27ygOI3jTalHvb\nWsJYFaNCVDwobMYBulTpkaOii7EuFwgit5Lci2kCgYEAimaTURYyX6MblMlLzcKd\nmtjvfldF8xFh1xKofbiQwfMOiKn0G2xuVWyIvv4U23ZV+Yzu18gr9lOT0JsAsn3G\nx91hUPyWPozoe/MHSecDGIMUUiHdPbIvOO4w3Mv/HCfKuIvruQaaMJ2gvj72zn6C\nCMJVQgxfBULCw4ByDd63pnw=\n-----END PRIVATE KEY-----\n", "client_email": "firebase-adminsdk-fbsvc@rosterchirp-push.iam.gserviceaccount.com", "client_id": "103819905443146316089", "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40rosterchirp-push.iam.gserviceaccount.com", "universe_domain": "googleapis.com"}

View File

@@ -3,28 +3,41 @@ DB_PASSWORD=change_me_strong_password
JWT_SECRET=change_me_super_secret_jwt_key JWT_SECRET=change_me_super_secret_jwt_key
# ── App identity ────────────────────────────────────────────────────────────── # ── App identity ──────────────────────────────────────────────────────────────
PROJECT_NAME=jama PROJECT_NAME=rosterchirp
APP_NAME=jama APP_NAME=rosterchirp
DEFCHAT_NAME=General Chat DEFCHAT_NAME=General Chat
ADMIN_NAME=Admin User ADMIN_NAME=Admin User
ADMIN_EMAIL=admin@jama.local ADMIN_EMAIL=admin@rosterchirp.local
ADMIN_PASS=Admin@1234 ADMIN_PASS=Admin@1234
ADMPW_RESET=false ADMPW_RESET=false
# ── Database ────────────────────────────────────────────────────────────────── # ── Database ──────────────────────────────────────────────────────────────────
DB_NAME=jama DB_NAME=rosterchirp
DB_USER=jama DB_USER=rosterchirp
# DB_HOST and DB_PORT are set automatically in docker-compose (host=db, port=5432) # DB_HOST and DB_PORT are set automatically in docker-compose (host=db, port=5432)
# ── Tenancy mode ────────────────────────────────────────────────────────────── # ── Tenancy mode ──────────────────────────────────────────────────────────────
# selfhost = single tenant (JAMA-CHAT / JAMA-BRAND / JAMA-TEAM) # selfhost = single tenant (RosterChirp-Chat / RosterChirp-Brand / RosterChirp-Team)
# host = multi-tenant (JAMA-HOST only) # host = multi-tenant (RosterChirp-Host only)
APP_TYPE=selfhost APP_TYPE=selfhost
# ── JAMA-HOST only (ignored in selfhost mode) ───────────────────────────────── # ── RosterChirp-Host only (ignored in selfhost mode) ─────────────────────────────────
# HOST_DOMAIN=jamachat.com # HOST_DOMAIN=rosterchirp.com
# HOST_ADMIN_KEY=change_me_host_admin_secret # HOST_ADMIN_KEY=change_me_host_admin_secret
# ── Optional ────────────────────────────────────────────────────────────────── # ── Optional ──────────────────────────────────────────────────────────────────
PORT=3000 PORT=3000
TZ=UTC TZ=UTC
# ── Firebase Cloud Messaging (FCM) — Android background push ──────────────────
# Required for push notifications to work on Android when the app is backgrounded.
# Get these from: Firebase Console → Project Settings → General → Your web app
# FIREBASE_API_KEY=
# FIREBASE_PROJECT_ID=
# FIREBASE_MESSAGING_SENDER_ID=
# FIREBASE_APP_ID=
# Get VAPID key from: Firebase Console → Project Settings → Cloud Messaging → Web Push certificates
# FIREBASE_VAPID_KEY=
# Get service account JSON from: Firebase Console → Project Settings → Service accounts → Generate new private key
# Paste the entire JSON content as a single-line string:
# FIREBASE_SERVICE_ACCOUNT={"type":"service_account","project_id":"..."}

View File

@@ -1,10 +1,10 @@
# JAMA — Claude Code Development Context # RosterChirp — Claude Code Development Context
## What is JAMA? ## What is RosterChirp?
**jama** (just another messaging app) is a self-hosted, closed-source, full-stack Progressive Web App for team messaging. It supports both single-tenant (selfhost) and multi-tenant (host) deployments. **RosterChirp** is a self-hosted, closed-source, full-stack Progressive Web App for team messaging. It supports both single-tenant (selfhost) and multi-tenant (host) deployments.
**Current version:** 0.11.25 **Current version:** 0.11.26
--- ---
@@ -23,7 +23,7 @@
## Repository Layout ## Repository Layout
``` ```
jama/ rosterchirp/
├── CLAUDE.md ← this file ├── CLAUDE.md ← this file
├── KNOWN_LIMITATIONS.md ├── KNOWN_LIMITATIONS.md
├── Dockerfile ├── Dockerfile
@@ -51,7 +51,7 @@ jama/
│ │ ├── users.js │ │ ├── users.js
│ │ ├── settings.js │ │ ├── settings.js
│ │ ├── push.js │ │ ├── push.js
│ │ ├── host.js ← JAMA-HOST control plane only │ │ ├── host.js ← RosterChirp-Host control plane only
│ │ ├── about.js │ │ ├── about.js
│ │ └── help.js │ │ └── help.js
│ └── utils/ │ └── utils/
@@ -106,7 +106,7 @@ jama/
## Version Bump — Files to Update ## Version Bump — Files to Update
When bumping the version (e.g. 0.11.25 → 0.11.26), update **all three**: When bumping the version (e.g. 0.11.26 → 0.11.27), update **all three**:
``` ```
backend/package.json "version": "X.Y.Z" backend/package.json "version": "X.Y.Z"
@@ -116,7 +116,7 @@ build.sh VERSION="${1:-X.Y.Z}"
One-liner: One-liner:
```bash ```bash
OLD=0.11.25; NEW=0.11.26 OLD=0.11.26; NEW=0.11.27
sed -i "s/\"version\": \"$OLD\"/\"version\": \"$NEW\"/" backend/package.json frontend/package.json sed -i "s/\"version\": \"$OLD\"/\"version\": \"$NEW\"/" backend/package.json frontend/package.json
sed -i "s/VERSION=\"\${1:-$OLD}\"/VERSION=\"\${1:-$NEW}\"/" build.sh sed -i "s/VERSION=\"\${1:-$OLD}\"/VERSION=\"\${1:-$NEW}\"/" build.sh
``` ```
@@ -127,14 +127,14 @@ The `.env.example` has no version field. There is no fourth location.
## Output ZIP ## Output ZIP
When packaging for delivery: `jama.zip` at `/mnt/user-data/outputs/jama.zip` When packaging for delivery: `rosterchirp.zip` at `/mnt/user-data/outputs/rosterchirp.zip`
Always exclude: Always exclude:
```bash ```bash
zip -qr jama.zip jama \ zip -qr rosterchirp.zip rosterchirp \
--exclude "jama/README.md" \ --exclude "rosterchirp/README.md" \
--exclude "jama/data/help.md" \ --exclude "rosterchirp/data/help.md" \
--exclude "jama/backend/src/data/help.md" --exclude "rosterchirp/backend/src/data/help.md"
``` ```
--- ---
@@ -146,7 +146,7 @@ zip -qr jama.zip jama \
| `selfhost` | Single tenant — one schema `public`. Default if APP_TYPE unset. | | `selfhost` | Single tenant — one schema `public`. Default if APP_TYPE unset. |
| `host` | Multi-tenant — one schema per tenant. Requires `HOST_DOMAIN` and `HOST_ADMIN_KEY`. | | `host` | Multi-tenant — one schema per tenant. Requires `HOST_DOMAIN` and `HOST_ADMIN_KEY`. |
JAMA-HOST tenants are provisioned at `{slug}.{HOST_DOMAIN}`. The host control panel lives at `https://{HOST_DOMAIN}/host`. RosterChirp-Host tenants are provisioned at `{slug}.{HOST_DOMAIN}`. The host control panel lives at `https://{HOST_DOMAIN}/host`.
--- ---
@@ -156,7 +156,7 @@ JAMA-HOST tenants are provisioned at `{slug}.{HOST_DOMAIN}`. The host control pa
- **Schema resolution:** `tenantMiddleware` sets `req.schema` from the HTTP `Host` header before any route runs. `assertSafeSchema()` validates all schema names against `[a-z_][a-z0-9_]*`. - **Schema resolution:** `tenantMiddleware` sets `req.schema` from the HTTP `Host` header before any route runs. `assertSafeSchema()` validates all schema names against `[a-z_][a-z0-9_]*`.
- **Migrations:** Auto-run on startup via `runMigrations(schema)`. Files in `migrations/` applied in order, tracked in `schema_migrations` table per schema. **Never edit an applied migration — add a new numbered file.** - **Migrations:** Auto-run on startup via `runMigrations(schema)`. Files in `migrations/` applied in order, tracked in `schema_migrations` table per schema. **Never edit an applied migration — add a new numbered file.**
- **Seeding:** `seedSettings → seedEventTypes → seedAdmin → seedUserGroups` on startup. All use `ON CONFLICT DO NOTHING`. - **Seeding:** `seedSettings → seedEventTypes → seedAdmin → seedUserGroups` on startup. All use `ON CONFLICT DO NOTHING`.
- **Current migrations:** 001 (initial schema) → 002 (triggers/indexes) → 003 (tenants) → 004 (host plan) → 005 (U2U restrictions) → 006 (scrub deleted users) - **Current migrations:** 001 (initial schema) → 002 (triggers/indexes) → 003 (tenants) → 004 (host plan) → 005 (U2U restrictions) → 006 (scrub deleted users) → 007 (FCM push) → 008 (rebrand)
--- ---
@@ -203,9 +203,9 @@ Stored in `settings` table per schema:
| `feature_branding` | `'true'`/`'false'` | Brand+ | | `feature_branding` | `'true'`/`'false'` | Brand+ |
| `feature_group_manager` | `'true'`/`'false'` | Team | | `feature_group_manager` | `'true'`/`'false'` | Team |
| `feature_schedule_manager` | `'true'`/`'false'` | Team | | `feature_schedule_manager` | `'true'`/`'false'` | Team |
| `app_type` | `'JAMA-Chat'`/`'JAMA-Brand'`/`'JAMA-Team'` | — | | `app_type` | `'RosterChirp-Chat'`/`'RosterChirp-Brand'`/`'RosterChirp-Team'` | — |
JAMA-HOST always forces `JAMA-Team` on the public schema at startup. RosterChirp-Host always forces `RosterChirp-Team` on the public schema at startup.
--- ---
@@ -303,13 +303,7 @@ Single-user add/remove via `groups.js` (GroupInfoModal) always uses the named me
## Outstanding / Deferred Work ## Outstanding / Deferred Work
### Android Background Push (KNOWN_LIMITATIONS.md) ### Android Background Push (KNOWN_LIMITATIONS.md)
**Status:** Deferred. Web Push with VAPID doesn't survive Android Doze mode. **Status:** Implemented (v0.11.26+). Replaced web-push/VAPID with Firebase Cloud Messaging (FCM). Requires Firebase project setup — see .env.example for required env vars and sw.js for the SW config block.
**Fix plan:** Integrate Firebase Cloud Messaging (FCM).
1. Create Firebase project (free tier)
2. Add Firebase config to `.env` and `sw.js`
3. Replace `web-push` subscription flow with Firebase SDK
4. Switch backend dispatch from `web-push` to `firebase-admin`
5. WebSocket reconnect-on-focus (frontend only, no Firebase needed)
### WebSocket Reconnect on Focus ### WebSocket Reconnect on Focus
**Status:** Deferred. Socket drops when Android PWA is backgrounded. **Status:** Deferred. Socket drops when Android PWA is backgrounded.
@@ -326,19 +320,25 @@ HOST_DOMAIN= # host mode only
HOST_ADMIN_KEY= # host mode only HOST_ADMIN_KEY= # host mode only
JWT_SECRET= JWT_SECRET=
DB_HOST=db DB_HOST=db
DB_NAME=jama DB_NAME=rosterchirp
DB_USER=jama DB_USER=rosterchirp
DB_PASSWORD= # avoid ! (shell interpolation issue with docker-compose) DB_PASSWORD= # avoid ! (shell interpolation issue with docker-compose)
ADMIN_EMAIL= ADMIN_EMAIL=
ADMIN_NAME= ADMIN_NAME=
ADMIN_PASS= ADMIN_PASS=
ADMPW_RESET=true|false ADMPW_RESET=true|false
APP_NAME=jama APP_NAME=rosterchirp
USER_PASS= # default password for bulk-created users USER_PASS= # default password for bulk-created users
DEFCHAT_NAME=General Chat DEFCHAT_NAME=General Chat
JAMA_VERSION= # injected by build.sh into Docker image ROSTERCHIRP_VERSION= # injected by build.sh into Docker image
VAPID_PUBLIC= # auto-generated on first start if not set VAPID_PUBLIC= # auto-generated on first start if not set
VAPID_PRIVATE= # auto-generated on first start if not set VAPID_PRIVATE= # auto-generated on first start if not set
FIREBASE_API_KEY= # FCM web app config
FIREBASE_PROJECT_ID= # FCM web app config
FIREBASE_MESSAGING_SENDER_ID= # FCM web app config
FIREBASE_APP_ID= # FCM web app config
FIREBASE_VAPID_KEY= # FCM Web Push certificate public key
FIREBASE_SERVICE_ACCOUNT= # FCM service account JSON (stringified, backend only)
``` ```
--- ---
@@ -347,7 +347,7 @@ VAPID_PRIVATE= # auto-generated on first start if not set
```bash ```bash
# Production: Ubuntu 22.04, Docker Compose v2 # Production: Ubuntu 22.04, Docker Compose v2
# Directory: /home/rick/jama/ # Directory: /home/rick/rosterchirp/
./build.sh # builds Docker image ./build.sh # builds Docker image
docker compose up -d # starts all services docker compose up -d # starts all services
@@ -359,4 +359,4 @@ Build sequence: `build.sh` → Docker build → `npm run build` (Vite) → `dock
## Session History ## Session History
Previous development was conducted via Claude.ai web interface (sessions summarised in this document). Development continues in Claude Code from v0.11.25. Development continues in Claude Code from v0.11.26 (rebranded from jama to RosterChirp).

View File

@@ -1,4 +1,4 @@
# Caddyfile.example — JAMA-HOST reverse proxy # Caddyfile.example — RosterChirp-Host reverse proxy
# #
# Caddy handles SSL automatically via Let's Encrypt. # Caddy handles SSL automatically via Let's Encrypt.
# Wildcard certs require a DNS challenge provider. # Wildcard certs require a DNS challenge provider.
@@ -12,23 +12,23 @@
# CF_API_TOKEN=your_cloudflare_token (or equivalent) # CF_API_TOKEN=your_cloudflare_token (or equivalent)
# #
# 3. Add a wildcard DNS record in your DNS provider: # 3. Add a wildcard DNS record in your DNS provider:
# *.jamachat.com → your server IP # *.rosterchirp.com → your server IP
# jamachat.com → your server IP # rosterchirp.com → your server IP
# #
# Usage: # Usage:
# Copy this file to /etc/caddy/Caddyfile (or wherever Caddy reads it) # Copy this file to /etc/caddy/Caddyfile (or wherever Caddy reads it)
# Reload: caddy reload # Reload: caddy reload
# ── Wildcard subdomain ──────────────────────────────────────────────────────── # ── Wildcard subdomain ────────────────────────────────────────────────────────
# Handles team1.jamachat.com, teamB.jamachat.com, etc. # Handles team1.rosterchirp.com, teamB.rosterchirp.com, etc.
# Replace jamachat.com with your actual HOST_DOMAIN. # Replace rosterchirp.com with your actual HOST_DOMAIN.
*.jamachat.com { *.rosterchirp.com {
tls { tls {
dns cloudflare {env.CF_API_TOKEN} dns cloudflare {env.CF_API_TOKEN}
} }
# Forward all requests to the jama app container # Forward all requests to the rosterchirp app container
reverse_proxy localhost:3000 reverse_proxy localhost:3000
# Security headers # Security headers
@@ -42,13 +42,13 @@
# Logs (optional) # Logs (optional)
log { log {
output file /var/log/caddy/jama-access.log output file /var/log/caddy/rosterchirp-access.log
format json format json
} }
} }
# ── Base domain (host admin panel) ─────────────────────────────────────────── # ── Base domain (host admin panel) ───────────────────────────────────────────
jamachat.com { rosterchirp.com {
reverse_proxy localhost:3000 reverse_proxy localhost:3000
header { header {
Strict-Transport-Security "max-age=31536000; includeSubDomains" Strict-Transport-Security "max-age=31536000; includeSubDomains"
@@ -60,7 +60,7 @@ jamachat.com {
# ── Custom tenant domains ───────────────────────────────────────────────────── # ── Custom tenant domains ─────────────────────────────────────────────────────
# When a tenant sets up a custom domain (e.g. chat.theircompany.com): # When a tenant sets up a custom domain (e.g. chat.theircompany.com):
# #
# 1. They add a DNS CNAME: chat.theircompany.com → jamachat.com # 1. They add a DNS CNAME: chat.theircompany.com → rosterchirp.com
# #
# 2. You add a block here and reload Caddy. # 2. You add a block here and reload Caddy.
# Caddy will automatically obtain and renew the SSL cert. # Caddy will automatically obtain and renew the SSL cert.
@@ -80,7 +80,7 @@ jamachat.com {
# } # }
# } # }
# #
# *.jamachat.com, jamachat.com { # *.rosterchirp.com, rosterchirp.com {
# tls { on_demand } # tls { on_demand }
# reverse_proxy localhost:3000 # reverse_proxy localhost:3000
# } # }

View File

@@ -12,12 +12,12 @@ FROM node:20-alpine
ARG VERSION=dev ARG VERSION=dev
ARG BUILD_DATE=unknown ARG BUILD_DATE=unknown
LABEL org.opencontainers.image.title="jama" \ LABEL org.opencontainers.image.title="rosterchirp" \
org.opencontainers.image.description="Self-hosted team chat PWA" \ org.opencontainers.image.description="Self-hosted team chat PWA" \
org.opencontainers.image.version="${VERSION}" \ org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.created="${BUILD_DATE}" org.opencontainers.image.created="${BUILD_DATE}"
ENV JAMA_VERSION=${VERSION} ENV ROSTERCHIRP_VERSION=${VERSION}
# No native build tools needed — pg uses pure JS by default # No native build tools needed — pg uses pure JS by default
WORKDIR /app WORKDIR /app

31
FUTURE_FEATURES.md Normal file
View File

@@ -0,0 +1,31 @@
# RosterChirp — Future Feature Requests
---
## Markdown Message Rendering
**Request:** Render markdown-formatted text in the message window.
**Scope recommendation:** Start with inline-only markdown (bold, italic, inline code, strikethrough, links). Full block-level markdown (headers, lists, tables, code blocks) is a follow-on.
### Why inline-only first
- Zero risk of misformatting existing plain-text messages
- Full markdown risks rendering existing content oddly (e.g. a message starting with `1.` or `#` rendering as a list/header)
### Implementation notes
- Integration point is `formatMsgContent()` in `Message.jsx` — already returns HTML for `dangerouslySetInnerHTML`; a markdown parser slots straight in
- Add `marked` npm package (~13KB) for parsing
- Add `DOMPurify` for XSS sanitization — **required**, markdown allows raw HTML passthrough and content comes from other users
- `marked` config: `{ breaks: true }` to preserve single-newline-as-`<br>` behaviour, `{ mangle: false, headerIds: false }` to suppress heading anchors
- Apply markdown parse first, then @mention substitution (so `**@[Name]**` renders correctly)
- Remove the existing URL regex linkifier in `formatMsgContent` — markdown handles links natively
- Strip markdown from reply previews (currently shows raw `**text**`)
- `handleCopy` copies `msg.content` (raw markdown source) — correct behaviour, no change needed
- Emoji-only detection runs on raw content before rendering — no change needed
- Compose box stays plain textarea for v1; no preview toolbar required
### Effort estimate
| Scope | Estimate |
|---|---|
| Inline-only (bold, italic, code, strikethrough) | ~1.5 hours |
| Full markdown (+ block elements, CSS for bubbles) | ~45 hours |

View File

@@ -1,4 +1,4 @@
# JAMA — Known Limitations # RosterChirp — Known Limitations
## Android Background Push Notifications ## Android Background Push Notifications
@@ -7,13 +7,13 @@
**Does not affect:** Desktop browsers, iOS PWA (iOS 16.4+) **Does not affect:** Desktop browsers, iOS PWA (iOS 16.4+)
### Symptom ### Symptom
Push notifications are not delivered when the jama PWA or Chrome browser loses focus on Android. The app also disconnects from the WebSocket (real-time chat) when backgrounded. Notifications only arrive while the app is open and in the foreground. Push notifications are not delivered when the RosterChirp PWA or Chrome browser loses focus on Android. The app also disconnects from the WebSocket (real-time chat) when backgrounded. Notifications only arrive while the app is open and in the foreground.
### Root Cause ### Root Cause
Android's battery optimization system (Doze mode + App Standby) aggressively kills background network connections for browsers. This affects two things: Android's battery optimization system (Doze mode + App Standby) aggressively kills background network connections for browsers. This affects two things:
1. **WebSocket** — the socket.io connection is dropped when Chrome/PWA loses focus, stopping real-time updates until the user returns to the app. 1. **WebSocket** — the socket.io connection is dropped when Chrome/PWA loses focus, stopping real-time updates until the user returns to the app.
2. **Web Push**jama uses the standard Web Push API with VAPID keys. Android throttles or blocks push delivery at the system level even when battery settings are set to "No restrictions", because that setting only controls the app's own background activity — it does not exempt the browser's push service socket. 2. **Web Push**RosterChirp uses the standard Web Push API with VAPID keys. Android throttles or blocks push delivery at the system level even when battery settings are set to "No restrictions", because that setting only controls the app's own background activity — it does not exempt the browser's push service socket.
Setting Chrome or the PWA to "No battery restrictions" in Android settings does **not** resolve this. Setting Chrome or the PWA to "No battery restrictions" in Android settings does **not** resolve this.
@@ -28,4 +28,4 @@ Firebase Cloud Messaging (FCM) maintains a privileged persistent connection that
5. Address WebSocket reconnect-on-focus separately (frontend only, no Firebase needed) 5. Address WebSocket reconnect-on-focus separately (frontend only, no Firebase needed)
### Workaround for Users ### Workaround for Users
None at the app level. Users who need reliable Android notifications should keep the jama PWA pinned/open, or check their Android vendor's specific battery exemption settings (Samsung DeX, MIUI, etc. have additional per-app exemption controls beyond the standard Android settings). None at the app level. Users who need reliable Android notifications should keep the RosterChirp PWA pinned/open, or check their Android vendor's specific battery exemption settings (Samsung DeX, MIUI, etc. have additional per-app exemption controls beyond the standard Android settings).

View File

@@ -1,5 +1,5 @@
{ {
"name": "jama-backend", "name": "rosterchirp-backend",
"version": "0.11.25", "version": "0.11.25",
"description": "TeamChat backend server", "description": "TeamChat backend server",
"main": "src/index.js", "main": "src/index.js",
@@ -12,13 +12,13 @@
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"firebase-admin": "^12.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nanoid": "^3.3.7", "nanoid": "^3.3.7",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"sharp": "^0.33.2", "sharp": "^0.33.2",
"socket.io": "^4.6.1", "socket.io": "^4.6.1",
"web-push": "^3.6.7",
"csv-parse": "^5.5.6", "csv-parse": "^5.5.6",
"pg": "^8.11.3" "pg": "^8.11.3"
}, },

View File

@@ -41,10 +41,10 @@ app.use('/api/about', require('./routes/about'));
app.use('/api/help', require('./routes/help')); app.use('/api/help', require('./routes/help'));
app.use('/api/push', pushRouter); app.use('/api/push', pushRouter);
// JAMA-HOST control plane — only registered when APP_TYPE=host // RosterChirp-Host control plane — only registered when APP_TYPE=host
if (APP_TYPE === 'host') { if (APP_TYPE === 'host') {
app.use('/api/host', require('./routes/host')); app.use('/api/host', require('./routes/host'));
console.log('[Server] JAMA-HOST control plane enabled at /api/host'); console.log('[Server] RosterChirp-Host control plane enabled at /api/host');
} }
// ── Link preview proxy ──────────────────────────────────────────────────────── // ── Link preview proxy ────────────────────────────────────────────────────────
@@ -67,7 +67,7 @@ app.get('/manifest.json', async (req, res) => {
const s = {}; const s = {};
for (const r of rows) s[r.key] = r.value; for (const r of rows) s[r.key] = r.value;
const appName = s.app_name || process.env.APP_NAME || 'jama'; const appName = s.app_name || process.env.APP_NAME || 'rosterchirp';
const icon192 = s.pwa_icon_192 || '/icons/icon-192.png'; const icon192 = s.pwa_icon_192 || '/icons/icon-192.png';
const icon512 = s.pwa_icon_512 || '/icons/icon-512.png'; const icon512 = s.pwa_icon_512 || '/icons/icon-512.png';
@@ -396,7 +396,7 @@ initDb().then(async () => {
console.warn('[Server] Could not load tenant cache:', e.message); console.warn('[Server] Could not load tenant cache:', e.message);
} }
} }
server.listen(PORT, () => console.log(`[Server] jama listening on port ${PORT}`)); server.listen(PORT, () => console.log(`[Server] RosterChirp listening on port ${PORT}`));
}).catch(err => { }).catch(err => {
console.error('[Server] DB init failed:', err); console.error('[Server] DB init failed:', err);
process.exit(1); process.exit(1);

View File

@@ -1,5 +1,5 @@
/** /**
* db.js — Postgres database layer for jama * db.js — Postgres database layer for rosterchirp
* *
* APP_TYPE environment variable controls tenancy: * APP_TYPE environment variable controls tenancy:
* selfhost (default) → single schema 'public', one Postgres database * selfhost (default) → single schema 'public', one Postgres database
@@ -32,8 +32,8 @@ if (APP_TYPE !== 'host') APP_TYPE = 'selfhost'; // only two valid values
const pool = new Pool({ const pool = new Pool({
host: process.env.DB_HOST || 'db', host: process.env.DB_HOST || 'db',
port: parseInt(process.env.DB_PORT || '5432'), port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'jama', database: process.env.DB_NAME || 'rosterchirp',
user: process.env.DB_USER || 'jama', user: process.env.DB_USER || 'rosterchirp',
password: process.env.DB_PASSWORD || '', password: process.env.DB_PASSWORD || '',
max: 20, max: 20,
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
@@ -52,12 +52,12 @@ function resolveSchema(req) {
if (APP_TYPE === 'selfhost') return 'public'; if (APP_TYPE === 'selfhost') return 'public';
const host = (req.headers.host || '').toLowerCase().split(':')[0]; const host = (req.headers.host || '').toLowerCase().split(':')[0];
const baseDomain = (process.env.HOST_DOMAIN || 'jamachat.com').toLowerCase(); const baseDomain = (process.env.HOST_DOMAIN || 'rosterchirp.com').toLowerCase();
// Internal requests (Docker health checks, localhost) → public schema // Internal requests (Docker health checks, localhost) → public schema
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return 'public'; if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return 'public';
// Subdomain: team1.jamachat.com → tenant_team1 // Subdomain: team1.rosterchirp.com → tenant_team1
if (host.endsWith(`.${baseDomain}`)) { if (host.endsWith(`.${baseDomain}`)) {
const slug = host.slice(0, -(baseDomain.length + 1)); const slug = host.slice(0, -(baseDomain.length + 1));
if (!slug || slug === 'www') throw new Error(`Invalid tenant slug: ${slug}`); if (!slug || slug === 'www') throw new Error(`Invalid tenant slug: ${slug}`);
@@ -198,7 +198,7 @@ async function runMigrations(schema) {
async function seedSettings(schema) { async function seedSettings(schema) {
const defaults = [ const defaults = [
['app_name', process.env.APP_NAME || 'jama'], ['app_name', process.env.APP_NAME || 'rosterchirp'],
['logo_url', ''], ['logo_url', ''],
['pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false'], ['pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false'],
['icon_newchat', ''], ['icon_newchat', ''],
@@ -213,7 +213,7 @@ async function seedSettings(schema) {
['feature_branding', 'false'], ['feature_branding', 'false'],
['feature_group_manager', 'false'], ['feature_group_manager', 'false'],
['feature_schedule_manager', 'false'], ['feature_schedule_manager', 'false'],
['app_type', 'JAMA-Chat'], ['app_type', 'RosterChirp-Chat'],
['team_group_managers', ''], ['team_group_managers', ''],
['team_schedule_managers', ''], ['team_schedule_managers', ''],
['team_tool_managers', ''], ['team_tool_managers', ''],
@@ -269,7 +269,7 @@ async function seedUserGroups(schema) {
async function seedAdmin(schema) { async function seedAdmin(schema) {
const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim(); const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim();
const adminEmail = strip(process.env.ADMIN_EMAIL) || 'admin@jama.local'; const adminEmail = strip(process.env.ADMIN_EMAIL) || 'admin@rosterchirp.local';
const adminName = strip(process.env.ADMIN_NAME) || 'Admin User'; const adminName = strip(process.env.ADMIN_NAME) || 'Admin User';
const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234'; const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234';
const pwReset = process.env.ADMPW_RESET === 'true'; const pwReset = process.env.ADMPW_RESET === 'true';
@@ -350,11 +350,11 @@ async function initDb() {
await seedAdmin('public'); await seedAdmin('public');
await seedUserGroups('public'); await seedUserGroups('public');
// Host mode: the public schema is the host's own workspace — always full JAMA-Team plan. // Host mode: the public schema is the host's own workspace — always full RosterChirp-Team plan.
// ON CONFLICT DO UPDATE ensures existing installs get corrected on restart too. // ON CONFLICT DO UPDATE ensures existing installs get corrected on restart too.
if (APP_TYPE === 'host') { if (APP_TYPE === 'host') {
const hostPlan = [ const hostPlan = [
['app_type', 'JAMA-Team'], ['app_type', 'RosterChirp-Team'],
['feature_branding', 'true'], ['feature_branding', 'true'],
['feature_group_manager', 'true'], ['feature_group_manager', 'true'],
['feature_schedule_manager', 'true'], ['feature_schedule_manager', 'true'],
@@ -365,7 +365,7 @@ async function initDb() {
[key, value] [key, value]
); );
} }
console.log('[DB] Host mode: public schema upgraded to JAMA-Team plan'); console.log('[DB] Host mode: public schema upgraded to RosterChirp-Team plan');
} }
console.log('[DB] Initialisation complete'); console.log('[DB] Initialisation complete');

View File

@@ -0,0 +1,5 @@
-- Migration 007: FCM push — add fcm_token column, relax NOT NULL on legacy web-push columns
ALTER TABLE push_subscriptions ADD COLUMN IF NOT EXISTS fcm_token TEXT;
ALTER TABLE push_subscriptions ALTER COLUMN endpoint DROP NOT NULL;
ALTER TABLE push_subscriptions ALTER COLUMN p256dh DROP NOT NULL;
ALTER TABLE push_subscriptions ALTER COLUMN auth DROP NOT NULL;

View File

@@ -0,0 +1,4 @@
-- Migration 008: Rebrand — update app_type values from JAMA-* to RosterChirp-*
UPDATE settings SET value = 'RosterChirp-Chat' WHERE key = 'app_type' AND value = 'JAMA-Chat';
UPDATE settings SET value = 'RosterChirp-Brand' WHERE key = 'app_type' AND value = 'JAMA-Brand';
UPDATE settings SET value = 'RosterChirp-Team' WHERE key = 'app_type' AND value = 'JAMA-Team';

View File

@@ -28,10 +28,10 @@ router.get('/', (req, res) => {
const about = { const about = {
...DEFAULTS, ...DEFAULTS,
...overrides, ...overrides,
version: process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev', version: process.env.ROSTERCHIRP_VERSION || process.env.TEAMCHAT_VERSION || 'dev',
// Always expose original app identity — not overrideable via about.json or settings // Always expose original app identity — not overrideable via about.json or settings
default_app_name: 'jama', default_app_name: 'rosterchirp',
default_logo: '/icons/jama.png', default_logo: '/icons/rosterchirp.png',
}; };
// Never expose docker_image — removed from UI // Never expose docker_image — removed from UI

View File

@@ -1,5 +1,5 @@
/** /**
* routes/host.js — JAMA-HOST control plane * routes/host.js — RosterChirp-Host control plane
* *
* All routes require the HOST_ADMIN_KEY header. * All routes require the HOST_ADMIN_KEY header.
* These routes operate on the 'public' schema (tenant registry). * These routes operate on the 'public' schema (tenant registry).
@@ -141,7 +141,7 @@ router.post('/tenants', async (req, res) => {
process.env.ADMIN_PASS = origPass; process.env.ADMIN_PASS = origPass;
// 5. Set app_type based on plan // 5. Set app_type based on plan
const planAppType = { chat: 'JAMA-Chat', brand: 'JAMA-Brand', team: 'JAMA-Team' }[plan] || 'JAMA-Chat'; const planAppType = { chat: 'RosterChirp-Chat', brand: 'RosterChirp-Brand', team: 'RosterChirp-Team' }[plan] || 'RosterChirp-Chat';
await exec(schemaName, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]); await exec(schemaName, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
if (plan === 'brand' || plan === 'team') { if (plan === 'brand' || plan === 'team') {
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_branding'"); await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_branding'");
@@ -161,7 +161,7 @@ router.post('/tenants', async (req, res) => {
// 7. Reload domain cache // 7. Reload domain cache
await reloadTenantCache(); await reloadTenantCache();
const baseDomain = process.env.HOST_DOMAIN || 'jamachat.com'; const baseDomain = process.env.HOST_DOMAIN || 'rosterchirp.com';
const tenant = tr.rows[0]; const tenant = tr.rows[0];
tenant.url = `https://${slug}.${baseDomain}`; tenant.url = `https://${slug}.${baseDomain}`;
@@ -220,7 +220,7 @@ router.patch('/tenants/:slug', async (req, res) => {
await exec(s, "UPDATE settings SET value=CASE WHEN $1 IN ('brand','team') THEN 'true' ELSE 'false' END WHERE key='feature_branding'", [plan]); await exec(s, "UPDATE settings SET value=CASE WHEN $1 IN ('brand','team') THEN 'true' ELSE 'false' END WHERE key='feature_branding'", [plan]);
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_group_manager'", [plan]); await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_group_manager'", [plan]);
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_schedule_manager'", [plan]); await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_schedule_manager'", [plan]);
const planAppType = { chat: 'JAMA-Chat', brand: 'JAMA-Brand', team: 'JAMA-Team' }[plan] || 'JAMA-Chat'; const planAppType = { chat: 'RosterChirp-Chat', brand: 'RosterChirp-Brand', team: 'RosterChirp-Team' }[plan] || 'RosterChirp-Chat';
await exec(s, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]); await exec(s, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
} }
@@ -310,7 +310,7 @@ router.get('/status', async (req, res) => {
try { try {
const tenantCount = await queryOne('public', 'SELECT COUNT(*) AS count FROM tenants'); const tenantCount = await queryOne('public', 'SELECT COUNT(*) AS count FROM tenants');
const active = await queryOne('public', "SELECT COUNT(*) AS count FROM tenants WHERE status='active'"); const active = await queryOne('public', "SELECT COUNT(*) AS count FROM tenants WHERE status='active'");
const baseDomain = process.env.HOST_DOMAIN || 'jamachat.com'; const baseDomain = process.env.HOST_DOMAIN || 'rosterchirp.com';
res.json({ res.json({
ok: true, ok: true,
appType: process.env.APP_TYPE || 'selfhost', appType: process.env.APP_TYPE || 'selfhost',

View File

@@ -1,50 +1,62 @@
const express = require('express'); const express = require('express');
const webpush = require('web-push'); const router = express.Router();
const router = express.Router(); const { query, queryOne, exec } = require('../models/db');
const { query, queryOne, queryResult, exec } = require('../models/db');
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware } = require('../middleware/auth');
// VAPID keys are stored in settings; lazily initialised on first request // ── Firebase Admin ─────────────────────────────────────────────────────────────
let vapidPublicKey = null; let firebaseAdmin = null;
let firebaseApp = null;
async function getVapidKeys(schema) { function getMessaging() {
const pub = await queryOne(schema, "SELECT value FROM settings WHERE key = 'vapid_public'"); if (firebaseApp) return firebaseAdmin.messaging(firebaseApp);
const priv = await queryOne(schema, "SELECT value FROM settings WHERE key = 'vapid_private'"); const json = process.env.FIREBASE_SERVICE_ACCOUNT;
if (!pub?.value || !priv?.value) { if (!json) return null;
const keys = webpush.generateVAPIDKeys();
await exec(schema,
"INSERT INTO settings (key,value) VALUES ('vapid_public',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
[keys.publicKey]
);
await exec(schema,
"INSERT INTO settings (key,value) VALUES ('vapid_private',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
[keys.privateKey]
);
console.log('[Push] Generated new VAPID keys');
return keys;
}
return { publicKey: pub.value, privateKey: priv.value };
}
async function initWebPush(schema) {
const keys = await getVapidKeys(schema);
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
return keys.publicKey;
}
// Called from index.js socket push notifications — schema comes from caller
async function sendPushToUser(schema, userId, payload) {
try { try {
if (!vapidPublicKey) vapidPublicKey = await initWebPush(schema); firebaseAdmin = require('firebase-admin');
const subs = await query(schema, 'SELECT * FROM push_subscriptions WHERE user_id = $1', [userId]); const svc = JSON.parse(json);
firebaseApp = firebaseAdmin.initializeApp({
credential: firebaseAdmin.credential.cert(svc),
});
console.log('[Push] Firebase Admin initialised');
return firebaseAdmin.messaging(firebaseApp);
} catch (e) {
console.error('[Push] Firebase Admin init failed:', e.message);
return null;
}
}
// ── Helpers ────────────────────────────────────────────────────────────────────
// Called from index.js socket push notifications
async function sendPushToUser(schema, userId, payload) {
const messaging = getMessaging();
if (!messaging) return;
try {
const subs = await query(schema,
'SELECT * FROM push_subscriptions WHERE user_id = $1 AND fcm_token IS NOT NULL',
[userId]
);
for (const sub of subs) { for (const sub of subs) {
try { try {
await webpush.sendNotification( await messaging.send({
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, token: sub.fcm_token,
JSON.stringify(payload) data: {
); title: payload.title || 'New Message',
body: payload.body || '',
url: payload.url || '/',
groupId: payload.groupId ? String(payload.groupId) : '',
},
android: { priority: 'high' },
webpush: { headers: { Urgency: 'high' } },
});
} catch (err) { } catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) { // Remove stale tokens
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]); await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
} }
} }
@@ -54,56 +66,47 @@ async function sendPushToUser(schema, userId, payload) {
} }
} }
router.get('/vapid-public', async (req, res) => { // ── Routes ─────────────────────────────────────────────────────────────────────
try {
if (!vapidPublicKey) vapidPublicKey = await initWebPush(req.schema); // Public — frontend fetches this to initialise the Firebase JS SDK
res.json({ publicKey: vapidPublicKey }); router.get('/firebase-config', (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); } const apiKey = process.env.FIREBASE_API_KEY;
const projectId = process.env.FIREBASE_PROJECT_ID;
const messagingSenderId = process.env.FIREBASE_MESSAGING_SENDER_ID;
const appId = process.env.FIREBASE_APP_ID;
const vapidKey = process.env.FIREBASE_VAPID_KEY;
if (!apiKey || !projectId || !messagingSenderId || !appId) {
return res.status(503).json({ error: 'FCM not configured' });
}
res.json({ apiKey, projectId, messagingSenderId, appId, vapidKey });
}); });
// Register / refresh an FCM token for the logged-in user
router.post('/subscribe', authMiddleware, async (req, res) => { router.post('/subscribe', authMiddleware, async (req, res) => {
const { endpoint, keys } = req.body; const { fcmToken } = req.body;
if (!endpoint || !keys?.p256dh || !keys?.auth) if (!fcmToken) return res.status(400).json({ error: 'fcmToken required' });
return res.status(400).json({ error: 'Invalid subscription' });
try { try {
const device = req.device || 'desktop'; const device = req.device || 'desktop';
await exec(req.schema, await exec(req.schema,
'DELETE FROM push_subscriptions WHERE endpoint = $1 OR (user_id = $2 AND device = $3)', 'DELETE FROM push_subscriptions WHERE user_id = $1 AND device = $2',
[endpoint, req.user.id, device] [req.user.id, device]
); );
await exec(req.schema, await exec(req.schema,
'INSERT INTO push_subscriptions (user_id, device, endpoint, p256dh, auth) VALUES ($1,$2,$3,$4,$5)', 'INSERT INTO push_subscriptions (user_id, device, fcm_token) VALUES ($1, $2, $3)',
[req.user.id, device, endpoint, keys.p256dh, keys.auth] [req.user.id, device, fcmToken]
); );
res.json({ success: true }); res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
router.post('/generate-vapid', authMiddleware, async (req, res) => { // Remove the FCM token for the logged-in user / device
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admins only' });
try {
const keys = webpush.generateVAPIDKeys();
await exec(req.schema,
"INSERT INTO settings (key,value) VALUES ('vapid_public',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
[keys.publicKey]
);
await exec(req.schema,
"INSERT INTO settings (key,value) VALUES ('vapid_private',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
[keys.privateKey]
);
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
vapidPublicKey = keys.publicKey;
res.json({ publicKey: keys.publicKey });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/unsubscribe', authMiddleware, async (req, res) => { router.post('/unsubscribe', authMiddleware, async (req, res) => {
const { endpoint } = req.body;
if (!endpoint) return res.status(400).json({ error: 'Endpoint required' });
try { try {
const device = req.device || 'desktop';
await exec(req.schema, await exec(req.schema,
'DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2', 'DELETE FROM push_subscriptions WHERE user_id = $1 AND device = $2',
[req.user.id, endpoint] [req.user.id, device]
); );
res.json({ success: true }); res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }

View File

@@ -37,7 +37,7 @@ router.get('/', async (req, res) => {
for (const r of rows) obj[r.key] = r.value; for (const r of rows) obj[r.key] = r.value;
const admin = await queryOne(req.schema, 'SELECT email FROM users WHERE is_default_admin = TRUE'); const admin = await queryOne(req.schema, 'SELECT email FROM users WHERE is_default_admin = TRUE');
if (admin) obj.admin_email = admin.email; if (admin) obj.admin_email = admin.email;
obj.app_version = process.env.JAMA_VERSION || 'dev'; obj.app_version = process.env.ROSTERCHIRP_VERSION || 'dev';
obj.user_pass = process.env.USER_PASS || 'user@1234'; obj.user_pass = process.env.USER_PASS || 'user@1234';
// Tell the frontend whether this request came from the HOST_DOMAIN. // Tell the frontend whether this request came from the HOST_DOMAIN.
// Used to show/hide the Control Panel menu item — only visible on the host's own domain. // Used to show/hide the Control Panel menu item — only visible on the host's own domain.
@@ -105,7 +105,7 @@ router.patch('/colors', authMiddleware, adminMiddleware, async (req, res) => {
router.post('/reset', authMiddleware, adminMiddleware, async (req, res) => { router.post('/reset', authMiddleware, adminMiddleware, async (req, res) => {
try { try {
const originalName = process.env.APP_NAME || 'jama'; const originalName = process.env.APP_NAME || 'rosterchirp';
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='app_name'", [originalName]); await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='app_name'", [originalName]);
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key='logo_url'"); await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key='logo_url'");
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key IN ('icon_newchat','icon_groupinfo','pwa_icon_192','pwa_icon_512','color_title','color_title_dark','color_avatar_public','color_avatar_dm')"); await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key IN ('icon_newchat','icon_groupinfo','pwa_icon_192','pwa_icon_512','color_title','color_title_dark','color_avatar_public','color_avatar_dm')");
@@ -114,9 +114,9 @@ router.post('/reset', authMiddleware, adminMiddleware, async (req, res) => {
}); });
const VALID_CODES = { const VALID_CODES = {
'JAMA-TEAM-2024': { appType:'JAMA-Team', branding:true, groupManager:true, scheduleManager:true }, 'ROSTERCHIRP-TEAM-2024': { appType:'RosterChirp-Team', branding:true, groupManager:true, scheduleManager:true },
'JAMA-BRAND-2024': { appType:'JAMA-Brand', branding:true, groupManager:false, scheduleManager:false }, 'ROSTERCHIRP-BRAND-2024': { appType:'RosterChirp-Brand', branding:true, groupManager:false, scheduleManager:false },
'JAMA-FULL-2024': { appType:'JAMA-Team', branding:true, groupManager:true, scheduleManager:true }, 'ROSTERCHIRP-FULL-2024': { appType:'RosterChirp-Team', branding:true, groupManager:true, scheduleManager:true },
}; };
router.post('/register', authMiddleware, adminMiddleware, async (req, res) => { router.post('/register', authMiddleware, adminMiddleware, async (req, res) => {
@@ -124,11 +124,11 @@ router.post('/register', authMiddleware, adminMiddleware, async (req, res) => {
try { try {
if (!code?.trim()) { if (!code?.trim()) {
await setSetting(req.schema, 'registration_code', ''); await setSetting(req.schema, 'registration_code', '');
await setSetting(req.schema, 'app_type', 'JAMA-Chat'); await setSetting(req.schema, 'app_type', 'RosterChirp-Chat');
await setSetting(req.schema, 'feature_branding', 'false'); await setSetting(req.schema, 'feature_branding', 'false');
await setSetting(req.schema, 'feature_group_manager', 'false'); await setSetting(req.schema, 'feature_group_manager', 'false');
await setSetting(req.schema, 'feature_schedule_manager', 'false'); await setSetting(req.schema, 'feature_schedule_manager', 'false');
return res.json({ success:true, features:{branding:false,groupManager:false,scheduleManager:false,appType:'JAMA-Chat'} }); return res.json({ success:true, features:{branding:false,groupManager:false,scheduleManager:false,appType:'RosterChirp-Chat'} });
} }
const match = VALID_CODES[code.trim().toUpperCase()]; const match = VALID_CODES[code.trim().toUpperCase()];
if (!match) return res.status(400).json({ error: 'Invalid registration code' }); if (!match) return res.status(400).json({ error: 'Invalid registration code' });

View File

@@ -7,7 +7,7 @@ async function getLinkPreview(url) {
const res = await fetch(url, { const res = await fetch(url, {
signal: controller.signal, signal: controller.signal,
headers: { 'User-Agent': 'JamaBot/1.0' } headers: { 'User-Agent': 'RosterChirpBot/1.0' }
}); });
clearTimeout(timeout); clearTimeout(timeout);

View File

@@ -1,10 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# jama — Docker build & release script # rosterchirp — Docker build & release script
# #
# Usage: # Usage:
# ./build.sh # builds jama:latest # ./build.sh # builds rosterchirp:latest
# ./build.sh 1.2.0 # builds jama:1.2.0 AND jama:latest # ./build.sh 1.2.0 # builds rosterchirp:1.2.0 AND rosterchirp:latest
# ./build.sh 1.2.0 push # builds, tags, and pushes to registry # ./build.sh 1.2.0 push # builds, tags, and pushes to registry
# #
# To push to a registry, set REGISTRY env var: # To push to a registry, set REGISTRY env var:
@@ -13,10 +13,10 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
VERSION="${1:-0.11.26}" VERSION="${1:-0.12.0}"
ACTION="${2:-}" ACTION="${2:-}"
REGISTRY="${REGISTRY:-}" REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama" IMAGE_NAME="rosterchirp"
# If a registry is set, prefix image name # If a registry is set, prefix image name
if [[ -n "$REGISTRY" ]]; then if [[ -n "$REGISTRY" ]]; then
@@ -26,7 +26,7 @@ else
fi fi
echo "╔══════════════════════════════════════╗" echo "╔══════════════════════════════════════╗"
echo "║ jama Docker Builder ║" echo "║ rosterchirp Docker Builder ║"
echo "╠══════════════════════════════════════╣" echo "╠══════════════════════════════════════╣"
echo "║ Image : ${FULL_IMAGE}" echo "║ Image : ${FULL_IMAGE}"
echo "║ Version : ${VERSION}" echo "║ Version : ${VERSION}"
@@ -67,7 +67,7 @@ fi
echo "" echo ""
echo "─────────────────────────────────────────" echo "─────────────────────────────────────────"
echo "To deploy this version, set in your .env:" echo "To deploy this version, set in your .env:"
echo " JAMA_VERSION=${VERSION}" echo " ROSTERCHIRP_VERSION=${VERSION}"
echo "" echo ""
echo "Then run:" echo "Then run:"
echo " docker compose up -d" echo " docker compose up -d"

View File

@@ -1,6 +1,6 @@
# docker-compose.host.yaml — JAMA-HOST multi-tenant deployment # docker-compose.host.yaml — RosterChirp-Host multi-tenant deployment
# #
# Use this instead of docker-compose.yaml when running JAMA-HOST. # Use this instead of docker-compose.yaml when running RosterChirp-Host.
# Adds Caddy as the reverse proxy for automatic wildcard SSL. # Adds Caddy as the reverse proxy for automatic wildcard SSL.
# #
# Usage: # Usage:
@@ -8,14 +8,14 @@
# #
# Required .env additions for host mode: # Required .env additions for host mode:
# APP_TYPE=host # APP_TYPE=host
# HOST_DOMAIN=jamachat.com # HOST_DOMAIN=rosterchirp.com
# HOST_ADMIN_KEY=your_secret_host_admin_key # HOST_ADMIN_KEY=your_secret_host_admin_key
# CF_API_TOKEN=your_cloudflare_dns_api_token (or equivalent for your DNS provider) # CF_API_TOKEN=your_cloudflare_dns_api_token (or equivalent for your DNS provider)
services: services:
jama: rosterchirp:
image: jama:${JAMA_VERSION:-latest} image: rosterchirp:${ROSTERCHIRP_VERSION:-latest}
container_name: ${PROJECT_NAME:-jama} container_name: ${PROJECT_NAME:-rosterchirp}
restart: unless-stopped restart: unless-stopped
# No direct port exposure — traffic comes through Caddy # No direct port exposure — traffic comes through Caddy
expose: expose:
@@ -25,21 +25,21 @@ services:
- TZ=${TZ:-UTC} - TZ=${TZ:-UTC}
- APP_TYPE=host - APP_TYPE=host
- ADMIN_NAME=${ADMIN_NAME:-Admin User} - ADMIN_NAME=${ADMIN_NAME:-Admin User}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@rosterchirp.local}
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234} - ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
- ADMPW_RESET=${ADMPW_RESET:-false} - ADMPW_RESET=${ADMPW_RESET:-false}
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required} - JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
- APP_NAME=${APP_NAME:-jama} - APP_NAME=${APP_NAME:-rosterchirp}
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat} - DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
- DB_HOST=db - DB_HOST=db
- DB_PORT=5432 - DB_PORT=5432
- DB_NAME=${DB_NAME:-jama} - DB_NAME=${DB_NAME:-rosterchirp}
- DB_USER=${DB_USER:-jama} - DB_USER=${DB_USER:-rosterchirp}
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required} - DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
- HOST_DOMAIN=${HOST_DOMAIN:?HOST_DOMAIN is required in host mode} - HOST_DOMAIN=${HOST_DOMAIN:?HOST_DOMAIN is required in host mode}
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:?HOST_ADMIN_KEY is required in host mode} - HOST_ADMIN_KEY=${HOST_ADMIN_KEY:?HOST_ADMIN_KEY is required in host mode}
volumes: volumes:
- jama_uploads:/app/uploads - rosterchirp_uploads:/app/uploads
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -51,16 +51,16 @@ services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
container_name: ${PROJECT_NAME:-jama}_db container_name: ${PROJECT_NAME:-rosterchirp}_db
restart: unless-stopped restart: unless-stopped
environment: environment:
- POSTGRES_DB=${DB_NAME:-jama} - POSTGRES_DB=${DB_NAME:-rosterchirp}
- POSTGRES_USER=${DB_USER:-jama} - POSTGRES_USER=${DB_USER:-rosterchirp}
- POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required} - POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
volumes: volumes:
- jama_db:/var/lib/postgresql/data - rosterchirp_db:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-jama} -d ${DB_NAME:-jama}"] test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-rosterchirp} -d ${DB_NAME:-rosterchirp}"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -70,7 +70,7 @@ services:
# Pre-built images: https://github.com/abiosoft/caddy-docker # Pre-built images: https://github.com/abiosoft/caddy-docker
# Or build your own: xcaddy build --with github.com/caddy-dns/cloudflare # Or build your own: xcaddy build --with github.com/caddy-dns/cloudflare
image: caddy:2-alpine image: caddy:2-alpine
container_name: ${PROJECT_NAME:-jama}_caddy container_name: ${PROJECT_NAME:-rosterchirp}_caddy
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:80" - "80:80"
@@ -84,12 +84,12 @@ services:
- caddy_config:/config - caddy_config:/config
- /var/log/caddy:/var/log/caddy - /var/log/caddy:/var/log/caddy
depends_on: depends_on:
- jama - rosterchirp
volumes: volumes:
jama_db: rosterchirp_db:
driver: local driver: local
jama_uploads: rosterchirp_uploads:
driver: local driver: local
caddy_data: caddy_data:
driver: local driver: local

View File

@@ -1,7 +1,7 @@
services: services:
jama: rosterchirp:
image: jama:${JAMA_VERSION:-latest} image: rosterchirp:${ROSTERCHIRP_VERSION:-latest}
container_name: ${PROJECT_NAME:-jama} container_name: ${PROJECT_NAME:-rosterchirp}
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${PORT:-3000}:3000" - "${PORT:-3000}:3000"
@@ -10,21 +10,21 @@ services:
- TZ=${TZ:-UTC} - TZ=${TZ:-UTC}
- APP_TYPE=${APP_TYPE:-selfhost} - APP_TYPE=${APP_TYPE:-selfhost}
- ADMIN_NAME=${ADMIN_NAME:-Admin User} - ADMIN_NAME=${ADMIN_NAME:-Admin User}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@rosterchirp.local}
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234} - ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
- ADMPW_RESET=${ADMPW_RESET:-false} - ADMPW_RESET=${ADMPW_RESET:-false}
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024} - JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
- APP_NAME=${APP_NAME:-jama} - APP_NAME=${APP_NAME:-rosterchirp}
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat} - DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
- DB_HOST=db - DB_HOST=db
- DB_PORT=5432 - DB_PORT=5432
- DB_NAME=${DB_NAME:-jama} - DB_NAME=${DB_NAME:-rosterchirp}
- DB_USER=${DB_USER:-jama} - DB_USER=${DB_USER:-rosterchirp}
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required} - DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
- HOST_DOMAIN=${HOST_DOMAIN:-} - HOST_DOMAIN=${HOST_DOMAIN:-}
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:-} - HOST_ADMIN_KEY=${HOST_ADMIN_KEY:-}
volumes: volumes:
- jama_uploads:/app/uploads - rosterchirp_uploads:/app/uploads
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -36,22 +36,22 @@ services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
container_name: ${PROJECT_NAME:-jama}_db container_name: ${PROJECT_NAME:-rosterchirp}_db
restart: unless-stopped restart: unless-stopped
environment: environment:
- POSTGRES_DB=${DB_NAME:-jama} - POSTGRES_DB=${DB_NAME:-rosterchirp}
- POSTGRES_USER=${DB_USER:-jama} - POSTGRES_USER=${DB_USER:-rosterchirp}
- POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required} - POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
volumes: volumes:
- jama_db:/var/lib/postgresql/data - rosterchirp_db:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-jama} -d ${DB_NAME:-jama}"] test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-rosterchirp} -d ${DB_NAME:-rosterchirp}"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
volumes: volumes:
jama_db: rosterchirp_db:
driver: local driver: local
jama_uploads: rosterchirp_uploads:
driver: local driver: local

View File

@@ -2,13 +2,13 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/icons/jama.png" /> <link rel="icon" type="image/png" href="/icons/rosterchirp.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="#1a73e8" /> <meta name="theme-color" content="#1a73e8" />
<meta name="description" content="jama - just another messaging app" /> <meta name="description" content="RosterChirp - team messaging" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" /> <link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>jama</title> <title>RosterChirp</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,5 +1,5 @@
{ {
"name": "jama-frontend", "name": "rosterchirp-frontend",
"version": "0.11.25", "version": "0.11.25",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -17,7 +17,8 @@
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"date-fns": "^3.3.1", "date-fns": "^3.3.1",
"marked": "^12.0.0" "marked": "^12.0.0",
"firebase": "^10.14.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "jama", "name": "RosterChirp",
"short_name": "jama", "short_name": "RosterChirp",
"description": "Modern team messaging application", "description": "Modern team messaging application",
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",

View File

@@ -1,4 +1,27 @@
const CACHE_NAME = 'jama-v1'; // ── Firebase Messaging (background push for Android PWA) ──────────────────────
// Fill in the values below from Firebase Console → Project Settings → General → Your apps
// Leave apiKey as '__FIREBASE_API_KEY__' if not using FCM (push will be disabled).
importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js');
const FIREBASE_CONFIG = {
apiKey: "AIzaSyDx191unzXFT4WA1OvkdbrIY_c57kgruAU",
authDomain: "rosterchirp-push.firebaseapp.com",
projectId: "rosterchirp-push",
storageBucket: "rosterchirp-push.firebasestorage.app",
messagingSenderId: "126479377334",
appId: "1:126479377334:web:280abdd135cf7e0c50d717"
};
// Only initialise Firebase if the config has been filled in
let messaging = null;
if (FIREBASE_CONFIG.apiKey !== '__FIREBASE_API_KEY__') {
firebase.initializeApp(FIREBASE_CONFIG);
messaging = firebase.messaging();
}
// ── Cache ─────────────────────────────────────────────────────────────────────
const CACHE_NAME = 'rosterchirp-v1';
const STATIC_ASSETS = ['/']; const STATIC_ASSETS = ['/'];
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
@@ -27,53 +50,35 @@ self.addEventListener('fetch', (event) => {
); );
}); });
// Track badge count in SW scope // ── Badge counter ─────────────────────────────────────────────────────────────
let badgeCount = 0; let badgeCount = 0;
self.addEventListener('push', (event) => { function showRosterChirpNotification(data) {
if (!event.data) return;
let data = {};
try { data = event.data.json(); } catch (e) { return; }
badgeCount++; badgeCount++;
if (self.navigator?.setAppBadge) self.navigator.setAppBadge(badgeCount).catch(() => {});
// Update app badge return self.registration.showNotification(data.title || 'New Message', {
if (self.navigator && self.navigator.setAppBadge) { body: data.body || '',
self.navigator.setAppBadge(badgeCount).catch(() => {}); icon: '/icons/icon-192.png',
} badge: '/icons/icon-192-maskable.png',
data: { url: data.url || '/' },
// Check if app is currently visible — if so, skip the notification tag: data.groupId ? `rosterchirp-group-${data.groupId}` : 'rosterchirp-message',
const showNotification = clients.matchAll({ renotify: true,
type: 'window',
includeUncontrolled: true,
}).then((clientList) => {
const appVisible = clientList.some(
(c) => c.visibilityState === 'visible'
);
// Still show if app is open but hidden (minimized), skip only if truly visible
if (appVisible) return;
return self.registration.showNotification(data.title || 'New Message', {
body: data.body || '',
icon: '/icons/icon-192.png',
badge: '/icons/icon-192-maskable.png',
data: { url: data.url || '/' },
// Use unique tag per group so notifications group by conversation
tag: data.groupId ? `jama-group-${data.groupId}` : 'jama-message',
renotify: true,
});
}); });
}
event.waitUntil(showNotification); // ── FCM background messages ───────────────────────────────────────────────────
}); if (messaging) {
messaging.onBackgroundMessage((payload) => {
return showRosterChirpNotification(payload.data || {});
});
}
// ── Notification click ────────────────────────────────────────────────────────
self.addEventListener('notificationclick', (event) => { self.addEventListener('notificationclick', (event) => {
event.notification.close(); event.notification.close();
badgeCount = 0; badgeCount = 0;
if (self.navigator && self.navigator.clearAppBadge) { if (self.navigator?.clearAppBadge) self.navigator.clearAppBadge().catch(() => {});
self.navigator.clearAppBadge().catch(() => {});
}
event.waitUntil( event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
const url = event.notification.data?.url || '/'; const url = event.notification.data?.url || '/';
@@ -88,17 +93,15 @@ self.addEventListener('notificationclick', (event) => {
); );
}); });
// Clear badge when app signals it // ── Badge control messages from main thread ───────────────────────────────────
self.addEventListener('message', (event) => { self.addEventListener('message', (event) => {
if (event.data?.type === 'CLEAR_BADGE') { if (event.data?.type === 'CLEAR_BADGE') {
badgeCount = 0; badgeCount = 0;
if (self.navigator && self.navigator.clearAppBadge) { if (self.navigator?.clearAppBadge) self.navigator.clearAppBadge().catch(() => {});
self.navigator.clearAppBadge().catch(() => {});
}
} }
if (event.data?.type === 'SET_BADGE') { if (event.data?.type === 'SET_BADGE') {
badgeCount = event.data.count || 0; badgeCount = event.data.count || 0;
if (self.navigator && self.navigator.setAppBadge) { if (self.navigator?.setAppBadge) {
if (badgeCount > 0) { if (badgeCount > 0) {
self.navigator.setAppBadge(badgeCount).catch(() => {}); self.navigator.setAppBadge(badgeCount).catch(() => {});
} else { } else {

View File

@@ -27,7 +27,7 @@ function AuthRoute({ children }) {
} }
function RestoreTheme() { function RestoreTheme() {
const saved = localStorage.getItem('jama-theme') || 'light'; const saved = localStorage.getItem('rosterchirp-theme') || 'light';
document.documentElement.setAttribute('data-theme', saved); document.documentElement.setAttribute('data-theme', saved);
return null; return null;
} }

View File

@@ -32,8 +32,8 @@ export default function AboutModal({ onClose }) {
}, []); }, []);
// Always use the original app identity — not the user-customised settings name/logo // Always use the original app identity — not the user-customised settings name/logo
const appName = about?.default_app_name || 'jama'; const appName = about?.default_app_name || 'rosterchirp';
const logoSrc = about?.default_logo || '/icons/jama.png'; const logoSrc = about?.default_logo || '/icons/rosterchirp.png';
const version = about?.version || ''; const version = about?.version || '';
const a = about || {}; const a = about || {};

View File

@@ -321,7 +321,7 @@ export default function BrandingModal({ onClose }) {
useEffect(() => { useEffect(() => {
api.getSettings().then(({ settings }) => { api.getSettings().then(({ settings }) => {
setSettings(settings); setSettings(settings);
setAppName(settings.app_name || 'jama'); setAppName(settings.app_name || 'rosterchirp');
setColourTitle(settings.color_title || DEFAULT_TITLE_COLOR); setColourTitle(settings.color_title || DEFAULT_TITLE_COLOR);
setColourTitleDark(settings.color_title_dark || DEFAULT_TITLE_DARK_COLOR); setColourTitleDark(settings.color_title_dark || DEFAULT_TITLE_DARK_COLOR);
setColourPublic(settings.color_avatar_public || DEFAULT_PUBLIC_COLOR); setColourPublic(settings.color_avatar_public || DEFAULT_PUBLIC_COLOR);
@@ -329,7 +329,7 @@ export default function BrandingModal({ onClose }) {
}).catch(() => {}); }).catch(() => {});
}, []); }, []);
const notifySidebarRefresh = () => window.dispatchEvent(new Event('jama:settings-changed')); const notifySidebarRefresh = () => window.dispatchEvent(new Event('rosterchirp:settings-changed'));
const handleSaveName = async () => { const handleSaveName = async () => {
if (!appName.trim()) return; if (!appName.trim()) return;
@@ -391,7 +391,7 @@ export default function BrandingModal({ onClose }) {
await api.resetSettings(); await api.resetSettings();
const { settings: fresh } = await api.getSettings(); const { settings: fresh } = await api.getSettings();
setSettings(fresh); setSettings(fresh);
setAppName(fresh.app_name || 'jama'); setAppName(fresh.app_name || 'rosterchirp');
setColourTitle(DEFAULT_TITLE_COLOR); setColourTitle(DEFAULT_TITLE_COLOR);
setColourTitleDark(DEFAULT_TITLE_DARK_COLOR); setColourTitleDark(DEFAULT_TITLE_DARK_COLOR);
setColourPublic(DEFAULT_PUBLIC_COLOR); setColourPublic(DEFAULT_PUBLIC_COLOR);
@@ -433,7 +433,7 @@ export default function BrandingModal({ onClose }) {
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex', border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0 alignItems: 'center', justifyContent: 'center', flexShrink: 0
}}> }}>
<img src={settings.logo_url || '/icons/jama.png'} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> <img src={settings.logo_url || '/icons/rosterchirp.png'} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
</div> </div>
<div> <div>
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}> <label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>

View File

@@ -48,11 +48,11 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
setIconGroupInfo(settings.icon_groupinfo || ''); setIconGroupInfo(settings.icon_groupinfo || '');
setAvatarColors({ public: settings.color_avatar_public || '#1a73e8', dm: settings.color_avatar_dm || '#a142f4' }); setAvatarColors({ public: settings.color_avatar_public || '#1a73e8', dm: settings.color_avatar_dm || '#a142f4' });
}).catch(() => {}); }).catch(() => {});
window.addEventListener('jama:settings-updated', handler); window.addEventListener('rosterchirp:settings-updated', handler);
window.addEventListener('jama:settings-changed', handler); window.addEventListener('rosterchirp:settings-changed', handler);
return () => { return () => {
window.removeEventListener('jama:settings-updated', handler); window.removeEventListener('rosterchirp:settings-updated', handler);
window.removeEventListener('jama:settings-changed', handler); window.removeEventListener('rosterchirp:settings-changed', handler);
}; };
}, []); }, []);

View File

@@ -4,24 +4,24 @@ import { api } from '../utils/api.js';
export default function GlobalBar({ isMobile, showSidebar, onBurger }) { export default function GlobalBar({ isMobile, showSidebar, onBurger }) {
const { connected } = useSocket(); const { connected } = useSocket();
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' }); const [settings, setSettings] = useState({ app_name: 'rosterchirp', logo_url: '' });
const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark'); const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark');
useEffect(() => { useEffect(() => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {}); api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
const handler = () => api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {}); const handler = () => api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
window.addEventListener('jama:settings-changed', handler); window.addEventListener('rosterchirp:settings-changed', handler);
const themeObserver = new MutationObserver(() => { const themeObserver = new MutationObserver(() => {
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark'); setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
}); });
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => { return () => {
window.removeEventListener('jama:settings-changed', handler); window.removeEventListener('rosterchirp:settings-changed', handler);
themeObserver.disconnect(); themeObserver.disconnect();
}; };
}, []); }, []);
const appName = settings.app_name || 'jama'; const appName = settings.app_name || 'rosterchirp';
const logoUrl = settings.logo_url; const logoUrl = settings.logo_url;
const titleColor = (isDark ? settings.color_title_dark : settings.color_title) || null; const titleColor = (isDark ? settings.color_title_dark : settings.color_title) || null;
@@ -48,7 +48,7 @@ export default function GlobalBar({ isMobile, showSidebar, onBurger }) {
</svg> </svg>
</button> </button>
<div className="global-bar-brand"> <div className="global-bar-brand">
<img src={logoUrl || '/icons/jama.png'} alt={appName} className="global-bar-logo" /> <img src={logoUrl || '/icons/rosterchirp.png'} alt={appName} className="global-bar-logo" />
<span className="global-bar-title" style={titleColor ? { color: titleColor } : {}}>{appName}</span> <span className="global-bar-title" style={titleColor ? { color: titleColor } : {}}>{appName}</span>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
/** /**
* HostPanel.jsx — JAMA-HOST Control Panel * HostPanel.jsx — RosterChirp-Host Control Panel
* *
* Renders inside the main JAMA right-panel area (not a separate page/route). * Renders inside the main RosterChirp right-panel area (not a separate page/route).
* Protected by: * Protected by:
* 1. Only shown when is_host_domain === true (server-computed from HOST_DOMAIN) * 1. Only shown when is_host_domain === true (server-computed from HOST_DOMAIN)
* 2. Only accessible to admin role users * 2. Only accessible to admin role users
@@ -15,9 +15,9 @@ import UserFooter from './UserFooter.jsx';
// ── Constants ───────────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────────
const PLANS = [ const PLANS = [
{ value: 'chat', label: 'JAMA-Chat', desc: 'Chat only' }, { value: 'chat', label: 'RosterChirp-Chat', desc: 'Chat only' },
{ value: 'brand', label: 'JAMA-Brand', desc: 'Chat + Branding' }, { value: 'brand', label: 'RosterChirp-Brand', desc: 'Chat + Branding' },
{ value: 'team', label: 'JAMA-Team', desc: 'Chat + Branding + Groups + Schedule' }, { value: 'team', label: 'RosterChirp-Team', desc: 'Chat + Branding + Groups + Schedule' },
]; ];
const PLAN_COLOURS = { const PLAN_COLOURS = {
@@ -307,7 +307,7 @@ function KeyEntry({ onSubmit }) {
setChecking(true); setError(''); setChecking(true); setError('');
try { try {
const res = await fetch('/api/host/status', { headers: { 'X-Host-Admin-Key': key.trim() } }); const res = await fetch('/api/host/status', { headers: { 'X-Host-Admin-Key': key.trim() } });
if (res.ok) { sessionStorage.setItem('jama-host-key', key.trim()); onSubmit(key.trim()); } if (res.ok) { sessionStorage.setItem('rosterchirp-host-key', key.trim()); onSubmit(key.trim()); }
else setError('Invalid admin key'); else setError('Invalid admin key');
} catch { setError('Connection error'); } } catch { setError('Connection error'); }
finally { setChecking(false); } finally { setChecking(false); }
@@ -336,7 +336,7 @@ function KeyEntry({ onSubmit }) {
export default function HostPanel({ onProfile, onHelp, onAbout }) { export default function HostPanel({ onProfile, onHelp, onAbout }) {
const { user } = useAuth(); const { user } = useAuth();
const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('jama-host-key') || ''); const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('rosterchirp-host-key') || '');
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const [tenants, setTenants] = useState([]); const [tenants, setTenants] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -363,7 +363,7 @@ export default function HostPanel({ onProfile, onHelp, onAbout }) {
toast(e.message, 'error'); toast(e.message, 'error');
// Key is invalid — clear it so the prompt shows again // Key is invalid — clear it so the prompt shows again
if (e.message.includes('401') || e.message.includes('Invalid') || e.message.includes('401')) { if (e.message.includes('401') || e.message.includes('Invalid') || e.message.includes('401')) {
sessionStorage.removeItem('jama-host-key'); sessionStorage.removeItem('rosterchirp-host-key');
setAdminKey(''); setAdminKey('');
} }
} finally { setLoading(false); } } finally { setLoading(false); }

View File

@@ -5,9 +5,9 @@ import { useToast } from '../contexts/ToastContext.jsx';
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
const APP_TYPES = { const APP_TYPES = {
'JAMA-Chat': { label: 'JAMA-Chat', desc: 'Chat only. No Branding, Group Manager or Schedule Manager.' }, 'RosterChirp-Chat': { label: 'RosterChirp-Chat', desc: 'Chat only. No Branding, Group Manager or Schedule Manager.' },
'JAMA-Brand': { label: 'JAMA-Brand', desc: 'Chat and Branding.' }, 'RosterChirp-Brand': { label: 'RosterChirp-Brand', desc: 'Chat and Branding.' },
'JAMA-Team': { label: 'JAMA-Team', desc: 'Chat, Branding, Group Manager and Schedule Manager.' }, 'RosterChirp-Team': { label: 'RosterChirp-Team', desc: 'Chat, Branding, Group Manager and Schedule Manager.' },
}; };
// ── Team Management Tab ─────────────────────────────────────────────────────── // ── Team Management Tab ───────────────────────────────────────────────────────
@@ -34,7 +34,7 @@ function TeamManagementTab() {
try { try {
await api.updateTeamSettings({ toolManagers }); await api.updateTeamSettings({ toolManagers });
toast('Team settings saved', 'success'); toast('Team settings saved', 'success');
window.dispatchEvent(new Event('jama:settings-changed')); window.dispatchEvent(new Event('rosterchirp:settings-changed'));
} catch (e) { toast(e.message, 'error'); } } catch (e) { toast(e.message, 'error'); }
finally { setSaving(false); } finally { setSaving(false); }
}; };
@@ -82,7 +82,7 @@ function RegistrationTab({ onFeaturesChanged }) {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {}); api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
}, []); }, []);
const appType = settings.app_type || 'JAMA-Chat'; const appType = settings.app_type || 'RosterChirp-Chat';
const activeCode = settings.registration_code || ''; const activeCode = settings.registration_code || '';
const adminEmail = settings.admin_email || '—'; const adminEmail = settings.admin_email || '—';
@@ -104,7 +104,7 @@ function RegistrationTab({ onFeaturesChanged }) {
const fresh = await api.getSettings(); const fresh = await api.getSettings();
setSettings(fresh.settings); setSettings(fresh.settings);
toast('Registration applied successfully.', 'success'); toast('Registration applied successfully.', 'success');
window.dispatchEvent(new Event('jama:settings-changed')); window.dispatchEvent(new Event('rosterchirp:settings-changed'));
onFeaturesChanged && onFeaturesChanged(f); onFeaturesChanged && onFeaturesChanged(f);
} catch (e) { toast(e.message || 'Invalid registration code', 'error'); } } catch (e) { toast(e.message || 'Invalid registration code', 'error'); }
finally { setRegLoading(false); } finally { setRegLoading(false); }
@@ -116,12 +116,12 @@ function RegistrationTab({ onFeaturesChanged }) {
const fresh = await api.getSettings(); const fresh = await api.getSettings();
setSettings(fresh.settings); setSettings(fresh.settings);
toast('Registration cleared.', 'success'); toast('Registration cleared.', 'success');
window.dispatchEvent(new Event('jama:settings-changed')); window.dispatchEvent(new Event('rosterchirp:settings-changed'));
onFeaturesChanged && onFeaturesChanged(f); onFeaturesChanged && onFeaturesChanged(f);
} catch (e) { toast(e.message, 'error'); } } catch (e) { toast(e.message, 'error'); }
}; };
const typeInfo = APP_TYPES[appType] || APP_TYPES['JAMA-Chat']; const typeInfo = APP_TYPES[appType] || APP_TYPES['RosterChirp-Chat'];
const siteUrl = window.location.origin; const siteUrl = window.location.origin;
return ( return (
@@ -132,7 +132,7 @@ function RegistrationTab({ onFeaturesChanged }) {
Registration {activeCode ? 'is' : 'required:'} Registration {activeCode ? 'is' : 'required:'}
</p> </p>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6 }}> <p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6 }}>
JAMA {activeCode ? 'is' : 'will be'} registered to:<br /> RosterChirp {activeCode ? 'is' : 'will be'} registered to:<br />
<strong>Type:</strong> {typeInfo.label}<br /> <strong>Type:</strong> {typeInfo.label}<br />
<strong>URL:</strong> {siteUrl} <strong>URL:</strong> {siteUrl}
</p> </p>
@@ -185,9 +185,9 @@ function RegistrationTab({ onFeaturesChanged }) {
)} )}
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 16, lineHeight: 1.5 }}> <p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 16, lineHeight: 1.5 }}>
Registration codes unlock application features. Contact your JAMA provider for a code.<br /> Registration codes unlock application features. Contact your RosterChirp provider for a code.<br />
<strong>JAMA-Brand</strong> unlocks Branding.&nbsp; <strong>RosterChirp-Brand</strong> unlocks Branding.&nbsp;
<strong>JAMA-Team</strong> unlocks Branding, Group Manager and Schedule Manager. <strong>RosterChirp-Team</strong> unlocks Branding, Group Manager and Schedule Manager.
</p> </p>
</div> </div>
); );
@@ -270,18 +270,18 @@ function WebPushTab() {
// ── Main modal ──────────────────────────────────────────────────────────────── // ── Main modal ────────────────────────────────────────────────────────────────
export default function SettingsModal({ onClose, onFeaturesChanged }) { export default function SettingsModal({ onClose, onFeaturesChanged }) {
const [tab, setTab] = useState('registration'); const [tab, setTab] = useState('registration');
const [appType, setAppType] = useState('JAMA-Chat'); const [appType, setAppType] = useState('RosterChirp-Chat');
useEffect(() => { useEffect(() => {
api.getSettings().then(({ settings }) => { api.getSettings().then(({ settings }) => {
setAppType(settings.app_type || 'JAMA-Chat'); setAppType(settings.app_type || 'RosterChirp-Chat');
}).catch(() => {}); }).catch(() => {});
const handler = () => api.getSettings().then(({ settings }) => setAppType(settings.app_type || 'JAMA-Chat')).catch(() => {}); const handler = () => api.getSettings().then(({ settings }) => setAppType(settings.app_type || 'RosterChirp-Chat')).catch(() => {});
window.addEventListener('jama:settings-changed', handler); window.addEventListener('rosterchirp:settings-changed', handler);
return () => window.removeEventListener('jama:settings-changed', handler); return () => window.removeEventListener('rosterchirp:settings-changed', handler);
}, []); }, []);
const isTeam = appType === 'JAMA-Team'; const isTeam = appType === 'RosterChirp-Team';
const tabs = [ const tabs = [
isTeam && { id: 'team', label: 'Team Management' }, isTeam && { id: 'team', label: 'Team Management' },

View File

@@ -14,20 +14,20 @@ function nameToColor(name) {
} }
function useAppSettings() { function useAppSettings() {
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '', color_avatar_public: '', color_avatar_dm: '' }); const [settings, setSettings] = useState({ app_name: 'rosterchirp', logo_url: '', color_avatar_public: '', color_avatar_dm: '' });
const fetchSettings = () => { const fetchSettings = () => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {}); api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
}; };
useEffect(() => { useEffect(() => {
fetchSettings(); fetchSettings();
window.addEventListener('jama:settings-changed', fetchSettings); window.addEventListener('rosterchirp:settings-changed', fetchSettings);
return () => window.removeEventListener('jama:settings-changed', fetchSettings); return () => window.removeEventListener('rosterchirp:settings-changed', fetchSettings);
}, []); }, []);
useEffect(() => { useEffect(() => {
const name = settings.app_name || 'jama'; const name = settings.app_name || 'rosterchirp';
const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] || ''; const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] || '';
document.title = prefix + name; document.title = prefix + name;
const faviconUrl = settings.logo_url || '/icons/jama.png'; const faviconUrl = settings.logo_url || '/icons/rosterchirp.png';
let link = document.querySelector("link[rel~='icon']"); let link = document.querySelector("link[rel~='icon']");
if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); } if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
link.href = faviconUrl; link.href = faviconUrl;

View File

@@ -3,10 +3,10 @@ import { useAuth } from '../contexts/AuthContext.jsx';
import Avatar from './Avatar.jsx'; import Avatar from './Avatar.jsx';
function useTheme() { function useTheme() {
const [dark, setDark] = useState(() => localStorage.getItem('jama-theme') === 'dark'); const [dark, setDark] = useState(() => localStorage.getItem('rosterchirp-theme') === 'dark');
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light'); document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
localStorage.setItem('jama-theme', dark ? 'dark' : 'light'); localStorage.setItem('rosterchirp-theme', dark ? 'dark' : 'light');
}, [dark]); }, [dark]);
return [dark, setDark]; return [dark, setDark];
} }

View File

@@ -52,8 +52,8 @@ export function AuthProvider({ children }) {
setUser(null); setUser(null);
setMustChangePassword(false); setMustChangePassword(false);
}; };
window.addEventListener('jama:session-displaced', handler); window.addEventListener('rosterchirp:session-displaced', handler);
return () => window.removeEventListener('jama:session-displaced', handler); return () => window.removeEventListener('rosterchirp:session-displaced', handler);
}, []); }, []);
const updateUser = (updates) => setUser(prev => ({ ...prev, ...updates })); const updateUser = (updates) => setUser(prev => ({ ...prev, ...updates }));

View File

@@ -44,7 +44,7 @@ export function SocketProvider({ children }) {
// Session displaced: another login on the same device type has kicked this session // Session displaced: another login on the same device type has kicked this session
socket.on('session:displaced', () => { socket.on('session:displaced', () => {
window.dispatchEvent(new CustomEvent('jama:session-displaced')); window.dispatchEvent(new CustomEvent('rosterchirp:session-displaced'));
}); });
// Bug B fix: when app returns to foreground, force socket reconnect if disconnected // Bug B fix: when app returns to foreground, force socket reconnect if disconnected

View File

@@ -26,7 +26,7 @@ if ('serviceWorker' in navigator) {
// //
// 2. PULL-TO-REFRESH → blocked in PWA standalone mode only. // 2. PULL-TO-REFRESH → blocked in PWA standalone mode only.
(function () { (function () {
const LS_KEY = 'jama_font_scale'; const LS_KEY = 'rosterchirp_font_scale';
const MIN_SCALE = 0.8; const MIN_SCALE = 0.8;
const MAX_SCALE = 2.0; const MAX_SCALE = 2.0;

View File

@@ -42,7 +42,7 @@ export default function Chat() {
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager' const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager'
const [page, setPage] = useState('chat'); // 'chat' | 'schedule' | 'groupmessages' const [page, setPage] = useState('chat'); // 'chat' | 'schedule' | 'groupmessages'
const [drawerOpen, setDrawerOpen] = useState(false); 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 [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [showSidebar, setShowSidebar] = useState(true); const [showSidebar, setShowSidebar] = useState(true);
@@ -81,7 +81,7 @@ export default function Chat() {
branding: settings.feature_branding === 'true', branding: settings.feature_branding === 'true',
groupManager: settings.feature_group_manager === 'true', groupManager: settings.feature_group_manager === 'true',
scheduleManager: settings.feature_schedule_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 || '[]'), teamToolManagers: JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'),
isHostDomain: settings.is_host_domain === 'true', isHostDomain: settings.is_host_domain === 'true',
})); }));
@@ -93,53 +93,59 @@ export default function Chat() {
useEffect(() => { useEffect(() => {
loadFeatures(); loadFeatures();
window.addEventListener('jama:settings-changed', loadFeatures); window.addEventListener('rosterchirp:settings-changed', loadFeatures);
return () => window.removeEventListener('jama:settings-changed', loadFeatures); return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures);
}, [loadFeatures]); }, [loadFeatures]);
// Register / refresh push subscription // Register / refresh FCM push subscription
useEffect(() => { useEffect(() => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return; if (!('serviceWorker' in navigator)) return;
const registerPush = async () => { const registerPush = async () => {
try { try {
const permission = Notification.permission; if (Notification.permission === 'denied') return;
if (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 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 (Notification.permission !== 'granted') {
const granted = await Notification.requestPermission();
if (!sub) {
// First time or subscription was lost — request permission then subscribe
const granted = permission === 'granted'
? 'granted'
: await Notification.requestPermission();
if (granted !== 'granted') return; 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', { await fetch('/api/push/subscribe', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, 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) { } catch (e) {
console.warn('[Push] Subscription failed:', e.message); console.warn('[Push] FCM subscription failed:', e.message);
} }
}; };
registerPush(); 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 = () => { const handleVisibility = () => {
if (document.visibilityState === 'visible') registerPush(); if (document.visibilityState === 'visible') registerPush();
}; };
@@ -263,7 +269,7 @@ export default function Chat() {
// so we force logout unconditionally — the new session will reconnect cleanly) // so we force logout unconditionally — the new session will reconnect cleanly)
localStorage.removeItem('tc_token'); localStorage.removeItem('tc_token');
sessionStorage.removeItem('tc_token'); sessionStorage.removeItem('tc_token');
window.dispatchEvent(new CustomEvent('jama:session-displaced')); window.dispatchEvent(new CustomEvent('rosterchirp:session-displaced'));
}; };
// Online presence // Online presence
@@ -320,7 +326,7 @@ export default function Chat() {
if (isMobile) { if (isMobile) {
setShowSidebar(false); setShowSidebar(false);
// Push a history entry so swipe-back returns to sidebar instead of exiting the app // 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 // Clear notifications and unread count for this group
setNotifications(prev => prev.filter(n => n.groupId !== id)); setNotifications(prev => prev.filter(n => n.groupId !== id));
@@ -334,7 +340,7 @@ export default function Chat() {
setShowSidebar(true); setShowSidebar(true);
setActiveGroupId(null); setActiveGroupId(null);
// Push another entry so subsequent back gestures are also intercepted // Push another entry so subsequent back gestures are also intercepted
window.history.pushState({ jamaChatOpen: true }, ''); window.history.pushState({ rosterchirpChatOpen: true }, '');
} }
}; };
window.addEventListener('popstate', handlePopState); window.addEventListener('popstate', handlePopState);

View File

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

View File

@@ -36,7 +36,7 @@ async function req(method, path, body, opts = {}) {
if (res.status === 401 && data.error?.includes('Session expired')) { if (res.status === 401 && data.error?.includes('Session expired')) {
localStorage.removeItem('tc_token'); localStorage.removeItem('tc_token');
sessionStorage.removeItem('tc_token'); sessionStorage.removeItem('tc_token');
window.dispatchEvent(new CustomEvent('jama:session-displaced')); window.dispatchEvent(new CustomEvent('rosterchirp:session-displaced'));
} }
throw new Error(data.error || 'Request failed'); throw new Error(data.error || 'Request failed');
} }
@@ -123,7 +123,7 @@ export const api = {
bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }), bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }),
importPreview: (file) => { importPreview: (file) => {
const fd = new FormData(); fd.append('file', file); const fd = new FormData(); fd.append('file', file);
return fetch('/api/schedule/import/preview', { method: 'POST', headers: { Authorization: 'Bearer ' + localStorage.getItem('jama-token') }, body: fd }).then(r => r.json()); return fetch('/api/schedule/import/preview', { method: 'POST', headers: { Authorization: 'Bearer ' + localStorage.getItem('tc_token') }, body: fd }).then(r => r.json());
}, },
importConfirm: (rows) => req('POST', '/schedule/import/confirm', { rows }), importConfirm: (rows) => req('POST', '/schedule/import/confirm', { rows }),
@@ -166,16 +166,11 @@ export const api = {
}, },
resetSettings: () => req('POST', '/settings/reset'), resetSettings: () => req('POST', '/settings/reset'),
// Push notifications // Push notifications (FCM)
getPushKey: () => req('GET', '/push/vapid-public'), getFirebaseConfig: () => req('GET', '/push/firebase-config'),
subscribePush: (sub) => req('POST', '/push/subscribe', sub), subscribePush: (fcmToken) => req('POST', '/push/subscribe', { fcmToken }),
unsubscribePush: (endpoint) => req('POST', '/push/unsubscribe', { endpoint }), unsubscribePush: () => req('POST', '/push/unsubscribe'),
// Link preview // Link preview
getLinkPreview: (url) => req('GET', `/link-preview?url=${encodeURIComponent(url)}`), getLinkPreview: (url) => req('GET', `/link-preview?url=${encodeURIComponent(url)}`),
// VAPID key management (admin only)
generateVapidKeys: () => req('POST', '/push/generate-vapid'),
}; };