- Dockerfile: add ffmpeg (Alpine).
- static.js: new /thumbs/<id> — ffmpeg extracts the first frame (0.5s), caches it next to
the file, serves as the video poster (cosmetic; 404s gracefully if ffmpeg unavailable).
- static.js: /files now supports HTTP Range (206 Partial Content) + Accept-Ranges, which
iOS requires to stream/seek video reliably (fixes the buffer-before-play / multi-tap);
media (image/video/audio) now served inline, other files still download. Shared
attachment auth refactored into one helper used by /files and /thumbs.
- home.html: video poster points at /thumbs/<id>. build batch157.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Swipe-back: the finger-following version is dropped (parked for the future) per request —
back to the reliable release-triggered swipe (rightward edge release runs bzcBack's slide).
Video: the custom download->play overlay caused layout 'dancing' on load and flaky
multi-tap playback. Replaced with a plain native <video controls playsinline preload=
metadata> (poster via #t=0.1) at a fixed box size — poster + OS play button, plays inline
on one tap, streams once and is cached (no re-downloads). build batch156.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug 1: .ptr-ind used top:8px so the pull-to-refresh spinner sat under the notch/Dynamic
Island. Now top:calc(var(--sat)+8px) clears the safe-area inset.
Bug 2: video attachments rendered as a plain download link that re-downloaded on every
tap. Server now sends isVideo/isAudio on message attachments; videos render as an in-chat
player — masked poster with a DOWNLOAD button that loads the file ONCE (preload=none ->
load on tap), then becomes a PLAY button; playing hands off to native inline controls, so
no repeat downloads. build batch155.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The conversation pane now tracks your finger from the left edge and reveals the chat
list behind it, completing the back past ~35% width or springing back otherwise. Reuses
the existing body.chat-dragging layout (already defined, identical to chat-closing that
showWelcome uses): content z-index:2 at translateX(0), list .chatcol absolute behind at
z-index:0 — so the pane starts at the correct on-screen origin (the earlier attempt's
'one screen-width off' bug was a different setup). Vertical drags still scroll; popup/
search edge-release still closes via bzcBack. build batch154.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Investigations are closed, so strip the probes: bzDbg + __perfProbe definitions, the
older/render/slide/pwaFocus call sites, and the server-side /api/dbg (MDBG) sink. Kept
the functional code around each probe (renderThread's innerHTML build, the older-page
re-anchor, the slide fade). No behavior change. build batch153.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of 'tapping stops the Bluetooth audio': bzUnlockAudio fires on EVERY tap
during a call and called bzApplyRoute -> setSpeaker(false) -> configureDevice, whose
setCategory+setActive(true) reconfigures the AVAudioSession mid-call and interrupts
WebKit's audio unit, dropping the call audio. Fix: bzApplyRoute on iOS no longer touches
the session at all (only wires the route-icon listener); iOS keeps auto-routing. build batch152.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Telemetry confirmed the ceiling: a single speaker override holds ~1s over active BT then
WebKit reverts. So stop trying to control the route on iOS — always use the default port
(iOS auto-routes: BT/wired if connected, else loudspeaker), and make the button a live
INDICATOR of the real output; tapping shows a toast (connect/disconnect a headset to
change). Removes the temp nroute/sptap probes. Pure web change; plugin v1.1.2 already
supports it. build batch151.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1.1.1's route observer re-forced speaker on every change, and WebKit re-added Bluetooth
each time -> the audio flapped speaker<->BT many times/sec (telemetry: dozens of route
flips from 3 taps), which read as 'sound doesn't switch'. WKWebView won't let the app
hold the built-in speaker over an active BT device. So: one override per tap, observer
only REPORTS the output (no fighting). Also stop dimming the iOS button (it's a live
output indicator, not on/off; the dim read as 'disabled' on BT). Probe now carries the
native marker so we can confirm the binary. plugin v1.1.2-stable, web batch150.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
overrideOutputAudioPort(.speaker) alone can't beat a connected BT headset (BT is higher
priority), so 'Speaker' snapped back to BT. Now setSpeaker(true) sets category options
[.defaultToSpeaker] (no allowBluetooth) so BT isn't an eligible output and the speaker
wins; setSpeaker(false) restores [.allowBluetooth,.allowBluetoothA2DP] and uses the
default port (routes to the headset). Observer re-holds speaker if a BT connect steals it.
Adds a TEMP web probe (nroute/sptap) to verify from telemetry. plugin v1.1.1, web batch149.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BT audio already routes on iOS, but the web UI can't see it (iOS hides audio outputs
from enumerateDevices), so the icon was stuck on speaker. Plugin v1.1.0 now exposes the
active output: getRoute() + a 'routeChange' event ('speaker'|'bluetooth'|'wired'|
'receiver'|'airplay'). Web subscribes and drives the icon/label from the real route
(bluetooth/headphones/speaker), and the iOS toggle becomes a 2-state Speaker <-> Device
cycle (JS can't enumerate outputs there). Also strips the earpiece-investigation debug
logging from the plugin. Native needs one Codemagic build; web is live (batch148).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Proven via device telemetry: inside a WKWebView, WebKit owns the WebRTC audio unit and
forces the loudspeaker; overrideOutputAudioPort(.none) is a no-op (route settles on
Speaker 1.2s later), so the built-in earpiece cannot be selected. Present only what
actually works on iOS: Speaker, and Bluetooth/wired headset when connected. bzApplyRoute
now maps only 'speaker' to the loudspeaker override; bt/headset use the default port.
Coerce any stale 'earpiece' pref to speaker on iOS. Also strips the route debug telemetry.
Other platforms keep the earpiece option. build batch147.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1.0.3 telemetry showed mode already == .voiceChat when earpiece still landed on
Speaker, and setSpeaker read the route synchronously (stale) right after the override.
So the mode re-pin alone may be insufficient and the sync read is unreliable.
1.0.5: keeps the .voiceChat re-pin, but stamps live mode into every route-change log
line and re-reads the SETTLED port+mode at +0.4s and +1.2s after each toggle (reported
in the next toggle's trail). This definitively answers whether WebKit flips to .videoChat
and where the route truly settles. native marker 1.0.5-settle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause (WebKit source MediaSessionManagerCocoa.mm + Apple DTS, confirmed by
telemetry): WKWebView's WebRTC re-pins the session to mode .videoChat while capture
is active, and .videoChat auto-implies .defaultToSpeaker. So override(.none) reverts
to the mode default = LOUDSPEAKER, and .none alone can never reach the earpiece once
WebKit flips the mode. Our first override won only because .voiceChat was still active.
Fix: for earpiece, setMode(.voiceChat) (its default route IS the receiver) before
override(.none) in both setSpeaker and the debounced route-change re-assert. Add an
accessory guard so a connected BT/wired headset isn't yanked to the built-in receiver.
Reconcile the launch patch: drop .defaultToSpeaker from inject-audio.js so it stops
contradicting the plugin. native marker 1.0.4-mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Telemetry proof: overrideOutputAudioPort(.none) routes to Receiver once, then every
later toggle latches to built-in Speaker despite opts=36 (no .defaultToSpeaker) —
WebKit's WebRTC engine re-forces the loudspeaker after our override. The prior build
ignored .override-reason route changes and never corrected it.
Now: react to ALL route changes, debounced 0.25s, and re-assert the chosen port only
on a genuine mismatch (self-terminating, capped at 6/toggle to avoid thrash). setSpeaker
returns a reason->port route-change trail so the log shows whether WebKit is one-shot
or persistent. native marker 1.0.3-reassert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Telemetry only showed 'override didn't throw', and the web __BUILD tag can't tell
native binaries apart, so we couldn't see WHERE iOS actually routed the audio or
which plugin build ran. setSpeaker now resolves with the real currentRoute output
port (Receiver/Speaker/Bluetooth), the live AVAudioSession category/mode/options,
and a native-build marker (1.0.2-diag) so the route log is unambiguous.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Speaker worked but the earpiece was silent, and call audio flickered on speaker
then dropped. Causes: (1) the category set .defaultToSpeaker, so overrideOutputAudioPort(.none)
fell back to the loudspeaker instead of the receiver; (2) setSpeaker re-ran
setCategory+setActive on every toggle mid-call, tearing down the audio unit WebKit's
WebRTC engine was using and silencing the earpiece route.
Fix: drop .defaultToSpeaker (drive the port explicitly), make setSpeaker flip ONLY
overrideOutputAudioPort, and observe routeChangeNotification to re-assert the chosen
route when WebKit reconfigures the session at call start / device change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capacitor derives the pod name from the npm package name (audio-route -> AudioRoute)
and writes 'pod AudioRoute, :path => ../../plugins/audio-route' into the generated
Podfile. CocoaPods then requires a file literally named AudioRoute.podspec whose
s.name is 'AudioRoute'. The old AudioRoutePlugin.podspec (s.name AudioRoutePlugin)
caused the Codemagic build to fail with 'No podspec found for AudioRoute'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device telemetry proved the app-embedded AudioRoute class never registered (not in
Capacitor.Plugins) — appending a CAPBridgedPlugin to AppDelegate.swift gets stripped/undiscovered
in release builds. The plugins that DO register (Share, Camera, Filesystem) are all npm packages
wired by cap sync. So AudioRoute is now a local plugin package (mobile/plugins/audio-route,
file: dep in mobile/package.json) with a podspec + CAPBridgedPlugin Swift — cap sync adds its pod
and Capacitor registers it like the others. load() sets the launch speaker default; setSpeaker({on})
overrides the output port. inject-audio.js no longer injects the plugin class (would duplicate);
it keeps only the AppDelegate launch default as a fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Server streams the upload body straight to /data/uploads (a .part temp file, atomic rename on
success), backpressure-aware, so a 1 GB file never buffers in RAM. MAX_UPLOAD_MB env (default
1024 = 1 GB) controls the cap; error message reflects it.
- Client size guard raised 25 MB -> 1 GB.
- docker-compose documents MAX_UPLOAD_MB and the required Nginx Proxy Manager client_max_body_size.
NOTE: the actual bottleneck for the user's 9.7 MB reject is almost certainly NPM's client_max_body_size
(nginx default 1 MB) — that must be raised in the NPM admin; the app change alone can't lift it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ios-patch.sh now injects an AudioRoutePlugin (CAPBridgedPlugin, Capacitor 7 auto-registers it)
into AppDelegate.swift with setSpeaker({on}) -> AVAudioSession.overrideOutputAudioPort. Tolerant/
build-safe: if it doesn't register, the web call just no-ops (can't crash or fail the build).
- web: nativeAudioRoute()/bzApplyRoute() drive the plugin; toggleSpeakerphone + the on-join/on-tap
unlock now actually switch the route on iOS (setSinkId can't). canRouteAudio() shows the toggle
when the native plugin is present. Dormant until the next Codemagic build ships the plugin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drag the open conversation rightward from the left edge and it follows the finger 1:1 with the
list parallaxing in underneath; release past ~35% (or a quick flick) completes the pop, else it
snaps back. Fixes the old stuck-pane bug: once a clear horizontal drag is detected we
preventDefault (passive:false) to CLAIM the gesture so iOS/scroll can't steal it and fire
touchcancel; touchcancel always resolves to a clean state. showWelcome(skipAnim) does the state
swap without re-animating. Works in native app, PWA and mobile browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Calls were routing to the quiet earpiece. ios-patch.sh now runs a Node helper (Node 20 is
already in the build env) that injects an AVAudioSession .playAndRecord/.voiceChat category with
.defaultToSpeaker + Bluetooth into the generated AppDelegate. Tolerant: exits 0 and no-ops if the
template differs, so it can never fail the Codemagic build. Verified locally against the Cap 7
AppDelegate template — injects correctly and is idempotent. First pass; if WebRTC re-grabs the
session mid-call on device, a follow-up plugin will re-assert .overrideOutputAudioPort(.speaker).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Suppressing the save dialog made downloads completely silent — "nothing happened" even though
the file saved to Downloads. Now on download 'done': (1) tell the web UI → branded toast
"Saved X to your Downloads folder" when the window is focused; (2) native OS notification
(click → reveal in Explorer) when the app is minimized/in the tray, so it's never double-noticed;
(3) a failure notice. preload exposes onDownloadDone; home.html shows the toast.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The app closes to tray, so it kept running on the old version and only checked on a fresh launch.
Now: (1) check for updates on EVERY window show (X-to-tray then reopen, taskbar, relaunch via
second-instance) via win.on('show'), throttled 1/10min; (2) explicit autoInstallOnAppQuit=true so
a downloaded update installs on the next real quit / PC restart even if the user never clicks
"Restart now"; (3) native notification when an update finishes downloading while hidden in the
tray. The existing 6-hour background timer is unchanged and still runs regardless of window state.
Also carries the 0.1.18 downloads-to-Downloads-folder change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Accessory bar: call Keyboard.setAccessoryBarVisible({isVisible:false}) on iOS in the native
IIFE, hiding the grey chevrons+Done strip above the keyboard. The plugin is already bundled
in the current TestFlight build, so this takes effect on a web deploy — no rebuild.
- Attach: tapping the paperclip now opens a composer-anchored Photos/Camera/Document menu
(like the emoji/mention popups) instead of firing the generic mid-screen OS chooser as the
first thing. Each option opens a type-scoped picker (image/*, capture). Web fix, all clients.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Mobile: the Return key now inserts a newline instead of sending (send via the button, like
every native chat app). Desktop keeps Enter=send / Shift+Enter=newline.
- Image preview: hide the on-screen +/- zoom buttons on mobile (pinch-to-zoom covers it).
- Desktop (Electron): a will-download handler saves straight to the OS Downloads folder with no
"where to save?" dialog, de-duping the name if it exists. NOTE: desktop code only — NOT
published to the update feed (needs an explicit desktop rebuild/publish).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mobile viewport fixes only ever went into home.html. The other standalone pages still had
the old viewport, so in the native app they auto-zoom on input focus and (dashboard/host) run
under the notch/Dynamic Island. Bring every page's viewport to match home.html
(maximum-scale=1, user-scalable=no, viewport-fit=cover) and add safe-area top padding to the
dashboard header and the host body/indicator so nothing sits under the island now that the
viewport is cover. index/connect/share already pad for safe-area; they only needed maximum-scale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iOS home-screen PWAs IGNORE viewport maximum-scale (Apple disabled it for accessibility), so
the maximum-scale=1 that stops auto-zoom in the Capacitor WebView does nothing in the PWA — it
still zooms on input focus. Real cross-platform fix: make every focusable text field 16px on
mobile (the composer was .92rem). autoGrow's empty-guard keeps it one line.
Also the PWA has no Capacitor Keyboard plugin, so the composer never lifted above the keyboard
("not the same keyboard"). Added a VisualViewport-based lift for non-native clients (browser +
PWA); native still uses the plugin. >100px threshold ignores the Safari toolbar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug: for ~3.4s after opening a chat, the "keep newest in view as images load" pin schedule
would yank you back to the bottom if you tried to scroll up for older history — so scrolling
up snapped back to latest, and only worked once the schedule expired (~5s). Fix: the user's
first scroll gesture now sets _openScrolled, which cancels the pending pin timers and short-
circuits _pinNewest (so the late image-load pins stop too). loadOlder/anchor then works
immediately, no yank.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device log showed the slide motion itself is now clean 60fps (frames 0-15 all ~16ms in every
sample); the only remaining spike was at frame ~16 — AFTER the slide lands — from applying the
network thread result + pin at that instant (the "adjusts after it lands" feel). Since the open
is a compositor opacity/transform fade, main-thread work during it doesn't stall the animation
and is masked by the low opacity. So apply the reconcile/render + pins immediately when the
fetch returns (mid-fade) instead of deferring to slide-end. By the time the pane is fully opaque
the content is already settled. Removed the now-obsolete appendBubble mid-slide deferral.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reflow that caused the end-of-slide hitch was never the content slide itself — it was
SHOWING the list column during the slide and hiding it (display:flex->none) at the end. So
keep .chatcol display:none the whole time (untouched) and animate ONLY .content: it pushes in
from translateX(12%)->0 with an opacity fade, on its already-promoted GPU layer. Real slide
motion, and nothing to reflow when it ends. (This also explains the batch126 breakage: that
made .chatcol position:absolute, disturbing the touch/layout target — not touched here.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User chose the fade over chasing the WebView slide-teardown reflow. Open now: the cache-
rendered conversation is placed instantly and fades opacity 0->1 over 140ms. No transform,
no off-screen pane, no list-column display toggle -> nothing to reflow at the end, so the
~40ms end-of-slide settle cannot occur. Same afterOpenSlide queue flushes network reconcile/
pins/appends at fade-end. Desktop unaffected (fade is mobile-only via __freshOpen).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The list-column position:absolute change stopped conversations from opening. Revert to the
known-good display:none. Keep the other batch124-125 fixes (content promoted, reconcile,
reopen-at-latest, download interceptor). Slide back to the batch125 behaviour (opens fine,
minor end hitch) while I find a safe way to remove the reflow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device log proved the residual 30-78ms end-of-slide hitch is NOT the content-layer demote
(content stays promoted) and NOT the re-render (reconcile, longtasks:[]). It's the chat-list
column flipping display:flex->none at transitionend, which reflows that whole subtree. Keep
the list RENDERED behind the conversation (position:absolute; z-index:0; covered by the opaque
content pane) instead of toggling display. Teardown now only clears transforms = no reflow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slide: the residual ~40ms end-of-slide hitch (down from 128ms in b124) was the layer
DEMOTE + the deferred full renderThread at transitionend. Two fixes:
- Keep .content promoted for the WHOLE time a chat is open (will-change lives on
body.chat-open, not on the animating class). The open transition now only changes the
transform VALUE 100%->0; when it ends there is nothing to tear down -> no demote raster.
- Slide-end no longer rebuilds all 40 nodes: reconcileOpen() appends only the 0-2 genuinely
new tail messages (full renderThread only if the page structure diverged).
Download: routing to Safari failed auth (no login cookie). Real fix: fetch the file in the
WebView (cookie present) and hand the bytes to the OS save/share sheet via Filesystem+Share
(added to mobile deps; ships next TestFlight build). Until then, images fall back to the iOS
long-press "Save to Photos" instead of breaking the app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slide: the 40-128ms hitch was a compositor RE-RASTER at transitionend, not JS
(longtasks:[], renderMs 2-6ms in the device log). Removed contain:layout paint /
isolation:isolate from the .content slide layer and the filter:brightness parallax
on .chatcol (both force a full re-raster on teardown); defer the layer demotion two
frames past transitionend so it lands off the motion path. Also replaced the fragile
length+lastId slide-end skip (it almost never held once a live message grew the cache,
so the full innerHTML rebuild kept running at slide-end) with an exact per-message diff.
Reopen-at-latest: loadOlder wrote the GROWN thread (100+ msgs) into THREAD_CACHE, so
re-opening re-rendered all of it and stranded you mid-history as images shifted the
(pre-load) bottom past the 1200px pin guard. Open now renders the latest PAGE only;
added _forcePinOpen to glue to the newest through late image loads until the user scrolls.
Download hang: <a download> navigated the whole WKWebView away to the raw file (no back,
app frozen). Native-only capture-phase interceptor opens downloads in the system browser.
Lightbox close/download buttons: solid dark chips so they're visible over bright images.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device log: slide is 60fps EXCEPT one ~60-130ms hitch at the END = the deferred renderThread rebuilding
the (identical) DOM at slide end. Skip it when the network result matches the cache render (signature).
Load-older: fired at scrollTop<120 = during the rubber-band over-scroll past the top (beforeScrollTop
was -289), so the prepend+anchor jerked momentum. Trigger earlier (<700, skip negative scrollTop) so it
loads while still scrolling and never interrupts the bounce.
Build marker -> 2026-07-19-batch123.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The perf-diagnostics script tag was never closed, so it merged with the following native/keyboard
<script> and threw 'Unexpected token <', disabling the keyboard/safe-area init. Add the missing </script>.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>