Compare commits
17 Commits
620039a2ff
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ece8e1061 | |||
| 03de639e1a | |||
| 133208ae3f | |||
| 703cf1fcf0 | |||
| 9d5af66a0e | |||
| ab6d8161ee | |||
| c013536e2b | |||
| ba75dc8daa | |||
| a4726dd10c | |||
| b03a32b10d | |||
| e39828ed56 | |||
| 35ce641046 | |||
| 1efb2e4314 | |||
| 677d418ed0 | |||
| 07c728bf6a | |||
| 948eae249f | |||
| 88a6b6a21e |
@@ -50,3 +50,7 @@ Thumbs.db
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Firebase service-account keys (SECRET private key — NEVER commit)
|
||||
**/*firebase-adminsdk*.json
|
||||
**/*service-account*.json
|
||||
|
||||
@@ -165,3 +165,102 @@ workflows:
|
||||
# submit_to_app_store: false
|
||||
# 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.
|
||||
|
||||
# ── 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.
|
||||
|
||||
@@ -20,7 +20,7 @@ account-specific or secret and must NOT be committed). Order below ≈ the order
|
||||
|
||||
| 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 |
|
||||
| **Primary category** | Business |
|
||||
| **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
|
||||
- [ ] (Optional) iPad if you enable iPad support
|
||||
|
||||
Suggested 4–5 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 |
@@ -14,7 +14,12 @@
|
||||
"SafeArea": {
|
||||
"detectViewportFitCoverChanges": true,
|
||||
"initialViewportFitCover": true,
|
||||
"offsetForKeyboardInsetBug": false
|
||||
"offsetForKeyboardInsetBug": true,
|
||||
"statusBarStyle": "DARK",
|
||||
"navigationBarStyle": "DARK"
|
||||
},
|
||||
"SystemBars": {
|
||||
"insetsHandling": "disable"
|
||||
},
|
||||
"Keyboard": {
|
||||
"resize": "none"
|
||||
|
||||
@@ -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
@@ -27,6 +27,15 @@
|
||||
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). */
|
||||
: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%;}
|
||||
|
||||
/* ---- Top bar ---- */
|
||||
@@ -713,7 +722,12 @@
|
||||
/* #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
|
||||
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;}
|
||||
.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;}
|
||||
@@ -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.
|
||||
(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);
|
||||
// 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
|
||||
// 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
|
||||
@@ -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('keyboardWillHide', function(){ setKb(0); });
|
||||
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(_){} })();
|
||||
// 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>'
|
||||
+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="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>';
|
||||
document.body.appendChild(ov);
|
||||
ov.onclick=e=>{ if(e.target===ov) 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 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 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);
|
||||
@@ -2141,7 +2175,7 @@ function welcomeHTML(){
|
||||
+ '<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="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>';
|
||||
}
|
||||
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.
|
||||
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'); } }
|
||||
// "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"
|
||||
// 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.
|
||||
@@ -3629,7 +3682,7 @@ async function openConvo(kind,id){
|
||||
// #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.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 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
|
||||
@@ -4208,6 +4261,15 @@ let _nativeTok=null;
|
||||
async function setupNativePush(){
|
||||
const PN=capPlugin('PushNotifications'); const plat=nativePlatform();
|
||||
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{
|
||||
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'); }
|
||||
@@ -4766,7 +4828,7 @@ function connectChatWs(){
|
||||
if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; }
|
||||
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.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)
|
||||
}catch(_){}
|
||||
}
|
||||
@@ -5110,7 +5172,7 @@ function renderMeetingLobby(){
|
||||
const el=document.getElementById('meetingPanel'); if(!el) return;
|
||||
el.innerHTML='<div class="meet-dash">'
|
||||
+ '<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-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>'
|
||||
|
||||
+13
-2
@@ -30,7 +30,14 @@ if (webpush && PUBLIC && PRIVATE) {
|
||||
// ---------------- FCM (Android), HTTP v1 ----------------
|
||||
let fcmSA = null; // { client_email, private_key, project_id }
|
||||
(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;
|
||||
try { fcmSA = JSON.parse(raw.trim().startsWith('{') ? raw : fs.readFileSync(raw, 'utf8')); }
|
||||
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 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
|
||||
// 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 };
|
||||
|
||||
@@ -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),
|
||||
// #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()),
|
||||
// "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),
|
||||
// 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).
|
||||
|
||||
+20
-1
@@ -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
|
||||
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
// participant's pinned strip updates live.
|
||||
route('POST', '/api/messages/pin', async (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user