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>
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>
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>
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>
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>
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>
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>
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>
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>
@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>
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>
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>
#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>
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>
- 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>
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>
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>
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>
Fix the confusing flow: tapping Share Screen dumped the user straight into a
meeting and auto-shared, with the join code hard to find. Now it shows a clear
full-screen "Share your screen" step with the big 6-digit code + Copy + a
"Waiting for them to join…" spinner (and Cancel). The native screen broadcast
starts only when the helper actually joins with that code (meeting-peer-joined) —
matching the remote-support mental model.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The remote-support Share/Connect tabs use P2P WebRTC (screen + voice + chat) that
WKWebView can't capture, so "Share Screen" never worked on iPhone. Rather than
rebuild the whole P2P session on LiveKit, route iOS "Share Screen" to a
screen-share MEETING (chosen with the user): tapping Share Screen on iOS starts
an instant meeting, auto-starts the native ReplayKit screen share (the path just
verified on device), and toasts the join code. A helper joins by code (Meeting)
to watch — and gets voice + chat for free. Viewing already works in the webview,
so only the SHARE side is rerouted; desktop keeps the full P2P remote-support
flow, and iOS Safari (no app) is unchanged.
Web-only, no new build (reuses the shipped startMeetingScreenShare native method).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gap: on iOS, screen share only worked in native CallKit calls. In a scheduled /
code-joined SFU meeting the webview called setScreenShareEnabled() ->
getDisplayMedia(), which WKWebView does not implement, so it failed silently.
Production meetings are real LiveKit rooms, so we can publish natively instead.
Fix (native ReplayKit -> same LiveKit room as a dedicated screen participant):
- server: /api/meetings/token accepts screen:true -> mints a distinct
<peerId>-screen identity (LiveKit allows one connection per identity), so the
native publisher doesn't collide with the webview's own connection.
- native plugin: startMeetingScreenShare/stopMeetingScreenShare + connectScreenRoom
(a screen-only LiveKit connection: mic off, no camera, no callConnected) that
reuses the existing broadcast-extension publishing path.
- webview: toggleScreen routes to the native method on iOS SFU meetings; the
screenShareState listener now drives the SFU case too (and tears the screen
connection down on the system "Stop Broadcast"); sfuAttach/sfuDetach map the
'<peerId>-screen' participant's screen track onto the sharer's tile and suppress
the phantom person tile + the sharer's own self-view.
Web/server deploy now; the native method needs the next Codemagic build to test
on device. Verified: db-smoke 22/22; Swift braces balanced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two more anonymized 1320x2868 shots of features that work on iOS: a group
conversation (sender chips, read receipts) and the Schedule-a-call flow
(participant names anonymized). Deliberately did NOT screenshot Share/Connect:
webview screen capture (getDisplayMedia) is unavailable in WKWebView, so the
remote-support screen-share tab doesn't function on iOS — advertising it would
be inaccurate metadata. (Screen share works in native calls via ReplayKit and
fully on desktop.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- mobile/appstore-screenshots/: 3 anonymized 1320x2868 screenshots generated from
the live app (Chats, a Conversation, Meetings). Real names/faces replaced with
demo identities, notification banner dismissed, internal roadmap copy removed.
- APPSTORE_SUBMISSION.md: pre-submission checklist now all green; Privacy Policy
and Support URLs filled with the live /privacy and /support pages; demo login
and export-compliance noted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
App Store submission needs a public Privacy Policy URL and Support URL, reachable
without login. Added branded, self-contained pages served at /privacy and
/support (static.js path mappings, like /home). The privacy policy accurately
describes Biz Connect: BizGaze-account login, messages/media stored on our
servers, call media via LiveKit (only recorded on explicit user action),
on-device transcription (audio never leaves the device), push via APNs/FCM, no
sale of data / no ads, retention, security, and user/GDPR rights. Contact:
support@bizgaze.com (confirm/replace if different).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tester feedback:
1. "Block contact not on the contact info." The earlier Block button was only on
the group-sender mini-profile, which a 1:1 chat never opens. A DM's info panel
is openSharedItems('dm',…) (tap the conversation header). Added a full-width
"Block contact / Unblock contact" button there.
2. "Reporter gets no notification on resolution." Delivery was actually working
(verified: the reporter receives the 'report-resolved' event over the chat WS),
but the client only added a silent bell entry — easy to miss — and a self-
resolve (same admin reported + resolved) is intentionally skipped. Made it a
visible toast + ping for an online reporter (report-new likewise toasts admins).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses tester feedback on the Report/Block feature:
1. Block/Unblock is now on the CONTACT's profile card (openMiniProfile), not only
in a message's ⋮/long-press menu — you can block someone straight from their
info popup.
2. Admin Delete/confirm popups were appearing BEHIND the Reports window. The
moderation modals used z-index 100000 (above bzConfirm's .modal-ov at 9800);
lowered them to 9750 so the confirm dialog sits on top.
3. Clarified admin action. "Block" is a PERSONAL mute (per guideline 1.2) and does
not touch login. The report view now offers a real account action instead:
"Suspend account" (deactivate -> signed out + cannot log in) with a confirm,
toggling to "Reactivate account" — both reversible in-place, driven by a new
reportedActive flag on /api/reports. (Uses the existing /api/users/manage
deactivate/activate.)
4. Resolving a report now notifies the reporter (live 'report-resolved' event +
background push), and admins get a 'report-new' activity entry.
repos: reports.byId. Verified: moderation suite 18/18 (adds reportedActive +
suspend->login-blocked->reactivate->login-restored).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause: the scheduled-meeting INVITE (routes.js) and the ~10-min REMINDER
(reminders.js) both notified only via CHAT.pushToUser — the WebSocket channel,
which only reaches an OPEN tab with a live socket. Neither called
PUSH.sendToUser, the path that produces a background APNs/FCM/web-push alert.
On iOS the webview is suspended in the background, so the WS event was simply
missed and no notification appeared. (Chat messages already call PUSH.sendToUser,
which is why chat notices arrive on iOS but meeting ones didn't.)
Fix:
- schedule invite: also PUSH.sendToUser to every invited participant + group
members (kind:'meeting', id:roomCode) so a closed app is notified.
- reminders.js: also PUSH.sendToUser to all reminder recipients.
- client: a kind:'meeting' notification tap now opens the Meeting tab + its list
(both the live-tab open-chat handler and the cold-boot openKind path), instead
of calling selectChat with an unsupported kind.
Also: APPSTORE_SUBMISSION.md §8 updated — the UGC Report/Block gate (guideline
1.2) is now implemented, with a suggested reviewer note.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apple requires user-generated-content apps to offer a way to report
objectionable content and block abusive users. The chat had neither, which is
the #1 rejection cause for messaging apps. Added both, server-enforced.
Server:
- schema: message_reports + user_blocks tables.
- repos: reports {add,listForTeam,setStatus}, blocks {add,remove,has,listFor},
users.adminsOf(); thread + threadByConversation now exclude blocked senders in
SQL (like message_hidden) so the LIMIT counts only visible rows (no pagination
stall).
- routes: POST /api/messages/report, /api/users/block|unblock, GET
/api/users/blocked, GET /api/reports + POST /api/reports/resolve (admin only).
- enforcement: a blocked sender's DM/group messages are persisted but not
delivered (no live push, no background notification) to anyone who blocked
them; blocked users can't ring you (/api/calls/dm/start + /api/calls/invite);
admins can delete reported content (delete route now allows role=admin).
Client (home.html, all platforms via the web UI — no rebuild):
- message menu gains Report (canned-reason picker) + Block/Unblock.
- profile menu: "Blocked users" manager (list + unblock) for everyone;
"Reported messages" review (delete / block / resolve) for admins.
- blocked DMs hidden from the sidebar; block list loaded on boot.
- reports route to the workspace's OWN admins (org-internal moderation).
Verified: db-smoke 22/22 + a new moderation suite 12/12 (report+admin-list,
non-admin 403, block hides post-block history but sender still sees sent,
blocked call 403, unblock restores history + calling). New flag/ban icons added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The iOS splash read as unbranded for two concrete reasons:
1. The light-mode master (resources/splash.png) was on a WHITE background
while the SplashScreen plugin paints #16294F navy -> a white->navy->app
flash on light-mode devices.
2. The logo + "Biz Connect" wordmark spanned ~82% of the 2732 square, but the
launch storyboard scales it scaleAspectFill; on tall iPhones ~27% of each
side is cropped, clipping the wordmark edges.
New splash (both light + dark masters, identical navy so there is no flash):
brand navy gradient (#20396f -> #16294f) matching the plugin background, the
C-mark as hero, and the wordmark typeset in Corbel (closest installed match to
the brand geometric wordmark) sized to 1041px -> inside the ~1260px aspectFill
safe zone, so nothing clips on any device.
Also hardened codemagic.yaml: the asset step used `|| echo skipped`, which
silently shipped Capacitor's blank default splash if generation failed. Now it
hard-verifies the generated iOS Splash.imageset exists and fails the build
otherwise. Rides the next Codemagic build (native asset; no server redeploy).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The instant-paint work renders a group thread from cache BEFORE /api/groups/members
(convoMembers) loads, so senderAvatar had no avatar and drew initials only; the later
network render diffs/skips the unchanged messages, so the photos never came back until
a full reload. Platform-agnostic (desktop + iOS), group-only — matching the report.
Fix: senderAvatar now falls back to the global CONTACTS avatar so the cache paint is
already correct, and openConvo repaints the sender avatars (refreshSenderAvatars) once
convoMembers loads. Web-only; live on next app launch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sidebar shows "This message was deleted" whenever a row's last_deleted flag is
set, but the new-message handlers (incoming, my-sent, edit) updated the preview text
and never cleared last_deleted. So once a conversation's last message had been deleted
(server-computed last_deleted=true at load), a NEWER message left the flag stale and
the list kept showing "deleted"/"you deleted this" even though the actual last message
was a normal one. Remote deletes already self-corrected via onChatDeleted->loadSidebar;
this was the in-session new-message path. Now last_deleted is cleared wherever a fresh
non-deleted message becomes the row's last message. Web-only; live on next app launch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Duplicate ("Transcript shows two times"): finalizeTranscript had a race — for the
SAME user on two devices (#12 multi-device), both devices leaving at once each
passed the subscriber membership check across an await before either removed the
sub, so both wrote a private transcript. Now the subscriber is CLAIMED
SYNCHRONOUSLY (subs.delete filter) before any await, so only the first writer wins.
Verified with a concurrency simulation (2 concurrent leaves -> 1 write).
- iOS download: the /mrec transcript link had no `download` attribute, so WKWebView
NAVIGATED to the file and loaded it inline with no way back (had to force-quit the
app). Added download + data-mime so browsers download it and the existing native
click-interceptor catches it: it now saves to the Files folder and opens in native
Quick Look (view + its own share/save — into Files or Word) instead of hijacking
the WebView. recDTO now exposes the recording mime.
Web/server only — no native build needed; live on next app launch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In a scheduled meeting the WebView owns the mic (the plugin has no LiveKit room), so
the native-call AudioRenderer tap didn't apply. Now the WebView reads its OWN local
mic PCM via Web Audio (the same non-intrusive tap the app already uses for
active-speaker metering — NOT a 2nd getUserMedia, which would fight the call's mic on
iOS and yield silence), downsamples to 16 kHz mono Int16, and forwards it to the
plugin's SFSpeechRecognizer via feedAudio(). Native calls keep the LiveKit tap.
Muted -> the local track carries silence -> nothing transcribed; the feed re-inits
when the mic goes live on unmute.
- Plugin: startTranscription({external:true}) runs the recognizer without a track;
feedAudio({pcm,rate}) decodes base64 LE Int16 -> Float32 buffer -> recognizer.
- Web: startSR now uses the native recognizer for ALL iOS meetings (native call =
LiveKit tap, scheduled = PCM feed); desktop/browser unchanged (Web Speech API).
Web deploys now; the plugin's feedAudio/startExternal ride the next Codemagic build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#12 — same user on two devices now shows as two independent tiles (was: LiveKit
kicked the older connection, "audio jumps to whichever joined last"):
- LiveKit identity is now the per-connection mesh peerId, not the user id.
/api/meetings/token + guest-token mint identity=peerId when the client supplies
it (anti-hijack: never mint another live user's peerId). Web maps SFU tracks by
identity==peerId, keeping peerIdForUid as a fallback for the transition/native.
- Mesh dedup (dropDupPeers) now keys on a stable per-device clientId (persisted,
sent on meeting-join, echoed by the server) instead of user id — so two real
devices keep separate tiles while a same-device reconnect ghost still collapses.
Verified in a real browser: 2 devices -> 2 tiles; same-device reconnect -> 1.
- Native: plugin gains reconnectRoom(); after the native WebView joins the mesh it
re-homes the LiveKit media onto its peerId identity. syncVideoTiles keys by peerId.
Token-identity + anti-hijack + clientId echo verified by a server test.
#5 — iOS live transcript (WKWebView has no Web Speech API, so an iOS participant
was never transcribed; desktop already works):
- native-call plugin transcribes the local mic with SFSpeechRecognizer, fed by a
LiveKit AudioRenderer on the local mic track (reuses the call's open mic — no 2nd
AVAudioEngine). Finalized segments -> 'transcript' event -> web sends
meeting-transcript (same server assembly as desktop). startSR/stopSR use the
native recognizer on native calls; Web Speech API path unchanged elsewhere.
- NSSpeechRecognitionUsageDescription added to the iOS Info.plist.
Native pieces (#12 reconnect, #5 transcript) need a Codemagic build; the web+server
half is verified and deploys now (already fixes the reported laptop+phone case).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previous follow-up kept the in-progress edit text only as a plain draft, so sending
it after returning posted a NEW message instead of updating the original. Now leaving
a chat mid-edit parks {msgId, text, pre-edit draft} in _pendingEdits; reopening the
chat resumes edit MODE (editTarget + "Editing message" bar + the in-progress text)
once the thread is loaded (cache render, then network render as fallback). So Send
runs saveEdit → UPDATES the original message. If the message can't be found in the
loaded page (or was deleted) it falls back to a plain draft so the text isn't lost.
Verified with puppeteer against the live app:
- edit "hi bob" -> switch to Cara -> back -> edit mode resumes (composer "hi bob
EDITED", bar showing) -> Send -> thread count stays 1, message.edited_at set:
PASS (updated original, no new message).
- plain draft (no edit) switch-and-return still restores: PASS (no regression).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
persistCurrentDraft() no longer skips while editing — leaving a chat mid-edit
abandons the edit (openConvo clears editTarget) but now keeps whatever's in the
composer as that chat's draft, so your typing isn't lost. It returns as a normal
draft (sending posts a new message, not an edit). The edit-cancel (X) path still
restores the pre-edit draft, unchanged. Verified with puppeteer: edit "hi bob" ->
"hi bob EDITED" -> switch chats -> back -> composer holds "hi bob EDITED" (PASS).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reproduced with a real headless browser driving the live app: typing saved the draft
and it survived switching chats, but RETURNING to the chat deleted it. Stack trace
pinned it exactly:
setDraft(REMOVE) <- _restoreDraftAfterEdit <- cancelEdit <- openConvo:3484 <- selectChat
openConvo ran `clearReply(); cancelEdit(); hideAttach();` on every open. cancelEdit ->
_restoreDraftAfterEdit -> setDraft(selected,'') — and since `selected` is already the
chat being opened, it wiped THAT chat's draft (and blanked the composer) BEFORE the
draft-restore a few lines later could read it. So the draft never survived reopening —
this predated the recent rounds; my _restoreDraftAfterEdit change just made the wipe
explicit. Fix: drop cancelEdit() from openConvo (edit state is already reset at the top
via editTarget=null/_editSavedDraft='', and the shell was just rebuilt fresh). The
edit-cancel button + saveEdit still call cancelEdit normally.
Verified with puppeteer: type in chat A -> open B -> back to A -> "hello draft" restored
(PASS), no setDraft REMOVE on return.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-keystroke 'input' saves can be missed on mobile (predictive text or a fast
tap-away fires no final input event), which lost the whole draft when you switched
chats and came back. Added persistCurrentDraft() — snapshots the composer value
into the current chat's draft key the instant you leave it (selectChat before the
swap, and showWelcome/back). Restore on open was already correct. Skipped while
editing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Older-messages pagination (couple of chats wouldn't scroll back): the thread query
returned the latest 40 rows and JS filtered out hidden (delete-for-me) messages
AFTER the LIMIT, so a chat with a hidden recent message returned <40 → the client
read that as "no older history." Now excluded in SQL (repos.thread /
threadByConversation take the viewer id), so a page is always 40 VISIBLE rows.
Verified locally: hide 3 recent → page still returns 40 (older ones fill in).
#2 iOS in-chat tone was silent: WebAudio context is created suspended and only
resumes inside a user gesture. Added unlockAudio() on first tap/click (resume +
0-gain blip), re-armed each gesture so a background→foreground re-suspend recovers.
#9 Long-press "works once then stops" on images was iOS's native touch-callout
(Save Image / selection magnifier) hijacking the gesture. Disabled
-webkit-touch-callout/user-select on #msgs bubbles; added a Save action to the
sheet so image-saving isn't lost.
#13 Pin/unpin WAS being audited (verified: message.pin in /api/audit) — there's just
no in-app viewer. Surfaced "Pinned by X" in the pinned bar for immediate context.
#14 Hardened draft save: it now runs BEFORE maybeAutocorrect/autoGrow (wrapped) in the
input handler, so a throw there can't skip persisting the draft.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
repos.listByTenant selected id,email,name,role,active,avatar_url,created_at but NOT
last_seen or status. So /api/messages/contacts and /api/messages/conversations
always sent lastSeen:null (and status:'active'). Last-seen only ever appeared via
LIVE presence events (broadcastPresence reads the full row) — which is why it
"worked on desktop" (caught live), not on a fresh iOS load, and why round-2's
loadSidebar-on-focus then clobbered the live value → "Offline for all". Verified
locally: contacts now returns the real lastSeen timestamp; db-smoke 22/22.
Also lightened refreshPresenceOnResume: reconnect the socket if it's dead (its
onopen already resyncs the sidebar) but no longer force an unconditional
loadSidebar on every focus — that churn caused the #8 regression and could
momentarily reset an unread badge (#3). Session sliding (touchSession) stays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Session (New): stop the ~24h auto-logout. SESSION_TTL 24h -> 90d, and /api/me now
SLIDES the session forward + re-stamps the cookie on every app load / focus /
6h heartbeat — so an actively-used session never lapses; you only log out by
choosing to. Login no longer depends on "remember me".
#2 A new message in the chat you're actively viewing now plays a soft, distinct
in-chat tone (playMsgTone) — no popup — instead of being silent. A different
chat / a backgrounded chat still gets the alert ping + notification.
#13 Pin/unpin is now written to the audit log (actor + which message, and whose pin
was removed on an unpin) — the accountability gap when anyone can unpin.
Pagination: a floating "Loading earlier messages…" pill now shows while older
history is being fetched (loadOlder had no visible indicator).
#9 Mobile long-press now opens a dimmed + blurred bottom ACTION SHEET (quick
reactions + reply/edit/forward/copy/pin/delete) instead of the flaky hover-style
reveal that hid behind images and broke after the lightbox opened.
#14 editTarget is cleared on conversation switch — starting an edit then switching
chats used to leave editTarget set, which silently stopped ALL draft saving.
Also added Edit to the shared action list so mobile long-press can edit too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cold Postgres boot (connect + apply the full schema) takes a few seconds — the
fixed 300ms that was fine for SQLite's instant in-memory init raced the server
bind and the first fetch hit ECONNREFUSED. Poll BASE/ until it responds (≤30s).
Validated on local PostgreSQL 16: db-smoke 22/22 pass; e2e passes all DB-backed
checks (auth, messages, polls, groups, calls+invite, meetings) then stops at the
known pre-existing WS meeting-joined flake (unrelated to the DB).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#2 Don't ping/notify when you're ACTIVELY viewing a chat (app visible + chat
open). Alert only when a different chat, OR the open chat while the app is
minimised (the backgrounded case that used to stay silent).
#3 A reaction to my message now raises an unread badge on that conversation
(like a new message), not just a notification.
#6 Image lightbox pulls EVERY image in the conversation via /api/messages/media
(older images aren't in the DOM yet) — nav arrows reach them all. Nav buttons
always in the DOM; syncArrows shows/hides at the ends and for a single image.
#8 On app resume (visibilitychange / native appStateChange), reconnect the chat
socket if it isn't OPEN and re-pull the sidebar so online/last-seen refresh —
iOS freezes the WebView so the socket can be dead while its onclose lags,
leaving contacts stuck on a stale "Offline".
#9 Real cause was iOS "sticky :hover": a single tap latched :hover and popped the
action bar. Gate the hover-reveal behind @media (hover:hover) so touch reveals
actions ONLY via long-press; a plain tap performs the primary action.
#13 Pinned bar gains a "‹ 1 of n ›" pager to walk through multiple pinned messages
(shown only when more than one is pinned).
#14 Editing a message no longer eats a half-written draft — the real draft is set
aside on edit start and restored on save/cancel. edited_at is now in the message
DTO so the "edited" tag survives a reload.
#18 One "Delete" entry opens a branded dialog with "Delete for me" / "Delete for
everyone" (icons + descriptions) and a ✕/backdrop cancel, replacing the two
separate menu items.
New: a participant who LEAVES a still-running call is no longer auto-rung back in
on every socket reconnect. Track who left per call; replayActiveCalls sends
them noRing state (refreshes the Join affordance without ringing). An explicit
re-invite clears that and rings again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dual backend (SQLite via db.js + Postgres via schema.pg.sql) was a
maintenance foot-gun: a schema change could land on the SQLite path only and
silently 500 every read on prod (it just did, with #18/#13). Production has run
on Postgres for weeks, so SQLite is retired: ONE schema source of truth
(db/schema.pg.sql), no drift possible.
- dbx.js: default DB_BACKEND=pg; an unknown backend now fails loudly at require
time instead of silently selecting a stale engine.
- Deleted server/db.js, server/db/sqlite.js, server/db/migrate-sqlite-to-pg.js,
server/scripts/migrate-bizgaze-only.js (all SQLite-only, none in the runtime
path — the running server loads db/pg.js).
- Tests (e2e, db-smoke) target Postgres now and fail-fast (skip) unless
DATABASE_URL points at a disposable test DB — never SQLite, never prod.
- Removed the dead DB_PATH env + fixed misleading SQLite comments in the
Dockerfile / docker-compose (kept the /data volume: it holds
uploads/recordings/transcripts/downloads, not just the old data.db).
- CLAUDE.md: stack + repo-layout + run-locally updated for Postgres-only.
Runtime is unaffected (prod already sets DB_BACKEND=pg and pg is a prod dep);
this only removes the unused SQLite path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Production runs DB_BACKEND=pg, but the message_hidden table (#18) and
pinned_at/pinned_by columns (#13) were only added to the SQLite migrations in
db.js — never to schema.pg.sql. So thread/conversations/pinned queries hit a
missing relation/column and 500'd, which surfaced as "chat history removed"
(no data was ever deleted — the reads just errored).
pg.js init() runs schema.pg.sql on every boot. Added the message_hidden table
and, because CREATE TABLE IF NOT EXISTS can't add columns to the existing
messages table, idempotent ALTER TABLE ... ADD COLUMN IF NOT EXISTS for
pinned_at/pinned_by. Restores all chat history and re-enables pin + delete-for-me.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Any participant can pin/unpin a message from its ⋮ menu. Adds pinned_at/pinned_by
columns, /api/messages/pin (toggle, broadcasts chat-pinned) and
/api/messages/pinned (list, newest first, excludes deleted + delete-for-me).
The conversation shows a pinned strip under the header (latest pin + count);
tap it to jump to the message, × to unpin. Live-updates across participants and
devices. Added pin/pinOff Lucide icons.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On touch, tapping a message used to reveal the reply/react/more bar (so the
action needed a second tap). Now a single tap performs the primary action
(open image/file, jump to a reply), and the action bar is revealed by a
~420ms long-press (with a small haptic); movement or an early release cancels,
and a fired long-press suppresses the trailing tap. A tap elsewhere dismisses
the revealed bar. Desktop hover behaviour is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A group's members are automatically the meeting's participants, so the
Invite-participants, Invite-by-email and "Guests must be admitted by host"
sections are noise there. openScheduleModal now omits them when a group id is
present (inviteBlock is empty), and the email/participant/lobby handlers are
null-guarded so the save path still works without those fields.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#1: focusing a text box zoomed + stretched the whole page on iOS. Several
inputs were < 16px and the existing 16px rule lived in a width-based mobile
@media that misses iPad/landscape (and maximum-scale is unreliable on iOS).
Added a @media (pointer: coarse) rule forcing 16px on every focusable field
for ALL touch devices; desktop is untouched.
#6: the image lightbox used visibility:hidden for the end arrows (invisible but
still occupying space / reading as a ghost button). Switched to display toggling
so there's truly no right arrow at the last image (and no left at the first).
#7: play a soft two-note chime + a brief "<name> joined the call" toast when a
new participant joins the meeting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#14: after editing a message the edited text reappeared in the composer as a
draft and re-sent as a new message. The input listener saved a draft while
editing, but saveEdit/cancelEdit cleared only the input value, not the stored
draft — so openConvo restored it. Now editing never writes a draft, and
save/cancel clear it.
#8: a contact's subtitle flapped between "last seen …" and "Offline". A
loadSidebar rebuild replaced the row with the server's lastSeen, which is
sometimes null (the disconnect touchSeen is fire-and-forget and can lag the
presence broadcast). Now the client keeps last-seen sticky (never overwrites a
known value with null) and the server never broadcasts a null last-seen for an
offline user.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#10: deleting the last message showed "No messages yet" in the chat list.
The sidebar sent an empty last_body for a deleted (content-cleared) row; now
it sends a last_deleted flag and the row renders "This message was deleted"
(or "You deleted this message"), matching the in-thread placeholder.
#18: added "Delete for me" alongside "Delete for everyone". A new message_hidden
table records a per-user hide; the thread + sidebar (last message, unread) filter
out the requesting user's hidden messages, and the hide is echoed to their other
devices (chat-hidden). "Delete for me" is offered on any message; "Delete for
everyone" stays sender-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#2: a message arriving in the currently-OPEN chat produced no notification
when the app was minimized. onChatMessage marked the open chat read even
while document.hidden, which fired a notif-clear that closed the very
notification the service worker had just shown. Now the open chat is only
marked read while visible; markOpenChatRead() catches up on focus/visibility
return, and a message received while hidden stays unread with its alert intact.
#3: reacting to a message fired no notification. The react route only pushed
over the live socket (nothing for a closed app) and the client added a silent
bell entry. Now the server sends a native/web push to the message owner, and
onChatReaction pings + shows an OS/in-page popup (unless you're viewing that chat).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When someone is added to a 1:1 (DM) call, promoteDmToGroup() now creates a
real group conversation (named after the participants) and migrates the live
call from dmCalls -> groupCalls, keeping the same room/uuid/history so media
and the transcript continue uninterrupted. Two wins:
- the call survives anyone leaving (group calls only end when the room empties)
- an added person who drops can rejoin from the group's active-call banner
(they're now a member, so replayActiveCalls / group-call resurface it)
/api/calls/invite promotes on a DM call and lets the group-call broadcast ring
the invitees in; it only sends the plain call-invite when NOT promoted. Guarded
so inviting an existing pair-member (memberIds < 3) stays a 1:1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#11: adding a 3rd person to a 1:1 call then having one participant close
the app disconnected the call for everyone. leaveMeeting() ended a DM room
for ALL peers on any leave; now it only tears down when <2 people remain,
otherwise it falls through to the normal peer-left path (call continues).
#4b: after someone left a call they never reappeared under "Add people".
meeting-peer-left cleaned meetPeers/tiles but not meetPeerUids/meetNames,
so the departed uid stayed in hereUids and was filtered out. Now deleted.
#4a: a guest who enabled mic/cam on the pre-join screen had to re-tap after
being admitted — the choices were applied on a blind 900ms timer that fired
while still in the lobby. Now applied in the meeting-joined handler, after
admission + media connect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The only way to stop a screen share was toggling the share button again —
buried in the ⋮ More menu on mobile. Now a floating "You're sharing your
screen · Stop" banner appears at the top whenever you're sharing (driven by
meetScreen via updateScreenBtn), with a red Stop button that calls
toggleScreen. Works on iOS/desktop/web. Web-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previous hole-punch builds showed no video because WKWebView can IGNORE
isOpaque=false when it's set AFTER the page has already rendered (during the
call). Now:
- makeWebViewTransparent() runs once at plugin load (startup): isOpaque=false,
backgroundColor=.clear, and crucially scrollView.isOpaque=false +
scrollView.backgroundColor=.clear (an opaque scrollView occludes content
behind the WebView).
- setHolePunch now just toggles a BLACK backing on the WebView's parent during
the call (transparency is already applied); restored after.
- Tile frames use host.convert(rect, to: superview) instead of a manual origin
offset — robust to any WebView inset.
Plugin-only — needs a Codemagic build. If this still shows no video, WKWebView
hole-punch is a dead end here and we revert to native-video-on-top.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diagnosis: no native video rendered at all (bg went black, but camera AND
screen tiles were empty). WKWebView does NOT composite native subviews placed
under its scrollView through transparent web content — only the webView's own
layer background shows through. So the video was effectively invisible.
Fix (the standard hole-punch): insert the video tiles into the WebView's
SUPERVIEW, BEHIND the (fully transparent) WebView, so the web content paints
on top and the video shows through. Changes:
- makeTileView / syncVideoTiles: insert belowSubview: host (the WebView) in
host.superview, and offset frames by the WebView's origin (getBoundingClientRect
is viewport-relative).
- webView.backgroundColor = .clear (was .black — a black webView bg would have
occluded the video behind it); the VC view's black background is the backing.
Plugin-only change — needs a Codemagic build. Web (transparent chain, zoom,
bz-hasvid) already correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three fixes for the hole-punch build:
1. White background + shared screen not showing: body and .content both use
var(--bg) (a light colour) and were still opaque, occluding the native
video behind the WebView. Make the WHOLE meeting chain transparent under
.bz-hp (body + .content, on top of .meet-grid/tiles).
2. Backing: also set the view controller's view background to black (saved/
restored) so any transparent gap reads black, not white — belt-and-braces
with webView.backgroundColor.
3. Keyboard covering the in-call chat: keyboard resize is "none", so the
absolutely-positioned meet panels don't move for the keyboard. Lift
.meet-panel by the reported keyboard height (body.kb-open → bottom:
calc(var(--kb)+12px)); --kb/kb-open are already set globally by the
Keyboard listener.
Plugin change (VC background) needs a build; the CSS is web-deployed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Permanent fix for the z-order whack-a-mole (controls/menus/panels hiding
behind the native video). Instead of drawing native video ON TOP of the
WebView, draw it BEHIND a transparent WebView so ALL web UI floats on top
naturally — no suppressing, no clamping, no docked-only bar.
Plugin:
- setHolePunch(on): webView.isOpaque=false + black layer bg + clear scroll
bg (restored on off / call end). Tiles inserted belowSubview:scrollView.
- Dropped native name/mute overlays (the web tile's own .nm/.meet-mute/avatar
render on top now) and the PaddingLabel.
- Zoom re-plumbed: touches hit the WebView, so native gesture zoom can't work;
new setTileZoom({uid,scale,tx,ty}) applies a web-forwarded transform to the
tile's inner video. TileVideoView simplified to a container + applyZoom.
Web:
- bzNativeStartTiles/StopTiles toggle NC.setHolePunch + a body.bz-hp class
(only when the plugin supports it — old builds keep the suppress fallback).
- Tiles with live native video get .bz-hasvid → CSS makes them transparent +
hides the web avatar so the video shows through; name/mute/border stay.
- New web-forwarded pinch/pan/double-tap on the shared screen → setTileZoom.
- Suppress-on-overlay + the height clamp now only apply when NOT hole-punched.
Needs a Codemagic build (plugin). Web deployed; no-ops to the prior behavior
on builds without setHolePunch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Native video draws on top of the WebView, so the More/audio menu,
participant panel, meeting chat, modals and the image lightbox all opened
BEHIND the shared screen. Now bzNativeSyncTiles clears the native tiles
whenever any of those overlays is present (.meet-panel/.spk-menu/.modal-ov/
.lightbox), and the menu/panel toggles trigger an immediate sync so there's
no lag; the video returns when they close.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The control bar is a draggable FLOATING bar (position:fixed when moved/
restored) meant to sit over the shared screen — which works on web (video is
DOM) but on a native call the screen is a native overlay ABOVE the WebView,
so the floating bar hides behind it, and its live position fed my height
clamp → dragging it resized/reoriented the screen.
Fix: skip makeDraggable on native calls so the bar stays DOCKED in flow at
the bottom. The grid then reserves space above it and the native shared
screen fills that stable area — bar and screen are independent, controls
stay visible and tappable. (A floating bar that overlays the native video
would require a hole-punch rework.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CSS-only stage sizing wasn't enough — the shared screen still overlapped
the meeting controls. Since the native video is drawn ON TOP of the WebView,
now clamp each tile's height in bzNativeSyncTiles so it can never extend past
the top of the .meet-bar (control bar) — regardless of how the web lays out
the stage. Robust and web-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
scr-full forced #meetGrid to height:100%, so the stage (and the native
video positioned on its rect) extended down over the control bar. The grid
is already flex:1 — it should fill only the space above the bar. Now the
stage flexes to fill that area (flex:1 1 auto) instead of height:100%, so
the native screen view sits above the controls, not over them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build error: `cannot inherit from non-open class 'VideoView' outside of its
defining module` — VideoView is public, not open, so it can't be subclassed
in the plugin module. Reworked TileVideoView from a VideoView subclass into a
UIView CONTAINER that holds a VideoView: the container is frame-synced to the
web tile rect, the pinch/pan zoom transform lives on the inner video (so it
never fights the position poll), and the name/mute overlays sit on the
container so they no longer scale with zoom. track/layoutMode are forwarded.
Also silenced the extension warning: SampleHandler now restates
`@unchecked Sendable`.
The broadcast extension itself compiled + linked in the failed build, so the
SPM-linked extension injection + App Group setup are working.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lets a native-call user share their iPhone screen (whole device, works
backgrounded). LiveKit 2.15.3 ships the broadcast stack (BroadcastManager +
LKSampleHandler + IPC), so:
- New Broadcast Upload Extension target "BroadcastExtension"
(com.bizgaze.connect.broadcast): SampleHandler.swift subclasses
LKSampleHandler; injected by mobile/scripts/add-broadcast-extension.rb
which also LINKS the LiveKit SPM product into the extension + sets the
App Group. Sources in mobile/ios-broadcast/.
- Plugin: Room created with ScreenShareCaptureOptions(useBroadcastExtension:
true); startScreenShare -> BroadcastManager.requestActivation() (system
picker); stopScreenShare -> requestStop(); BroadcastManagerDelegate ->
fires screenShareState to the web. LiveKit auto-publishes the track.
- ios-patch.sh: RTCScreenSharingExtension + RTCAppGroupIdentifier keys.
- codemagic.yaml: run the injector + sign the 3rd bundle id (.broadcast).
- home.html: toggleScreen native -> start/stop; screenShareState listener
reflects state + broadcasts meeting-screen so peers' stage shows it.
REQUIRES a one-time manual Apple portal step: enable the App Group on the
com.bizgaze.connect.broadcast App ID (see mobile/IOS_SETUP.md) or the
archive fails code-signing. SPM-linked extension is new on our CI — expect
build iteration. Web deployed (no-ops on builds without startScreenShare).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Better visibility: on a native call, a shared screen now fills the whole
meeting area and hides the small participant tiles (new .scr-full grid
class, toggled when meetNative && sharing). The plugin renders the screen
over that full-area stage rect.
Zoom: the tile video view is now TileVideoView with pinch-to-zoom + pan
(and double-tap to reset) enabled only while it's showing a screen. While
zoomed the view holds a transform and syncVideoTiles stops overwriting its
frame; name/mute overlays hide during zoom.
Needs a Codemagic build (plugin change). Web deployed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Last native-video gap: on a native (iOS) call the WebView has no LiveKit
connection, so a screen shared by a web/desktop participant never rendered.
The mesh already flips the sharer's tile into the big "stage" (meeting-
peer-screen -> meetSharers -> sharing-mode), so extend the tile sync: each
tile now carries a `screen` flag (meetSharers.has(id)). The plugin renders
that participant's screen-share track (source .screenShareVideo) on the
tile with layoutMode .fit (contain, no crop) instead of the camera.
Outgoing screen-share from iOS is still unsupported (no getDisplayMedia /
ReplayKit broadcast extension) — toggleScreen now shows a clear toast on
native instead of silently failing.
Needs a Codemagic build (plugin change). Web deployed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Label legibility: the name + mute overlays were both white/invisible.
- Mute badge is now a RED (#dc2626) circle with a white mic-slash, matching
the web tile's .meet-mute, moved to the top-left.
- Name is now white on a dark translucent pill (PaddingLabel) so it stays
legible over any video, bottom-left.
Front/back camera switch: new NativeCall.switchCamera() flips the local
CameraCapturer front<->back (switchCameraPosition, verified in 2.15.3). New
"Flip camera" button on the meeting bar (switchCamera icon), shown only
while a native call's camera is on (updateFlipBtn). Mirroring auto-corrects
(VideoView mirrorMode .auto only mirrors the front camera).
Needs a Codemagic build (plugin change). Web deployed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The native video covers the web tile's name/mute badges, so redraw those
natively. syncVideoTiles now also carries each tile's name + muted state;
each tile VideoView gets a bottom-left name label (with shadow for
legibility) and a bottom-right mic-slash badge shown when that participant
is muted. Kept above the video renderer via bringSubviewToFront. Web sends
name/muted from meetNames/meetMuted (and ME.name/!meetMic for __local).
Front/back camera switch + screen-share rendering still deferred.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Video (2b): render every participant's camera natively, positioned to
match the web meeting tiles. The web has no LiveKit connection on a native
call, so the plugin draws native VideoViews over the WebView. New
NativeCall.syncVideoTiles({tiles:[{uid,local,x,y,w,h}]}) — the web polls
each tile's getBoundingClientRect + user id (400ms + on camera toggle) and
the plugin places a VideoView (subview of the WKWebView, so CSS-px rects ==
points) for whoever has a live, unmuted camera track; camera-off keeps the
web avatar. Replaces the 2a fixed-corner self-view: your own camera now
renders on the __local tile. Remote video correlated by LiveKit identity ==
meetPeerUids user id. APIs verified vs client-sdk-swift 2.15.3 source:
Room.remoteParticipants[Participant.Identity(from:)], Participant.videoTracks,
TrackPublication.source/.track/.isMuted, Track.Source.camera.
Audio: fix "sound starts on the earpiece until I tap something" — the
LiveKit audio engine starting after CallKit activates the session flips the
route to the receiver. Added an AVAudioSession routeChange observer that
re-asserts the loudspeaker (via preferSpeaker) whenever we land on the
built-in receiver mid-call (headset/BT still win).
Web change is safe on the current (2a) build: syncVideoTiles is absent so
the poll no-ops. Needs a Codemagic build to take effect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the camera button on native calls to the plugin instead of a
"not available" toast. New NativeCall.setCamera({on}) calls LiveKit
localParticipant.setCamera(enabled:) so the iOS user's camera is
published to the room — every web/desktop peer renders it via their
existing SFU subscription. Locally the plugin shows a small rounded
self-view (VideoView) pinned top-right over the WebView.
APIs verified against client-sdk-swift 2.15.3 source: setCamera ->
LocalTrackPublication?, TrackPublication.track, VideoView(.track/.layoutMode),
CameraCaptureOptions(position:.front). Front camera only for now.
Rendering the OTHER participants as native tiles synced to the web
meeting grid is Increment 2b (the fragile part) — next build. Until the
new IPA ships, the web branch falls back to an "update the app" toast.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Push, native calling, and Photos save are all confirmed working, so strip
the diagnostic instrumentation: the pdbg() helper and all its call sites in
home.html (native-setup-*, registration-*, perm-*, nc-* call events,
photos-fail) and the matching /api/push-debug route in routes.js. Real
console.log/console.warn lines and all functional logic are kept; a couple
of pdbg-only error paths now log via console.warn instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Photos/Files bug: the lightbox download handed nativeSaveFile a bare
"/files/<id>" with no name, extension, or mime. Empty mime made
bzSaveToPhotos short-circuit as "notmedia" — so saveToAlbum (and its
permission prompt) never ran, and the image landed in the generic Files
folder as an extension-less blob. Now nativeSaveFile trusts the server's
Content-Type when the mime is unknown and appends a real extension
(bzExtForMime/bzEnsureExt), so images reach the Images folder AND Photos.
File preview: replace the @capacitor/share "share sheet" open with a new
native FileOpener plugin (QLPreviewController). bzOpenFile now prefers a
real Quick Look preview and only falls back to the share sheet if the
plugin isn't in the build. Wired file-opener into mobile/package.json and
the codemagic SPM diagnostics loop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3. File attachments on native now work like videos: a download icon (with %),
tap to download into the app's Files folder, then the icon becomes an "open"
(external-link) control — tap again to open the file via the iOS share/preview
sheet (@capacitor/share; Quick Look / open-in). State tracked in the local
library (bzSyncFileTiles), reconciled at startup. Web/PWA keeps the plain
<a download> link. Added an externalLink icon.
2. Photos save: bzSaveToPhotos now returns a reason; the toast tells the user to
allow Photos access in Settings when it's permission-denied (the likely cause
after repeated reinstall testing), and pdbg logs the reason otherwise (noplugin
vs error) so we can pinpoint it. media-library plugin Swift is unchanged/correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of the SPM runtime regressions (call/chat push when closed, photo save):
our local file: plugins didn't function because their package.json `files` array
omitted "Package.swift", so npm can drop it from node_modules on install → cap sync
can't wire the plugin into the CapApp-SPM package → the plugin isn't loaded at
runtime. Official plugins all list Package.swift in files; ours now do too.
Also add build-log diagnostics (node_modules symlink/copy + Package.swift presence,
and the generated CapApp-SPM/Package.swift) so the wiring is provable, not guessed.
Reapplies the SPM migration (Step B) that was reverted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move off CocoaPods (sunsetting; trunk goes read-only Dec 2026) to SPM, the Cap 8
default. This also ends the LiveKit git-pin hack — LiveKit becomes a real SPM dep.
- Each local plugin gets a Package.swift (swift-tools 5.9, iOS 15, capacitor-swift-pm
from 8.0.0, source path ios/Sources/<Plugin>). native-call additionally declares
LiveKit: .package(client-sdk-swift, exact 2.15.3) + product "LiveKit".
- native-call Swift: import LiveKitClient -> import LiveKit (SPM product name).
- codemagic.yaml: `cap add ios --packagemanager SPM`; removed the "Install CocoaPods"
(pod install) step; XCODE_WORKSPACE -> XCODE_PROJECT (App.xcodeproj); build-ipa
--workspace -> --project. Xcode resolves the Swift packages during archive.
- ios-patch.sh: removed the Podfile LiveKit git-pin injection + Podfile.lock deletion
(no Podfile under SPM). Info.plist/entitlements/AppDelegate/notif.wav patches stay.
- add-share-extension.rb is SPM-safe (operates on App.xcodeproj, no workspace/Pods refs).
Authored blind (no local Xcode) — expect build iterations on the first SPM build
(cap-add SPM layout, CapApp-SPM plugin wiring, LiveKit SPM resolution, signing).
Fully revertible: git revert -> back to Cap 8 + CocoaPods (working).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hanging up an UNANSWERED outgoing call re-rang the caller's own phone: the server
sent the "cancel" VoIP push to BOTH parties, and the plugin must reportNewIncomingCall
for every VoIP push (iOS rule) → a phantom ring on the caller who just hung up.
Incoming calls don't hit this (an answered call sends no cancel). Fix: DM cancel goes
only to the callee (not startedBy); group cancel skips the starter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bump to Capacitor 8 while staying on CocoaPods (decouples the major-version bump
from the SPM switch, per the verified plan). Changes:
- mobile/package.json: all @capacitor/* + @capacitor-community/safe-area + cli → ^8.0.0
(safe-area 8.0.1 verified Cap 8 compatible; @capacitor/assets stays 3.x, version-agnostic).
- 4 local plugins: @capacitor/core peer/dev dep → ^8.0.0; podspec ios deployment_target 14→15.
- codemagic.yaml: node 20→22 (Cap 8 requires Node 22+); `cap add ios --packagemanager Cocoapods`
(Cap 8 defaults to SPM — force CocoaPods); fail loudly if no Podfile is generated.
- add-share-extension.rb: fallback deployment target 14→15.
Requirements per the Cap 7→8 guide: Node 22+, Xcode 26+, iOS 15 min. capacitor.config has no
adjustMarginsForEdgeToEdge to remove. LiveKit git-pins + native calling unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iOS 13+ terminates the app if a PushKit VoIP push doesn't call reportNewIncomingCall
before completion(). My earlier "no re-ring blip" change made the cancel handler SKIP
the report for already-known/ended calls — which is exactly what iOS kills the app
for, surfacing as a crash after several calls (a cancel push for a prior call's UUID).
Revert to ALWAYS report then immediately end: for a still-active/ringing UUID the
report errors harmlessly (no second ring) and reportCall ends it; only a late duplicate
cancel shows a brief, unavoidable blip. A crash is far worse than a blip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The .voiceChat AVAudioSession mode defaults to the earpiece, so call audio came
out of the earpiece and only settled after mute/unmute toggling. Switch to
.videoChat + .defaultToSpeaker + allow Bluetooth so audio goes to the loudspeaker
by default while wired/BT headsets still win. Add preferSpeaker() (override to
speaker when on the built-in receiver) in didActivate AND after mic toggles (the
route can flip back to earpiece on unmute). Report the chosen route in telemetry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LiveKitClient + LiveKitUniFFI now pre-download OK; the last unresolved dep is
LiveKitWebRTC (= 144.7559.11), also SPM-only (CDN only has 125.x). Its repo ships
a podspec at that tag with NO further deps, but its source is an :http release-zip
(not in the git tree), so we pin via :podspec => <podspec URL> (not :git, which
would clone a repo with no xcframework). SwiftProtobuf resolves from the CDN. This
should complete resolution for LiveKit 2.15.3 on CocoaPods.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
--repo-update still failed: "Unable to find a specification for LiveKitUniFFI
(= 0.0.6)". LiveKitUniFFI is genuinely NOT on the CocoaPods CDN (trunk returns
"No pod found") — it's SPM-only — but its repo ships a podspec at tag 0.0.6 with
no further deps (it just downloads its prebuilt XCFramework). So pin it to its
git tag too, alongside LiveKitClient. LiveKitWebRTC 144.x + SwiftProtobuf resolve
from the CDN. This should complete resolution and keep us on CocoaPods (no SPM
migration).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Git pin now works (LiveKitClient 2.15.3 pre-downloaded), but the build box's
cached spec repo is stale: "Unable to find a specification for LiveKitUniFFI
(= 0.0.6)". Both LiveKitUniFFI 0.0.6 and LiveKitWebRTC 144.7559.11 are published
on the trunk, so --repo-update on the real pod install refreshes the repo and
resolves them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The git-tag pin worked, but the build failed: "could not find compatible versions
… In snapshot (Podfile.lock): LiveKitClient (= 2.0.18) … In Podfile: LiveKitClient
(from git, tag 2.15.3)". Cause: `npx cap sync` runs `pod install` internally with
the PRE-pin Podfile, creating a Podfile.lock pinned to 2.0.18; our injected git-tag
source then conflicts with that lock. Fix: rm the Podfile.lock right after injecting
the pin so the real "Install CocoaPods" step re-resolves against tag 2.15.3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The LiveKitClient CocoaPod on trunk caps at 2.0.18 (2.1+ is SPM-only), so the
2.15 CallKit audio API was unreachable. BUT the repo still ships a valid podspec
at tag 2.15.3, and its deps (LiveKitWebRTC 144.7559.11, LiveKitUniFFI 0.0.6,
SwiftProtobuf) ARE on trunk. So instead of a risky SPM migration:
- ios-patch.sh injects `pod 'LiveKitClient', :git => <repo>, :tag => '2.15.3'`
into the generated Podfile (after cap sync, before pod install). The NativeCall
podspec's '~> 2.0' is satisfied by 2.15.3. Idempotent; hard-fails if it can't
find the App target so we never silently fall back to 2.0.18.
- Plugin re-adds the CallKit<->LiveKit audio-session coordination, now compilable:
auto-config OFF + engine OFF at load; configure session + enable engine in
didActivate; disable in didDeactivate; request mic permission on connect.
Core Room APIs (connect/disconnect/setMicrophone) verified compatible between
2.0.18 and 2.15.3 against the real source.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of the intermittent dead-mic / no-audio / late-speaker: LiveKit's
automatic AVAudioSession config races CallKit's activation. The fix needs the
2.15+ audio API, which the build wasn't getting ('~> 2.0' resolved an older 2.x
from a stale spec cache). So:
- NativeCall.podspec: pin LiveKitClient '~> 2.15'.
- codemagic.yaml: 'pod install --repo-update' so the spec repo knows 2.15.x.
- Plugin: disable LiveKit auto audio-session config + keep the engine OFF; in
CXProvider didActivate set the session category and enable the engine; in
didDeactivate disable it. Request mic permission on connect so enabling the
engine in didActivate doesn't block on undetermined permission.
All AudioManager APIs verified against the raw 2.15.3 source (setEngineAvailability,
AudioEngineAvailability.default/.none, audioSession.isAutomaticConfigurationEnabled).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second failure showed 'AudioManager' has no member 'audioSession' either — even
though the 2.15.3 source has both audioSession and setEngineAvailability. So the
build's CocoaPods is resolving an OLDER 2.x (stale spec repo/cache) that predates
the engine-observer audio API. Rather than keep guessing, drop ALL LiveKit
AudioManager audio-session code and keep the last known-good audio behavior
(LiveKit defaults). The valuable fixes that use only CallKit/AVFoundation stay:
re-ring blip guard, CallKit<->UI mute sync, and the WS reportIncomingCall ring
path. Proper CallKit audio-session coordination is deferred until the pod is
pinned/upgraded to LiveKit 2.15+.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The build failed: 'AudioManager' has no member 'setEngineAvailability' — that API
is only on unreleased/main docs, not in the resolved LiveKitClient 2.15.3. Use the
API that actually ships (per audio.md): disable LiveKit's automatic AVAudioSession
configuration (AudioManager.shared.audioSession.isAutomaticConfigurationEnabled =
false) and configure the session ourselves in CXProvider didActivate. Removed all
setEngineAvailability(.none/.default) calls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Plugin (rides next build):
- Audio: disable LiveKit auto audio-session config + keep the engine OFF, then
configure the session and start the engine ONLY in CXProvider didActivate
(stop in didDeactivate). Fixes intermittent dead mic / no audio and the "speaker
turns on late" routing. Request mic permission on connect so enabling the engine
in didActivate can't block on undetermined permission (SDK #815).
- Re-ring blip: a cancel push for a call we already ended/known no longer reports
a NEW incoming call (that was the phantom "rings back for a second"); it ends
the known call cleanly, and only reports+ends for a truly unknown (cold) call.
- Mute display: answer/outgoing reflect muted-by-default on the CallKit screen;
setMuted now drives mute THROUGH CallKit so the system screen and the in-app
meeting UI stay in sync.
- reportIncomingCall: new method to ring CallKit from a WebSocket call event — a
2nd path alongside the VoIP push for when the app is open (push can be delayed);
deduped by UUID.
Web (deploys now; the WS ring path activates once the build has the new method):
- onDmCall/onGroupCall call nativeReportIncoming for native incoming calls.
- audioActivated telemetry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Plugin connects the LiveKit room without enabling the mic (nothing captured/
published until the user taps Mic, which is also when iOS asks permission).
Web: meetMic starts false so the mic button shows muted; on callConnected the
web pushes the muted state to the plugin so builds whose plugin still connects
the mic live are muted too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The custom call overlay was wrong — the native call must use the app's actual
meeting UI. Fix: a native call now JOINS the mesh room like any participant, so
the caller/callee tiles, roster, mute state and the whole answer/end lifecycle
run through the existing (tested) meeting code. The only native-specific bit is
meetNative=true → the WebView does NOT open its own SFU media connection (the
plugin already owns this identity's one LiveKit connection); mic/hang-up bridge
to the plugin. This fixes, via existing server code, all the reported bugs:
- "no meeting window" → the real meeting window opens on answer/outgoing.
- "caller stuck Ringing after pickup" → mesh peer-join clears the waiting tile
and finishMeetingJoin marks the call answered.
- "call still running after the other side hung up" → mesh leave ends the DM
for both (signaling leaveMeeting); plus an idempotent endDmCallByRoom backup
kicks a stuck peer when the ending side's WebSocket is down.
- "accept on one device doesn't stop the other" → markDmAnswered (fired on mesh
join) emits call-taken to the user's other sockets; deliverLocal fans to all.
- "second-device accept wins / collision" → the other device's ring is dismissed
so it can't double-join the same identity.
home.html: enterMeeting(code, audioOnly, {native, uuid}); skip sfuConnect when
native; toggleMic->plugin; toggleCam blocked (video is the next phase); leave ->
callkitEnd (guarded against the plugin's endCall re-firing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of "answered but the call disconnects": native calls carry media
over LiveKit and bypass the mesh, so the server only learned "answered" from a
WebView POST. On a cold/locked answer the app is still launching and that event
was lost, so the 40s unanswered timer fired and cancelled the live call. Fix:
- Plugin: notifyListeners("answerCall", retainUntilConsumed:true) so a killed/
locked pickup isn't lost before the WebView JS attaches.
- Server markDmAnswered: emit call-taken to the callee's OTHER devices (stop the
ring; no teardown) and call-answered to the caller (flip UI to connected).
- Server declineDmCall: ignore a decline once the call is answered, so dismissing
a stale ring on a second device can't kill the live call.
Also adds a UI-only in-app call screen for native calls (caller + callee):
avatar, name, live timer, mute (-> plugin), end (-> CallKit). Native media has
no meeting window of its own; this covers "no meeting window / can't unmute".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Caller saw the native callee TWICE — the outgoing 'Ringing…' placeholder (never cleared,
since a native callee doesn't send the mesh 'answered') plus a separate id-labelled tile.
Now when a native LiveKit participant joins: remove the __waiting placeholder, stop
ringback, and label the tile from CONTACTS (name + DP) instead of the raw user id.
Also (plugin, needs build): connectRoom disconnects any previous LiveKit connection
before joining, so repeated calls never leave duplicate/stale participants in the room.
WebView fix deploys now (no rebuild); the connectRoom fix rides the next build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Native (CallKit+LiveKit) participants join the LiveKit room but not our WS mesh, so
peerIdForUid() had no mapping and sfuAttach dropped their track — the web caller stayed
on 'waiting' and never heard the native callee. Now sfuAttach/sfuDetach fall back to
keying the tile+audio by the LiveKit identity ('lk:'+id) when there's no mesh peer, and
stop the ringback. So a native<->web call crosses audio. Served — no rebuild.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Re-ring on disconnect: sendCallCancel now only fires for UNANSWERED calls. An answered
call ends via the WS event on both (awake) sides; a cancel push was re-ringing the
device that just hung up.
- Native-call telemetry (temporary): the plugin fires callConnected/callError on its
LiveKit connection; the WebView reports nc-answer/nc-connected/nc-error/nc-end/
nc-outgoing to /api/push-debug so we can see from server logs whether the native room
actually connects (no device console available). Served — no rebuild needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build error 'unable to resolve module dependency: LiveKit' — the LiveKitClient pod
sets no module_name, so CocoaPods names the module after the pod (LiveKitClient). The
SDK compiled/linked fine; only the import statement was wrong. Types (Room, etc.)
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Complete the WebView/server side of native LiveKit calls:
- routes.js: /api/calls/answered (markDmAnswered) + /api/calls/end (endCallByRoom) so
the server learns a NATIVE call was answered/ended (native media runs over LiveKit,
bypassing our mesh/WS lifecycle). Additive no-ops for WebView/mesh calls.
- home.html: for native calls the WebView no longer joins the room (one connection per
identity — the plugin holds it). answerCall -> POST /api/calls/answered + clear invite;
endCall -> /api/calls/end (answered) or /api/calls/decline (still ringing). Outgoing
DM/group calls fetch a LiveKit token and hand it to NativeCall.reportOutgoingCall
instead of enterMeeting. Removed the old callHandoff mic-repush.
Server deploys now; the plugin (native LiveKit) needs a Codemagic build. Still gated by
CALLKIT_ENABLED=0 — flip to 1 only after the build is installed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The plugin now carries the call media NATIVELY: on answer it connects the LiveKit Room
from the VoIP payload's url+token and publishes the mic; outgoing calls connect via
reportOutgoingCall(url,token). CallKit stays ACTIVE for the whole call (foregrounds the
app, keeps it alive) — the mic works because LiveKit's audio runs natively and
coordinates with CallKit (unlike WebKit's WebRTC). setMuted -> setMicrophone; end ->
disconnect. Removed the handoff hack.
NOT testable yet: still need (a) WebView to stop joining the room for native calls (one
connection per identity) and drive outgoing via reportOutgoingCall, and (b) server
lifecycle endpoints for native calls (answered/ended), since native media bypasses our
mesh/WS signaling. LiveKit Swift API authored without a local compile — expect a build
iteration or two. Don't build/flip CALLKIT_ENABLED yet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Increment 1 server side. Move livekitToken() into server/livekit.js (shared by routes.js
and calls.js). calls.js now mints a per-callee LiveKit join token and calls.js/push.js
put {livekitUrl, livekitToken} in the VoIP invite payload, so the native plugin can
connect the LiveKit room immediately on answer — even from a killed state, before the
WebView loads. No behaviour change while CALLKIT_ENABLED=0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for native LiveKit calling: the LiveKit iOS SDK carries the call media
natively and coordinates its audio engine with CallKit's AVAudioSession (AudioManager
.setEngineAvailability on didActivate/didDeactivate) — the thing WebKit's WebRTC can't
do. With this, the CallKit call stays active (stable ring + lock-screen answer +
background) AND the mic works. Native connection code + server LiveKit token in the VoIP
payload + WebView coordination come next. Don't build yet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CRASH: iOS terminates an app that receives a VoIP push without calling
reportNewIncomingCall. My re-ring 'fix' made the cancel path call completion() without
reporting -> crash. Always report then immediately end on cancel (a tiny ring blip is
unavoidable; a crash is worse).
MIC + earpiece: an ACTIVE CallKit call reserves the mic (WebView WebRTC gets a dead mic
+ earpiece routing). So on answer keep CallKit active only long enough to foreground the
app, then end it and fire 'callHandoff'; the WebView forces the loudspeaker and
re-acquires the mic (sfuSetMic off/on, retried while it finishes joining).
MISSED CALL: endDmCallByRoom now sends a plain missed-call banner to the callee when the
call ends unanswered (timeout / caller hung up before pickup); skipped on decline.
Server (missed banner) deploys now; plugin + web handoff need a Codemagic build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Answering did not open the app: ending the CallKit call immediately (to free the mic)
made iOS cancel the app launch before it foregrounded. Keep the call ACTIVE on answer
— fulfilling an active-call answer is what foregrounds/unlocks the app — and fire the
answerCall event so the WebView joins. didActivate stays a no-op (don't fight WebKit's
mic). The CallKit call is ended later when the WebView call ends.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pivot away from the native-LiveKit rewrite. Discovery: the 'voip' UIBackgroundMode
already keeps the WebView's call audio alive when backgrounded (confirmed on device),
so background audio is solved WITHOUT native media. The only issue was CallKit
reserving the mic. So use CallKit purely for the incoming RING:
- Plugin CXAnswerCallAction: fulfill, then immediately end the CallKit call
(reportCall endedAt) to RELEASE the mic, and fire answerCall to the WebView after a
~1s beat so iOS tears down the CallKit audio session first. didActivate no longer
reconfigures the session (was fighting WebKit).
- home.html: outgoing calls no longer register with CallKit (WebView-only → mic works);
incoming still rings via CallKit → hands off to the WebView on answer.
Net: native full-screen ring + working mic + background audio + all existing call
features. Needs a Codemagic build; then flip CALLKIT_ENABLED=1 to test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MIC BROKEN with CallKit: a CallKit call reserves the microphone, so the WebView's
WebRTC can't capture it — calls are unusable until native LiveKit media lands. Add a
server kill-switch (CALLKIT_ENABLED, default OFF) so CallKit can be flipped without an
app rebuild: /api/meetings/config now returns callkit; setupNativeCall bails when off
(-> WebView calls, mic works); push.js only sends VoIP/CallKit pushes when enabled.
Deploying with the flag unset immediately restores working WebView calls.
RE-RING: a late cancel push for an already-declined call hit the plugin's 'unknown
uuid' path and re-reported a fresh incoming call (~1s re-ring). Track endedCalls and
make a late cancel for an already-ended call a no-op.
Server part deploys now (no rebuild); plugin re-ring fix ships with the native build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug: a killed/backgrounded callee is woken only for the CallKit ring and has no
WebSocket yet, so the existing dm-call active:false (WS-only) never reaches it and
it keeps ringing after the caller hangs up.
Fix: send a 'cancel' VoIP push on call teardown.
- push.js: sendCallCancel() sends a {type:'cancel',callUUID} VoIP push to the user's
ios-voip tokens; invites now carry type:'invite'.
- calls.js: endDmCallByRoom + endGroupCallByRoom fire sendCallCancel to the rung users.
- NativeCallPlugin: on a cancel push, end the reported call (reportCall endedAt); if the
invite was never seen, report-then-end to satisfy iOS's 'report a call per VoIP push'.
Server part deploys now; the plugin part needs the next Codemagic build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- didReceiveIncomingPush: normalise payload.dictionaryPayload ([AnyHashable:Any])
to [String:Any] before storing/using (the reported build error at :121).
- CXProviderConfiguration(localizedName:) instead of the no-arg init (available on
all deployment targets, avoids an availability edge).
- Make two 'calls[uuid] ?? [:]' bindings explicitly [String:Any] to avoid empty-
literal inference ambiguity.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The native call feature (iOS). Backward-compatible: without the plugin (current
builds) nativeCallOn() is false and every CallKit branch is skipped, so web/older
builds behave exactly as before.
Native (mobile/plugins/native-call, a local Capacitor plugin like audio-route):
- PushKit: registers for VoIP pushes, reports the VoIP token to JS (-> /api/v1/devices
'ios-voip'). On an incoming VoIP push, reports a CallKit incoming call (full-screen
ring, works when the app is force-killed).
- CallKit: answer/decline/end -> events to JS; configures the call AVAudioSession on
didActivate so the WebView's WebRTC audio rides a call-priority session (background).
- Outgoing calls register with CallKit too (reportOutgoingCall) so they get the same
active-call background-audio context.
- NativeCall.podspec (frameworks CallKit/PushKit/AVFoundation); added to mobile deps;
ios-patch.sh now sets UIBackgroundModes = [audio, voip] (voip required for PushKit).
Web bridge (home.html): setupNativeCall() registers the VoIP token, joins on CallKit
answer, leaves/declines on CallKit end; on CallKit devices the in-app call-invite popup
+ WebAudio ring are suppressed (the system rings instead); outgoing calls are reported
to CallKit; call-end events dismiss the CallKit call. calls.js threads a stable call
uuid through the dm-call/group-call WS events + start responses so both sides can match
the CallKit call.
Needs a Codemagic build to compile the plugin; first on-device iteration expected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of the native call feature. Backward-compatible: with no VoIP tokens
registered yet it behaves exactly like today's banner push.
- push.js: sendApnsVoip() sends a PushKit VoIP push (apns-push-type 'voip', topic
<bundle>.voip, reusing the same .p8) to wake a killed app for CallKit; and
sendCallNotification() which PREFERS a VoIP push when the user has an 'ios-voip'
token, else falls back to the normal alert/banner push (Android/web/pre-CallKit iOS).
- routes.js: /api/devices now accepts platform 'ios-voip' (the PushKit token, stored
alongside the normal alert token in device_tokens).
- calls.js: each call now carries a stable crypto.randomUUID() (CallKit needs a UUID
to report + later cancel the call); DM and group call notifications route through
PUSH.sendCallNotification instead of the raw banner push.
Next: the native-call Capacitor plugin (PushKit + CallKit + LiveKit iOS SDK).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Notifications were silent despite the payload requesting sound:'default' and correct
device settings. Ship an explicit tone: a generated PCM .wav is bundled into the app
(ios-patch.sh copies it in; add-share-extension.rb adds it to the App target's Copy
Bundle Resources, tolerantly) and the server now sends sound:'notif.wav'. Part of the
consolidated iOS build alongside the background-audio + call-push + AppDelegate fixes.
Only affects iOS-native-app tokens (currently just the one test device); web push
ignores the field. Needs a fresh Codemagic build for the bundled file to exist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capacitor/WebView apps get suspended by iOS a few seconds after backgrounding, which
freezes the WebRTC mic + audio pipeline — so when the user minimised the app or locked
the phone during a call, no one could hear anyone. Add UIBackgroundModes=[audio] to
Info.plist (via ios-patch.sh) so iOS keeps the audio session (and the app) alive while
a call is actively playing/recording. Video rendering still pauses in the background
(unavoidable in a WebView) but voice continues. Needs a fresh Codemagic build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Calls only notified over the chat WebSocket (CHAT.pushToUser), so a CLOSED app
(no live socket) never rang — unlike messages, which also call PUSH.sendToUser.
Add PUSH.sendToUser for both DM (startDmCall -> callee) and group (startGroupCall
-> other members) so APNs/FCM/WebPush alerts a closed device.
To make the alert actionable, add CALLS.replayActiveCalls(userId, ws), invoked
from the chat-hello handler: when a socket (re)connects, re-send any dm-call /
group-call the user is currently being rung into (the original events fire once at
call start and are missed by an app that was closed). Opening the app from the push
then re-surfaces the invite so they can answer within the ring window.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ROOT CAUSE of iOS push not working: Capacitor 7's default AppDelegate.swift
template does NOT implement application(_:didRegisterForRemoteNotificationsWithDeviceToken:)
or ...didFailToRegisterForRemoteNotificationsWithError:. So when @capacitor/push-
notifications calls registerForRemoteNotifications(), iOS fetches the APNs token
and calls the AppDelegate, but nothing posts .capacitorDidRegisterForRemoteNotifications,
so the plugin never delivers the token to JS. register() 'succeeds' yet neither the
registration nor registrationError event fires — proven by server push telemetry
(register-called logged; no token, no error; device_tokens stayed empty).
inject-push.js adds the two forwarding methods to the CI-generated AppDelegate
(idempotent, tolerant — never fails the build), wired into ios-patch.sh after the
audio patch. Verified against the real Capacitor 7 template: methods land inside
the class, braces balance, both listeners present.
This is the missing piece alongside the earlier aps-environment entitlement fix and
the server APNs config. Needs a fresh Codemagic build to take effect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
device_tokens stays empty after reinstall+Allow, so the APNs token is never
obtained or never reaches the server, and there's no device console on Windows.
Add a /api/push-debug collector and breadcrumbs through setupNativePush (plugin
presence, permission state, register call, registration event/error, token POST
result) so the failing step is visible in server logs. Temporary — remove once
push is confirmed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The @capacitor/push-notifications plugin does not add the Push Notifications
capability to the CI-generated Xcode project (that's a manual Xcode step), and
add-share-extension.rb only merged the App Group into App.entitlements, assuming
aps-environment was already there. It never was — so on device PushNotifications.
register() failed with 'no valid aps-environment entitlement', no APNs token was
obtained, and device_tokens stayed empty (server had nothing to push to).
ios-patch.sh now creates App/App.entitlements with aps-environment=production
before the share-extension script merges the App Group in. Still requires the App
ID to have Push Notifications enabled (so the profile carries the entitlement) and
the server APNS_* key set (Step 5) for end-to-end delivery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of a post-deploy outage: recreating the app container can assign it a NEW
docker network IP. Nginx Proxy Manager caches the app's upstream IP at config-load,
so it kept connecting to the OLD IP (which, after adding the postgres/redis services,
was reassigned to bizgaze-postgres) → 'Connection refused' → site down until nginx
re-resolved. deploy.sh now runs 'nginx -s reload' in the NPM container after verify
(normal + rollback), non-fatal if NPM isn't detected. This makes deploys self-healing
for the app-IP-change case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two things:
1. FIX a live regression the async conversion missed: chat.js calls repos via the
lazy repos() helper (not the R. prefix), so my sweep skipped it — effectiveStatus
/ broadcastPresence read `repos().users.byId(userId)` synchronously, but that's a
Promise now, so presence broadcasts always reported status 'active' and dropped
last_seen. Now awaited (effectiveStatus/broadcastPresence async); touchSeen is a
fire-and-forget UPDATE with .catch. Audited all non-R. repo calls — only chat.js
was affected (media.js backfill was already awaited).
2. Swappable pub/sub for multi-instance real-time fan-out (the actual blocker to
running >1 instance — not the DB). server/pubsub.js picks a backend by
PUBSUB_BACKEND (default 'memory'). Local socket delivery is UNCHANGED; publish is
additive — memory = no-op (zero hot-path cost, identical single-instance
behaviour), redis = fan-out to other instances with a self-echo guard. chat.js
pushToUser/broadcastPresence now also publish; each instance subscribes to deliver
remote events to its local sockets. Interface is tiny so Redis is one swappable
file (Postgres LISTEN/NOTIFY or NATS could drop in the same way — never hardwired,
as requested). Dormant redis service added to compose behind the 'scale' profile;
redis dep added; PUBSUB_BACKEND/REDIS_URL documented.
Validated: smoke 22/22 (memory), e2e chat delivery green. NOTE: full multi-instance
also needs distributed presence (isOnline is per-process) + meeting-signaling
sharing — chat/presence fan out via this layer; those are follow-ups.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a dedicated Postgres 16 service (container bizgaze-postgres, own named volume
bizgaze_pg_data, healthcheck) on the shared NPM network. The app depends_on it
healthy. Inert until DB_BACKEND=pg is set in .env — default stays SQLite, so this
deploy changes nothing functionally; it just makes the engine available. Documented
POSTGRES_PASSWORD / DATABASE_URL / DB_BACKEND in .env.example.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DM/group thread queries used `(? IS NULL OR created_at < ?)` — an all-NULL
param Postgres can't type ('could not determine data type of parameter') — and an
unaliased FROM-subquery (Postgres requires an alias). Both rewritten to add the
`created_at < ?` clause only when a cursor is given, and alias the subquery `t`.
Portable; sqlite db-smoke still 22/22.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- db/pg.js: the pg backend (prepare/exec/tx/init) — ?→$N translation, BIGINT parsed
as Number (matches sqlite; else expires_at<Date.now() compares string<number),
transactions on one pooled client, init() applies schema.pg.sql. Same interface as
db/sqlite.js, so repos are unchanged.
- repos.js: the ~7 SQLite-only queries rewritten to run on BOTH engines —
audit.add @named→positional; email lookups COLLATE NOCASE→LOWER()=LOWER();
INSERT OR IGNORE→ON CONFLICT DO NOTHING (addMember/poll vote/favorite);
mergeInto's UPDATE OR IGNORE→UPDATE…WHERE NOT EXISTS/NOT IN and INSERT OR
REPLACE→ON CONFLICT DO UPDATE. Re-validated on sqlite: db-smoke still 22/22.
- server.js: boot now `await db.init()` before listening (pg creates tables; sqlite
no-op), so the first request can't hit a missing table.
- db/migrate-sqlite-to-pg.js: one-shot row copy in FK order (bulk insert, TRUNCATE
first so re-runnable). audit_log id left to PG's identity.
- package.json: add pg ^8.13.1.
Next: validate DB_BACKEND=pg smoke against a real Postgres on the server, then merge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The full sync→async conversion is complete and green on the SQLite backend. Every
DB call across the app now awaits the async adapter, so the identical code runs on
Postgres at cutover.
Converted (this commit finishes Phase 3):
- session.js: currentUser/apiKeyFromReq async → 63 route awaits + WS + static.
- routes.js: all ~250 R.* awaited; DTO helpers (namesFor, avatarsFor, buildMsgDTO,
buildPollDTO, reactionsForMessage, postSystemMessage, pushGroupUpdate,
issueRefreshToken, provisionFromBizgaze) made async; every `.map(x=>buildDTO(x))`
restructured to `await Promise.all(...map(async...))` preserving order; `.filter`
predicates that hit the DB moved to an `asyncFilter` helper; chained
`R.x.y(...).length/.map/.filter` wrapped as `(await R.x.y(...)).method`; stream
upload handlers (recording/transcript/attachment) made async.
- calls.js / signaling.js: all call/meeting fns async; leaveMeeting AWAITS
persistCallHistory + finalizeTranscript BEFORE endCallByRoom (ordering matters —
fire-and-forget would race the map teardown); WS handle()/cleanup() async with
.catch guards.
- static.js: authAttachment(Raw) async (the .some carrier check became a loop),
handleGet async; server.js dispatch catches handler rejections → 500 not a hang.
- media.js backfill, push.js, reminders.js, webhooks.js await their repo calls.
Validation on DB_BACKEND=sqlite: db-smoke 22/22; legacy e2e 80 checks pass with zero
FAILs (throws only at a PRE-EXISTING WS lobby-drift assertion, unrelated). Every
server file `node --check` clean.
Still on the branch — master untouched. Next: Phase 5 (pg backend + ~7 dialect
queries + data migration + Docker Postgres + cutover), then merge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On the db-migration branch only; master stays clean + deployable. Foundation
(adapter, pg schema, smoke harness) is already on master and safe.
Done:
- repos.js fully async (Phase 2, validated: node --check clean, no missed transforms).
- session.js currentUser/apiKeyFromReq async.
- Mechanical `await` prefix applied across routes/static/calls/signaling/reminders/
webhooks/push.
Remaining (does NOT compile yet — deterministic to finish):
1. Async cascade: helper fns that now contain `await` must be marked async and their
callers awaited. node --check points to each (namesFor, authAttachmentRaw/
authAttachment in static, the WS handlers in calls/signaling, reminders/webhooks
loops).
2. DTO builders are the real work: namesFor, avatarsFor, buildPollDTO, buildMsgDTO,
recDTO all became async — every `.map(x => buildMsgDTO(...))` etc. must become
`await Promise.all(arr.map(async x => ...))`.
3. Chained calls `R.x.y(...).map/.length/.includes` → `(await R.x.y(...)).method`.
4. Then: node --check all green → node test/db-smoke.js green → e2e → merge to master.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
server/dbx.js selects a backend by DB_BACKEND (default sqlite; pg added at cutover).
server/db/sqlite.js wraps the synchronous node:sqlite instance in the async
interface repos will call — prepare(sql).{get,all,run}, exec(sql), tx(fn), init().
Results come back as resolved Promises so identical repo code runs on synchronous
SQLite (dev/test) and asynchronous Postgres (prod).
tx() gives multi-statement atomicity that stays correct on both engines (sqlite is
single-connection; the pg backend will run it on one pooled client) — needed for the
account-merge transaction in repos.
Verified: get/all/run/tx all work end-to-end; confirmed no code reads
.changes/.lastInsertRowid, so the repo conversion is purely sync->Promise. Unwired —
nothing requires dbx.js yet; prod path untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Covers the DB-backed HTTP paths the async repo conversion touches — auth, users,
messages, attachments, conversations/groups, reactions, mentions, edit/delete,
polls, scheduled meetings (paginated), favorites, audit — asserting current API
shapes. Runs to completion with a pass/fail count and honours DB_BACKEND so it
doubles as the sqlite-vs-pg parity check at cutover. No WS/signaling (in-memory,
not the DB).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
server/db/schema.pg.sql — the full Postgres DDL, every column defined up front (no
ALTER-ordering fragility). SQLite→PG type mapping documented in-file (epoch-ms
INTEGER→BIGINT, 0/1 flags→SMALLINT kept numeric so app code is unchanged, sizes→
BIGINT, audit rowid→GENERATED IDENTITY). Mirrors the three existing FKs and adds a
new idx_messages_attachment (the /files auth scan we cached earlier becomes a keyed
lookup).
Validated against a throwaway Postgres 16: loads with no errors, 24 tables + 44
indexes created. Unwired — nothing uses it yet; the SQLite path is untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Migration prep. Two real fixes surfaced while building a regression harness:
1. db.js: the `guest_emails` and `lobby` ALTER TABLEs sat at lines 241/244, BEFORE
scheduled_meetings is CREATEd (line 299). On a FRESH database the ALTER fails
(no table yet), is swallowed by the try/catch, and the columns are never added —
so a brand-new deploy is missing them and scheduling with guests crashes. Prod
escaped it only by incremental deploy history. Moved both ALTERs to after the
CREATE. (The upcoming Postgres schema defines every column up front, so this
whole class of ordering bug goes away there.)
2. test/e2e.js: /api/meetings returns paginated `{list, pastTotal, page, pageSize}`
now, not a bare array — updated three `.data.find` → `.data.list.find`.
No prod behaviour change (prod already has the columns; ALTERs are idempotent).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The top-bar buttons looked heavy against the navy bar (thick grey X-circle, bold
"Send" pill). Swapped for light SF Symbols on the navy bar: a thin `xmark` for
cancel and a `paperplane.fill` for send (semibold, enables when ≥1 chat is picked).
Icon-only send matches the Teams reference — the radio checks already show what's
selected, so the "(N)" count text is dropped. Native-only — needs a build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two things from testing (the in-sheet picker with avatars + multi-select works):
1. Double "Send to": after the extension sent, opening the app ALSO popped the web
"Send to" modal for the same file. Cause: the extension wrote a safety-net
manifest up front, which the app then picked up. Now the manifest represents an
UNSENT share only — written solely when the extension can't send (no token) or a
send fails. A successful in-sheet send clears the staged files and leaves nothing,
so the app never re-offers it. Cancel also clears staged files (no orphans).
2. Layout aligned to the Teams reference:
- Preview strip of thumbnails for what's being shared (image → the image, video →
first frame via AVAssetImageGenerator, else a doc icon).
- Radio selectors on the right — an always-visible empty circle that fills to a
navy check when selected (clearer multi-select than an appear-on-select tick).
- "Recent chats" section header; subtitle under each name (Direct message /
Group · N members).
- Clearer branding: bold white "Share to Biz Connect" on the navy bar.
Native-only — NEEDS A NEW iOS BUILD. Balance + selectors checked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three follow-ups on the in-sheet picker:
- Profile photos: rows now show the real avatar (fetched from the conversations
API's `avatar` field with the bearer token, or a data-URL decoded inline),
rendered as a circle; coloured initials as the fallback — matching the app.
- Multi-select: tap toggles a checkmark instead of sending immediately; a "Send (N)"
button in the nav bar sends to every selected chat. Each file is uploaded ONCE and
its attachment id reused across all targets (the server allows the uploader to
reattach the same id), so multi-send doesn't re-upload.
- Branding: navy (#1F3B73) navigation bar with a white "Biz Connect" prompt over the
"Send to…" title and white controls.
Native-only — NEEDS A NEW iOS BUILD. Balance + selectors checked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the wrong approach (stage + try to bounce into the app, which iOS blocks)
with the one Teams/WhatsApp actually use: the picker and the send happen INSIDE the
share extension, so there's no app-open at all. Tap Share → Biz Connect → pick a
chat → it uploads and sends, right there in the sheet.
How the extension can send without the app: it's a separate process that can't see
the web app's HttpOnly cookie, so:
- server: GET /api/share/token mints a bearer token for the logged-in user.
- web: on every launch the app fetches that token and hands it to the extension via
the App Group (ShareInbox.setAuth writes token+base to the shared UserDefaults).
- extension: reads the token and calls the SAME API the native client uses —
GET /api/messages/conversations to list chats, POST /api/messages/upload for each
file, POST /api/messages to send. Native UITableView picker with search.
Robustness: it still stages the files + writes a manifest first, so if there's no
token yet (user never signed in) or the send fails, the file isn't lost — the app
collects it on next open, exactly as before. On success the manifest is cleared so
the app doesn't re-offer it.
Server + web are live now; the token endpoint is harmless until a build ships the
extension. NEEDS A NEW iOS BUILD for the picker itself.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Share Extension opening its host app is unsupported on modern iOS (restricted
~iOS 14), so the programmatic bizconnect://share open is silently blocked and the
user just saw a blank flash back to Photos — looking broken even though the files
staged fine.
The extension now shows a small native card after staging: "✓ Ready to send — Open
Biz Connect to choose a chat", with an "Open Biz Connect" button (user-initiated
open has the best chance of working) and a Done button. It still attempts the
auto-open first. Either way the app collects the staged files when next opened, so
the manual path that already works is unchanged — this just removes the "did it
even work?" confusion.
Renamed the local `staged` array to `collected` to free `staged` for the state
flag. Balance + selectors checked. NEEDS A NEW iOS BUILD (native change).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-ups from testing the share flow (which now works end-to-end):
- Send-to picker showed only initials — now shows the real profile photo when the
chat has one (matching the sidebar/forward avatars), coloured initials otherwise.
- During send it only said "Uploading 60%" with no idea WHO to — now the header and
progress name the target ("Sending to Manasa Rapolu · 60%").
- After sending, a video bubble sat blank (just a timestamp) for a second or two
while the poster generated on first view. media.js now warms the poster thumbnail
at UPLOAD (temp-then-rename), and the on-demand /thumbs handler also writes via a
temp, so the two can't serve a half-written JPEG. The bubble shows its poster
right away.
Web + server only — live on deploy. Does NOT address the share extension failing to
auto-open the app (an iOS limitation, handled next in the native build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sharing a photo into the app: the "Send to…" picker appeared and staging worked,
but picking a chat said "no file found". Cause: the app loads its UI from the
REMOTE origin (remote.bizgaze.com), and I read the staged bytes with
fetch(convertFileSrc(uri)). Capacitor's local `_capacitor_file_` serving isn't on
the remote origin, so that fetch is CORS-blocked / hits the remote server → 404.
Read through the native bridge instead: Filesystem.readFile returns base64 in
native code, never touching the webview network stack, so it reads the App Group
file the app has entitlement access to. Falls back to it.path, then to the old
webview path for local-asset builds. Web-only fix — no rebuild.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The archive keeps failing with only "Failed to archive" and exit 65 — no reason
shown. Cause: `xcode-project build-ipa` prints a prettified summary and swallows
xcodebuild's raw "error:" lines, which only survive in the /tmp/xcodebuild_logs
artifact. On failure we now grep that log for the signing/entitlement/compile
error and print it inline, so the next red build states WHY instead of just 65.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codemagic log: "xcodeproj not found at /Users/builder/clone/ios/App/App.xcodeproj"
— missing the `mobile/` segment. The script lives in mobile/scripts, so __dir__ is
mobile/scripts and `File.expand_path('../..', __dir__)` resolved to the REPO ROOT,
not mobile/. The project is at mobile/ios/App/App.xcodeproj. Changed to '..' so ROOT
= mobile, which fixes PROJECT, SRC_DIR, APP_DIR and the entitlements path together.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the reverse direction: share FROM Photos/Files/Safari INTO a Biz Connect
conversation. An app can only appear in the iOS share sheet as an app-extension
target, so this is real native work, not a web change.
Pieces:
- mobile/ios-share/ShareViewController.swift: a UI-less Share Extension. It stages
the shared items into the App Group container and opens bizconnect://share. It
deliberately does NOT reimplement the chat picker — that lives in the app, which
already has the chat list, search and upload progress. Appends to the manifest
(never overwrites), so sharing twice before opening the app loses nothing.
- mobile/scripts/add-share-extension.rb: injects the extension target into the
Capacitor-generated Xcode project on every CI build (Codemagic checks out fresh),
using the xcodeproj gem that ships with CocoaPods. Embeds it, sets the bundle id
<app>.share, and MERGES the App Group into the app's entitlements rather than
clobbering them (push's aps-environment must survive). Idempotent.
- mobile/plugins/share-inbox: getPending()/clear() to read that manifest — the App
Group container isn't one of Filesystem's known directories, so it needs a bridge.
- home.html: on bizconnect://share (and every resume, and cold-launch), read the
inbox and show a "Send to…" picker over the chat list; chosen files run the SAME
upload + /api/messages send as an in-app attachment. Reuses convertFileSrc to read
the staged bytes with no base64 marshalling.
- ios-patch.sh registers the bizconnect URL scheme; codemagic.yaml fetches a profile
for the .share bundle id too.
One-time manual gate (CI cannot toggle App capabilities): the App Group
group.com.bizgaze.connect must be created and enabled on both App IDs in the Apple
portal — documented in mobile/IOS_SETUP.md. Without it the two processes can't see
each other's files and sharing silently no-ops; everything else still works.
Validated cross-file: pod-name/jsName/method wiring for all three plugins, App
Group id identical in all 4 files, URL scheme consistent across extension/plist/web,
entitlement-merge preserves push. Needs a new iOS build (new targets + plugins).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Files folder is the app's own copy — it powers offline playback and Manage
storage — but Files is not where anyone looks for photos and videos. The Photos app
is, and only PhotoKit can write there; @capacitor/filesystem cannot, because an
app sandbox and the photo library are separate stores. So this adds a small native
plugin, mirroring the existing audio-route one.
- mobile/plugins/media-library: saveToAlbum({path, album, kind}) finds or creates
the album and adds the asset. Uses addResource(with:fileURL:), which is uniform
for photo and video and non-optional, unlike creationRequestForAssetFrom*, which
can silently no-op.
- Requests .readWrite, NOT .addOnly: addOnly can add an asset but cannot look up or
create an ALBUM, which is the whole point here. Both photo-library usage strings
are already set by ios-patch.sh.
- If the album can't be resolved (e.g. "limited" access), the asset is still saved
to the camera roll — landing somewhere beats failing outright. A racing create
from two simultaneous downloads re-looks-up instead of erroring.
- Podspec named MediaLibrary.podspec with s.name = 'MediaLibrary' to match
PascalCase of the package name — the same trap that broke the AudioRoute build.
Checked by a script: pod name, jsName and declared-vs-implemented methods.
Entirely best-effort from the web side: a denied permission or an older app build
never fails a download that is already safe in the app folder. Added a Settings
toggle since this does keep a second copy of the file.
Needs a new iOS build — new native plugin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The download button was a small corner overlay, which landed on top of the native
control bar's speaker icon. That was not the design asked for either: it should be
a single control in the CENTRE that turns into a play button once downloaded.
The tile is now the masked poster plus one centred button:
not downloaded → download icon (the element has no src at all, so nothing is
fetched until it is tapped)
downloading → live % inside that same button, in place
downloaded → play icon; tapping plays the LOCAL file
The native control bar is switched on only when playback starts, so there is
nothing for the control to collide with. Progress reports into the button rather
than the floating chip, so a video download no longer shows two indicators.
Also gives the tile a min-height so the thread doesn't jump while the poster loads.
Web/PWA is untouched — it has nowhere to download to, so it keeps streaming with
native controls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three connected pieces, so downloads stop being write-only.
1. A folder the user can actually find. ios-patch.sh now sets UIFileSharingEnabled
and LSSupportsOpeningDocumentsInPlace, and downloads go to the app's Documents
folder in typed subfolders. iOS shows it as:
Files -> On My iPhone -> Biz Connect -> Images | Videos | Files
Previously everything was written to CACHE (private, and iOS purges it whenever
it likes) and pushed straight at the share sheet, so nothing was ever really
"kept" by the app.
2. Manage storage (Settings -> Storage). Lists what this device has downloaded,
grouped by type with per-group and total sizes; each row can be shared to the OS
sheet (this is where "Save to Photos" now lives) or deleted. Plus Delete all.
Deleting removes ONLY the local copy — the attachment stays on the server, so
anything deleted can be downloaded again from the chat.
3. A downloaded video never downloads twice. Images and files have their own
download link, but a video's tile IS the player, so it had no control at all and
re-streamed on every play. It now carries a download button; once saved, the
button becomes a tick and the tile plays from the local file — no network.
The index is treated as a cache of the filesystem, never as truth, because the user
can delete these from the Files app behind our back: every listing re-stats and
forgets what is gone, the library is reconciled at startup, and a local file that
has vanished by play time falls straight back to streaming instead of showing a
broken player.
Unit-checked the path allocator: collisions between different attachments with the
same filename resolve to "name (2)", re-downloading the SAME attachment reuses its
path, and path traversal / illegal characters are neutralised.
Note: the folder and the save location need a new iOS build to take effect. The web
side degrades cleanly — none of this UI appears outside the native app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Uploads showed a bar + %; downloads showed nothing. On the native app that gap is
worse than on web, because there is no browser download UI behind it — the app
fetches the bytes itself, so a large video looked like a frozen tap.
- Adds the same chip an upload uses (name, bar, %) driven by Content-Length.
Falls back to an indeterminate "…" when the server sends no length.
- Streams the response and appends to the file in 3-byte-aligned blocks rather
than holding blob + base64 simultaneously. The old path peaked around 250 MB of
memory for a 75 MB video, which is enough to get a WebView killed on a phone.
Verified byte-exact against empty / 1 B / 2 B (base64 padding edges) / ragged
chunk sizes / 27 MB, reassembling identical bytes every time.
- Older WebViews without streams keep the previous one-shot path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reported: "the loading buffer is not the spinner, it still loads at the left of
the timer." Correct — and it was our bug, not a cosmetic preference.
The spinner was driven by a hand-picked event list (waiting/seeking/stalled). But
a cold start from preload="none" NEVER fires `waiting`: it runs
loadstart -> loadedmetadata -> loadeddata -> canplay -> playing straight through.
Since the bandwidth fix, that cold start is the only stall left — so the spinner
sat out the exact moment it existed for, leaving just the OS control bar's own
small indicator where the play button sits, i.e. left of the timer.
Now the spinner is derived from the element's real state rather than guessed from
events: busy = seeking || (!paused && !ended && readyState < HAVE_FUTURE_DATA),
recomputed on every relevant media event. Simulated against the real event
sequences before shipping — cold start, mid-stream stall and seek all spin;
paused/ended/idle never do.
Also dim the frame to 72% brightness while buffering so the spinner reads
instantly against a bright poster, and give it a dark backing disc.
Note: the small indicator inside the native control bar belongs to the OS's own
video controls and cannot be suppressed while we use them. Ours is now the loud,
central one; removing the OS indicator entirely would mean custom controls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
THE ANSWER to "why does an already-downloaded video still buffer?" — it was never
the download, and it was not the server. Probing the real uploads on the box:
d0e49e58… 1920x1080 19.4 Mbps 75 MB / 31 s
ad929d0b… 1920x1080 19.0 Mbps 27 MB / 11 s
9f4e0865… 720x1584 3.6 Mbps 14 MB / 31 s
To play a 19 Mbps file the client has to SUSTAIN a 19 Mbps download for the whole
clip. No mobile link does, so the <video> buffer drains every few seconds: buffers,
plays, buffers, plays. Server-side disk read was instant and load was 1.7 on 20
cores throughout — the bottleneck is the media itself, not the delivery path.
Second, independent defect: phone MP4s store `moov` AFTER `mdat` (verified on two
uploads), so the player must fetch the file's tail before it can start at all.
Fix — keep the original bytes untouched (that is what the download button serves,
full quality) and build <id>.web.mp4 beside it: longest side capped at 1280,
~2.5 Mbps ceiling, +faststart. Measured on the 19 Mbps file:
27.3 MB @ 19.0 Mbps -> 2.55 MB @ 1.78 Mbps (10.7x less bandwidth)
transcode took 2.4 s for an 11.5 s clip
- server/media.js (new): probe, decide, 2-at-a-time background queue. Already
light + correctly sized + faststart => no rendition at all. Light but wrong atom
order => remux -c copy (seconds, no re-encode). Otherwise re-encode. A rendition
that lands bigger than the original is discarded. MP4 box-walker for the
faststart test is unit-checked against known fast/slow files, both directions.
- /stream/<id> serves the rendition, falling back to the original while it is still
transcoding, so a video is never unplayable. /files/<id> is unchanged and still
serves the pristine original for download.
- Renditions are queued at upload, and backfilled 15 s after boot for the videos
that predate this. Range serving is now one shared helper for both routes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of a downloaded/streaming video buffering repeatedly: every /files Range request
(a playing video fires dozens) re-ran the full attachment authorization, which scans the
messages table by attachment_id (un-indexed) — a per-chunk table scan = stutter. Now the
auth decision is cached per user+attachment for 60s (module-level, bounded), so range
requests after the first are ~free.
Also: preload='none' (nothing about a video downloads until the user taps play — only the
small poster loads), per 'no auto-download'. And the buffering spinner no longer hides on
canplay/loadeddata (they fire mid-buffer), so it reliably spins whenever it's buffering.
build batch159.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The native controls' buffering indicator sits by the timer and is easy to miss. Wrap the
<video> and overlay a centered spinner shown while it buffers (waiting/seeking/stalled ->
show; playing/canplay/seeked/loadeddata -> hide). Media events don't bubble, so the
listeners run in the capture phase; the overlay is pointer-events:none so it never blocks
the native controls. build batch158.
- Dockerfile: add ffmpeg (Alpine).
- static.js: new /thumbs/<id> — ffmpeg extracts the first frame (0.5s), caches it next to
the file, serves as the video poster (cosmetic; 404s gracefully if ffmpeg unavailable).
- static.js: /files now supports HTTP Range (206 Partial Content) + Accept-Ranges, which
iOS requires to stream/seek video reliably (fixes the buffer-before-play / multi-tap);
media (image/video/audio) now served inline, other files still download. Shared
attachment auth refactored into one helper used by /files and /thumbs.
- home.html: video poster points at /thumbs/<id>. build batch157.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Swipe-back: the finger-following version is dropped (parked for the future) per request —
back to the reliable release-triggered swipe (rightward edge release runs bzcBack's slide).
Video: the custom download->play overlay caused layout 'dancing' on load and flaky
multi-tap playback. Replaced with a plain native <video controls playsinline preload=
metadata> (poster via #t=0.1) at a fixed box size — poster + OS play button, plays inline
on one tap, streams once and is cached (no re-downloads). build batch156.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug 1: .ptr-ind used top:8px so the pull-to-refresh spinner sat under the notch/Dynamic
Island. Now top:calc(var(--sat)+8px) clears the safe-area inset.
Bug 2: video attachments rendered as a plain download link that re-downloaded on every
tap. Server now sends isVideo/isAudio on message attachments; videos render as an in-chat
player — masked poster with a DOWNLOAD button that loads the file ONCE (preload=none ->
load on tap), then becomes a PLAY button; playing hands off to native inline controls, so
no repeat downloads. build batch155.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The conversation pane now tracks your finger from the left edge and reveals the chat
list behind it, completing the back past ~35% width or springing back otherwise. Reuses
the existing body.chat-dragging layout (already defined, identical to chat-closing that
showWelcome uses): content z-index:2 at translateX(0), list .chatcol absolute behind at
z-index:0 — so the pane starts at the correct on-screen origin (the earlier attempt's
'one screen-width off' bug was a different setup). Vertical drags still scroll; popup/
search edge-release still closes via bzcBack. build batch154.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Investigations are closed, so strip the probes: bzDbg + __perfProbe definitions, the
older/render/slide/pwaFocus call sites, and the server-side /api/dbg (MDBG) sink. Kept
the functional code around each probe (renderThread's innerHTML build, the older-page
re-anchor, the slide fade). No behavior change. build batch153.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of 'tapping stops the Bluetooth audio': bzUnlockAudio fires on EVERY tap
during a call and called bzApplyRoute -> setSpeaker(false) -> configureDevice, whose
setCategory+setActive(true) reconfigures the AVAudioSession mid-call and interrupts
WebKit's audio unit, dropping the call audio. Fix: bzApplyRoute on iOS no longer touches
the session at all (only wires the route-icon listener); iOS keeps auto-routing. build batch152.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Telemetry confirmed the ceiling: a single speaker override holds ~1s over active BT then
WebKit reverts. So stop trying to control the route on iOS — always use the default port
(iOS auto-routes: BT/wired if connected, else loudspeaker), and make the button a live
INDICATOR of the real output; tapping shows a toast (connect/disconnect a headset to
change). Removes the temp nroute/sptap probes. Pure web change; plugin v1.1.2 already
supports it. build batch151.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1.1.1's route observer re-forced speaker on every change, and WebKit re-added Bluetooth
each time -> the audio flapped speaker<->BT many times/sec (telemetry: dozens of route
flips from 3 taps), which read as 'sound doesn't switch'. WKWebView won't let the app
hold the built-in speaker over an active BT device. So: one override per tap, observer
only REPORTS the output (no fighting). Also stop dimming the iOS button (it's a live
output indicator, not on/off; the dim read as 'disabled' on BT). Probe now carries the
native marker so we can confirm the binary. plugin v1.1.2-stable, web batch150.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
overrideOutputAudioPort(.speaker) alone can't beat a connected BT headset (BT is higher
priority), so 'Speaker' snapped back to BT. Now setSpeaker(true) sets category options
[.defaultToSpeaker] (no allowBluetooth) so BT isn't an eligible output and the speaker
wins; setSpeaker(false) restores [.allowBluetooth,.allowBluetoothA2DP] and uses the
default port (routes to the headset). Observer re-holds speaker if a BT connect steals it.
Adds a TEMP web probe (nroute/sptap) to verify from telemetry. plugin v1.1.1, web batch149.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BT audio already routes on iOS, but the web UI can't see it (iOS hides audio outputs
from enumerateDevices), so the icon was stuck on speaker. Plugin v1.1.0 now exposes the
active output: getRoute() + a 'routeChange' event ('speaker'|'bluetooth'|'wired'|
'receiver'|'airplay'). Web subscribes and drives the icon/label from the real route
(bluetooth/headphones/speaker), and the iOS toggle becomes a 2-state Speaker <-> Device
cycle (JS can't enumerate outputs there). Also strips the earpiece-investigation debug
logging from the plugin. Native needs one Codemagic build; web is live (batch148).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Proven via device telemetry: inside a WKWebView, WebKit owns the WebRTC audio unit and
forces the loudspeaker; overrideOutputAudioPort(.none) is a no-op (route settles on
Speaker 1.2s later), so the built-in earpiece cannot be selected. Present only what
actually works on iOS: Speaker, and Bluetooth/wired headset when connected. bzApplyRoute
now maps only 'speaker' to the loudspeaker override; bt/headset use the default port.
Coerce any stale 'earpiece' pref to speaker on iOS. Also strips the route debug telemetry.
Other platforms keep the earpiece option. build batch147.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1.0.3 telemetry showed mode already == .voiceChat when earpiece still landed on
Speaker, and setSpeaker read the route synchronously (stale) right after the override.
So the mode re-pin alone may be insufficient and the sync read is unreliable.
1.0.5: keeps the .voiceChat re-pin, but stamps live mode into every route-change log
line and re-reads the SETTLED port+mode at +0.4s and +1.2s after each toggle (reported
in the next toggle's trail). This definitively answers whether WebKit flips to .videoChat
and where the route truly settles. native marker 1.0.5-settle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause (WebKit source MediaSessionManagerCocoa.mm + Apple DTS, confirmed by
telemetry): WKWebView's WebRTC re-pins the session to mode .videoChat while capture
is active, and .videoChat auto-implies .defaultToSpeaker. So override(.none) reverts
to the mode default = LOUDSPEAKER, and .none alone can never reach the earpiece once
WebKit flips the mode. Our first override won only because .voiceChat was still active.
Fix: for earpiece, setMode(.voiceChat) (its default route IS the receiver) before
override(.none) in both setSpeaker and the debounced route-change re-assert. Add an
accessory guard so a connected BT/wired headset isn't yanked to the built-in receiver.
Reconcile the launch patch: drop .defaultToSpeaker from inject-audio.js so it stops
contradicting the plugin. native marker 1.0.4-mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Telemetry proof: overrideOutputAudioPort(.none) routes to Receiver once, then every
later toggle latches to built-in Speaker despite opts=36 (no .defaultToSpeaker) —
WebKit's WebRTC engine re-forces the loudspeaker after our override. The prior build
ignored .override-reason route changes and never corrected it.
Now: react to ALL route changes, debounced 0.25s, and re-assert the chosen port only
on a genuine mismatch (self-terminating, capped at 6/toggle to avoid thrash). setSpeaker
returns a reason->port route-change trail so the log shows whether WebKit is one-shot
or persistent. native marker 1.0.3-reassert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Telemetry only showed 'override didn't throw', and the web __BUILD tag can't tell
native binaries apart, so we couldn't see WHERE iOS actually routed the audio or
which plugin build ran. setSpeaker now resolves with the real currentRoute output
port (Receiver/Speaker/Bluetooth), the live AVAudioSession category/mode/options,
and a native-build marker (1.0.2-diag) so the route log is unambiguous.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Speaker worked but the earpiece was silent, and call audio flickered on speaker
then dropped. Causes: (1) the category set .defaultToSpeaker, so overrideOutputAudioPort(.none)
fell back to the loudspeaker instead of the receiver; (2) setSpeaker re-ran
setCategory+setActive on every toggle mid-call, tearing down the audio unit WebKit's
WebRTC engine was using and silencing the earpiece route.
Fix: drop .defaultToSpeaker (drive the port explicitly), make setSpeaker flip ONLY
overrideOutputAudioPort, and observe routeChangeNotification to re-assert the chosen
route when WebKit reconfigures the session at call start / device change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capacitor derives the pod name from the npm package name (audio-route -> AudioRoute)
and writes 'pod AudioRoute, :path => ../../plugins/audio-route' into the generated
Podfile. CocoaPods then requires a file literally named AudioRoute.podspec whose
s.name is 'AudioRoute'. The old AudioRoutePlugin.podspec (s.name AudioRoutePlugin)
caused the Codemagic build to fail with 'No podspec found for AudioRoute'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device telemetry proved the app-embedded AudioRoute class never registered (not in
Capacitor.Plugins) — appending a CAPBridgedPlugin to AppDelegate.swift gets stripped/undiscovered
in release builds. The plugins that DO register (Share, Camera, Filesystem) are all npm packages
wired by cap sync. So AudioRoute is now a local plugin package (mobile/plugins/audio-route,
file: dep in mobile/package.json) with a podspec + CAPBridgedPlugin Swift — cap sync adds its pod
and Capacitor registers it like the others. load() sets the launch speaker default; setSpeaker({on})
overrides the output port. inject-audio.js no longer injects the plugin class (would duplicate);
it keeps only the AppDelegate launch default as a fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Server streams the upload body straight to /data/uploads (a .part temp file, atomic rename on
success), backpressure-aware, so a 1 GB file never buffers in RAM. MAX_UPLOAD_MB env (default
1024 = 1 GB) controls the cap; error message reflects it.
- Client size guard raised 25 MB -> 1 GB.
- docker-compose documents MAX_UPLOAD_MB and the required Nginx Proxy Manager client_max_body_size.
NOTE: the actual bottleneck for the user's 9.7 MB reject is almost certainly NPM's client_max_body_size
(nginx default 1 MB) — that must be raised in the NPM admin; the app change alone can't lift it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ios-patch.sh now injects an AudioRoutePlugin (CAPBridgedPlugin, Capacitor 7 auto-registers it)
into AppDelegate.swift with setSpeaker({on}) -> AVAudioSession.overrideOutputAudioPort. Tolerant/
build-safe: if it doesn't register, the web call just no-ops (can't crash or fail the build).
- web: nativeAudioRoute()/bzApplyRoute() drive the plugin; toggleSpeakerphone + the on-join/on-tap
unlock now actually switch the route on iOS (setSinkId can't). canRouteAudio() shows the toggle
when the native plugin is present. Dormant until the next Codemagic build ships the plugin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drag the open conversation rightward from the left edge and it follows the finger 1:1 with the
list parallaxing in underneath; release past ~35% (or a quick flick) completes the pop, else it
snaps back. Fixes the old stuck-pane bug: once a clear horizontal drag is detected we
preventDefault (passive:false) to CLAIM the gesture so iOS/scroll can't steal it and fire
touchcancel; touchcancel always resolves to a clean state. showWelcome(skipAnim) does the state
swap without re-animating. Works in native app, PWA and mobile browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Calls were routing to the quiet earpiece. ios-patch.sh now runs a Node helper (Node 20 is
already in the build env) that injects an AVAudioSession .playAndRecord/.voiceChat category with
.defaultToSpeaker + Bluetooth into the generated AppDelegate. Tolerant: exits 0 and no-ops if the
template differs, so it can never fail the Codemagic build. Verified locally against the Cap 7
AppDelegate template — injects correctly and is idempotent. First pass; if WebRTC re-grabs the
session mid-call on device, a follow-up plugin will re-assert .overrideOutputAudioPort(.speaker).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Suppressing the save dialog made downloads completely silent — "nothing happened" even though
the file saved to Downloads. Now on download 'done': (1) tell the web UI → branded toast
"Saved X to your Downloads folder" when the window is focused; (2) native OS notification
(click → reveal in Explorer) when the app is minimized/in the tray, so it's never double-noticed;
(3) a failure notice. preload exposes onDownloadDone; home.html shows the toast.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The app closes to tray, so it kept running on the old version and only checked on a fresh launch.
Now: (1) check for updates on EVERY window show (X-to-tray then reopen, taskbar, relaunch via
second-instance) via win.on('show'), throttled 1/10min; (2) explicit autoInstallOnAppQuit=true so
a downloaded update installs on the next real quit / PC restart even if the user never clicks
"Restart now"; (3) native notification when an update finishes downloading while hidden in the
tray. The existing 6-hour background timer is unchanged and still runs regardless of window state.
Also carries the 0.1.18 downloads-to-Downloads-folder change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Accessory bar: call Keyboard.setAccessoryBarVisible({isVisible:false}) on iOS in the native
IIFE, hiding the grey chevrons+Done strip above the keyboard. The plugin is already bundled
in the current TestFlight build, so this takes effect on a web deploy — no rebuild.
- Attach: tapping the paperclip now opens a composer-anchored Photos/Camera/Document menu
(like the emoji/mention popups) instead of firing the generic mid-screen OS chooser as the
first thing. Each option opens a type-scoped picker (image/*, capture). Web fix, all clients.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Mobile: the Return key now inserts a newline instead of sending (send via the button, like
every native chat app). Desktop keeps Enter=send / Shift+Enter=newline.
- Image preview: hide the on-screen +/- zoom buttons on mobile (pinch-to-zoom covers it).
- Desktop (Electron): a will-download handler saves straight to the OS Downloads folder with no
"where to save?" dialog, de-duping the name if it exists. NOTE: desktop code only — NOT
published to the update feed (needs an explicit desktop rebuild/publish).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mobile viewport fixes only ever went into home.html. The other standalone pages still had
the old viewport, so in the native app they auto-zoom on input focus and (dashboard/host) run
under the notch/Dynamic Island. Bring every page's viewport to match home.html
(maximum-scale=1, user-scalable=no, viewport-fit=cover) and add safe-area top padding to the
dashboard header and the host body/indicator so nothing sits under the island now that the
viewport is cover. index/connect/share already pad for safe-area; they only needed maximum-scale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iOS home-screen PWAs IGNORE viewport maximum-scale (Apple disabled it for accessibility), so
the maximum-scale=1 that stops auto-zoom in the Capacitor WebView does nothing in the PWA — it
still zooms on input focus. Real cross-platform fix: make every focusable text field 16px on
mobile (the composer was .92rem). autoGrow's empty-guard keeps it one line.
Also the PWA has no Capacitor Keyboard plugin, so the composer never lifted above the keyboard
("not the same keyboard"). Added a VisualViewport-based lift for non-native clients (browser +
PWA); native still uses the plugin. >100px threshold ignores the Safari toolbar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug: for ~3.4s after opening a chat, the "keep newest in view as images load" pin schedule
would yank you back to the bottom if you tried to scroll up for older history — so scrolling
up snapped back to latest, and only worked once the schedule expired (~5s). Fix: the user's
first scroll gesture now sets _openScrolled, which cancels the pending pin timers and short-
circuits _pinNewest (so the late image-load pins stop too). loadOlder/anchor then works
immediately, no yank.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device log showed the slide motion itself is now clean 60fps (frames 0-15 all ~16ms in every
sample); the only remaining spike was at frame ~16 — AFTER the slide lands — from applying the
network thread result + pin at that instant (the "adjusts after it lands" feel). Since the open
is a compositor opacity/transform fade, main-thread work during it doesn't stall the animation
and is masked by the low opacity. So apply the reconcile/render + pins immediately when the
fetch returns (mid-fade) instead of deferring to slide-end. By the time the pane is fully opaque
the content is already settled. Removed the now-obsolete appendBubble mid-slide deferral.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reflow that caused the end-of-slide hitch was never the content slide itself — it was
SHOWING the list column during the slide and hiding it (display:flex->none) at the end. So
keep .chatcol display:none the whole time (untouched) and animate ONLY .content: it pushes in
from translateX(12%)->0 with an opacity fade, on its already-promoted GPU layer. Real slide
motion, and nothing to reflow when it ends. (This also explains the batch126 breakage: that
made .chatcol position:absolute, disturbing the touch/layout target — not touched here.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User chose the fade over chasing the WebView slide-teardown reflow. Open now: the cache-
rendered conversation is placed instantly and fades opacity 0->1 over 140ms. No transform,
no off-screen pane, no list-column display toggle -> nothing to reflow at the end, so the
~40ms end-of-slide settle cannot occur. Same afterOpenSlide queue flushes network reconcile/
pins/appends at fade-end. Desktop unaffected (fade is mobile-only via __freshOpen).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The list-column position:absolute change stopped conversations from opening. Revert to the
known-good display:none. Keep the other batch124-125 fixes (content promoted, reconcile,
reopen-at-latest, download interceptor). Slide back to the batch125 behaviour (opens fine,
minor end hitch) while I find a safe way to remove the reflow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device log proved the residual 30-78ms end-of-slide hitch is NOT the content-layer demote
(content stays promoted) and NOT the re-render (reconcile, longtasks:[]). It's the chat-list
column flipping display:flex->none at transitionend, which reflows that whole subtree. Keep
the list RENDERED behind the conversation (position:absolute; z-index:0; covered by the opaque
content pane) instead of toggling display. Teardown now only clears transforms = no reflow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slide: the residual ~40ms end-of-slide hitch (down from 128ms in b124) was the layer
DEMOTE + the deferred full renderThread at transitionend. Two fixes:
- Keep .content promoted for the WHOLE time a chat is open (will-change lives on
body.chat-open, not on the animating class). The open transition now only changes the
transform VALUE 100%->0; when it ends there is nothing to tear down -> no demote raster.
- Slide-end no longer rebuilds all 40 nodes: reconcileOpen() appends only the 0-2 genuinely
new tail messages (full renderThread only if the page structure diverged).
Download: routing to Safari failed auth (no login cookie). Real fix: fetch the file in the
WebView (cookie present) and hand the bytes to the OS save/share sheet via Filesystem+Share
(added to mobile deps; ships next TestFlight build). Until then, images fall back to the iOS
long-press "Save to Photos" instead of breaking the app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slide: the 40-128ms hitch was a compositor RE-RASTER at transitionend, not JS
(longtasks:[], renderMs 2-6ms in the device log). Removed contain:layout paint /
isolation:isolate from the .content slide layer and the filter:brightness parallax
on .chatcol (both force a full re-raster on teardown); defer the layer demotion two
frames past transitionend so it lands off the motion path. Also replaced the fragile
length+lastId slide-end skip (it almost never held once a live message grew the cache,
so the full innerHTML rebuild kept running at slide-end) with an exact per-message diff.
Reopen-at-latest: loadOlder wrote the GROWN thread (100+ msgs) into THREAD_CACHE, so
re-opening re-rendered all of it and stranded you mid-history as images shifted the
(pre-load) bottom past the 1200px pin guard. Open now renders the latest PAGE only;
added _forcePinOpen to glue to the newest through late image loads until the user scrolls.
Download hang: <a download> navigated the whole WKWebView away to the raw file (no back,
app frozen). Native-only capture-phase interceptor opens downloads in the system browser.
Lightbox close/download buttons: solid dark chips so they're visible over bright images.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device log: slide is 60fps EXCEPT one ~60-130ms hitch at the END = the deferred renderThread rebuilding
the (identical) DOM at slide end. Skip it when the network result matches the cache render (signature).
Load-older: fired at scrollTop<120 = during the rubber-band over-scroll past the top (beforeScrollTop
was -289), so the prepend+anchor jerked momentum. Trigger earlier (<700, skip negative scrollTop) so it
loads while still scrolling and never interrupts the bounce.
Build marker -> 2026-07-19-batch123.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The perf-diagnostics script tag was never closed, so it merged with the following native/keyboard
<script> and threw 'Unexpected token <', disabling the keyboard/safe-area init. Add the missing </script>.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-add /api/dbg + a frame-gap/long-task probe over the chat-open slide, the innerHTML render time, and
before/after scroll for load-older — to measure on-device where the jank actually is (main-thread block
vs compositor) instead of guessing. Temporary; removed once fixed.
Build marker -> 2026-07-19-batch122.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slide (workflow-verified): the jump was the main thread building the message DOM + scroll writes DURING
the 300ms animation. Now: render off-screen first, then arm a transform-only slide on a promoted GPU
layer (translate3d/contain:layout paint) with all innerHTML/scrollTop work deferred to transitionend
(afterOpenSlide queue). armOpenSlide runs after the sync render; appendBubble defers live writes mid-slide.
Load-older jump: anchor on the specific oldest-loaded message element (not scrollHeight math, which broke
when prepended images loaded and shifted content) and re-anchor as those images load.
Removed all /api/dbg diagnostics (client reporter + server route + spike/diag probes).
Build marker -> 2026-07-18-batch121.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User's insight: the jank/jumps come from rendering ALL ~500 messages on open. Load only the latest 40
(server + client PAGE), and the existing loadOlder() pages in older history on scroll-up with a scroll
anchor (no jump). Whole-thread SEARCH is a separate endpoint, unaffected. Also #1: keyboard-show only
pins to newest when already near the bottom, so replying to an OLD message no longer yanks to latest.
Build marker -> 2026-07-18-batch120.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Pager: force the staged (opposite-edge) frame to PAINT (void offsetWidth) before animating in, so the
incoming image enters from the correct side (was appearing from the exit side). Hide prev-arrow at the
first image and next-arrow at the last.
- #1: keyboard closed after reply-cancel but the --kb lift stayed (empty gap). Add keyboardDidHide reset +
a composer-blur fallback that drops --kb when focus leaves the composer.
Build marker -> 2026-07-18-batch119.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Reply quote: was showing the FULL quoted body untruncated -> DIV.quote rendered 1571px, ballooning the
bubble + leaving the chat not-at-bottom. Clamp to 2 lines (-webkit-line-clamp) + collapse newlines.
- Swipe-to-reply: translateX(+72px) on a right-edge bubble pushed past the viewport -> a horizontal scroll
bar. .convo-msgs overflow-x:clip stops it.
- Image pager (#8): replace instant src swap with a real slide — current image slides out, next slides in
from the opposite side; clamp at first/last (no wrap -> no blank slide past the end).
Build marker -> 2026-07-18-batch118.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Parallel root-cause analysis: during body.chat-opening the pane is momentarily ~0 width; word-break:
break-word / overflow-wrap:anywhere let bubbles collapse to 1-GLYPH min-content, so pre-wrap text wraps
into hundreds of lines (5684px 'balloon'); same inflates the empty/draft textarea (autoGrow->140=4 lines);
and translateX(100%) of a ~0-width pane = no travel = the 'jump'. The tall box hid in anonymous line
boxes (renderMsgBody emits bare text nodes), which is why the probe only saw the 84px timestamp span.
Fixes (all @media max-width:760px / chat-opening scoped, desktop untouched):
1. .bubble: word-break:normal + overflow-wrap:break-word (min-content = longest word, never collapses)
2. mobile bubble group: overflow-wrap:anywhere -> break-word
3. .msg-link: word-break:break-word -> overflow-wrap:break-word (URL-only msgs can't reopen the hole)
4. body.chat-opening .content: width:100vw -> definite width, no collapse frame, real slide travel
Build marker -> 2026-07-18-batch117.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tallImg was no-img, so the balloon is a TEXT bubble stretching (even in a 17-msg chat). Add: composer/
textarea height+scrollHeight+rows+value+fontSize+lineHeight (why ~4 lines), whether chat-opening class +
.content animationName are applied (why transition jumps), and the tall bubble's text + tallest child.
Build marker -> 2026-07-18-batch116.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Spike log showed bubbles ballooning to 3576-5684px (an image loading at natural ~2778px height before
constraint). att-img is capped 240 but some image path wasn't — added .convo-msgs img/.bubble img
max-height:340px as a blanket cap so no message can stretch the thread. Enhanced spike report to name
the tall bubble's img (natural size, rendered height, computed max-height) to confirm.
Build marker -> 2026-07-18-batch115.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Revert font-size:16px !important (it made the composer ~4 lines tall). Disable iOS zoom with
viewport maximum-scale=1,user-scalable=no instead -> composer keeps its size, AND double-tap-to-zoom
(in-chat search arrows #3) is gone. touch-action:manipulation on controls as belt-and-suspenders.
Custom lightbox pinch still works (it's transform-based, not browser zoom).
- Transition: show the newest message immediately (was hidden until images loaded -> empty pane slid in
then popped = the 'jumping' feel). Pinning still prevents dance.
Build marker -> 2026-07-18-batch114.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Transition: open now slides the whole .content pane in over the list with a subtle parallax + iOS
easing (was an instant/panel-only slide). Close/back already parallax.
- scrollH blip: added a spike watcher that reports the tallest element (class + img src tail) when the
thread's per-message height balloons, so we can see what stretches then settles.
Build marker -> 2026-07-18-batch113.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device log (post-zoom-fix): keyboard open leaves clientHeight AND visualViewport at 926 -> vv gives 0 ->
composer covered. On resize:none the plugin's keyboardHeight is the only signal; now that zoom is gone
it's clean, so --kb = keyboardHeight lifts the composer flush, and keyboardWillShow at animation start +
the CSS bottom-transition makes it slide up WITH the keyboard (smooth). Config pinned to resize:none.
Build marker -> 2026-07-18-batch112.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Debug now reports curKb/compBottom/kbTop/coveredBy on keyboard events so I can see WHY the composer is
covered (lift coming out 0) from the server log.
- Transition: force the bzConvoIn slide to replay on every chat open (CSS animation only auto-plays on a
class change, so chat->chat looked instant). Restart via reflow + inline animation.
Build marker -> 2026-07-18-batch111.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device log confirmed zoom persisted (vvW:394,vvTop:74) because .composer-row textarea{font-size:.92rem}
out-specified the generic rule, keeping the composer <16px -> iOS still zoomed on focus. !important
forces 16px on all fields so the zoom never fires. (#7 latest + budge already verified fixed.)
Build marker -> 2026-07-18-batch110.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device log revealed the truth:
- Keyboard: focusing the <16px composer made iOS ZOOM the page (vvW 428->394, vvTop->74), scrambling
every viewport/keyboard calc. Set all form fields to 16px on mobile -> no zoom -> keyboard math holds.
- Latest messages: on a 500-msg chat atBottom was false because late images grow the thread after the
initial scroll. Now re-pin on a schedule + on each image load while near the bottom.
- Budge already gone (device reported ovf:false).
Build marker -> 2026-07-18-batch109.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Temporary: the app POSTs its real runtime state to the server log so device-only bugs (budge source,
keyboard viewport numbers, chat-open scroll state) can be diagnosed from docker logs instead of
screenshots. Removed once mobile issues settle.
Build marker -> 2026-07-18-batch108.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Keyboard: the 60ms timer in batch106 sampled the viewport MID-animation (clientHeight/visualViewport
temporarily inconsistent) and over-lifted -> gap. Reverted to reading clientHeight-visualViewport LIVE
on every vv resize, which is correct and self-adapting (unit test: residual=46 flush on the device).
- Budge: reverted the file <input> back to display:none (the position:fixed;left:50% hack overflowed
the viewport horizontally).
- Config back to resize:native to match the installed build (works, no rebuild).
- Added test/keyboard-lift.test.js (5/5 pass) verifying the lift math against real device numbers.
Build marker -> 2026-07-18-batch107.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Keyboard: auto-detect resize mode. resize:none -> lift full keyboard height at willShow so the CSS
transition slides the composer up WITH the keyboard (smooth, no iOS resize lag). resize:native ->
residual only. Config set to resize:none for the smooth path (needs build); web stays correct on the
current resize:native build meanwhile.
- #7: force scroll-to-bottom at 0/120/320/600ms on open so a chat always opens on the newest message.
- File picker: anchor the file <input> to a fixed bottom spot (was display:none -> iOS dropped the menu
mid-screen); now it comes up as a bottom sheet even as the composer moves.
Build marker -> 2026-07-18-batch106.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Debug confirmed resize:native shrinks the WebView by the keyboard height, so the composer only needs the
residual lift = clientHeight - visualViewport.height (46px on the test device — the prediction-bar sliver).
Tracked live via VisualViewport so it follows the keyboard. Zero per-device constants. Removed debug bar;
reverted Keyboard config to resize:native (which the logic relies on).
Build marker -> 2026-07-18-batch105.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the hardcoded factor with the real overlap = clientHeight - visualViewport.height (works on any
screen). Adds a temporary on-screen debug line to confirm which signal the device reports, so the final
logic needs no per-device tuning. Debug removed once confirmed.
Build marker -> 2026-07-18-batch104.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Measured from device screenshot: composer at ~678pt from bottom vs 351pt keyboard = 2x overshoot.
Scale the reported height by 0.52 so the composer sits flush on the keyboard. Single tunable const.
Build marker -> 2026-07-18-batch103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause: the Keyboard plugin reports keyboardHeight in device pixels on this build; CSS needs points,
so the raw value overshot by the devicePixelRatio. Divide by DPR when the raw value exceeds the screen's
point height. Web-only.
Build marker -> 2026-07-18-batch102.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Keyboard plugin's reported height overshot. With resize:none the WebView stays full-size, so
window.innerHeight - visualViewport.height is the EXACT keyboard overlap -> composer lands on the
keyboard. Web-only, works on the resize:none build already installed.
Build marker -> 2026-07-18-batch101.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
resize:native gave contradictory results (covered vs header) because the safe-area plugin fights it.
Set resize:none so the native layer does nothing, and lift the composer purely in JS by keyboardHeight
* KB_LIFT. Once resize:none is built, the lift is web-tunable with zero further builds.
Build marker -> 2026-07-18-batch100.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Isolated test showed 'too high' = resize:native already lifts the composer, so the JS --kb added a
second lift up to the header. Removed it. resize:native is the single mover now.
Build marker -> 2026-07-18-batch99.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
batch92 proved resize:native is a no-op with the safe-area plugin, and batch97 turned off the
safe-area keyboard offset, so re-adding the JS --kb lift makes it the ONLY thing moving the composer
= it sits exactly on the keyboard. Works on the current installed build (web-only).
Build marker -> 2026-07-18-batch98.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #3 keyboard: the overshoot proved resize:native DOES lift the composer; my --kb JS was a second lift.
Removed the JS hack entirely; keyboard is now purely @capacitor/keyboard resize:native. Config: turn
OFF safe-area offsetForKeyboardInsetBug so only one mechanism moves the view (needs the build).
- #7: gate the open-time hide/reveal with _convoRevealed so the cache->network double render doesn't
flash/dance; on mobile open at the LATEST message (skip auto-scroll to first-unread).
- #6: add SplashScreen config (bg #16294F, no spinner) — asset gen already produces the logo.
Build marker -> 2026-07-18-batch97.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The finger-follow edge gesture starting at the very left edge collided with iOS's own edge-swipe,
which fires touchcancel instead of touchend, so cleanup never ran and .content stayed transformed +
chat-dragging stuck on top -> bottom nav hidden and all clicks blocked. Back to the safe release-based
swipe (still gets showWelcome's parallax slide). Keyboard --kb inset, #7 no-dance, #8 image swipe kept.
Build marker -> 2026-07-17-batch96.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #3 keyboard (was B: chat didn't move, keyboard covered composer): native resize wasn't moving our
fixed layout, so drive it from JS — the Keyboard plugin reports its height, we expose --kb and lift the
composer to sit exactly on the keyboard; keyboard config -> resize:none so native won't double-adjust.
- #4: the left-edge back is now INTERACTIVE — the conversation follows the finger with the list revealed
underneath (parallax), commit past 1/3 width else spring back (like Teams). Falls back to release-based
bzcBack for popups/search.
- #8: the image now follows the finger sideways (and down) during the swipe, not just on release.
- #7: hold the thread hidden until its images load (max 700ms) then reveal at the bottom — no dance.
Build marker -> 2026-07-17-batch95.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #4: on back, reveal the chat list UNDERNEATH with a parallax slide-in while the conversation slides
off to the right (slide the whole .content, list forced visible beneath) — matches the Teams push/pop.
- #8: swipe left/right in the image viewer flips to the prev/next image (not just swipe-down to close).
- #7: re-pin to the bottom as each image finishes loading (when near bottom), so late images don't
reflow/'dance' the thread.
Build marker -> 2026-07-17-batch94.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses reported iOS observations (web-side, no rebuild):
- #1: the overflow-x:hidden I added to stop sideways-scroll was clipping the profile/bell popup menus;
now only the chat-list scroller is clipped and long content wraps instead of widening the page.
- #9: swipe a message bubble right to reply (WhatsApp/Teams style).
- #8: swipe an opened image down to dismiss the lightbox.
- #7: hide the thread for one frame on open so messages don't visibly scroll/'dance' into place.
- #4: slide the outgoing conversation off-screen on back (inline transform, reliable across WebKit).
Build marker -> 2026-07-17-batch93.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
App Store Connect rejected the upload: 'The bundle version must be higher than the
previously uploaded version: 1'. Capacitor defaults CFBundleVersion to 1, so every
build collided with the first upload. Patch it to the monotonic BUILD_NUMBER.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device screenshots proved the WebView reports env(safe-area-inset-*)=0, so no web/CSS/JS trick can
move content off the Dynamic Island — it must be fixed natively. Upgrade the Capacitor shell to v7 and
add @capacitor-community/safe-area (populates env() with real per-device insets, edge-to-edge) and
@capacitor/keyboard (resize:native so the composer sits directly on the keyboard, like Teams).
- mobile: Capacitor 6 -> 7 for all plugins; add safe-area + keyboard; SafeArea/Keyboard config.
- codemagic: npm ci -> npm install (dependency set changed); drop stale Cap6 lockfile.
- web: remove the temporary StatusBar/34px hacks (env() now works); keep var(--sat)/var(--sab) padding.
- share/connect: back link now respects env(safe-area-inset-top/left).
- add native-style slide in/out (push/pop) animation when opening/closing a chat on mobile.
Build marker -> 2026-07-17-batch92.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause from device screenshots: the iOS Capacitor WebView reports env(safe-area-inset-*)=0,
so CSS padding did nothing and content slid under the Dynamic Island (every page: home/share/
connect/login) and the bottom nav was clipped.
- Use the already-installed StatusBar plugin: setOverlaysWebView(false) pushes content below the
status bar natively (no rebuild). Applied on all entry pages via a small native-chrome snippet.
- Hard-code the bottom home-indicator inset (--sab:34px on html.native-ios) since env() can't give it.
- #2 keyboard: hide the bottom tab bar inside an open conversation so the composer sits directly on
the keyboard (was sandwiched between composer and keyboard).
- #3 h-scroll: clip every scroll surface + overflow-wrap:anywhere so long content can't widen the page.
Build marker -> 2026-07-17-batch91.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reported on the iOS TestFlight build:
- #1/#5 notch/home-indicator: apply top safe-area inset per surface (chat list column +
content panels) instead of on the whole shell, so headers sit below the Dynamic Island
and nothing is cut off top/bottom.
- #2: opening a chat no longer auto-focuses the composer (was popping the phone keyboard
and shoving the layout up); tapping the box still focuses normally.
- #3: overflow-x:hidden + max-width:100vw guards so pages can't pan left/right.
- #4: left-edge swipe-right now triggers the existing bzcBack() (close popup/search/open
chat) — a native-feeling back gesture with no native code.
- #6: hide the 'Download app' (Windows) button when running inside the Capacitor app.
Build marker -> 2026-07-17-batch90.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The IPA now builds, signs, uploads and processes on App Store Connect. Set
submit_to_testflight=false so the build stays green via internal testing; external
beta review (which needs Test Information + a demo login) is opt-in later. Also set
ITSAppUsesNonExemptEncryption=false in Info.plist so ASC stops prompting for export
compliance on every upload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fetch-signing-files --create alone mints a throwaway distribution certificate whose
private key dies with the build VM; the next build then finds a cert it has no key
for and fails 'Cannot save Signing Certificates without certificate private key'.
Supplying our own fixed private key (secure var in the ios_signing group) makes the
cert reproducible and reusable across builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The archive failed with 'App requires a provisioning profile' because use-profiles
ran inside the signing step before pod install created the workspace, so the
profile never bound to the App target. Move it to just before build-ipa, matching
Codemagic's Capacitor recipe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The environment.ios_signing block makes Codemagic fetch an EXISTING profile at
build init and fails with 'No matching profiles found' on a brand-new app. The
'Set up code signing' script already creates the cert+profile via
fetch-signing-files --create, so the block was both redundant and blocking.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iOS WebViews don't support getDisplayMedia, so sharing the device screen into a meeting needs a
native ReplayKit Broadcast Upload Extension (+ App Groups) — viewing a shared screen works today.
Also note native audio routing (AVAudioSession) as a phase-2 plugin. Adjusted the App Review note
so we don't advertise iOS screen-sharing before that extension ships.
The iOS app is a Capacitor shell over the live web UI — the web/server side is already fully
Capacitor-ready (nativePlatform() detects Capacitor; setupNativePush registers APNs tokens via
/api/v1/devices; the APNs sender is built into server/push.js, config-gated). So this adds only
the build/sign/upload path:
- codemagic.yaml: macOS-cloud workflow that generates the iOS project, patches Info.plist,
generates icons/splash, signs via an App Store Connect API key (automatic signing), archives,
and uploads to TestFlight. No Mac required.
- mobile/scripts/ios-patch.sh: adds the App-Review privacy usage strings (camera/mic/photos) +
display name to the generated Info.plist.
- mobile/IOS_SETUP.md: click-by-click runbook — ASC app record, API key, Codemagic integration,
first build, APNs key → server .env, and the public-submission checklist.
Bundle id com.bizgaze.connect. No secrets committed — Apple keys live in Codemagic + server .env.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sendGif ignored replyTarget, so picking a GIF while replying sent a plain message instead of a
reply. It now carries replyTo and clears the reply, like sendMessage. Reply bar + quote show
'GIF' instead of the raw media URL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
aspect-ratio:1 on the grid cells collapsed to ~0 height in the emoji-pop flex column, so the
images overflowed and overlapped. Use grid-auto-rows + a fixed cell height instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Server: GET /api/gifs proxies GIPHY search/trending. The API key is read from the server
env only (config.GIPHY_API_KEY, from the gitignored .env) and NEVER reaches the browser;
the picker is hidden when it isn't configured.
- Emoji picker gains a GIF tab (separated from the emoji categories) with a search box + a 2-col
grid, "Powered by GIPHY" attribution. Clicking a GIF sends it immediately.
- GIFs are HOTLINKED to GIPHY's CDN (their terms require this — no re-hosting): the message body
is the GIF url, and a body that is a lone GIF url renders inline as the animated GIF (reusing
the image/lightbox path). Sidebar previews + notifications show "🎞️ GIF", not the raw url.
Key is NOT in git — set as GIPHY_API_KEY in the server .env.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Revised rule per user: a call is not a meeting. Past meetings now = real SCHEDULED meetings +
INSTANT meetings (Start-a-meeting, logged in call_history as adhoc). Ad-hoc CALLS — 1:1 direct,
group calls, and a 1:1 that a 3rd person joined — are shown ONLY if they produced a
recording/transcript. Dropped the earlier ">2 participants shows it" exception.
Also stop rendering a bare "Host: —" on logged call cards.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#3 The 136 "Direct Call" cards: calls.js writes a scheduled_meetings row for EVERY call
("Direct Call" / "Group call"), which is separate from the call_history table. Those rows
came through the scheduled-meetings path and my earlier filter never touched them. Now an
auto call-history row is shown in Past ONLY if it produced a recording/transcript OR the
call ever held >2 people (peak from the call log); plain 1:1s with neither are dropped.
Removed the now-redundant callRows synthesis. Recordings stay attached (rows WITH a
recording are always kept).
#4 Date range: from ≤ to enforced by disabling out-of-range days in each picker (can't pick a
from after to, or a to before from). Filter controls share one height/baseline so the
calendar icon, preset dropdown and date fields align cleanly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#1 Last seen still read "Offline": last_seen is a NEW column, so every existing user was NULL
until they happened to reconnect. Backfill it from the newest message each user sent (best
evidence we already have), and also stamp it on message send — not just on connect/disconnect
— so it stays fresh if a socket never closes cleanly.
#3 Past-meetings filter is a report filter now: presets (Today, Yesterday, Last 7/30 days, This
month, Year to date, All time) plus a Custom range that uses the SAME branded calendar popup
as the scheduler. The raw <input type=date> looked foreign and behaved differently per browser.
#4 Hover action row overflowed off-panel on SHORT received messages: it's wider than the bubble
and was anchored to the bubble's right edge, so it ran off the left. Received bubbles now
anchor it from the left (growing into the empty space); own messages keep the right anchor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#7 The past list was hard-capped at 12 (a .slice(0,12) in the client) and there was NO record
of how many people were ever in a finished call — so the "only calls with >2 people" rule
was impossible to apply. Added a call_history table: signaling tracks the HIGH-WATER
participant count per room and logs the call when the room tears down (scheduled meetings
are skipped — they already have their own row).
Past meetings now follow the rules asked for:
• a plain 1:1 direct call is NOT listed — unless it produced a recording/transcript
(those already surface as recording entries);
• a call that ever held MORE than 2 people IS listed (e.g. a 1:1 a third person joined),
showing its participant count and duration;
• entries are visible only to people who were actually in the call (or the group).
Server-side pagination (10/page) + a from/to date filter; nothing is double-listed.
#2 Last seen now shows the exact time/date, WhatsApp-style — "last seen today at 1:36 PM",
"last seen yesterday at 10:15 AM", "last seen 14/07/2026 at 9:00 AM" — instead of "10
minutes ago".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#6 Emoji were slow because Twemoji swapped EVERY emoji for an <img> fetched individually
from a CDN — opening the picker fired hundreds of image requests. Now uses the OS's own
colour emoji font: instant, zero network. twemojify() kept as a no-op.
#4 Message hover row reworked: three one-tap reactions (Like/Laugh/Surprised) + the emoji
picker + (own) Edit. Reply / Forward / Copy / Delete moved behind a ⋮ menu. Added Copy.
The row now sits FULLY above the bubble (was top:-14px, overlapping the first text line).
#9 Phone numbers linkify to tel: — mobile gets the OS "call this number?" prompt; desktop has
no dialer so it offers to copy. Regex kept conservative (10–15 digits, needs +/grouping) so
it won't grab amounts, dates or 6-digit meeting codes.
#3 Image preview zooms: wheel + pinch + double-click + ± buttons, drag to pan, keys (+/-/0),
cursor-anchored. Arrows hide while zoomed so panning isn't hijacked.
#8 Upload progress: fetch() can't report upload progress at all, so a large file just said
"uploading…". Switched to XHR (upload.onprogress) → real bar + %, and cancel aborts in flight.
#1 Clicking a sender in a group opens a mini profile card (photo, presence, last seen) with a
Message button that opens the 1:1 (and a view-photo button).
#2 Last seen: new users.last_seen column, stamped on connect and when the last socket drops;
carried on the presence broadcast, so an offline contact reads "Last seen 10 minutes ago"
instead of a bare "Offline".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The check itself is cheap (one ~35-byte response, served from a variable the server reads
once at startup), but it was hooked to BOTH `focus` and `visibilitychange` with no throttle —
and a single alt-tab back into the app fires both, so every refocus cost two redundant
requests. Now: one check per minute at most, never overlapping itself, and it stops checking
entirely once a new build is known (the retry loop takes over).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DP bug — real cause found (client-side, not the DB):
loadSidebar builds a DM row for every contact you haven't messaged yet, but it copied only
{name, online} from the contact and DROPPED `avatar` (and status/email). So an un-messaged
contact always rendered initials in the 1:1, while the SAME person showed their photo in a
group (which reads /api/groups/members). Carry the whole contact through.
Kept a server-side safety net: avatarsFor() now indexes known photos under person-id, email
AND name, so a duplicate row missing a photo can match on any of them (the previous single
composite key missed twins with different emails).
Web updates are now completely silent: no banner, no toast. A web build is an implementation
detail — surfacing it makes users reason about "web build vs app version", which is exactly
the confusion to avoid. New code simply applies itself as soon as it's safe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auto-update (no manual step, no version confusion):
- New code now applies ITSELF. The client polls /api/build and, as soon as it's SAFE,
silently hard-reloads onto the new build. Safe = not in a call, no live screen session,
no dialog open, nothing half-typed; if the user is busy we wait and apply the moment
they're free. A brief "Updated to the latest version" toast confirms it.
- Removed the "web build" row from Settings: users must never have to reason about an app
version vs a web build. The only version surfaced is the desktop app's (auto-updater).
DP bug: a contact showed their photo in a GROUP but fell back to initials in the 1:1.
Two causes, both handled:
- Duplicate rows for one person (signed in by email once and by mobile another time before
the bizgaze_user_id merge landed) — only one row carries the DP, and the group happened to
reference the row WITH the photo. avatarsFor() now keys rows by stable person identity
(bizgaze person id → email → name) so a photo-less row borrows its twin's photo. Applied to
contacts, conversations, group members and group info.
- A DM whose counterparty was merged away is now keyed by the SURVIVING account, so the row
carries that account's name/photo/presence (and split threads collapse into one).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of "the fix works on mobile but not on desktop/web": nothing was wrong with
the code — the desktop app now CLOSES TO TRAY, so it can run for weeks on the page it
loaded on day one and never re-fetch after a deploy. There was also no way to force it
off a stale page (no menu bar → no reload accelerator).
- Server: GET /api/build returns home.html's __BUILD marker.
- Client: polls it (boot, on focus/visibility, every 10 min); when the server's build
differs from the running one, shows a branded "A new version is available — Refresh"
banner. Settings gains an always-available "Refresh app" with the current build shown.
- hardReloadApp(): in the browser it unregisters service workers + clears CacheStorage
then reloads cache-busted; in the shell it calls the native hard reload.
- Desktop: hard-reload IPC (clears the session HTTP cache + reloadIgnoringCache), wired to
Ctrl+R (reload), Ctrl+Shift+R / F5 (hard reload), and a "Refresh app (get latest)" tray
item. Previously there was literally no way to clear the cache from the app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#6 Pull-to-refresh genuinely broken: the indicator lived INSIDE the scroller (so it
scrolled away unseen) and only became visible after ~96px while the trigger fired at
70 — and preventDefault was deferred until the pull passed 10px, by which point
iOS/Chrome had already claimed the gesture. Indicator now mounts on the list's
non-scrolling parent with the branded loader, the gesture is claimed immediately, and
the trigger is reachable. Works on Android + iOS.
#4 ⋮ More now TOGGLES (it used to remove then instantly rebuild, so it never closed).
#3 "Ringing…" is centred over the tile with the avatar/name dimmed behind it.
#2 Meeting bar fits the screen on mobile: one row of controls sized to the viewport,
room code on its own line, safe-area padding.
#5 Search box in the in-call Add-people tab.
#1 Speaker control: setSinkId (the only web API for audio output) is NOT implemented in
Android Chrome or iOS Safari — the OS owns the route there, and iOS forces loudspeaker
whenever a mic track is live. Rather than ship a button that silently does nothing, it
now only renders where output switching actually works. Real speaker/earpiece/Bluetooth
switching on phones needs the native (Capacitor) audio plugin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Regressions I introduced, now fixed:
#8d Mic/Camera OFF went DIM — my new `.meet-ic.off` rule overrode the original RED.
Scoped the lite style to the speaker button only; mic/cam OFF are red again.
#7 Guest "left the meeting" was unstyled (dark text on blue, no card): the card CSS was
scoped to .guest-prejoin only. Shared with .guest-left. Rejoin no longer dead-ends —
if the room is gone we say "This meeting has ended" and hide the button.
Reported:
#8d Speaker button no longer shows on desktop (devices already live under the mic ▾).
#8c/#8e Mobile speaker button now CYCLES Speaker → Earpiece → Bluetooth (icon follows the
route); dropped the redundant "Audio devices" entry from the ⋮ menu.
#8a iOS gap under the bottom rail: the page rubber-band-bounced, exposing background beneath
the fixed bar. overscroll-behavior:none + fixed body pins it.
#8b Pull-to-refresh now fires on iOS too (overscroll-behavior:contain on the lists so Safari's
rubber band stops swallowing the gesture; scrollTop>2 tolerance for momentum).
#5 A meeting link no longer opens a whole new window: same-origin urls navigate the main
window in the shell, and a link clicked in chat joins the meeting IN PLACE.
New observations:
1. The viewer's mic now starts MUTED on a screen session (and can actually be un/muted).
2. iOS lightbox close/download buttons moved below the Dynamic Island (safe-area insets).
3. Meeting chat: recipient picker moved to the BOTTOM next to the input; a private message
auto-targets your reply back to that person; private vs everyone are visibly different
(brand amber vs blue/neutral) and the compose area tints in private mode.
4. Meeting bar + screen-session bars are DRAGGABLE (position remembered) so they stop
covering the shared screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#4 Participants list only ever showed "you": in SFU mode meetMakePeer drew a tile but
NEVER added to meetPeers, so the roster (and the meeting-chat "To" list) was empty.
#5 Same person as several tiles: each tab/reload/reconnect gets a new peerId, leaving
ghost peers. One uid = one tile now (dropDupPeers keeps the newest).
#1 Remote control needed a login: /share opened top-level (the no-login "Share my
screen") has the preload bridge RIGHT THERE, but we only postMessage'd to a parent
frame that doesn't exist — so control never armed. Use the native bridge directly.
#2 Sharer's session bar moved to the shared Lucide icon set (was old inline SVGs).
#3/#6 Device menu: collapse Windows' "Default -"/"Communications -" triplicates into one
clean list (single "System default" + each real device once), and actually re-apply the
saved mic/speaker to the room on connect so a picked headset/Bluetooth is used.
#7 Guest "left the meeting" screen rebranded to match the new pre-join.
#9 Tapping a member in group info opens the private conversation with them.
#8a iOS bottom rail: border-box + height incl. the home-indicator inset, px env() fallbacks
(unitless 0 breaks calc() in Safari), promoted layer so it stops drifting.
#8c/#8e Mobile meeting bar: Mic / Camera / Speaker / End only; screen, record, transcript,
chat, participants move behind a ⋮ More menu. No mic device dropdown on mobile.
#8d Speaker button reflects the real route: speaker-on / speaker-off (dimmed) / bluetooth
when a headset is in use; re-detected on devicechange.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. Guest pre-join redesigned: brand backdrop, live camera preview, mic/cam toggles
applied on entry, initials avatar, name required, "host may admit you" note.
2. Post-admit bug: the guest stayed on "Waiting for the host…" forever — the lobby
screen had replaced the call UI and meeting-joined never re-rendered it. Now it
rebuilds the call on admission (_inLobby).
3. Audio devices: dropped the standalone headphones button. The MIC now has a ▾ caret
opening one Teams-style menu with Speaker + Microphone sections (radio-selected);
speaker uses setSinkId/LiveKit switchActiveDevice, mic switches the live input.
Mobile gets a speakerphone toggle that prefers a connected BT/headset when off.
4. Remote-control keyboard:
- Injector now maps the PHYSICAL key (KeyboardEvent.code) instead of the character,
so Shift+1 types "!" etc. Character mapping was why typing "performed differently".
- Keys reach the sharer ONLY while control is ENGAGED (window focused AND you clicked
their screen). Minimised/unfocused/chat typing stays local. Esc or clicking away
releases; modifiers are released on disengage so nothing sticks.
- Explicit control icons: viewer gets a Control ON/OFF button (green when engaged) +
an on-screen hint; the SHARER gets a control icon beside mic/chat to allow/stop
access at a glance, synced with the consent dialog and banner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an ephemeral chat inside meetings (relayed over the meeting signaling socket,
not persisted). A chat button in the meeting bar (with an unread badge) opens a
brand-styled panel: pick "Everyone" or a specific participant (private), send with
Enter/Send. Direct messages are marked private on both sides. Works for guests too.
Chat state resets when you leave the call.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
General #1/#2 (closed-app notifications): the desktop app now CLOSES TO TRAY instead
of quitting, keeping its chat WebSocket alive so calls/messages still notify. Tray
icon + menu (Open / Quit), single-instance lock, first-close hint.
Guest #4 (lobby/admit): meetings can require the host to admit guests joining by link.
Setting on the schedule form ("Guests must be admitted by the host", default on) +
ad-hoc default. Guests wait on a "waiting to be let in" screen; the host gets an
Admit/Deny prompt; auto-cleanup on leave. Logged-in members always join directly.
Guest #5 (speaker): headphones/speaker output picker in the meeting (setSinkId),
remembered and applied to every tile.
Guest #3 (link expiry): guest link/token dies ~2h after a scheduled meeting's end
(HTTP 410) with a clear message; live-room links expire when the room empties.
RC #4 (mobile): touch→mouse mapping so a phone/tablet viewer can control (tap=click,
drag=move). Uses the same letterbox-correct coordinate mapping.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remote control (connect.html viewer):
- Coordinate offset fixed: map clicks to the actual video CONTENT rect (object-fit
letterbox-aware), not the element rect — cursor now lands where you click.
- Keyboard: capture at document level while a session is live (the <video> lost
focus on bar clicks → keys did nothing). Skips the chat input.
- Control bar moved to bottom-right with tiny modern icons; video fills the viewport.
- Smoother cursor (mousemove ~60/s).
Guest meetings:
- Guest mic inaudible + guest invisible in the participant list + outsider screen
share not showing (#1/#8/#11): root cause was the SFU media→tile map keyed on
identity==uid, but guests had a random LiveKit identity and a null signaling uid.
Guests now carry ONE stable id across signaling (meeting-join guestId) and the
LiveKit token identity, so their media attaches and they appear to everyone.
- Guests can't add participants (#9) and don't see the transcript button (#12).
- Search box in the schedule participant list (#10).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes the core "viewer can't control the sharer even on desktop" gap. Three root
causes addressed:
- share.html DISCARDED every input-channel message (onmessage=()=>{}). It now
parses the viewer's mouse/keyboard events and forwards them to the desktop shell.
- The main desktop app had NO OS injector (it lived only in the separate agent).
Ported the nut-js injector (agent/input/inject.js) into desktop/input, wired an
inject IPC + injectInput bridge, HARD-gated behind a consent flag (rcArmed).
- /share runs in an iframe (no direct bridge access) → it postMessages input to
the top frame (home.html), which relays to the native bridge.
Consent + safety: the sharer sees an Allow/Deny prompt the first time the agent
interacts; while active a persistent "your screen is being controlled — Stop"
banner; instant revoke; auto-release on session end/teardown. Browser sharers stay
view-only (no OS injection possible). nut-js is an optionalDependency (N-API, ABI-
stable across Electron) — degrades to no-op if the native module is unavailable.
Windows-first; maps to the primary display.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#4: scheduled meetings can now invite people who aren't on Connect.
- SMTP config (config.js, env-gated: SMTP_HOST/PORT/USER/PASS/FROM/SECURE,
PUBLIC_BASE_URL) + a small nodemailer wrapper (mailer.js) with a branded
meeting-invite template carrying the guest join link. No-op until SMTP is set.
- /api/meetings/schedule + /update accept participantEmails; external emails are
persisted (scheduled_meetings.guest_emails migration) and emailed the guest
link (plus any invited Connect users with an email on file). Fire-and-forget —
a mail outage never fails scheduling.
- Meetings list DTO returns `link`; schedule form gains an "Invite by email"
chip input; each scheduled-meeting card gets a "Copy link" action.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Server: POST /api/meetings/guest-token issues an unauthenticated LiveKit token
for a room that is currently live or a valid scheduled meeting (throwaway guest
identity). Mesh signaling already tolerates anonymous peers.
- Client: opening /home?meet=CODE while signed out runs a lightweight GUEST mode —
name prompt, then straight into the meeting with the full meeting client and the
chat/sidebar chrome hidden. Signed-in users who open the link auto-join.
- sfuConnect uses the guest token for guests. Guests get a friendly "left the
meeting / rejoin" screen (no chat to fall back to).
- Meeting "Add people" panel gains a "Copy invite link" (copyMeetingLink) that
copies the guest link — foundation for the emailed invites in #4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contact search no longer lists BizGaze directory people who aren't on Connect as
addable rows (they can't be messaged). It shows only people actually on Connect;
if a search matches nobody on Connect it shows either "Not on Connect" (they exist
on BizGaze but haven't signed in) or "Contact not found".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. Group message toast had no icon: the generated group icon is a data: URL, but
the 0.1.11 handler only used ALREADY-cached icons and skipped it. data: URLs
are synchronous — use them immediately, so every group toast shows an icon.
2. Spell suggestions on a LEFT click: clicking a red-squiggled word in the message
box now pops the native suggestions menu (renderer asks the shell to synthesize
a right-click at that point → real dictionary suggestions). No right-click.
3. Scroll felt "stuck" because an incoming message yanked you to the bottom even
when you'd scrolled up to read history (worse now that bg messages arrive live).
appendBubble now keeps your position unless you were at the bottom / it's your
own message, and surfaces the "jump to latest" control instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. Scroll felt "stuck": the seen-by card had TWO nested scroll containers
(.modal overflow-y:auto + inner .seen-scroll) fighting each other — card is
now overflow:hidden so only the inner list scrolls. Global scrollbar widened
6→11px, dropped the inset border, added a min thumb length so it's easy to
grab and drags smoothly everywhere.
2. Group message toast now carries a generated group icon (initials on a brand
disc) when the group has no photo — was iconless.
3. Seen / Not-seen split by a visual divider in the message-info popup.
4. Auto-correct: curated common-typo dictionary applied as you type (on a word
boundary) — corrections happen automatically, no right-click. Web/desktop/mobile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. Message-info popup: ONE scroll region for the whole list with sticky
Seen / Not-seen headers (was a separate scrollbar per section), realigned.
2. Group message notifications now title = GROUP name, body = "Sender: text".
3. Unread taskbar badge: solid red rounded-square (Teams-style) + white outline
instead of the gradient disc.
4. Pasting Excel/Sheets cells no longer uploads a screenshot — when the clipboard
carries real text, the text is pasted; image-only clipboards still upload.
5. Spell check in the message box (red squiggles) with a right-click menu of
corrections + add-to-dictionary; spellcheck enabled on the textarea.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. Notifications/messages lagged when the desktop window was backgrounded —
Electron throttles hidden renderers by default, stalling the chat WS's
onmessage + timers. Set backgroundThrottling:false → real-time in the tray.
(Transport is WebSocket; no Firebase needed.)
2. Joining a call via the header Join button left the Join/Decline invite popup
on screen — enterMeeting now dismisses the invite (and stops the ring).
3. Seen-by is now "Message info": real contact DPs, a "Not seen yet" list for
groups, and dark lettering (white-on-pastel initials were invisible).
4. App-icon unread badge: red→pink gradient pill with a white ring + shadow,
drawn at 2x, instead of a flat red disc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of the persistent "notification opens a dead page / no Join" bug:
openFromNotif gated the in-place open on `window.ME`, but ME is declared with
`let` — which never creates a global property — so window.ME was ALWAYS
undefined and every click hit the full-page-reload fallback. Use bare `ME`.
Also:
- Notifications fire instantly: never block on the DP download. Use the photo
only if already cached; warm the cache in the background + pre-warm all
contact DPs on chat load (precache-avatars IPC). Removes the ~2.5s lag.
- Wire call Join/Decline handlers BEFORE firing the OS notification so Join is
live the instant the invite popup appears (was dead until the toast settled).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Custom branded screen/window picker (picker.html) — useSystemPicker was
silently no-op on Win11 and auto-shared the primary display with no choice.
- Use checkForUpdates (not ...AndNotify): drops electron-updater's own native
"Update ready" toast that duplicated our in-app banner (two restart prompts).
- Call notification DP: wait up to 2.5s for the caller photo on persistent
(call) toasts instead of 600ms so the DP actually shows.
- Re-surface Join/Decline invite when opening a chat with an active incoming
call, so a call-notification click always lands on a joinable call.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #2: 'Seen by' opens an on-screen popup listing readers (was a flash toast).
- #5: the DM contact-info view now shows the DP; clicking it previews full-size.
- Desktop notifications: cache DPs per-sender (fast AND with photo after the first);
removed timeoutType:'never' which added an unwanted 'Close' button.
- Update flow: dropped the unbranded native restart dialog — the branded web banner
handles Restart. Banner text clearer ('Downloading update…'), plus an update
indicator that cascades profile 'i' badge → Settings → version line, with the
Settings button becoming 'Restart now' when the update is downloaded. desktop 0.1.7.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #1: URLs in chat are now clickable links (open externally on desktop).
- #2: group 'Seen by X, Y +N more' now shows under the LATEST message to EVERYONE
(server computes reads for all messages; click shows the names).
- #5: tapping the conversation-header avatar previews the DP/group photo full-size.
- #4: in-call invite lists only people you've messaged, excludes those already in the
call/invited, and the Invite button is sticky.
- #3 (0.1.6 shell): auto-updater emits checking/available/downloading/ready/error to
the web UI, which shows a progress banner (+ Restart button) so updates aren't silent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Desktop notifications were slow (~15s), vanished in ~1s, and clicks did nothing:
* don't block the toast on the avatar download (race a 600ms cap) → shows instantly
* keep a strong reference to each Notification (Electron GC'd them → premature close
+ dead click)
* call invites use timeoutType:'never' + a 45s window so they stay until clicked/ended;
web marks call notifications persistent.
- #9: enable the OS screen/window PICKER (useSystemPicker) so users choose what to share
(a single window avoids the whole-screen mirror); falls back to primary display.
- desktop 0.1.5 -> 0.1.6.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #3: profile avatar now shows the photo (avatarUrl) not just initials.
- #5: header is position:sticky so the brand + profile stay visible while scrolling.
- #4: App installs table gets a search box (user/platform/version/OS) and 10-per-page
pagination with prev/next + count.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #9 REAL cause: the tile was forced to avatar whenever the peer's CAMERA was off
(addTile + meeting-peer-state), so a screen-share only appeared if the camera was
also on. Now camera-off only shows the avatar when the peer is NOT sharing a screen.
- #9: screen-share button is highlighted (blue) while active, like mic/cam/record.
- Unanswered call text is viewer-relative: the caller sees 'Call not answered', the
callee sees 'Missed call'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- REGRESSION (More#1): removed overflow-anchor:none (it stopped the view from staying
at the bottom as images settle) + renderThread re-asserts scroll-to-bottom after late
content, unless a 'New messages' divider is shown → chats open at the latest message.
- #9: dynacast off — a screen-share layer was being paused unless a camera track was
also flowing (blank / only-with-camera / slow). Now every published track flows.
- More#7/#9: 1:1 calls track 'answered'; unanswered calls auto-end after ~40s (caller
no longer stuck ringing) and post 'Missed call' instead of a duration; answered calls
show duration from the answer time. meeting-ended reason 'unanswered' → 'No answer'.
- More#6: selected chat now has a thin yellow (brand) boundary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #9: sharing your whole screen no longer creates a 'hall of mirrors' — your own
screen is NOT shown back to you (you see your camera/avatar + a 'You're sharing'
badge); other participants still see your screen. Your own tile is never the local
stage.
- #1: opening a DM now syncs the sidebar tick to the thread's true delivered/read
state, so it can't show a single tick while the conversation shows double.
- #3: nativePlatform() now also detects older desktop builds that expose
bizConnectNative but not __NATIVE__. Those were wrongly subscribing to Web Push, so
the service worker's openWindow RELOADED the page on notification click (losing the
header DP). Desktop now uses only the native toast → opens in-place.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #7: outgoing 1:1 call shows the person you're calling (DP + name + 'Ringing…')
instead of only your own tile; removed when they answer or the call ends.
- #8: declineDmCall now ignores the decline if that user has ALREADY accepted on
another device (they're in the room) — declining the ringing invite on a 2nd
device no longer tears down the active call.
- #9: sfuAttach retries (bounded) when the uid→peerId map lags the LiveKit track,
so a shared screen isn't silently dropped due to a race.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #4: forward modal buttons now branded (bzc-* un-scoped from .bz-confirm); target
rows get real checkboxes + border highlight; selected MESSAGES highlight in green
with a filled tick (distinct from blue bubbles), checkbox on every message.
- #5: forwarded messages show an italic 'Forwarded from <origin>' label; new fwd_from
column preserves the true origin across re-forwards.
- #6: image-preview arrows are smaller (32px) chevron icons, clear of the image
(image max 82vw); icons.js?v=5.
- #10: image preview now opens ABOVE the media modal (z 9900) and Esc/close returns
to the media view instead of closing everything.
- #11: Media/Links/Docs active tab underline is brand-blue, not green.
- #2: chat-list + conversation scrollbars 6px, stepper arrows hidden; pagination adds
a cooldown + overflow-anchor:none so scrolling up no longer sticks/jumps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When you open/read a conversation, the server now also pushes a notif-clear to your
OWN other sockets. Each device tags notifications by conversation (kind:id), so on
notif-clear it closes the matching page Notification + any Service-Worker (Web Push)
notifications, and drops matching activity-center entries so the bell badge stays in
sync. (Desktop Electron native toasts are transient/auto-expire; the web+PWA surface
is covered.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Settings now shows 'Biz Connect for Desktop · Version x.y.z' with a Check for
updates button (desktop only, feature-detected).
- preload exposes checkForUpdates(); main.js adds the check-updates IPC (returns
available/current/dev/error) and, when a build finishes downloading, shows a
Restart now / Later dialog instead of only the silent on-next-launch install.
- desktop version bumped 0.1.4 -> 0.1.5.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Message action pill gains a Forward button; tapping it enters selection mode
(tap bubbles to multi-select, footer bar shows count + Forward/Cancel, Esc exits).
- Forward picker lists EXISTING conversations only (DMs + groups from the sidebar),
searchable, multi-target. POST /api/messages/forward copies body+attachment into
each target (authorized as participant/member), live-pushed like a normal send.
- /files auth now accepts ANY message carrying an attachment (allByAttachment), so
forwarded images stay viewable for the new recipients.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #8: reply quotes are now clickable — jump to the original message (paging older
history in if needed), then flash it. reply DTO carries the target's timestamp.
- #3: image lightbox now has ← / → arrows + keyboard nav to flip through all images
in the conversation.
- #5: the composer queues MULTIPLE files (file input is multiple; paste still works);
each is shown as a removable chip and sent as its own message (first carries the
typed text as caption), in order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #2/#7/#19: openFromNotif opened chats via a full-page reload (location.assign),
which was slow AND dropped live state — the sidebar hadn't reloaded so the header
showed 'Conversation' with no DP, and an incoming call's Join/invite popup was lost.
Now it opens the chat IN-PLACE (selectChat) since the app is already running;
full-page nav only as a fallback when the app isn't initialised.
- #9: slimmer 6px scrollbar; explicitly hide all webkit stepper-arrow variants.
- #16: 1:1/small call no longer shows a stray scrollbar — meet-grid centers tiles
and clips overflow (call UI fits the viewport).
- #17: caller now hears a gentle ringback while waiting; stops on answer, on any
call exit, and auto-stops after 45s so it never rings forever.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pull-down-at-top called reloadThread (reload newest 500 + scroll to bottom), so
reaching the oldest message yanked the view to the newest — jarring, especially on a
single page. Now the pull gesture calls loadOlder: it pages in older history keeping
scroll position, or no-ops when there's nothing older. Never jumps to the bottom.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Older messages (beyond the newest 500) now load as you scroll to the top — the
thread endpoint takes a ?before=<created_at> cursor, the client prepends the older
page and preserves scroll position (renderThread keepScroll). _hasMoreOlder stops
paging when a short page returns.
Search now covers the ENTIRE thread, not just the loaded window: new
/api/messages/search (DM + group, LIKE with escaped wildcards) returns all matching
message ids; the client debounces the query, and jumping to a hit older than the
loaded window pages history back (ensureLoadedBack) until the match is in view, then
highlights + flashes it. Cap raised to 500.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
THE disappearing-messages root cause (found via THREAD.length=300 vs 351 on server).
messages.thread and threadByConversation did 'ORDER BY created_at ASC LIMIT 300' —
the oldest 300. Once a DM/group passed 300 messages, every newer message was silently
dropped from the fetch, so anything sent after that point 'disappeared' (persisted
server-side, never returned to the client). Now: inner 'ORDER BY created_at DESC LIMIT'
takes the NEWEST N, outer ASC presents them oldest-first. Cap raised 300 -> 500.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
THE disappearing-messages root cause. openConvo did: let msgs=[]; try{ msgs=fetch()
}catch{}; THREAD = Array.isArray(msgs)?msgs:[]. On a flaky desktop network a failed
fetch left msgs=[] (still an array) → THREAD=[] AND THREAD_CACHE.set(ckey,[]) — so
reopening a chat blanked it AND overwrote the cache, making messages vanish and stay
gone even though they persist server-side (confirmed: 982 DMs, zero dangling ids).
Now msgs stays null on a failed/non-OK fetch; we only replace THREAD/cache on a real
array response, and otherwise keep the cached render instead of blanking.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of 'messages to some contacts disappear (gone after reopen)': the #2
account merge deletes the merged-away user row. Any lingering reference to that old
id — a cached contact, an in-flight DM — then saved against a dead recipient / 404'd
on thread fetch, so messages silently vanished.
- New user_aliases table records old_id -> survivor on every merge (mergeInto).
- users.resolve(id) follows the redirect.
- DM send (recipient), thread fetch (with), and read now resolve() the peer id, so a
stale id transparently routes to the surviving account.
Fixes future merges fully. Contacts merged BEFORE this (no alias recorded) may need a
one-off data check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Broken images: UPLOADS_DIR/REC_DIR/TRANS_DIR were server/<dir> INSIDE the image,
so every deploy.sh rebuild wiped uploaded files — old images 404'd ('broken
image') though their DB rows survived. Make them env-overridable and point prod
at /data/uploads|recordings|transcripts (persistent volume), matching DB/downloads.
NOTE: files already lost to prior rebuilds can't be recovered; new uploads persist.
- Duplicate notifications: harden connectChatWs — close/detach any prior socket
before opening a new one and keep a single pending reconnect timer, so a flaky
reconnect can't leave two live sockets delivering every event/notification twice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
batch42's resync-on-reconnect reopened the conversation via openConvo, which
replaces THREAD wholesale. On the desktop app (flaky WS), a reload racing with a
just-sent message could momentarily blank the chat ('messages disappear after
sending', seen on the Manasa chat). resync now MERGES: adds messages missed while
disconnected + refreshes read/delivered/seen/edited/deleted flags, but never
removes messages already on screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bundled livekit-client@2.20 uses signaling protocol 17 and the /rtc/v1 path,
which the v1.7 server didn't implement (404). The client fell back to the legacy
path, leaving the track publisher in a bad state so mic/cam publishing failed with
'InvalidAccessError: The sender was not created by this peer connection' — surfaced
to users as a misleading 'permission required' toast. v1.9 supports protocol 17.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #10/#18/#7: on WebSocket RE-connect, re-pull sidebar + re-open the current
thread so the desktop/mobile apps recover missed messages/ticks without a
refresh (the old onopen only re-sent chat-hello).
- #11: branded bzConfirm() dialog replaces window.confirm for delete (the OS/
Electron default dialog looked off-brand on desktop).
- #4: global Esc no longer closes the conversation while an image preview is
open — the preview closes first, the conversation on a second Esc.
- #6: live 'typing…' now also shows inside the conversation panel (animated),
not only the header/sidebar.
- #15 (diagnostic): mic/cam failures now report the REAL cause (permission vs
no-device vs in-use) with desktop-specific guidance, and log the raw error,
instead of always saying 'permission required'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prod box is behind NAT (private 192.168.88.61 behind public 118.95.33.89), so
LiveKit auto-IP-detection would pick the wrong (outbound) address. Pin
rtc.node_ip=118.95.33.89 and collapse media to one UDP port (50000) + TCP 7881 to
minimize the upstream gateway port-forward the network team must add. Docs updated
with the exact forward table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A normal deploy no longer starts the livekit container (it would crashloop with
empty keys before provisioning). Enable with 'docker compose --profile sfu up -d'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Step-by-step to enable meetings SFU: generate key/secret, .env vars, DNS record,
NPM proxy host for wss signaling, VPS firewall for UDP media + TCP fallback, deploy,
verify. Includes the one-line rollback to mesh (remove the LIVEKIT_* vars).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Meeting media flows through LiveKit when the server reports sfu:true; otherwise the
P2P mesh is unchanged. Reuses the ENTIRE existing meeting UI + WS control plane
(join/host/mute/screen/recording/transcript) and only swaps the transport:
- vendored livekit-client 2.20 UMD (lazy-loaded; no build step).
- sfuInit() reads /api/meetings/config at boot; sfuConnect() joins the LiveKit room
with the minted token after the WS meeting-join.
- remote tracks map back to the WS peerId via uid (LiveKit identity = app user id);
per-peer stream prefers screen over camera, driving the existing sharing/stage UI.
- toggleMic/Cam/Screen publish via LiveKit; local tracks reflected into meetLocalStream
so tiles, active-speaker meter, canvas recording and transcript keep working.
- meetMakePeer/peer-joined/peer-left/leaveMeeting branch on SFU.on; mesh path intact.
Needs a running LiveKit server + live test (phase 3 ops) to exercise end-to-end.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the server side of scaling meetings past the ~5-peer mesh:
- config.js: LIVEKIT_URL/API_KEY/API_SECRET + LIVEKIT_ENABLED flag. All optional;
when unset the app keeps the built-in P2P mesh (fully additive, like push).
- routes.js: GET /api/meetings/config (tells the client sfu on/off + wss url) and
POST /api/meetings/token (mints a per-user, per-room LiveKit join token — hand-rolled
HS256 JWT like the FCM/APNs tokens, no new dependency; secret stays server-side).
- docker-compose.yml: optional livekit service (single-node, no Redis), keys injected
via LIVEKIT_KEYS from the same .env; media over published UDP 50000-50100 + TCP 7881,
signaling proxied by NPM.
- livekit.yaml + .env.example documented.
Client (mesh->LiveKit media swap, behind the flag) lands in phase 2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Thread: every group message I send now carries a receipt tick — sent (1 grey)
-> seen-by-some (2 grey) -> seen-by-all (2 yellow) — computed from the per-message
seenBy the server already returns and kept live by onGroupRead. Tap the tick to see
exactly who ('Seen by X, Y') with an 'N of M' tooltip. Replaces the old last-message-
only 'Seen by' line with a universal, tappable per-message receipt.
Sidebar: the group row tick now reflects real read state (read/delivered/sent) via
memberReads vs member count, instead of a hardcoded 'sent' — and refreshes live when
the open group is read by all.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ephemeral over the chat WebSocket (no DB). Composer emits chat-typing on/off
(throttled 2.5s, auto-stop after 4s idle / on send / on leaving the chat).
Server relays to the DM peer or fans out to group members (membership-checked).
Receiver shows 'typing…' in the conversation header subtitle and the sidebar
row preview (brand-blue italic), with per-sender auto-expiry so a dropped 'off'
can't stick. Group shows names ('Alice is typing…', 'N people are typing…').
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors redeploy.sh conventions (pinned host key, DEPLOY_PASS/deploy.secret/
prompt password resolution, plink discovery). Uploads the three electron-builder
artifacts via pscp to a temp dir, then docker cp's them into the app container's
/data/downloads (volume-path-independent), and verifies the public feed serves
the new version. Keeps older versions; only latest.yml is overwritten.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/data is a named Docker volume, not a host bind-mount, so the prior
scp root@host:/data/downloads/ path was wrong. Document the real host
path (/var/lib/docker/volumes/bizgaze_support_data/_data/downloads) plus
the docker cp alternative, and note latest.yml is the only overwritten file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Message actions (reply/react/edit/delete) consolidated into ONE hover pill anchored to the
bubble's top-right, overlapping it so on short messages the icons no longer float off into
empty space and vanish before you can click.
- Desktop toast + mobile(FCM)/web-background push now show the sender's real DP:
* renderer passes the DP URL through; desktop shell downloads it for the toast icon
(canvas-drawing an external DP tainted it → initials). Desktop bumped to 0.1.4.
* DM push payload carries icon=sender avatar; sw.js already uses it (web background),
sendFcm sets notification.image (Android).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sender can edit their own text messages: a pencil action on the bubble loads the text into the
composer in an 'Editing' mode; saving updates the body, marks it 'edited', and pushes the change
live to the other side/tabs (chat-edited, mirroring delete). Adds messages.edited_at + editBody().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #5: use the ACTUAL brand C (extracted transparent white C + gold dot from the master
app-icon) on the blue headers instead of a hand-drawn mark; login card keeps the app icon.
- #3: read-receipt double tick on my own (blue) bubble is now brand yellow (was blue-on-blue,
invisible).
- #2: Sign-in button shows a spinner + 'Signing in…' on submit.
- #1: web notification uses the sender/group DP URL directly as the icon (drawing an external
DP to canvas tainted it → silently fell back to initials).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The header is already blue, so the app-icon-in-a-white-pill looked like blue→white→blue→white
nesting. Use a transparent mark (mark-light.svg: white C + gold dot) placed directly on the blue
header (home/index/connect/dashboard), dropping the white background + padding. share.html keeps
the full app icon (it's a light card, not the blue bar).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Replace the leftover 'Your Virtual CXO' logo.png with the app icon (C mark) in every
header (home/index/connect/dashboard) + the app icon on share.html; delete logo.png.
- Loading screen now shows the orbit mark + 'Biz Connect' wordmark + 'Loading…' (was bare).
- Keep the branded splash up until the chat list finishes loading — no blank/stuck gap
after login (previously hidden before loadSidebar()).
- Login accepts email OR phone (field was type=email, which rejected phone numbers and so
blocked the mobile-login that triggers the account merge). Label → 'Email or phone'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #6: the server echoes your own sent message back over the socket; onChatMessage reset
last_status='sent', overwriting a delivered/read that arrived first → the chat-list tick
flipped back to single. Track last_msg_id and never downgrade the same message's tick.
- #3: pin the 'You' note-to-self chat in its own slot — tinted row + 'Note to self' tag +
a divider separating it from the conversation list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- docker-compose: DOWNLOADS_DIR=/data/downloads so uploaded installers/latest.yml
survive image rebuilds (deploy.sh) instead of being wiped.
- DEPLOY.md: step-by-step for publishing a desktop release (build → upload the
3 feed files → verify) so the Download button + auto-update go live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live presence (fixes stale in-call/status until refresh — impossible in apps):
- server broadcasts a user's status over the chat socket on connect/disconnect, call
join/leave, and status change (chat.js broadcastPresence; signaling + routes hooks).
- client onPresence() updates the contact dot + open-chat header live.
Chat delivery ticks (#6): chat-list row now mirrors the thread (delivered→double grey,
read→blue) via a new 'with' field on the delivered relay + onChatRead/onChatDelivered.
Call fixes: no bogus 'host handed over' when a 1:1 call ends (leaveMeeting forced);
branded call-connecting + chat-thread loaders; header subtitle tracks live call state.
Notifications: web notify + sw.js use sender/group DP + brand icon (not old wordmark);
desktop shell drops Web Push so only the single native toast fires (#5).
Brand: master icon/splash/loaders wired everywhere (PWA/favicon/apple-touch/.ico),
branded login (blue + gold CTA), branded toasts (BZToast) on all pages, Electron splash.
Desktop: dev auto-targets localhost (packaged→prod); version 0.1.3 with new multi-size
icon; dropped unused node-notifier; removed home-mockup.html.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From brand-assets/ masters (C + orbiting-dot on brand blue):
- Regenerated PWA icons (192/512/maskable), apple-touch (180), favicons
(32/16 + root favicon.ico) from the 1024 master; bumped manifest to v3.
- Rebuilt desktop/build/icon.ico as a proper multi-size ICO (16..256,
PNG-compressed) to fix the tiny/blurry taskbar icon.
- Updated mobile masters (resources/icon.png 1024, splash.png/splash-dark.png
2732) for capacitor-assets to regenerate native icons/splash.
- Wired the animated branded orbit loader into the app boot 'Loading…' screen;
added favicon/theme-color links to index + home heads.
- logo.png (horizontal wordmark) left untouched — masters have no wordmark.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Provisioning now keys the Biz Connect account on bz.bizgazeUserId (the same
value whether the person signs in with email or mobile) instead of the typed
identifier, so both logins resolve to a single contact. Legacy rows get the
person-id stamped on next login; an existing duplicate created under the same
identifier is folded in via a transactional users.mergeInto() that reassigns
all messages/memberships/reactions/votes/favorites/ownership to the survivor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Loads @twemoji/api (jsDelivr) and renders emojis as Twemoji images in messages, the emoji
picker, and reactions (twemojify at each render point). Picker inserts via data-emoji so it
survives the <img> swap. Falls back to plain Unicode if the CDN is unavailable. build batch25.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pinned 'You' chat at the top of the list (always available); messaging yourself works
(to===me), with no self push/echo notification and a 'Message yourself' header. Self is
filtered from the normal contacts and shows no status dot. build batch24.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Conversations DTO now returns last_status (sent/delivered/read for DMs; sent for groups) for
my last message. The list row shows the matching tick (single/double, blue when read) and a
red 'Draft:' indicator when there's unsent text. Live 'sent' on send/receive; upgrades to
delivered/read on refresh. e2e 119.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Unsent text is saved per conversation (survives switching chats and reloads) and restored
when you reopen the chat; cleared on send. (#1)
- Emoji picker: stopPropagation on tab/grid clicks so switching category no longer closes it
(the re-render was detaching the clicked node -> outside-click handler fired). (#6)
build batch23.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
node-notifier/SnoreToast click callback never fired without the crashing wait mode. Switch the
chat toast to Electron's built-in Notification: shows avatar + message and its 'click' event
reliably raises the app + opens the chat. No SnoreToast, no external tools.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bundled SnoreToast crashes (0xC0000409) handling a text reply, and that crash triggered
a second (fallback) toast. Removed the direct-SnoreToast reply path entirely; the chat toast
now reliably shows avatar + message via node-notifier and opens the chat on click. Inline
text reply needs a different toast engine (deferred).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Testing showed SnoreToast blocks/waits for the toast interaction when given -pipeName; the
-w flag actually fails on this build (exit -1) which forced the reply-less fallback. Without
-w it waits, captures the typed reply, and writes it to our pipe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
snoreExe returned the app.asar path (fs.existsSync lies about asar paths), so spawn failed
with ENOENT and always fell back to the reply-less WindowsToaster. Map to app.asar.unpacked
unconditionally so the real SnoreToast binary (with -w) runs and captures the reply.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause: node-notifier's toaster whitelist has no -w, so SnoreToast never waits and the
reply is lost. Now drive SnoreToast directly with -w + our own pipe (correct args, no
-application which had broken the toast). If the binary is missing or fails to show, fall
back to node-notifier's WindowsToaster so a toast always appears. Logs code+raw for diagnosis.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
node-notifier returned an empty result for text replies. Now spawn the bundled SnoreToast
with -tb -w -pipeName against our own pipe, read the raw UTF-16LE result, and parse the
reply (keeps spaces). Logs the raw pipe string to userData/toast-debug.log to pin the
exact reply field on real hardware.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- node-notifier's WindowsToaster forwards raw opts to SnoreToast, so inject -tb (reply box)
+ -p (image). Reuses its named-pipe + result parsing (exit 5 = TextEntered). No pwsh needed.
Logs the raw toast result to userData/toast-debug.log to confirm the reply field on real HW.
- home.html: notifAvatarDataUrl draws the DM sender's pic / group's DP (else colored initials)
to a round PNG and passes it as the toast image. Reply -> sendReplyTo; click -> open chat.
- dropped powertoast (ESM + needs pwsh 7, absent here). build batch22.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reply to a chat straight from the OS notification, no app switch:
- node-notifier (bundles SnoreToast) renders a native Windows toast with a reply box —
Electron's own Notification can't do Windows inline reply.
- main.js reply-notification handler resolves {text}|{open}|null; preload exposes replyNotify.
- home.html notify() routes chat toasts through it on desktop: a typed reply -> sendReplyTo()
POSTs to /api/messages without opening the app; a click opens the chat. Web/PWA path unchanged.
- Works only in the installed app (needs the installer's AppUserModelID). desktop 0.1.2, build batch21.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- dashboard.html: admin "App installs" card — table of user · platform · version · OS ·
first/last seen, from GET /api/v1/admin/installs (loadInstalls).
- index.html: replaced the long inline link with a compact white "Download app" button in
the blue top header (top-right); hidden when already inside the desktop app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Downloads/updates:
- config: DOWNLOADS_DIR (override to a mounted volume in prod).
- static.js: serves /downloads/* (installer, latest.yml, .blockmap) with range support for
resumable + differential auto-updates; /download/windows redirects to the current .exe
(stable link). Landing page gets a "Download the Windows desktop app" button (hidden in-app).
Install tracking (who installed the app):
- db app_installs + repos.appInstalls (upsert by install_id, fills in the user on sign-in).
- POST /api/v1/telemetry/install (records install + user once authenticated);
GET /api/v1/admin/installs (admin: list installs with user/version/os/last-seen).
- desktop main.js: stable per-install id in userData, exposed via preload
(bizConnectNative.installId/version/os); home.html reportInstall() posts it after login.
- e2e: +2 checks (telemetry recorded, admin sees it). 119/119. build batch20.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- home.html: login is now a real <form> with a submit button, so Enter (or click) both
submit natively — replaces the ad-hoc keydown handler. (build batch19; header stays removed)
- desktop/main.js: app.setAppUserModelId('com.bizgaze.connect.desktop') so Windows resolves
the installed shortcut and toasts read "Biz Connect" instead of "electron.app.BizConnect".
- desktop version → 0.1.1; installer rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The desktop app was hardcoded to /home, so it jumped straight to the login form and
skipped the 'before login' landing (no-login 'Share my screen' + sign-in). Now loads / —
same entry as the website; it redirects logged-in users to /home.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- package.json build config: NSIS installer, app icon, generic publish provider →
https://remote.bizgaze.com/downloads/ (self-hosted update feed).
- main.js: electron-updater checks the feed on launch + every 6h, downloads in the
background, installs on restart. Active only in packaged builds.
- build/icon.ico app icon; PACKAGING.md documents build/release/signing.
- Key design: web/UI changes reach installed apps instantly (they load the live server);
only native shell changes need an auto-updated build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Only the chat LIST had styled scrollbars; the chat message area and every other scroll
region fell back to the OS scrollbar, which in the Electron/Windows shell shows classic
up/down stepper arrows. Added a global ::-webkit-scrollbar style (thin, rounded thumb,
scrollbar-button hidden) + scrollbar-width:thin to home.html, share.html, connect.html,
dashboard.html. (home build batch18)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the rail's unread-chat count onto the Windows taskbar icon (overlay) and the
macOS/Linux dock badge. updateRailUnread() draws a small red count badge on a canvas
and hands it to the shell via bizConnectNative.setUnread(count, dataUrl); main sets it
with win.setOverlayIcon + app.setBadgeCount. Clears at 0, shows 99+ past 99. No-op in a
browser/mobile. (home.html build batch17)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- desktop/main.js: grant media (camera/mic), display-capture, notifications, clipboard,
fullscreen, pointerLock for the app origin (Electron denies these by default, which
silently broke meetings' camera/mic). Adds setPermissionRequestHandler +
setPermissionCheckHandler on the app session.
- Clicking a notification now raises + focuses the window: preload exposes
bizConnectNative.focusApp(), main handles 'focus-window' IPC, and the web notify()
onclick calls it when running in the desktop shell. (home.html build batch16)
- CLIENTS.md: Phase D — inline-reply notifications (Windows Toast RemoteInput /
Android RemoteInput / iOS UNTextInputNotificationAction) queued right after packaging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two latent bugs that broke input control on any setup:
- Data channel was created by the viewer (the answerer), so the agent's offer had
no SCTP m-line and the channel never opened -> no input reached the agent. The
agent (offerer) now creates the 'input' channel; the viewer receives it.
- inject.js used nut.screen.getResolution() which doesn't exist in this nut-js;
switched to screen.width()/height() with per-session caching.
Verified end-to-end locally: screen streams + mouse injection moves the remote cursor.
Also commits desktop/ + mobile/ package-lock.json from client installs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
home.html: in a Capacitor app shell, setupPush() now uses the native FCM/APNs path
instead of Web Push — requests permission, registers, POSTs the OS device token to
/api/v1/devices, deep-links on notification tap (selectChat), and unregisters the
token on logout. Web Notification prompts are suppressed on native. Fully inert in a
normal browser (Web Push unchanged). build batch15.
CLIENTS.md Phase B push items checked off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- /api/v1/devices (register) + /api/v1/devices/remove — auth-required, validates
platform (ios|android), upserts by token; e2e covers register/validation/auth/remove.
- db device_tokens table + deviceTokens repo.
- push.js: FCM HTTP v1 (Android) and APNs token-based over HTTP/2 (iOS) folded into
the single push.sendToUser path alongside Web Push; each transport independently
config-gated and a silent no-op without creds. Dead tokens pruned on 404/410.
- docs: CLIENTS.md Phase B updated; DEPLOY.md env table adds FCM/APNs vars.
e2e 117/117.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Single-instance requirement, ALLOW_LOCAL_LOGIN-off, server-side directory token,
no-store HTML, Node>=22.5/web-push, required env vars (SSO/VAPID/TURN), and the
window.__BUILD per-release verification step.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chat / shared media:
- Media/Docs/Links: clean underline tabs (green active), audio & video now
classified as Media and rendered as tiles (download + headphone/play +
duration) instead of broken-image glyphs; image thumbnails -> lightbox
- Drag-and-drop a file/video/image onto a conversation to send it
- Fix: removed #chatPanel{position:relative} override that collapsed the
conversation pane (messages spilled into a clipped right-edge strip)
- "Media, links & docs" row cleaned up (no folder/placeholder icon); media
popup keeps the back arrow, drops the redundant close button
Presence / status:
- Single current-status row with an arrow that expands Available/Away/On leave
- On leave = circle with minus, In a call = solid red indicators
- Fix: selected-status tick now follows the chosen option
Icons: added headphones + play; bumped icons.js cache-bust to v4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Logo was dark-on-blue (low contrast); now centered on a white tile like the
header treatment. Icon URLs versioned (?v=2) so browsers/installs fetch the new
ones. Build marker -> pwa2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prevents a 404 (e.g. /manifest.json fetched before deploy) from being cached on
a device and persisting after the file exists — the cause of the manifest 404
on mobile but not desktop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- manifest.json (standalone display, theme color, maskable icons 192/512).
- generated square icons + apple-touch-icon (180) from the logo.
- apple-mobile-web-app + theme-color meta in home.html.
- sw.js gets a no-op fetch handler so it meets installability criteria (still
no caching). static.js serves .json/.webmanifest with correct MIME.
- Installing as a PWA also unlocks Web Push on iOS (Apple requires Add to Home Screen).
Build marker -> pwa1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Browsers were serving a cached old home.html on normal reloads (only incognito/
hard-refresh got the new one). HTML now sends Cache-Control: no-store; versioned
assets keep ETag revalidation. Bumps build marker to push4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
subscribePush() swallowed all errors, so if pushManager.subscribe() failed
(e.g. called before the service worker was active) nobody ever subscribed and
there was no trace. Now: await serviceWorker.ready before subscribing, and
console.log/warn each step so the real failure is visible. Server send path
verified independently (web-push builds valid VAPID requests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The server echoes the sender's own message over WS before returning the HTTP
response, so onChatMessage could append it before sendMessage's await resolved,
then sendMessage appended again -> double. Both append paths now dedup by id.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Page-level Notifications can't fire when a tab is frozen/closed (and never on
mobile), which is why recipients on another tab/app got nothing. Adds a
notification-only service worker (sw.js, no caching) + Web Push:
- push.js: optional web-push wrapper (no-op unless web-push installed AND
VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY set -> app unaffected if unconfigured).
- push_subscriptions table + R.pushSubs repo (upsert by endpoint, prune dead).
- /api/push/vapid|subscribe|unsubscribe; DM + group message routes also send a
Web Push to recipients.
- Client registers /sw.js, subscribes when permission granted; hidden-tab popups
are left to push to avoid double-notifying (pushActive flag); SW suppresses the
OS popup when a tab is visible. Removes the old code that unregistered SWs.
Requires (prod, once): npm install + VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT env.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- callEnd is now a rotated-handset hang-up icon (was a phone-off placeholder).
- All pages reference /icons.js?v=3 so browsers/proxies fetch the corrected
file instead of a stale cached copy (fixes 'old end icon' + icons not
appearing until a re-render when an old/404 icons.js was cached).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Server echoes your own message back over WS (multi-tab/device sync) and
sendMessage already appended it optimistically; onChatMessage now skips the
append if the id is already in the thread.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
icons.js was never committed (untracked, lost from disk), so every page
404'd /icons.js and stalled at Loading. Restored from commit e05a788 and
added 16 icons referenced by current code but absent in that snapshot
(bell, bold, italic, strikethrough, code, list, listOrdered, type, crown,
checkCheck, calendarX, calendarClock, fileText, record, callEnd, settings).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:41:44 +05:30
150 changed files with 20637 additions and 1631 deletions
// Close = hide to tray (keep running for notifications). First time, tell the user where it went.
lettoldTray=false;
win.on('close',(e)=>{
if(isQuitting)return;// real quit → let it close
e.preventDefault();
win.hide();
if(!toldTray&&Notification.isSupported()){
toldTray=true;
try{constn=newNotification({title:'Biz Connect is still running',body:'It stays in the system tray so you keep getting calls & messages. Quit from the tray icon.'});n.show();}catch(_){}
}
});
// Open the landing page (same entry as the website): the "before login" screen with the
// no-login "Share my screen" option + sign-in. It redirects logged-in users straight to /home.
win.loadURL(SERVER_URL+'/');
// Links: EXTERNAL ones go to the system browser. OUR OWN urls (e.g. a meeting invite link clicked in
// chat) must NOT spawn a second app window (#5) — navigate the main window instead.
if(ok){constnote=newNotification({title:'Download complete',body:savedName+' — saved to your Downloads folder. Click to show it.'});note.on('click',()=>{try{shell.showItemInFolder(target);}catch(_){}});note.show();}
elsenewNotification({title:'Download failed',body:savedName+' could not be saved.'}).show();
}
}catch(_){}
});
}catch(_){}
});
}
// Single-instance: a tray app must not spawn a second copy. If another launch happens, focus the
// existing window (restoring it from the tray) instead.
// If the window is hidden in the tray, the branded in-app banner isn't visible — nudge with a native
// notification so tray users know an update is waiting (it also installs automatically on next quit).
try{if((!win||!win.isVisible())&&Notification.isSupported())newNotification({title:'Biz Connect update ready',body:'Version '+((info&&info.version)||'')+' installs when you restart. Open Biz Connect to restart now.'}).show();}catch(_){}
});
// Install a downloaded update on the next real quit (PC restart / tray-Quit) even if the user never
// clicks "Restart now" — so close-to-tray users don't get stuck on an old version. (This is the
// electron-updater default; set explicitly to be safe.)
onDownloadDone:(cb)=>{try{ipcRenderer.on('download-done',(_e,data)=>{try{cb(data);}catch(_){}});}catch(_){}},// desktop: a file finished downloading to the Downloads folder → show a toast
Everything App Store Connect asks for, drafted. Fill the **`<< … >>`** placeholders (they're
account-specific or secret and must NOT be committed). Order below ≈ the order App Store Connect walks you through.
---
## 0. Before you submit (gates that cause rejection)
- [x]**Verified build** uploaded from Codemagic to App Store Connect (splash build; multi-device tiles, transcripts, calls ring; moderation + meeting-push are live web-side).
- [x]**Reviewer demo account** — connect@bizgaze.com / Qwerty@789 (works; non-admin so it sees Report/Block but not the admin Reports view).
- **User Content → Photos or Videos, Other User Content (messages, files)** — App Functionality. (Chat/meeting content stored on your server.)
- **Identifiers → User ID** — App Functionality.
**Diagnostics / Usage:** declare only if you actually collect analytics/crash data. If not, mark **"Data Not Collected"** for those.
**Important clarifications to make in the notes:**
- **Microphone & Camera** audio/video for calls is transmitted between participants (via your LiveKit server) but is only *recorded/stored* when a user explicitly starts a recording or transcript. Say so.
- **Speech recognition** for transcripts runs **on-device** (Apple's `SFSpeechRecognizer`, on-device mode) — the audio is not sent to Apple, and only the finished text is added to the meeting transcript. This is a good thing to state explicitly; it reassures review.
- **Third-party:** if BizGaze/LiveKit are your own infrastructure, no third-party SDK data-sharing to declare. Confirm you have no analytics/ad SDKs.
**Privacy usage strings** (already in the build via `ios-patch.sh` — for reference):
- Camera: "Biz Connect uses the camera for video calls and to share photos and your screen."
- Microphone: "Biz Connect uses the microphone for voice and video calls."
- Speech Recognition: "Biz Connect uses speech recognition to create live meeting transcripts from your microphone."
- Photo Library / Add: send/save images.
---
## 5. Screenshots
Required (App Store Connect accepts one size and scales, but do at least these two):
- [ ]**6.9" iPhone** (1320 × 2868) — iPhone 16 Pro Max class
> Biz Connect requires a BizGaze account to sign in.
>
> Demo account for review:
> Email: << demo@yourdomain >>
> Password: << demo password >>
>
> How to test:
> 1. Open the app and sign in with the demo account above.
> 2. Chat tab: open a conversation to see messaging.
> 3. Start a call from a conversation, or the Meetings tab to start/join a meeting.
> 4. In a meeting, tap "Live transcript" to see on-device speech-to-text.
>
> Notes on permissions:
> • Microphone/Camera — used for voice and video calls.
> • Speech Recognition — used only to generate live meeting transcripts; recognition runs on-device.
> • Screen recording (broadcast) — used only when the user chooses to share their screen in a meeting.
> • VoIP push (PushKit) + CallKit — used to ring incoming calls like a normal phone call.
**Create the demo account now** and confirm it can actually log in and start a call. A dead demo login is the #1 rejection cause for account-gated apps.
---
## 7. Export compliance
The app uses only standard encryption (HTTPS/TLS, WebRTC/DTLS-SRTP) — no proprietary/custom crypto.
- In App Store Connect: **"Does your app use encryption?" → Yes**, then **"only … standard encryption algorithms" → Yes** → qualifies for the exemption (no CCATS/year-end self-classification report needed for standard encryption).
- Optional: set `ITSAppUsesNonExemptEncryption = NO` in Info.plist to skip the question each submission (add to `ios-patch.sh` if you want it permanent — say the word and I'll add it).
- **Account-gated app** → mitigated by the demo account (§6). Also fine per guideline 3.1.1 since it's a business tool, not gating features behind sign-in for a consumer app.
- **User-generated content (chat)** → guideline 1.2 satisfied (shipped 2026-08-19): every message has **Report** (long-press / ⋮ → Report, canned reasons) and **Block user**; blocked users can't message or call you (server-enforced). A **Blocked users** manager lives in the profile menu (unblock anytime), and workspace **admins** get a **Reported messages** review screen (delete content / block / resolve). Reports are org-internal (routed to the workspace's own admins). Reviewer note suggestion: "Report and Block are available on any message via long-press; Blocked users are managed from the profile menu."
- **CallKit + VoIP push** → legitimate; the demo/reviewer flow should show a real incoming call if possible.
- **Background modes** (audio, voip) → justified by calls; the review notes cover it.
---
## 9. Nice-to-haves (not blockers)
- App Store promotional/preview **video** (optional).
- Localized metadata if you target non-English regions.
- A short **"in-app account deletion"** path — Apple requires apps with account creation to offer account deletion (guideline 5.1.1(v)). If BizGaze accounts are created/managed externally (admin-provisioned, not self-signup in the app), note that in review; if users *can* self-register in the app, an in-app "delete my account" (or a clear link to do so) is required.
# 3. Copy them into the container's DOWNLOADS_DIR (volume-path-independent), then clean up.
# \$f stays literal so the REMOTE shell iterates/quotes the space-containing names.
REMOTE_CMD="set -e; for f in '$STAGE'/*; do docker cp \"\$f\" $CONTAINER:$DEST/; done; echo '--- $DEST now holds ---'; docker exec $CONTAINER ls -la $DEST; rm -rf '$STAGE'"
// Notify the OTHER members so a closed app is alerted to the group call — VoIP/CallKit if available,
// else a banner. broadcast() above only reaches connected sockets. Best-effort; never throws.
try{for(constmidofawaitR.conversations.members(group)){if(mid!==user.id)PUSH.sendCallNotification(mid,{callUUID:call.uuid,room,kind:'group',groupId:group,groupName:gName,callerId:user.id,callerName:call.startedByName,title:gName,body:'📞 '+call.startedByName+' started a group call',hasVideo:true,livekitUrl:LK.LIVEKIT_URL,livekitToken:LK.livekitToken(mid,null,room)});}}catch(_){}
return{room,uuid:call.uuid,active:true};
}
}
// Called from signaling when a mesh room empties — ends the group call if this room was one.
// Called from signaling when a mesh room empties — ends the group call if this room was one.
// Stop any CallKit ring on members' killed/backgrounded devices.
try{for(constmidofawaitR.conversations.members(group)){if(mid!==call.startedBy)PUSH.sendCallCancel(mid,call.uuid);}}catch(_){}// not the starter — a cancel to them re-rings their own phone
}
}
}
}
// 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join".
// 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join".
functionclearLeft(room,ids){constc=callForRoom(room);if(c&&c.left){for(constidof(ids||[]))c.left.delete(id);}}// an explicit re-invite should ring again
// Called from signaling when any mesh room empties.
// Called from signaling when any mesh room empties.
try{db.exec('UPDATE conversation_members SET admin=1 WHERE user_id IN (SELECT created_by FROM conversations WHERE conversations.id=conversation_members.conversation_id) AND admin=0');}catch(e){}
// Avatars: a user's profile picture (BizGaze photo URL) and a group's uploaded image
// (an attachment id, served via /files/<id> with group-membership auth).
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.