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>
#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>
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>
#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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>