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