Compare commits

...

17 Commits

Author SHA1 Message Date
Sravan 1ece8e1061 System bars: lock in Option 1 (brand-blue bars) on iOS + Android
User picked the brand-blue look for both platforms. Apply .sysbar-blue on any
native platform; set white icons via the safe-area plugin on Android and via
@capacitor/status-bar on iOS. capacitor.config statusBarStyle/navigationBarStyle
-> DARK (light icons) so native builds match before JS runs. iOS is live, so this
web change reaches it immediately — verify on an iPhone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 18:13:55 +05:30
Sravan 03de639e1a Android system bars — Option 2 preview: white bars + brand separator line
Swap the class to sysbar-sep (thin 1.5px brand line under the status bar + above
the nav bar, bars stay white) and icons back to dark. Second of the two looks for
the user to compare; pick one after this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 18:09:39 +05:30
Sravan 133208ae3f Android system bars — Option 1 preview: brand-blue bars + white icons
Fill the status/nav inset regions with brand blue (#1F3B73) via html.sysbar-blue
pseudo-elements and switch the bar icons to light so they read on blue. This is
one of two looks for the user to compare on-device; Option 2 (white + separator)
is already in the CSS and swaps in by changing the class + icon style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 18:07:31 +05:30
Sravan 703cf1fcf0 TEMP: keyboard diagnostics for the Android repeat-open gap
Behavior-neutral console logging on keyboard show/hide (kb height, innerHeight,
clientHeight, visualViewport height, --kb) to determine whether the Android
WebView resizes (interactive-widget) or relies on the manual --kb lift, and to
spot any residual between the first and second open. Remove once fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 18:00:47 +05:30
Sravan 9d5af66a0e Android: dark system-bar icons (visible on the white app)
Under Android edge-to-edge the status/nav bar icons default to light (white) and
vanished on the white app (clock/battery/nav invisible). Set SafeArea
statusBarStyle/navigationBarStyle to LIGHT (= dark content on a light background)
in capacitor.config.json, and add a runtime SafeArea.setSystemBarsStyle call in
home.html so already-installed APKs get dark icons on relaunch (no rebuild). iOS
unaffected (its status bar already shows dark content on white).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 00:24:39 +05:30
Sravan ab6d8161ee push: accept FCM_SERVICE_ACCOUNT_B64 (base64 service-account key)
Base64 is a single env-safe token, so the FCM service-account key can live in
.env without the quoting/interpolation hazards of inline JSON. Falls back to the
existing FCM_SERVICE_ACCOUNT (inline JSON or file path). No behavior change when
neither is set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 00:04:31 +05:30
Sravan c013536e2b Android: wire Firebase google-services.json into the build (client half)
Commit the client Firebase config (bizgaze-connect, package com.bizgaze.connect)
and have the Codemagic Android step copy it into android/app/ so the google-services
Gradle plugin applies and FirebaseApp initializes — fixing the root cause of the
"Default FirebaseApp is not initialized" crash. google-services.json is client-side
(ships in the APK), safe for this private repo. The service-account key stays OUT of
git (.gitignore: *firebase-adminsdk*.json / *service-account*.json) — it goes on the
server as FCM_SERVICE_ACCOUNT separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-17 19:22:22 +05:30
Sravan ba75dc8daa Android: fix black gap above keyboard (SafeArea/SystemBars config)
The safe-area plugin logged: set SystemBars.insetsHandling to "disable". Under
Android edge-to-edge (targetSdk 36) this + offsetForKeyboardInsetBug were causing
a black strip between the input and the soft keyboard on the login view. Set
SystemBars.insetsHandling="disable" and SafeArea.offsetForKeyboardInsetBug=true.
Native config — takes effect on the next Android build; verify on device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 21:57:35 +05:30
Sravan a4726dd10c Fix Android crash: don't register push without Firebase
On Android, PushNotifications.register() calls FirebaseMessaging.getInstance(),
which throws "Default FirebaseApp is not initialized" when the build has no
google-services.json. Capacitor runs the plugin method on its own thread, so the
exception is an UNCAUGHT NATIVE crash a JS try/catch can't stop — the app died
right after sign-in and on every relaunch.

Gate Android push registration on a new server flag: /api/meetings/config now
returns fcm (push.fcmReady() = FCM_SERVICE_ACCOUNT present). setupNativePush skips
register() on Android unless fcm is true. Web-side fix — deploys without a rebuild;
push auto-enables once Firebase (client google-services.json + server FCM) is set up.
iOS/APNs unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 21:55:09 +05:30
Sravan b03a32b10d Android CI: use JDK 21 (Capacitor 8 Android requires it)
@capacitor/android is ^8; Capacitor 8's Android toolchain (AGP 8.7 / Gradle 8.11)
requires JDK 21. The workflow used java: 17, which fails the Gradle build before an
APK is produced. Bump to java: 21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 16:36:47 +05:30
Sravan e39828ed56 Android CI: fail the build on a Gradle error + surface the real cause
The build step ended with `find … *.apk`, which exits 0 even when Gradle failed
and no APK exists — so Codemagic marked a failed Gradle run as a green build with
no artifact. Now: capture gradle output, use PIPESTATUS to detect failure, print
the "What went wrong" / FAILURE block, exit non-zero, and assert an APK exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 16:35:39 +05:30
Sravan 35ce641046 Android CI: run on mac_mini_m2 (linux_x2 not on the billing plan)
The android-apk workflow requested linux_x2, which Codemagic rejected with
"selected instance type is not available with the current billing plan". Switch
to mac_mini_m2 — the same instance the iOS workflow already uses on this account;
Codemagic's macOS images include the Android SDK + JDK. Also make the SDK path
(ANDROID_SDK_ROOT -> ANDROID_HOME fallback) and the google-services base64 decode
(GNU --decode / BSD -D) portable to the macOS image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 16:08:52 +05:30
Sravan 1efb2e4314 Fix: message text not selectable on desktop
#msgs .bubble had a blanket user-select:none (added to suppress iOS's long-press
magnifier), which also blocked mouse text-selection on desktop. Scope the
none to touch devices only (@media (hover:none)); keep it in multi-select mode.
Desktop can now highlight/copy message text. Verified: desktop=auto,
touch=none, sel-mode=none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 19:23:21 +05:30
Sravan 677d418ed0 Fix: dragging multiple files into a chat only sent the first
The conversation drop handler read dataTransfer.files[0] (a single file), so a
multi-file drag-and-drop uploaded just one. Iterate every dropped file, matching
the file-picker path. Verified in-browser: dropping 3 files queues + sends all 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 18:22:57 +05:30
Sravan 07c728bf6a Android CI build + delete-chat (self-only) + SFU copy refresh
- codemagic.yaml: add an Android workflow that builds an installable debug
  APK (Linux instance, no signing needed to test) — the Android equivalent of
  the iOS TestFlight loop. mobile/scripts/android-patch.js injects the
  camera/mic/notification permissions into the CI-generated manifest (android/
  is gitignored, same as ios/).
