Compare commits

..

20 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
Sravan 620039a2ff iOS remote support: Share Screen -> Connect Screen over LiveKit (core)
Rebuild the iOS remote-support screen share the RIGHT way: keep the exact
Share/Connect UX + session (consent + symmetric session-ended), swap only the
media to LiveKit since WKWebView can't getDisplayMedia. (Replaces the earlier
"route to a meeting" detour, which was reverted.)

Flow: customer taps Share Screen -> gets a 6-digit code (unchanged UI) -> helper
enters it in Connect Screen -> on the customer's "Allow", the app publishes the
screen natively (ReplayKit -> LiveKit) into a per-session room and tells the
agent it's a LiveKit session -> connect.html joins that room and shows the screen
in its existing viewer (recording/controls intact). Chat runs over the session
socket (no P2P data channel in this mode). Either side ending fires the existing
session-ended -> both tear down (symmetric disconnect).

- signaling.js: relay 'rs-livekit' + 'rs-chat' between the two ends.
- home.html: parent bridge so the /share iframe can drive startMeetingScreenShare/
  stop on the native plugin (+ a capability handshake).
- share.html (iOS): publish via native LiveKit instead of getDisplayMedia; chat
  over WS; hide mic (voice = next iteration) + remote-control (impossible on iOS).
- connect.html: LiveKit viewer for iOS-shared sessions, reusing the P2P viewer.