- Delete chat (self-only): new POST /api/messages/clear bulk-hides a whole DM
  (or group) for the requester via the existing per-user message_hidden
  mechanism — the other party keeps their copy entirely. "Delete chat" button
  added to the DM contact-info panel; chat-cleared synced to my other devices.
  Verified end-to-end on Postgres (A clears -> empty for A, B untouched).
- Meetings copy: SFU is live, so drop the "coming soon" / "small group (mesh)"
  wording on the welcome card and the Meetings header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 17:12:42 +05:30
Sravan 948eae249f App Store: add 13-inch iPad screenshots (universal build requires them)
The Capacitor build is universal (iPhone + iPad), so App Store Connect requires
iPad screenshots. Added three 2064x2752 (13" iPad) shots rendered on the app's
two-pane iPad layout (chat sidebar + open conversation, meetings, schedule),
anonymized. No rebuild needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 16:13:55 +05:30
Sravan 88a6b6a21e App Store: add screen-sharing screenshot (6 total)
06-screenshare.png — a meeting with a shared screen (a slide + chart on the
stage, "Sharing" badge, participant tiles, controls), rendered on the real call
UI at 1320x2868 and anonymized. Updated the submission pack's screenshot order to
include it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 14:51:15 +05:30
14 changed files with 302 additions and 11 deletions
+4
View File
@@ -50,3 +50,7 @@ Thumbs.db
# Editor # Editor
.vscode/ .vscode/
.idea/ .idea/
# Firebase service-account keys (SECRET private key — NEVER commit)
**/*firebase-adminsdk*.json
**/*service-account*.json
+99
View File
@@ -165,3 +165,102 @@ workflows:
# submit_to_app_store: false # submit_to_app_store: false
# No email recipients here on purpose — build status is watched on the Codemagic dashboard. Add # No email recipients here on purpose — build status is watched on the Codemagic dashboard. Add
# per-user notifications in the Codemagic UI (or a `publishing.email` block) later if you want them. # per-user notifications in the Codemagic UI (or a `publishing.email` block) later if you want them.
# ── Android test build ──────────────────────────────────────────────────────────────────────────
# Builds an INSTALLABLE debug APK of the same Capacitor shell (loads https://remote.bizgaze.com). This is
# the Android equivalent of the iOS TestFlight loop, but simpler — no Google Play account or signing is
# needed to test: download the APK artifact from the Codemagic build page (or wire an email/Slack in the
# UI), sideload it on a phone (enable "Install unknown apps"), and run.
#
# Runs on the SAME macOS instance as the iOS workflow (mac_mini_m2) — Codemagic's macOS images ship the
# Android SDK + JDK too. (A Linux instance would be cheaper/faster but linux_x2 isn't on every billing
# plan; mac_mini_m2 is the one this account already uses for iOS.)
#
# Firebase push (FCM) is OPTIONAL for this test build: app/build.gradle only applies the google-services
# plugin when google-services.json is present, so the APK builds fine WITHOUT it (push/call-wake just
# won't fire). To enable push, base64 the google-services.json and store it as a secure Codemagic env var
# GOOGLE_SERVICES_JSON (group `android_config`); the step below decodes it into place.
android-apk:
name: Biz Connect Android → test APK
max_build_duration: 45
instance_type: mac_mini_m2
environment:
# To enable FCM push later, create a Codemagic variable group holding GOOGLE_SERVICES_JSON (base64 of
# google-services.json, marked secure) and uncomment the two lines below. Left out for now so the first
# test build needs ZERO Codemagic setup.
# groups:
# - android_config
vars:
PACKAGE_NAME: "com.bizgaze.connect"
node: 22
java: 21 # Capacitor 8's Android build (AGP 8.7 / Gradle 8.11) requires JDK 21 — JDK 17 fails the build.
scripts:
- name: Install JS dependencies
script: |
cd mobile
npm install
- name: Generate the Android project (Capacitor)
script: |
cd mobile
# `cap add android` scaffolds android/; safe to re-run — it no-ops if it already exists (our committed
# project has custom manifest permissions, which cap sync preserves).
if [ ! -d "android" ]; then npx cap add android; fi
npx cap sync android
# App icon + splash from resources/icon.png & resources/splash*.png.
npx capacitor-assets generate --android || echo "capacitor-assets returned non-zero (see above)"
# The generated project points sdk.dir at wherever it was made; overwrite it with the CI SDK path so
# Gradle finds the SDK. Codemagic exports ANDROID_SDK_ROOT (fall back to ANDROID_HOME on macOS images).
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
echo "sdk.dir=$SDK" > android/local.properties
echo "Android SDK -> $SDK"
# android/ is gitignored and regenerated fresh in CI, so its manifest only has INTERNET. Inject the
# camera/mic/notification permissions the web UI needs (mirrors ios-patch.sh for iOS). Tolerant + idempotent.
node scripts/android-patch.js android/app/src/main/AndroidManifest.xml
- name: Firebase google-services.json for FCM push
script: |
# Place the client Firebase config into android/app/ so the google-services Gradle plugin applies and
# FirebaseApp initializes at runtime — WITHOUT it, PushNotifications.register() throws
# "Default FirebaseApp is not initialized" and crashes the app on Android. Prefer the committed file;
# fall back to a base64 GOOGLE_SERVICES_JSON env var. (Runs from the repo root; the Android project was
# generated in the previous step.)
DEST=mobile/android/app/google-services.json
if [ -f mobile/FirebaseAccount_Google/google-services.json ]; then
cp mobile/FirebaseAccount_Google/google-services.json "$DEST"
echo "google-services.json copied from repo → FCM enabled"
elif [ -n "$GOOGLE_SERVICES_JSON" ]; then
echo "$GOOGLE_SERVICES_JSON" | { base64 --decode 2>/dev/null || base64 -D; } > "$DEST"
echo "google-services.json written from env → FCM enabled"
else
echo "No google-services.json found — building WITHOUT FCM push"
fi
ls -l "$DEST" 2>/dev/null || echo "(no google-services.json placed)"
- name: Build the debug APK
script: |
set -e
cd mobile/android
chmod +x ./gradlew
# Debug build type is auto-signed with the Android debug keystore → directly installable, no Play
# account or upload key needed. (A signed release AAB for the Play Store is a later, separate step.)
# Capture the output so that, on failure, we surface Gradle's actual "What went wrong" block instead
# of a wall of internal stack frames — and FAIL the step (a trailing `find` used to exit 0 and mask it).
set +e
./gradlew assembleDebug --stacktrace 2>&1 | tee /tmp/gradle.log
STATUS=${PIPESTATUS[0]}
set -e
if [ "$STATUS" != "0" ]; then
echo "======================= GRADLE FAILURE ======================="
grep -n -A 25 "What went wrong" /tmp/gradle.log || true
grep -n -A 3 "FAILURE:" /tmp/gradle.log || true
echo "=============================================================="
exit 1
fi
echo "APK(s):"; find app/build/outputs -name "*.apk"
test -n "$(find app/build/outputs -name '*.apk' -print -quit)" || { echo "ERROR: no APK produced"; exit 1; }
artifacts:
- mobile/android/app/build/outputs/**/*.apk
# Download the APK from the build page. To get it emailed like TestFlight, add a `publishing.email` block
# here (or notifications in the Codemagic UI). A signed release AAB → Google Play internal testing is a
# separate workflow we can add once the shell is verified on a device.
+3 -2
View File
@@ -20,7 +20,7 @@ account-specific or secret and must NOT be committed). Order below ≈ the order
| Field | Value | | Field | Value |
|---|---| |---|---|
| **App name** | Biz Connect | | **App name** | BizGaze Connect (App Store listing name; renamed 2026-08-21) |
| **Subtitle** (30 char max) | Team chat, calls & meetings | | **Subtitle** (30 char max) | Team chat, calls & meetings |
| **Primary category** | Business | | **Primary category** | Business |
| **Secondary category** | Productivity | | **Secondary category** | Productivity |
@@ -106,7 +106,8 @@ Required (App Store Connect accepts one size and scales, but do at least these t
- [ ] **6.5" iPhone** (1242 × 2688) — fallback for older devices - [ ] **6.5" iPhone** (1242 × 2688) — fallback for older devices
- [ ] (Optional) iPad if you enable iPad support - [ ] (Optional) iPad if you enable iPad support
Suggested 45 shots, in order: **chat list → a conversation → an active video call → screen share / meeting → live transcript**. Use realistic but non-sensitive demo content. Ready-made set in `mobile/appstore-screenshots/` (1320×2868, anonymized). Upload in this order:
`01-chats → 02-conversation → 04-group → 03-meetings → 06-screenshare → 05-schedule`
--- ---
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "860472998800",
"project_id": "bizgaze-connect",
"storage_bucket": "bizgaze-connect.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:860472998800:android:107cc0fc6936d6898dd115",
"android_client_info": {
"package_name": "com.bizgaze.connect"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyB6gSqIDYr21GKYA4c1e7plUBQMXIcX1Dw"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

+6 -1
View File
@@ -14,7 +14,12 @@
"SafeArea": { "SafeArea": {
"detectViewportFitCoverChanges": true, "detectViewportFitCoverChanges": true,
"initialViewportFitCover": true, "initialViewportFitCover": true,
"offsetForKeyboardInsetBug": false "offsetForKeyboardInsetBug": true,
"statusBarStyle": "DARK",
"navigationBarStyle": "DARK"
},
"SystemBars": {
"insetsHandling": "disable"
}, },
"Keyboard": { "Keyboard": {
"resize": "none" "resize": "none"
+43
View File
@@ -0,0 +1,43 @@
// Inject the runtime permissions the web UI needs into the Capacitor-generated AndroidManifest.xml.
// Run on Codemagic from the Android workflow:
// node mobile/scripts/android-patch.js mobile/android/app/src/main/AndroidManifest.xml
//
// WHY: mobile/android/ is gitignored (regenerated in CI by `cap add android`, same as iOS ios/). The
// freshly generated manifest only declares INTERNET, so without this the WebRTC calls in the web UI can't
// get camera/mic and Android 13+ never prompts for notifications. This adds the same permissions listed in
// mobile/resources/android-permissions.xml. (When the native-call Android plugin lands, it will contribute
// its own manifest entries via Capacitor manifest-merging; this only covers the app-level WebView perms.)
//
// TOLERANT: exits 0 and no-ops if anything is off, so it can NEVER fail the build. Idempotent (keyed on
// the bzcAndroidPerms marker), so re-runs never duplicate the block.
const fs = require('fs');
const p = process.argv[2];
if (!p || !fs.existsSync(p)) { console.log(' (AndroidManifest.xml not found — permission patch skipped)'); process.exit(0); }
try {
let s = fs.readFileSync(p, 'utf8');
if (s.includes('bzcAndroidPerms') || s.includes('android.permission.RECORD_AUDIO')) {
console.log(' Android permissions already present'); process.exit(0);
}
const block = [
'',
' <!-- bzcAndroidPerms: permissions the Biz Connect web UI needs (see mobile/resources/android-permissions.xml) -->',
' <!-- Push notifications: Android 13 (API 33)+ shows a runtime prompt -->',
' <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />',
' <!-- Voice / video calls + camera, used by the WebRTC features in the web UI -->',
' <uses-permission android:name="android.permission.CAMERA" />',
' <uses-permission android:name="android.permission.RECORD_AUDIO" />',
' <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />',
' <!-- Camera is optional hardware (tablets without one can still install) -->',
' <uses-feature android:name="android.hardware.camera" android:required="false" />',
'',
].join('\n');
const orig = s;
// Insert directly before the closing </manifest> tag.
s = s.replace(/<\/manifest>\s*$/, block + '</manifest>\n');
if (s === orig) { console.log(' could not find </manifest> — permission patch skipped'); process.exit(0); }
fs.writeFileSync(p, s);
console.log(' Android permissions injected into ' + p);
} catch (e) {
console.log(' Android permission patch skipped:', e.message);
}
process.exit(0);
+67 -5
View File
@@ -27,6 +27,15 @@
which populates env(safe-area-inset-*) with real values on every device (notch, Dynamic Island, home which populates env(safe-area-inset-*) with real values on every device (notch, Dynamic Island, home
indicator). On the web these resolve to 0. All layout below pads with var(--sat)/var(--sab). */ indicator). On the web these resolve to 0. All layout below pads with var(--sat)/var(--sab). */
:root{--sat:env(safe-area-inset-top,0px);--sab:env(safe-area-inset-bottom,0px);--kb:0px;} :root{--sat:env(safe-area-inset-top,0px);--sab:env(safe-area-inset-bottom,0px);--kb:0px;}
/* Native system-bar look (Android), chosen via a class on <html>. Pseudo-elements fill/mark the status +
nav inset regions that are otherwise plain white under edge-to-edge. Exactly one class is set at a time. */
/* Option 1 — brand-blue bars (icons set to light/white to suit): */
html.native-app.sysbar-blue::before,html.native-app.sysbar-blue::after{content:"";position:fixed;left:0;right:0;background:#1F3B73;z-index:6000;pointer-events:none;}
html.native-app.sysbar-blue::before{top:0;height:env(safe-area-inset-top,0px);}
html.native-app.sysbar-blue::after{bottom:0;height:env(safe-area-inset-bottom,0px);}
/* Option 2 — clean white bars with a thin brand separator line (icons stay dark): */
html.native-app.sysbar-sep::before{content:"";position:fixed;top:env(safe-area-inset-top,0px);left:0;right:0;height:1.5px;background:rgba(31,59,115,.22);z-index:6000;pointer-events:none;}
html.native-app.sysbar-sep::after{content:"";position:fixed;bottom:env(safe-area-inset-bottom,0px);left:0;right:0;height:1.5px;background:rgba(31,59,115,.22);z-index:6000;pointer-events:none;}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;display:flex;flex-direction:column;height:100vh;height:100dvh;overflow:hidden;position:fixed;inset:0;width:100%;} body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;display:flex;flex-direction:column;height:100vh;height:100dvh;overflow:hidden;position:fixed;inset:0;width:100%;}
/* ---- Top bar ---- */ /* ---- Top bar ---- */
@@ -713,7 +722,12 @@
/* #9: disable iOS's native touch-callout on message bubbles — the "Save Image"/text-selection magnifier /* #9: disable iOS's native touch-callout on message bubbles — the "Save Image"/text-selection magnifier
that pops on touch-and-hold was hijacking our long-press action sheet (it worked once, then iOS's callout that pops on touch-and-hold was hijacking our long-press action sheet (it worked once, then iOS's callout
took over). Actions live in the long-press sheet now (incl. Save for images) and Copy for text. */ took over). Actions live in the long-press sheet now (incl. Save for images) and Copy for text. */
#msgs .bubble{-webkit-touch-callout:none;-webkit-user-select:none;user-select:none;} #msgs .bubble{-webkit-touch-callout:none;}
/* Only disable text selection on TOUCH devices — there a long-press must open our action sheet, not the OS
magnifier. On desktop (mouse) leave the message text selectable so it can be highlighted + copied. */
@media (hover:none){ #msgs .bubble{-webkit-user-select:none;user-select:none;} }
/* Multi-select mode: a click toggles the whole message, so never select text (any device). */
body.sel-mode #msgs .bubble{-webkit-user-select:none;user-select:none;}
#msgs .bubble img{-webkit-user-drag:none;} #msgs .bubble img{-webkit-user-drag:none;}
.att-file{display:inline-flex;align-items:center;gap:.4rem;background:rgba(0,0,0,.06);border:1px solid var(--line);border-radius:8px;padding:.4rem .6rem;color:inherit;text-decoration:none;font-size:.85rem;margin:.15rem 0;max-width:240px;} .att-file{display:inline-flex;align-items:center;gap:.4rem;background:rgba(0,0,0,.06);border:1px solid var(--line);border-radius:8px;padding:.4rem .6rem;color:inherit;text-decoration:none;font-size:.85rem;margin:.15rem 0;max-width:240px;}
.att-file span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .att-file span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
@@ -1306,6 +1320,18 @@ function twemojify(_el){ /* native emoji: nothing to do */ }</script>
// juggling here — we only tag <html> so CSS can make small native-only tweaks if needed. // juggling here — we only tag <html> so CSS can make small native-only tweaks if needed.
(function(){ try{ var C=window.Capacitor; if(!C||!C.isNativePlatform||!C.isNativePlatform()) return; (function(){ try{ var C=window.Capacitor; if(!C||!C.isNativePlatform||!C.isNativePlatform()) return;
var p=(C.getPlatform&&C.getPlatform())||''; var r=document.documentElement; r.classList.add('native-app'); if(p) r.classList.add('native-'+p); var p=(C.getPlatform&&C.getPlatform())||''; var r=document.documentElement; r.classList.add('native-app'); if(p) r.classList.add('native-'+p);
// Android edge-to-edge: the system-bar icons default to LIGHT (white) → invisible on our white app (the
// clock/battery/nav icons vanish). Set them to DARK content — in @capacitor-community/safe-area, style
// 'LIGHT' means "dark content on a light background". Also mirrored in capacitor.config.json (statusBarStyle/
// navigationBarStyle) for a permanent fix on the next build; this runtime call fixes already-installed APKs.
// System-bar look = OPTION 1 (brand-blue bars) on BOTH iOS and Android. CSS .sysbar-blue fills the status +
// nav inset regions with brand blue; the bar ICONS are set to light/white to read on it. Android uses the
// safe-area plugin's setSystemBarsStyle (style 'DARK' = light content); iOS uses @capacitor/status-bar
// setStyle (Style 'DARK' = light content) — the canonical iOS status-bar API (home.html makes no other
// status-bar calls, so there's no conflict with the safe-area plugin).
if(p){ r.classList.add('sysbar-blue'); }
if(p==='android'){ try{ var SA=C.Plugins&&C.Plugins.SafeArea; if(SA&&SA.setSystemBarsStyle){ SA.setSystemBarsStyle({ style:'DARK', type:'STATUS_BAR' }); SA.setSystemBarsStyle({ style:'DARK', type:'NAVIGATION_BAR' }); } }catch(_){} }
else if(p==='ios'){ try{ var SB=C.Plugins&&C.Plugins.StatusBar; if(SB&&SB.setStyle){ SB.setStyle({ style:'DARK' }); } }catch(_){} }
// Keyboard. The device log proved that on our build (resize:none) the WebView does NOT resize and // Keyboard. The device log proved that on our build (resize:none) the WebView does NOT resize and
// VisualViewport does NOT reflect the keyboard (clientHeight & vvHeight both stay full) — so the ONLY // VisualViewport does NOT reflect the keyboard (clientHeight & vvHeight both stay full) — so the ONLY
// reliable signal is the Capacitor Keyboard plugin's reported height. Now that the iOS auto-zoom is // reliable signal is the Capacitor Keyboard plugin's reported height. Now that the iOS auto-zoom is
@@ -1320,6 +1346,12 @@ function twemojify(_el){ /* native emoji: nothing to do */ }</script>
KB.addListener('keyboardWillShow', function(info){ var raw=(info&&info.keyboardHeight)||0; var kb = raw>window.innerHeight ? raw/(window.devicePixelRatio||1) : raw; setKb(kb); }); KB.addListener('keyboardWillShow', function(info){ var raw=(info&&info.keyboardHeight)||0; var kb = raw>window.innerHeight ? raw/(window.devicePixelRatio||1) : raw; setKb(kb); });
KB.addListener('keyboardWillHide', function(){ setKb(0); }); KB.addListener('keyboardWillHide', function(){ setKb(0); });
KB.addListener('keyboardDidHide', function(){ setKb(0); }); // belt-and-suspenders: some close paths only fire Did* KB.addListener('keyboardDidHide', function(){ setKb(0); }); // belt-and-suspenders: some close paths only fire Did*
// TEMP kbdiag (remove after diagnosing the Android repeat-open gap): logs the metrics that tell us whether
// the WebView resizes (interactive-widget) vs relies on --kb, and any residual across open #1 vs #2.
var _kbn=0, _cs=function(){ return getComputedStyle(document.documentElement).getPropertyValue('--kb').trim(); };
KB.addListener('keyboardWillShow', function(info){ _kbn++; console.log('[kbdiag] willShow#'+_kbn+' kbH='+((info&&info.keyboardHeight)||0)+' innerH='+window.innerHeight+' clientH='+document.documentElement.clientHeight+' vvH='+(window.visualViewport?Math.round(window.visualViewport.height):'-')+' preKb='+_cs()); });
if(KB.addListener){ KB.addListener('keyboardDidShow', function(info){ console.log('[kbdiag] didShow kbH='+((info&&info.keyboardHeight)||0)+' innerH='+window.innerHeight+' clientH='+document.documentElement.clientHeight+' vvH='+(window.visualViewport?Math.round(window.visualViewport.height):'-')+' curKb='+_cs()+' kbOpen='+document.body.classList.contains('kb-open')); }); }
KB.addListener('keyboardDidHide', function(){ console.log('[kbdiag] didHide innerH='+window.innerHeight+' clientH='+document.documentElement.clientHeight+' vvH='+(window.visualViewport?Math.round(window.visualViewport.height):'-')+' curKb='+_cs()); });
} }
}catch(_){} })(); }catch(_){} })();
// Browser / iOS home-screen PWA (NO Capacitor Keyboard plugin): lift the composer above the on-screen // Browser / iOS home-screen PWA (NO Capacitor Keyboard plugin): lift the composer above the on-screen
@@ -1934,11 +1966,13 @@ async function openSharedItems(kind,id,name){
ov.innerHTML='<div class="modal gi"><div class="gi-head" style="margin-bottom:.6rem"><button class="gi-photo" id="shPhoto" title="View photo"><span class="avatar" style="width:46px;height:46px;flex:0 0 46px;background:'+avColor(name)+'">'+pEsc(initials(name||'?'))+((r0&&r0.avatar)?'<img class="av-img" src="'+pEsc(r0.avatar)+'" alt="" onerror="this.remove()">':'')+'</span></button><div class="gi-name"><div class="gi-title-row"><span class="gi-title">'+pEsc(name||'')+'</span><button class="fav-star'+(fav?' on':'')+'" id="shFav" title="'+(fav?'Remove from favourites':'Add to favourites')+'">'+ic('star',16)+'</button></div><div class="gi-sub"><span class="st-dot '+statusCls(r0)+'"></span>'+statusLabel(r0)+'</div></div><button class="iconbtn" id="shClose">'+ic('x',18)+'</button></div>' ov.innerHTML='<div class="modal gi"><div class="gi-head" style="margin-bottom:.6rem"><button class="gi-photo" id="shPhoto" title="View photo"><span class="avatar" style="width:46px;height:46px;flex:0 0 46px;background:'+avColor(name)+'">'+pEsc(initials(name||'?'))+((r0&&r0.avatar)?'<img class="av-img" src="'+pEsc(r0.avatar)+'" alt="" onerror="this.remove()">':'')+'</span></button><div class="gi-name"><div class="gi-title-row"><span class="gi-title">'+pEsc(name||'')+'</span><button class="fav-star'+(fav?' on':'')+'" id="shFav" title="'+(fav?'Remove from favourites':'Add to favourites')+'">'+ic('star',16)+'</button></div><div class="gi-sub"><span class="st-dot '+statusCls(r0)+'"></span>'+statusLabel(r0)+'</div></div><button class="iconbtn" id="shClose">'+ic('x',18)+'</button></div>'
+mediaRowHTML() +mediaRowHTML()
+((kind==='dm' && id!==(ME&&ME.id))?'<button id="shBlock" style="width:100%;margin-top:.7rem;display:inline-flex;align-items:center;justify-content:center;gap:.4rem;border:1px solid '+(BLOCKED.has(id)?'#c7d6f0':'#f1c4c4')+';border-radius:10px;background:#fff;color:'+(BLOCKED.has(id)?'#1F3B73':'#b91c1c')+';font-size:.9rem;font-weight:600;padding:.6rem;cursor:pointer">'+ic('ban',16)+' '+(BLOCKED.has(id)?'Unblock contact':'Block contact')+'</button>':'') +((kind==='dm' && id!==(ME&&ME.id))?'<button id="shBlock" style="width:100%;margin-top:.7rem;display:inline-flex;align-items:center;justify-content:center;gap:.4rem;border:1px solid '+(BLOCKED.has(id)?'#c7d6f0':'#f1c4c4')+';border-radius:10px;background:#fff;color:'+(BLOCKED.has(id)?'#1F3B73':'#b91c1c')+';font-size:.9rem;font-weight:600;padding:.6rem;cursor:pointer">'+ic('ban',16)+' '+(BLOCKED.has(id)?'Unblock contact':'Block contact')+'</button>':'')
+((kind==='dm' && id!==(ME&&ME.id))?'<button id="shDelChat" style="width:100%;margin-top:.5rem;display:inline-flex;align-items:center;justify-content:center;gap:.4rem;border:1px solid #f1c4c4;border-radius:10px;background:#fff;color:#b91c1c;font-size:.9rem;font-weight:600;padding:.6rem;cursor:pointer">'+ic('trash',16)+' Delete chat</button>':'')
+'</div>'; +'</div>';
document.body.appendChild(ov); document.body.appendChild(ov);
ov.onclick=e=>{ if(e.target===ov) ov.remove(); }; ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#shClose').onclick=()=>ov.remove(); ov.querySelector('#shClose').onclick=()=>ov.remove();
const bb=ov.querySelector('#shBlock'); if(bb) bb.onclick=()=>{ ov.remove(); toggleBlock(id, name); }; // block/unblock this contact from their info panel const bb=ov.querySelector('#shBlock'); if(bb) bb.onclick=()=>{ ov.remove(); toggleBlock(id, name); }; // block/unblock this contact from their info panel
const dcb=ov.querySelector('#shDelChat'); if(dcb) dcb.onclick=()=>{ ov.remove(); deleteChat('dm', id); }; // delete this chat for me only
const sp=ov.querySelector('#shPhoto'); if(sp) sp.onclick=()=>{ const r=rowFor(kind,id)||r0; if(r&&r.avatar) openLightbox(r.avatar); else toast('No profile photo'); }; // #5: click the DP in the info view to preview it const sp=ov.querySelector('#shPhoto'); if(sp) sp.onclick=()=>{ const r=rowFor(kind,id)||r0; if(r&&r.avatar) openLightbox(r.avatar); else toast('No profile photo'); }; // #5: click the DP in the info view to preview it
const fb=ov.querySelector('#shFav'); if(fb) fb.onclick=async()=>{ const r=rowFor(kind,id); const on=!(r&&r.favorite); if(r) r.favorite=on; fb.classList.toggle('on',on); fb.title=on?'Remove from favourites':'Add to favourites'; try{ await postJSON('/api/favorites',{kind,id,on}); }catch(_){} renderChats(searchVal()); }; const fb=ov.querySelector('#shFav'); if(fb) fb.onclick=async()=>{ const r=rowFor(kind,id); const on=!(r&&r.favorite); if(r) r.favorite=on; fb.classList.toggle('on',on); fb.title=on?'Remove from favourites':'Add to favourites'; try{ await postJSON('/api/favorites',{kind,id,on}); }catch(_){} renderChats(searchVal()); };
wireMediaEntry(ov, kind, id, name); wireMediaEntry(ov, kind, id, name);
@@ -2141,7 +2175,7 @@ function welcomeHTML(){
+ '<div class="wcards">' + '<div class="wcards">'
+ '<div class="wcard" data-go="share"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></div><h3>Share Screen</h3><p>Show your screen with a 6-digit code</p></div>' + '<div class="wcard" data-go="share"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></div><h3>Share Screen</h3><p>Show your screen with a 6-digit code</p></div>'
+ '<div class="wcard" data-go="connect"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></div><h3>Connect Screen</h3><p>Enter a customer\'s code to help</p></div>' + '<div class="wcard" data-go="connect"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></div><h3>Connect Screen</h3><p>Enter a customer\'s code to help</p></div>'
+ '<div class="wcard" data-go="meeting"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg></div><h3>Meeting</h3><p>Multi-party video — coming soon</p></div>' + '<div class="wcard" data-go="meeting"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg></div><h3>Meeting</h3><p>Start or join a video meeting</p></div>'
+ '</div></div>'; + '</div></div>';
} }
function wireWelcome(){ document.querySelectorAll('#chatPanel .wcard').forEach(card=>{ card.onclick=()=>switchTab(card.dataset.go); }); } function wireWelcome(){ document.querySelectorAll('#chatPanel .wcard').forEach(card=>{ card.onclick=()=>switchTab(card.dataset.go); }); }
@@ -2833,6 +2867,25 @@ async function deleteForMe(id){ if(!(await bzConfirm('This will remove the messa
// #18: the branded delete dialog IS the confirmation, so it calls these confirm-free cores directly. // #18: the branded delete dialog IS the confirmation, so it calls these confirm-free cores directly.
async function _doDeleteForMe(id){ try{ await postJSON('/api/messages/hide',{id}); removeMsgLocally(id); }catch(e){ toast(e.message||'Could not delete'); } } async function _doDeleteForMe(id){ try{ await postJSON('/api/messages/hide',{id}); removeMsgLocally(id); }catch(e){ toast(e.message||'Could not delete'); } }
async function _doDeleteForEveryone(id){ try{ await postJSON('/api/messages/delete',{id}); markMsgDeleted(id); }catch(e){ toast(e.message||'Could not delete'); } } async function _doDeleteForEveryone(id){ try{ await postJSON('/api/messages/delete',{id}); markMsgDeleted(id); }catch(e){ toast(e.message||'Could not delete'); } }
// "Delete chat" — SELF-ONLY. Hides the whole conversation from MY view (and my other devices); the other
// person keeps their copy entirely (server bulk-hides via the same per-user mechanism as "Delete for me").
async function deleteChat(kind, id){
if(!(await bzConfirm('This removes the chat for you only. The other person keeps their copy.', {title:'Delete this chat?', okText:'Delete', danger:true}))) return;
try{
await postJSON('/api/messages/clear', kind==='group' ? { group:id } : { with:id });
_dropConversation(kind, id);
toast('Chat deleted');
}catch(e){ toast(e.message||'Could not delete chat'); }
}
// A conversation cleared here or on another of my devices → drop it from the list + close it if it's open.
function onChatCleared(d){ if(d&&d.id) _dropConversation(d.kind||'dm', d.id); }
function _dropConversation(kind, id){
ROWS = ROWS.filter(r=>!(r.kind===kind && r.id===id));
try{ THREAD_CACHE.delete(kind+':'+id); }catch(_){}
if(selected && selected.kind===kind && selected.id===id){ try{ showWelcome(); }catch(_){} }
try{ renderChats(searchVal()); }catch(_){}
try{ updateRailUnread(); }catch(_){}
}
// #18: one "Delete" entry opens a branded dialog with clear choices + a cancel (✕). "Delete for everyone" // #18: one "Delete" entry opens a branded dialog with clear choices + a cancel (✕). "Delete for everyone"
// only appears on your OWN, not-yet-deleted messages; "Delete for me" is always available (even to hide a // only appears on your OWN, not-yet-deleted messages; "Delete for me" is always available (even to hide a
// tombstone). Clicking the backdrop or ✕ cancels. // tombstone). Clicking the backdrop or ✕ cancels.
@@ -3629,7 +3682,7 @@ async function openConvo(kind,id){
// #1: drag-and-drop a file/video/image anywhere on the conversation to send it. // #1: drag-and-drop a file/video/image anywhere on the conversation to send it.
el.ondragover=(e)=>{ if(e.dataTransfer&&Array.from(e.dataTransfer.types||[]).includes('Files')){ e.preventDefault(); el.classList.add('drag-over'); } }; el.ondragover=(e)=>{ if(e.dataTransfer&&Array.from(e.dataTransfer.types||[]).includes('Files')){ e.preventDefault(); el.classList.add('drag-over'); } };
el.ondragleave=(e)=>{ if(e.relatedTarget===null||!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); }; el.ondragleave=(e)=>{ if(e.relatedTarget===null||!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); };
el.ondrop=(e)=>{ const f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0]; if(f){ e.preventDefault(); el.classList.remove('drag-over'); uploadFile(f); } }; el.ondrop=(e)=>{ const fl=e.dataTransfer&&e.dataTransfer.files; if(fl&&fl.length){ e.preventDefault(); el.classList.remove('drag-over'); Array.from(fl).forEach(uploadFile); } }; // queue EVERY dropped file (was files[0] only → dragging multiple sent just the first)
const back=document.getElementById('convoBack'); if(back) back.onclick=showWelcome; const back=document.getElementById('convoBack'); if(back) back.onclick=showWelcome;
const form=document.getElementById('composer'); if(form) form.addEventListener('submit',(e)=>{ e.preventDefault(); sendMessage(); }); const form=document.getElementById('composer'); if(form) form.addEventListener('submit',(e)=>{ e.preventDefault(); sendMessage(); });
// #14 ROOT CAUSE: do NOT call cancelEdit() here. It runs _restoreDraftAfterEdit → setDraft(selected,'') which // #14 ROOT CAUSE: do NOT call cancelEdit() here. It runs _restoreDraftAfterEdit → setDraft(selected,'') which
@@ -4208,6 +4261,15 @@ let _nativeTok=null;
async function setupNativePush(){ async function setupNativePush(){
const PN=capPlugin('PushNotifications'); const plat=nativePlatform(); const PN=capPlugin('PushNotifications'); const plat=nativePlatform();
if(!PN || (plat!=='ios' && plat!=='android')) return false; // not a mobile native app if(!PN || (plat!=='ios' && plat!=='android')) return false; // not a mobile native app
// Android push needs Firebase: google-services.json in the build AND FCM on the server. Without it,
// PushNotifications.register() throws "Default FirebaseApp is not initialized" — an UNCAUGHT NATIVE crash
// (thrown on Capacitor's plugin thread; a JS try/catch can't stop it, it kills the app on every launch).
// So on Android, only register when the server reports FCM is configured (we add google-services.json to the
// build + FCM_SERVICE_ACCOUNT to the server together). iOS (APNs) is unaffected and registers as before.
if(plat==='android'){
let cfg={}; try{ cfg=await fetch('/api/meetings/config').then(r=>r.json()); }catch(_){}
if(!cfg || !cfg.fcm){ console.log('[push] Android FCM not configured on the server — skipping native push registration (avoids the FirebaseApp-not-initialized crash)'); return true; }
}
try{ try{
PN.addListener('registration', async (t)=>{ const token=t&&t.value; if(!token) return; _nativeTok=token; PN.addListener('registration', async (t)=>{ const token=t&&t.value; if(!token) return; _nativeTok=token;
try{ await postJSON('/api/v1/devices',{ platform:plat, token }); pushActive=true; console.log('[push] native device registered'); } try{ await postJSON('/api/v1/devices',{ platform:plat, token }); pushActive=true; console.log('[push] native device registered'); }
@@ -4766,7 +4828,7 @@ function connectChatWs(){
if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; } if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; }
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws'); chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} if(_chatConnectedOnce) resyncChat(); _chatConnectedOnce=true; }; chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} if(_chatConnectedOnce) resyncChat(); _chatConnectedOnce=true; };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-hidden') onChatHidden(d); else if(d.type==='chat-pinned') onChatPinned(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='call-taken') onCallTaken(d); else if(d.type==='call-answered') onCallAnswered(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); else if(d.type==='report-resolved'){ addNotif({icon:'flag', text:'An admin reviewed a message you reported.'}); try{ playPing(); }catch(_){} try{ if(window.BZToast) BZToast.info('An admin reviewed a message you reported.'); else toast('An admin reviewed a message you reported.'); }catch(_){} } else if(d.type==='report-new'){ addNotif({icon:'flag', text:'A message was reported — open “Reported messages” to review.'}); try{ if(window.BZToast) BZToast.info('A message was reported — review it in Reported messages.'); }catch(_){} } }; chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-hidden') onChatHidden(d); else if(d.type==='chat-cleared') onChatCleared(d); else if(d.type==='chat-pinned') onChatPinned(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='call-taken') onCallTaken(d); else if(d.type==='call-answered') onCallAnswered(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); else if(d.type==='report-resolved'){ addNotif({icon:'flag', text:'An admin reviewed a message you reported.'}); try{ playPing(); }catch(_){} try{ if(window.BZToast) BZToast.info('An admin reviewed a message you reported.'); else toast('An admin reviewed a message you reported.'); }catch(_){} } else if(d.type==='report-new'){ addNotif({icon:'flag', text:'A message was reported — open “Reported messages” to review.'}); try{ if(window.BZToast) BZToast.info('A message was reported — review it in Reported messages.'); }catch(_){} } };
chatWs.onclose=()=>{ if(_chatReconnectT) clearTimeout(_chatReconnectT); _chatReconnectT=setTimeout(connectChatWs, 3000); }; // auto-reconnect (single pending timer) chatWs.onclose=()=>{ if(_chatReconnectT) clearTimeout(_chatReconnectT); _chatReconnectT=setTimeout(connectChatWs, 3000); }; // auto-reconnect (single pending timer)
}catch(_){} }catch(_){}
} }
@@ -5110,7 +5172,7 @@ function renderMeetingLobby(){
const el=document.getElementById('meetingPanel'); if(!el) return; const el=document.getElementById('meetingPanel'); if(!el) return;
el.innerHTML='<div class="meet-dash">' el.innerHTML='<div class="meet-dash">'
+ '<div class="md-top">' + '<div class="md-top">'
+ '<div class="md-title"><h1>Meetings</h1><p>Start or join a video meeting, or schedule one for later. Small group (mesh) for now — larger rooms coming with the SFU.</p></div>' + '<div class="md-title"><h1>Meetings</h1><p>Start or join a video meeting, or schedule one for later.</p></div>'
+ '<div class="md-actions">' + '<div class="md-actions">'
+ '<div class="md-join"><input id="meetCode" placeholder="Enter code" inputmode="numeric" maxlength="6"><button class="btn primary" id="meetJoinBtn">Join</button></div>' + '<div class="md-join"><input id="meetCode" placeholder="Enter code" inputmode="numeric" maxlength="6"><button class="btn primary" id="meetJoinBtn">Join</button></div>'
+ '<button class="btn" id="meetStart">'+ic('video',16)+' Start a meeting</button>' + '<button class="btn" id="meetStart">'+ic('video',16)+' Start a meeting</button>'
+13 -2
View File
@@ -30,7 +30,14 @@ if (webpush && PUBLIC && PRIVATE) {
// ---------------- FCM (Android), HTTP v1 ---------------- // ---------------- FCM (Android), HTTP v1 ----------------
let fcmSA = null; // { client_email, private_key, project_id } let fcmSA = null; // { client_email, private_key, project_id }
(function loadFcm() { (function loadFcm() {
const raw = process.env.FCM_SERVICE_ACCOUNT; // FCM_SERVICE_ACCOUNT = inline JSON or a file path. FCM_SERVICE_ACCOUNT_B64 = base64 of the JSON — the
// preferred way to put the service-account key in .env, since it's a single env-safe token (no quotes,
// spaces, or newlines to break env_file/compose interpolation).
let raw = process.env.FCM_SERVICE_ACCOUNT || '';
if (!raw && process.env.FCM_SERVICE_ACCOUNT_B64) {
try { raw = Buffer.from(process.env.FCM_SERVICE_ACCOUNT_B64, 'base64').toString('utf8'); }
catch (e) { console.warn('[push] FCM_SERVICE_ACCOUNT_B64 decode failed:', e.message); }
}
if (!raw) return; if (!raw) return;
try { fcmSA = JSON.parse(raw.trim().startsWith('{') ? raw : fs.readFileSync(raw, 'utf8')); } try { fcmSA = JSON.parse(raw.trim().startsWith('{') ? raw : fs.readFileSync(raw, 'utf8')); }
catch (e) { console.warn('[push] FCM service account unreadable:', e.message); } catch (e) { console.warn('[push] FCM service account unreadable:', e.message); }
@@ -179,6 +186,10 @@ console.log(enabled.length ? '[push] enabled: ' + enabled.join(', ') : '[push] d
function isEnabled() { return webReady; } // Web Push specifically (drives /api/push/vapid) function isEnabled() { return webReady; } // Web Push specifically (drives /api/push/vapid)
function publicKey() { return webReady ? PUBLIC : ''; } function publicKey() { return webReady ? PUBLIC : ''; }
// Whether Android FCM is configured on the server. The Android app must NOT call PushNotifications.register()
// unless Firebase is set up (client google-services.json + this) — otherwise it throws "Default FirebaseApp is
// not initialized", an uncaught NATIVE crash. The web uses this flag to gate Android push registration.
function fcmReady() { return !!fcmSA; }
// Fire-and-forget to every channel the user has: Web Push subscriptions + native device // Fire-and-forget to every channel the user has: Web Push subscriptions + native device
// tokens. Dead endpoints/tokens are pruned. Never throws. // tokens. Dead endpoints/tokens are pruned. Never throws.
@@ -207,4 +218,4 @@ async function sendToUser(userId, payload) {
} }
} }
module.exports = { isEnabled, publicKey, sendToUser, sendCallNotification, sendCallCancel }; module.exports = { isEnabled, publicKey, sendToUser, sendCallNotification, sendCallCancel, fcmReady };
+18
View File
@@ -213,6 +213,24 @@ const messages = {
markDeleted: (id) => db.prepare("UPDATE messages SET deleted=1, body='', attachment_id=NULL, poll_id=NULL WHERE id=?").run(id), markDeleted: (id) => db.prepare("UPDATE messages SET deleted=1, body='', attachment_id=NULL, poll_id=NULL WHERE id=?").run(id),
// #18 Delete-for-me: hide a message from ONE user's view (the row + everyone else are untouched). // #18 Delete-for-me: hide a message from ONE user's view (the row + everyone else are untouched).
hideForUser: (messageId, userId) => db.prepare('INSERT INTO message_hidden (message_id,user_id,hidden_at) VALUES (?,?,?) ON CONFLICT(message_id,user_id) DO NOTHING').run(messageId, userId, now()), hideForUser: (messageId, userId) => db.prepare('INSERT INTO message_hidden (message_id,user_id,hidden_at) VALUES (?,?,?) ON CONFLICT(message_id,user_id) DO NOTHING').run(messageId, userId, now()),
// "Delete chat" (self-only): hide the ENTIRE DM thread with one peer from a single user's view. Bulk-inserts
// message_hidden rows for every message of the pair, reusing the exact filters the thread + conversation list
// already apply (they skip message_hidden), so the chat disappears for this user while the peer keeps their
// copy. New messages afterwards start a fresh thread (they aren't hidden) — WhatsApp-style.
hideThreadForUser: (teamId, userId, peerId) => db.prepare(
`INSERT INTO message_hidden (message_id,user_id,hidden_at)
SELECT id, ?, ? FROM messages
WHERE team_id=? AND conversation_id IS NULL
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))
ON CONFLICT (message_id,user_id) DO NOTHING`
).run(userId, now(), teamId, userId, peerId, peerId, userId),
// "Delete chat" (self-only) for a GROUP conversation: hide every message from one member's view (they stay a
// member; new messages arrive as a fresh thread). The peer/other members are untouched.
hideConversationForUser: (conversationId, userId) => db.prepare(
`INSERT INTO message_hidden (message_id,user_id,hidden_at)
SELECT id, ?, ? FROM messages WHERE conversation_id=?
ON CONFLICT (message_id,user_id) DO NOTHING`
).run(userId, now(), conversationId),
hiddenForUser: async (userId) => (await db.prepare('SELECT message_id FROM message_hidden WHERE user_id=?').all(userId)).map((r) => r.message_id), hiddenForUser: async (userId) => (await db.prepare('SELECT message_id FROM message_hidden WHERE user_id=?').all(userId)).map((r) => r.message_id),
// Last message in a group that THIS user hasn't hidden (so a "delete for me" on the last message // Last message in a group that THIS user hasn't hidden (so a "delete for me" on the last message
// rolls the sidebar preview back to the previous one, instead of showing what they just removed). // rolls the sidebar preview back to the previous one, instead of showing what they just removed).
+20 -1
View File
@@ -993,7 +993,7 @@ route('POST', '/api/calls/invite', async (req, res) => {
// Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is // Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…). // false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
route('GET', '/api/meetings/config', (req, res) => { route('GET', '/api/meetings/config', (req, res) => {
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED }); json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED, fcm: PUSH.fcmReady() });
}); });
// #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending. // #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending.
@@ -1768,6 +1768,25 @@ route('POST', '/api/messages/hide', async (req, res) => {
try { CHAT.pushToUser(u.id, { type: 'chat-hidden', id, conversation_id: m.conversation_id || null }); } catch (_) {} // my other devices try { CHAT.pushToUser(u.id, { type: 'chat-hidden', id, conversation_id: m.conversation_id || null }); } catch (_) {} // my other devices
json(res, 200, { ok: true }); json(res, 200, { ok: true });
}); });
// "Delete chat" — SELF-ONLY. Hides the whole conversation from MY view (synced to my other devices); the
// other party keeps their copy entirely. DM via {with:peerId}; group via {group:conversationId} (I stay a
// member — new messages will start a fresh thread). Reuses the per-user "deleted for me" hide mechanism.
route('POST', '/api/messages/clear', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { with: withRaw, group } = await readBody(req);
if (group) {
if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
await R.messages.hideConversationForUser(group, u.id);
try { CHAT.pushToUser(u.id, { type: 'chat-cleared', kind: 'group', id: group }); } catch (_) {} // my other devices
return json(res, 200, { ok: true });
}
const peer = await R.users.resolve(withRaw);
if (!peer) return json(res, 400, { error: 'with or group required' });
await R.messages.hideThreadForUser(u.team_id, u.id, peer);
try { CHAT.pushToUser(u.id, { type: 'chat-cleared', kind: 'dm', id: peer }); } catch (_) {} // my other devices
json(res, 200, { ok: true });
});
// #13 Pin / unpin a message for the whole conversation (any participant may pin/unpin). Broadcast so every // #13 Pin / unpin a message for the whole conversation (any participant may pin/unpin). Broadcast so every
// participant's pinned strip updates live. // participant's pinned strip updates live.
route('POST', '/api/messages/pin', async (req, res) => { route('POST', '/api/messages/pin', async (req, res) => {