Web-only, no new build (reuses the shipped startMeetingScreenShare). Desktop
Share/Connect P2P unchanged. Two-way voice is the planned follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 13:16:06 +05:30
Sravan 224a2800c5 Revert "iOS "Share Screen": route to a screen-share meeting (remote-support on iPhone)"
This reverts commit eb685b42ca.
2026-08-22 12:21:36 +05:30
Sravan ba51a3c5d7 Revert "iOS Share Screen: show the code and wait; start sharing when the helper joins"
This reverts commit 1a089c0349.
2026-08-22 12:21:36 +05:30
17 changed files with 372 additions and 57 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);
+27 -1
View File
@@ -106,6 +106,26 @@ const card=document.getElementById('card'), wrap=document.getElementById('wrap')
agentChip=document.getElementById('agentChip'), bar=document.getElementById('bar'), agentChip=document.getElementById('agentChip'), bar=document.getElementById('bar'),
topbar=document.getElementById('topbar'), video=document.getElementById('video'), barStatus=document.getElementById('barStatus'); topbar=document.getElementById('topbar'), video=document.getElementById('video'), barStatus=document.getElementById('barStatus');
let ws,pc,inputChannel,chatChannel,sessionId,me=null; let ws,pc,inputChannel,chatChannel,sessionId,me=null;
let RS_LK=false, lkRoom=null; // iOS-shared session: view the screen over LiveKit instead of P2P
// Load the LiveKit browser SDK on demand (same vendored build the meeting UI uses).
function sfuLoadLib(){ return new Promise((res,rej)=>{ if(window.LivekitClient) return res(window.LivekitClient); const s=document.createElement('script'); s.src='/vendor/livekit-client.umd.min.js'; s.onload=()=>res(window.LivekitClient); s.onerror=()=>rej(new Error('livekit sdk failed to load')); document.head.appendChild(s); }); }
// The customer is on iPhone (WKWebView can't getDisplayMedia), so they publish their screen over LiveKit.
// Join that room and show the screen in the SAME viewer we use for P2P (recording/chat/controls unchanged).
async function startLiveKitView(room){
const statusEl=document.getElementById('status');
if(statusEl){ statusEl.className='status'; statusEl.innerHTML='<img src="/loaders/loader-orbit.svg" width="20" height="20" style="vertical-align:-5px;margin-right:7px" alt="">Connecting to the shared screen…'; }
try{
const LK=await sfuLoadLib();
const tk=await fetch('/api/meetings/token',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({room})}).then(r=>r.json());
if(!tk||!tk.token) throw new Error('no token');
const r=new LK.Room({adaptiveStream:false,dynacast:false}); lkRoom=r;
const showVideo=(mst)=>{ video.srcObject=new MediaStream([mst]); if(typeof wrap!=='undefined'&&wrap) wrap.style.display='none'; if(typeof topbar!=='undefined'&&topbar) topbar.style.display='none'; video.style.display='block'; try{ video.play(); }catch(_){}; try{ video.focus(); }catch(_){}; buildBar(); };
const attach=(track)=>{ if(!track) return; const mst=track.mediaStreamTrack; if(track.kind==='video'){ showVideo(mst); } else { let a=document.getElementById('remoteAudio'); if(!a){ a=document.createElement('audio'); a.id='remoteAudio'; a.autoplay=true; document.body.appendChild(a); } a.srcObject=new MediaStream([mst]); } };
r.on(LK.RoomEvent.TrackSubscribed,(track)=>attach(track));
await r.connect(tk.url, tk.token);
try{ r.remoteParticipants.forEach((p)=>{ p.trackPublications.forEach((pub)=>{ if(pub.track) attach(pub.track); }); }); }catch(_){}
}catch(e){ if(statusEl){ statusEl.className='status err'; statusEl.textContent='Could not connect to the shared screen.'; } }
}
async function api(path,body,method='POST'){ async function api(path,body,method='POST'){
const opt={method,headers:{'Content-Type':'application/json'}}; const opt={method,headers:{'Content-Type':'application/json'}};
@@ -191,6 +211,8 @@ function connectWS(){
const ans=await pc.createAnswer(); await pc.setLocalDescription(ans); const ans=await pc.createAnswer(); await pc.setLocalDescription(ans);
ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break; ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break;
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break; case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
case 'rs-livekit': RS_LK=true; startLiveKitView(m.room); break; // iOS customer shares over LiveKit — view it there
case 'rs-chat': if(m.msg) addChat({from:'other',name:m.msg.name||'Customer',text:m.msg.text}); break; // chat over the socket in LiveKit mode
case 'transcript': if(recogActive&&m.text) addLine('customer', m.name||'Customer', m.text, !!m.chat); break; case 'transcript': if(recogActive&&m.text) addLine('customer', m.name||'Customer', m.text, !!m.chat); break;
case 'session-denied': renderEnded('The customer declined the request.'); break; case 'session-denied': renderEnded('The customer declined the request.'); break;
case 'session-ended': { case 'session-ended': {
@@ -213,6 +235,7 @@ function renderWaiting(){
function renderEnded(msg){ function renderEnded(msg){
bzcSession(false); bzcSession(false);
try{ if(lkRoom){ lkRoom.disconnect(); lkRoom=null; } }catch(_){} // tear down the LiveKit view (symmetric disconnect)
try{ stopRecording(); }catch(_){} try{ stopRecording(); }catch(_){}
removeSessionUI(); removeSessionUI();
document.body.classList.remove('has-bar'); document.body.classList.remove('has-bar');
@@ -385,7 +408,10 @@ let __ac=null;
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}} function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}} function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){} try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}addChat({from:'__self',name:'You',text:t});if(recogActive)addLine('agent',(me&&(me.name||me.email))||'Agent',t,true);i.value='';} function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;
if(RS_LK){ try{ ws.send(JSON.stringify({type:'rs-chat',sessionId,msg:{name:(me&&(me.name||me.email))||'Support agent',text:t}})); }catch(_){} }
else if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}
addChat({from:'__self',name:'You',text:t});if(recogActive)addLine('agent',(me&&(me.name||me.email))||'Agent',t,true);i.value='';}
function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});} function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
async function setupPeer(){ async function setupPeer(){
+73 -45
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>'
@@ -6153,9 +6215,6 @@ async function onMeetMsg(e){
if(_pendingJoinMic){ _pendingJoinMic=false; if(!meetMic){ try{ await toggleMic(); }catch(_){} } } if(_pendingJoinMic){ _pendingJoinMic=false; if(!meetMic){ try{ await toggleMic(); }catch(_){} } }
if(_pendingJoinCam){ _pendingJoinCam=false; if(!meetCam){ try{ await toggleCam(); }catch(_){} } } if(_pendingJoinCam){ _pendingJoinCam=false; if(!meetCam){ try{ await toggleCam(); }catch(_){} } }
refreshMeetPanel(); updateHostControls(); refreshMeetPanel(); updateHostControls();
// iOS "Share Screen" entry: show the code + a "waiting for them to join" screen. The screen broadcast
// only STARTS once the helper joins (see meeting-peer-joined) — matching the remote-support mental model.
if(_shareWaitMode){ showShareWaitOverlay(meetRoom); }
return; return;
} }
// The room is gone (host ended it / code expired). Say so plainly instead of hanging on "Connecting…" // The room is gone (host ended it / code expired). Say so plainly instead of hanging on "Connecting…"
@@ -6176,8 +6235,6 @@ async function onMeetMsg(e){
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
if(meetIsHost){ meetSend({type:'meeting-host', to:meetHostId}); meetSend({type:'meeting-sharemode', multi:meetMultiShare}); if(meetRec) meetSend({type:'meeting-recording', on:true}); } if(meetIsHost){ meetSend({type:'meeting-host', to:meetHostId}); meetSend({type:'meeting-sharemode', multi:meetMultiShare}); if(meetRec) meetSend({type:'meeting-recording', on:true}); }
if(meetScreen) meetSend({type:'meeting-screen', on:true}); if(meetScreen) meetSend({type:'meeting-screen', on:true});
// iOS "Share Screen": the helper just joined → dismiss the waiting screen and start the screen broadcast now.
if(_shareWaitMode){ _shareWaitMode=false; hideShareWaitOverlay(); setTimeout(()=>{ try{ toggleScreen(); }catch(_){} }, 300); }
refreshMeetPanel(); return; refreshMeetPanel(); return;
} }
if(m.type==='meeting-peer-state'){ setTileMute(m.peerId, !!m.muted); meetCamOff.set(m.peerId, !!m.camOff); const _t=document.getElementById('meet-tile-'+m.peerId); if(_t){ const v=_t.querySelector('video'), s=v&&v.srcObject; const hv=!!(s&&s.getVideoTracks&&s.getVideoTracks().some(tr=>tr.enabled&&tr.readyState!=='ended')); _t.classList.toggle('novid', !hv || (!!m.camOff && !meetSharers.has(m.peerId))); } refreshMeetPanel(); return; } // camOff -> avatar, unless sharing a screen (#9) if(m.type==='meeting-peer-state'){ setTileMute(m.peerId, !!m.muted); meetCamOff.set(m.peerId, !!m.camOff); const _t=document.getElementById('meet-tile-'+m.peerId); if(_t){ const v=_t.querySelector('video'), s=v&&v.srcObject; const hv=!!(s&&s.getVideoTracks&&s.getVideoTracks().some(tr=>tr.enabled&&tr.readyState!=='ended')); _t.classList.toggle('novid', !hv || (!!m.camOff && !meetSharers.has(m.peerId))); } refreshMeetPanel(); return; } // camOff -> avatar, unless sharing a screen (#9)
@@ -6316,42 +6373,7 @@ const panels=document.querySelectorAll('.panel');
const chatcol=document.getElementById('chatcol'); const chatcol=document.getElementById('chatcol');
let loaded={share:false,connect:false}; let loaded={share:false,connect:false};
function currentTab(){ const b=document.querySelector('.railbtn.active'); return b?b.dataset.tab:'chat'; } function currentTab(){ const b=document.querySelector('.railbtn.active'); return b?b.dataset.tab:'chat'; }
let _shareWaitMode=false;
// iOS "Share Screen": WKWebView can't capture the P2P remote-support flow, so route it to a screen-share
// MEETING — the native ReplayKit path publishes into LiveKit, and a helper joins by code to watch (+ talk/chat).
// (Viewing a shared screen already works in the webview; only capture is blocked, so only the SHARE side moves.)
// UX: show the code + "waiting" first; screen sharing only STARTS once the helper joins (like remote support).
function startIosScreenShareMeeting(){
const NC=nativeCallPlugin();
if(!NC || typeof NC.startMeetingScreenShare!=='function'){ switchTab('meeting'); toast('Update the app to share your screen'); return; }
if(meetState==='call'){ switchTab('meeting'); toast('Youre already in a meeting — tap Share screen there.'); return; }
_shareWaitMode=true;
switchTab('meeting');
enterMeeting(null); // instant meeting; on join we show the code + wait, then auto-share when the helper joins
}
// Full-screen "give this code, waiting to join" screen shown to the iOS sharer before anyone connects.
function showShareWaitOverlay(code){
hideShareWaitOverlay();
const ov=document.createElement('div'); ov.id='shareWaitOv';
ov.style.cssText='position:fixed;inset:0;z-index:9200;background:linear-gradient(180deg,#20396f,#16294f);color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:24px;text-align:center';
ov.innerHTML='<div style="max-width:360px;width:100%">'
+'<div style="font-size:1.35rem;font-weight:700;margin-bottom:.35rem">Share your screen</div>'
+'<div style="color:#c9d4ec;font-size:.95rem;line-height:1.5;margin-bottom:1.6rem">Give this code to the person who will view your screen. Sharing starts automatically when they join.</div>'
+'<div style="background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.18);border-radius:16px;padding:1.2rem 1rem;margin-bottom:1.3rem">'
+ '<div style="font-size:.72rem;letter-spacing:.14em;color:#9fb2d8;text-transform:uppercase;margin-bottom:.55rem">Your code</div>'
+ '<div style="font-size:2.6rem;font-weight:800;letter-spacing:.28em;font-variant-numeric:tabular-nums">'+pEsc(code)+'</div>'
+ '<button id="shareWaitCopy" style="margin-top:1rem;background:#FFC708;color:#16294f;border:none;border-radius:10px;padding:.55rem 1.1rem;font-weight:700;font-size:.9rem;cursor:pointer">Copy code</button>'
+'</div>'
+'<div style="display:flex;align-items:center;justify-content:center;gap:.5rem;color:#c9d4ec;font-size:.9rem;margin-bottom:1.6rem"><img src="/loaders/loader-ring.svg" width="22" height="22" alt=""> Waiting for them to join…</div>'
+'<button id="shareWaitCancel" style="background:transparent;border:1px solid rgba(255,255,255,.4);color:#fff;border-radius:10px;padding:.55rem 1.3rem;font-size:.9rem;cursor:pointer">Cancel</button>'
+'</div>';
document.body.appendChild(ov);
const cp=ov.querySelector('#shareWaitCopy'); if(cp) cp.onclick=async()=>{ try{ await navigator.clipboard.writeText(code); cp.textContent='Copied!'; setTimeout(()=>{ cp.textContent='Copy code'; },1500); }catch(_){} };
const cx=ov.querySelector('#shareWaitCancel'); if(cx) cx.onclick=()=>{ hideShareWaitOverlay(); _shareWaitMode=false; try{ leaveMeeting(true); }catch(_){} };
}
function hideShareWaitOverlay(){ const o=document.getElementById('shareWaitOv'); if(o) o.remove(); }
function switchTab(tab){ function switchTab(tab){
if(tab==='share' && SFU.on && bzIsIOS()){ startIosScreenShareMeeting(); return; } // iOS: screen-share via a meeting, not the P2P iframe
railBtns.forEach(b=>b.classList.toggle('active',b.dataset.tab===tab)); railBtns.forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));
panels.forEach(p=>p.classList.toggle('active',p.dataset.panel===tab)); panels.forEach(p=>p.classList.toggle('active',p.dataset.panel===tab));
chatcol.classList.toggle('hidden', tab!=='chat'); chatcol.classList.toggle('hidden', tab!=='chat');
@@ -6627,6 +6649,12 @@ async function doRegister(){
window.addEventListener('message',(e)=>{ window.addEventListener('message',(e)=>{
if(e.origin!==location.origin) return; const d=e.data||{}; const n=window.bizConnectNative; if(e.origin!==location.origin) return; const d=e.data||{}; const n=window.bizConnectNative;
if(d.type==='rc-ping'){ const desktop=!!(n&&n.rcAvailable&&n.rcAvailable()); try{ e.source&&e.source.postMessage({type:'rc-pong', desktop}, location.origin); }catch(_){} return; } if(d.type==='rc-ping'){ const desktop=!!(n&&n.rcAvailable&&n.rcAvailable()); try{ e.source&&e.source.postMessage({type:'rc-pong', desktop}, location.origin); }catch(_){} return; }
// iOS remote-support: the /share iframe can't reach the native plugin, so it asks THIS top frame to
// publish the screen over LiveKit (WKWebView can't getDisplayMedia). Answer the capability handshake and
// drive startMeetingScreenShare/stopMeetingScreenShare (the same native method meetings use).
if(d.type==='bzc-native-ping'){ const ok=bzIsIOS() && !!(nativeCallPlugin() && nativeCallPlugin().startMeetingScreenShare); try{ e.source&&e.source.postMessage({type:'bzc-native', ok}, location.origin); }catch(_){} return; }
if(d.type==='rs-native-share'){ (async()=>{ try{ const NC=nativeCallPlugin(); if(!NC||!NC.startMeetingScreenShare||!d.room) return; const tk=await postJSON('/api/meetings/token',{ room:d.room, screen:true }); await NC.startMeetingScreenShare({ url:(tk.url||SFU.url), token:tk.token }); }catch(_){} })(); return; }
if(d.type==='rs-native-stop'){ try{ const NC=nativeCallPlugin(); if(NC&&NC.stopMeetingScreenShare) NC.stopMeetingScreenShare(); }catch(_){} return; }
if(!n) return; if(!n) return;
if(d.type==='rc-arm'){ try{ n.rcArm&&n.rcArm(!!d.on); }catch(_){} return; } if(d.type==='rc-arm'){ try{ n.rcArm&&n.rcArm(!!d.on); }catch(_){} return; }
if(d.type==='rc-input'){ try{ n.rcInput&&n.rcInput(d.evt); }catch(_){} return; } if(d.type==='rc-input'){ try{ n.rcInput&&n.rcInput(d.evt); }catch(_){} return; }
+27 -5
View File
@@ -106,6 +106,10 @@ let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
let SHARER_NAME='Customer'; let SHARER_NAME='Customer';
try{fetch('/api/me').then(r=>r.ok?r.json():null).then(m=>{if(m&&(m.name||m.email))SHARER_NAME=m.name||m.email;}).catch(()=>{});}catch(_){} try{fetch('/api/me').then(r=>r.ok?r.json():null).then(m=>{if(m&&(m.name||m.email))SHARER_NAME=m.name||m.email;}).catch(()=>{});}catch(_){}
const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||''); const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||'');
// iOS app: WKWebView can't getDisplayMedia. Ask the top frame (home.html) if it can publish the screen
// natively (ReplayKit -> LiveKit). If so, we run this session over LiveKit instead of P2P.
let NATIVE_IOS=false, RS_ROOM=null;
try{ if(window.parent && window.parent!==window){ window.addEventListener('message',(e)=>{ if(e.origin!==location.origin) return; const d=e.data||{}; if(d.type==='bzc-native'){ NATIVE_IOS=!!d.ok; } }); window.parent.postMessage({type:'bzc-native-ping'}, location.origin); } }catch(_){}
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){} let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;} async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));} function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
@@ -138,6 +142,7 @@ ws.onmessage=async(e)=>{const m=JSON.parse(e.data);switch(m.type){
case 'answer': if(pc) await pc.setRemoteDescription(new RTCSessionDescription(m.sdp)); break; case 'answer': if(pc) await pc.setRemoteDescription(new RTCSessionDescription(m.sdp)); break;
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break; case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
case 'recording': recNotice(m.on); if(m.on) startCustTranscription(); else stopCustTranscription(); break; case 'recording': recNotice(m.on); if(m.on) startCustTranscription(); else stopCustTranscription(); break;
case 'rs-chat': if(m.msg) addChat({from:'other', name:m.msg.name||'Agent', text:m.msg.text}); break; // chat over the socket in LiveKit mode (no P2P data channel)
case 'session-ended': endShareSession('Your support agent ended the session. Tap below for a new code if you still need help.'); break; case 'session-ended': endShareSession('Your support agent ended the session. Tap below for a new code if you still need help.'); break;
case 'error': setStatus(m.message,''); break; case 'error': setStatus(m.message,''); break;
}}; }};
@@ -169,6 +174,7 @@ function showConsent(m){
// getDisplayMedia unless it is called from a user gesture, so this must not run // getDisplayMedia unless it is called from a user gesture, so this must not run
// after a server round-trip. getDisplayMedia is called first to keep the gesture. // after a server round-trip. getDisplayMedia is called first to keep the gesture.
async function beginCapture(){ async function beginCapture(){
if(NATIVE_IOS){ return true; } // iOS: the app captures via ReplayKit on start-stream (no getDisplayMedia in WKWebView)
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); } try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
catch(err){ return false; } catch(err){ return false; }
// Mic is OFF by default — we do NOT prompt for it here. Asking for the screen and the // Mic is OFF by default — we do NOT prompt for it here. Asking for the screen and the
@@ -178,6 +184,18 @@ async function beginCapture(){
return true; return true;
} }
async function startStreaming(){ async function startStreaming(){
// iOS: publish the screen NATIVELY over LiveKit (WKWebView can't getDisplayMedia). Tell the app to start the
// ReplayKit broadcast into a room derived from this session, and tell the agent to view over LiveKit.
if(NATIVE_IOS){
RS_ROOM='rs'+String(sessionId||'').replace(/[^A-Za-z0-9]/g,'').slice(0,60);
try{ window.parent.postMessage({type:'rs-native-share', room:RS_ROOM}, location.origin); }catch(_){}
try{ ws.send(JSON.stringify({type:'rs-livekit', sessionId, room:RS_ROOM})); }catch(_){}
indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on'); bzcSession(true);
{ const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
window.onbeforeunload=function(){ if(!sessionOver){ return 'Leaving this page will end your screen sharing session.'; } };
buildBar();
return;
}
// If the Allow tap already captured the screen (mobile path), reuse it. // If the Allow tap already captured the screen (mobile path), reuse it.
if(!localStream){ if(!localStream){
await ensureIce(); await ensureIce();
@@ -320,6 +338,7 @@ function recNotice(on){
} else { clearInterval(recTimerInt); recTimerInt=null; if(n) n.remove(); } } else { clearInterval(recTimerInt); recTimerInt=null; if(n) n.remove(); }
} }
function endShareSession(msgText){ function endShareSession(msgText){
if(NATIVE_IOS){ try{ window.parent.postMessage({type:'rs-native-stop'}, location.origin); }catch(_){} } // stop the native ReplayKit broadcast + LiveKit
try{ rcStopControl(); }catch(_){} // release remote control when the session ends try{ rcStopControl(); }catch(_){} // release remote control when the session ends
sessionOver=true; window.onbeforeunload=null; bzcSession(false); { const hl=document.getElementById('homeLink'); if(hl) hl.style.display=''; } try{recNotice(false);stopCustTranscription();}catch(_){} sessionOver=true; window.onbeforeunload=null; bzcSession(false); { const hl=document.getElementById('homeLink'); if(hl) hl.style.display=''; } try{recNotice(false);stopCustTranscription();}catch(_){}
removeSessionUI(); removeSessionUI();
@@ -330,7 +349,7 @@ function endShareSession(msgText){
var card=document.querySelector('.panelside .card'); var card=document.querySelector('.panelside .card');
if(card){ card.innerHTML='<h1 style="color:var(--blue)">Session ended</h1><div class="sub">'+esc(msgText||'The session has ended.')+'</div><button onclick="location.reload()" style="width:100%;margin-top:.4rem">Get a new code</button>'; } if(card){ card.innerHTML='<h1 style="color:var(--blue)">Session ended</h1><div class="sub">'+esc(msgText||'The session has ended.')+'</div><button onclick="location.reload()" style="width:100%;margin-top:.4rem">Get a new code</button>'; }
} }
function teardown(){try{rcStopControl();}catch(_){}sessionOver=true;window.onbeforeunload=null;bzcSession(false);{const hl=document.getElementById('homeLink');if(hl)hl.style.display='';}try{recNotice(false);stopCustTranscription();}catch(_){}indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');} function teardown(){if(NATIVE_IOS){try{window.parent.postMessage({type:'rs-native-stop'},location.origin);}catch(_){}}try{rcStopControl();}catch(_){}sessionOver=true;window.onbeforeunload=null;bzcSession(false);{const hl=document.getElementById('homeLink');if(hl)hl.style.display='';}try{recNotice(false);stopCustTranscription();}catch(_){}indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');}
let chatOpen=false; let chatOpen=false;
const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>'; const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
@@ -349,10 +368,10 @@ function buildBar(){
const rcb=_btn('rcBtn',I('monitor'),'Remote control is OFF','#6b7280'); const rcb=_btn('rcBtn',I('monitor'),'Remote control is OFF','#6b7280');
const chat=_btn('chatBtn',I('chat'),'Chat','#475569'); const chat=_btn('chatBtn',I('chat'),'Chat','#475569');
const end=_btn('endBtn2',I('callEnd'),'End','#dc2626'); const end=_btn('endBtn2',I('callEnd'),'End','#dc2626');
bar.appendChild(mic);bar.appendChild(rcb);bar.appendChild(chat);bar.appendChild(end); if(!NATIVE_IOS){ bar.appendChild(mic); bar.appendChild(rcb); } // iOS: two-way voice is a follow-up; remote control is impossible on iOS
bar.appendChild(chat);bar.appendChild(end);
document.body.appendChild(bar); document.body.appendChild(bar);
rcb.onclick=()=>{ if(rcAllowed) rcStopControl(); else rcGrantControl(); }; if(!NATIVE_IOS){ rcb.onclick=()=>{ if(rcAllowed) rcStopControl(); else rcGrantControl(); }; updateRcBtn(); }
updateRcBtn();
makeBarDraggable(bar,'bzc_sharebar_pos'); // new #4: let the customer move the bar off their content makeBarDraggable(bar,'bzc_sharebar_pos'); // new #4: let the customer move the bar off their content
const setMic=(on)=>{mic.title=on?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+I(on?'mic':'micOff')+'</span>';mic.style.background=on?'#2563eb':'#6b7280';}; const setMic=(on)=>{mic.title=on?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+I(on?'mic':'micOff')+'</span>';mic.style.background=on?'#2563eb':'#6b7280';};
mic.onclick=async()=>{ mic.onclick=async()=>{
@@ -391,7 +410,10 @@ let __ac=null;
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}} function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}} function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){} try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:SHARER_NAME,text:t}));}addChat({from:'__self',name:'You',text:t});i.value='';} function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;
if(NATIVE_IOS){ try{ ws.send(JSON.stringify({type:'rs-chat',sessionId,msg:{name:SHARER_NAME,text:t}})); }catch(_){} }
else if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:SHARER_NAME,text:t}));}
addChat({from:'__self',name:'You',text:t});i.value='';}
function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});} function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));} function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
+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) => {
+10
View File
@@ -373,6 +373,16 @@ async function handle(ws, m, req) {
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m)); if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
break; break;
} }
// iOS remote-support over LiveKit: 'rs-livekit' tells the OTHER end this session's media runs over a
// LiveKit room (WKWebView can't getDisplayMedia); 'rs-chat' carries chat since there's no P2P data
// channel in that mode. Relayed between the two ends exactly like offer/answer/transcript.
case 'rs-livekit': case 'rs-chat': {
const sess = liveSessions.get(m.sessionId || ws.sessionId);
if (!sess) return;
const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
break;
}
case 'end-session': { case 'end-session': {
await endSession(ws.sessionId, m.reason || null); await endSession(ws.sessionId, m.reason || null);
break; break;