Adds a dedicated Postgres 16 service (container bizgaze-postgres, own named volume
bizgaze_pg_data, healthcheck) on the shared NPM network. The app depends_on it
healthy. Inert until DB_BACKEND=pg is set in .env — default stays SQLite, so this
deploy changes nothing functionally; it just makes the engine available. Documented
POSTGRES_PASSWORD / DATABASE_URL / DB_BACKEND in .env.example.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DM/group thread queries used `(? IS NULL OR created_at < ?)` — an all-NULL
param Postgres can't type ('could not determine data type of parameter') — and an
unaliased FROM-subquery (Postgres requires an alias). Both rewritten to add the
`created_at < ?` clause only when a cursor is given, and alias the subquery `t`.
Portable; sqlite db-smoke still 22/22.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- db/pg.js: the pg backend (prepare/exec/tx/init) — ?→$N translation, BIGINT parsed
as Number (matches sqlite; else expires_at<Date.now() compares string<number),
transactions on one pooled client, init() applies schema.pg.sql. Same interface as
db/sqlite.js, so repos are unchanged.
- repos.js: the ~7 SQLite-only queries rewritten to run on BOTH engines —
audit.add @named→positional; email lookups COLLATE NOCASE→LOWER()=LOWER();
INSERT OR IGNORE→ON CONFLICT DO NOTHING (addMember/poll vote/favorite);
mergeInto's UPDATE OR IGNORE→UPDATE…WHERE NOT EXISTS/NOT IN and INSERT OR
REPLACE→ON CONFLICT DO UPDATE. Re-validated on sqlite: db-smoke still 22/22.
- server.js: boot now `await db.init()` before listening (pg creates tables; sqlite
no-op), so the first request can't hit a missing table.
- db/migrate-sqlite-to-pg.js: one-shot row copy in FK order (bulk insert, TRUNCATE
first so re-runnable). audit_log id left to PG's identity.
- package.json: add pg ^8.13.1.
Next: validate DB_BACKEND=pg smoke against a real Postgres on the server, then merge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The full sync→async conversion is complete and green on the SQLite backend. Every
DB call across the app now awaits the async adapter, so the identical code runs on
Postgres at cutover.
Converted (this commit finishes Phase 3):
- session.js: currentUser/apiKeyFromReq async → 63 route awaits + WS + static.
- routes.js: all ~250 R.* awaited; DTO helpers (namesFor, avatarsFor, buildMsgDTO,
buildPollDTO, reactionsForMessage, postSystemMessage, pushGroupUpdate,
issueRefreshToken, provisionFromBizgaze) made async; every `.map(x=>buildDTO(x))`
restructured to `await Promise.all(...map(async...))` preserving order; `.filter`
predicates that hit the DB moved to an `asyncFilter` helper; chained
`R.x.y(...).length/.map/.filter` wrapped as `(await R.x.y(...)).method`; stream
upload handlers (recording/transcript/attachment) made async.
- calls.js / signaling.js: all call/meeting fns async; leaveMeeting AWAITS
persistCallHistory + finalizeTranscript BEFORE endCallByRoom (ordering matters —
fire-and-forget would race the map teardown); WS handle()/cleanup() async with
.catch guards.
- static.js: authAttachment(Raw) async (the .some carrier check became a loop),
handleGet async; server.js dispatch catches handler rejections → 500 not a hang.
- media.js backfill, push.js, reminders.js, webhooks.js await their repo calls.
Validation on DB_BACKEND=sqlite: db-smoke 22/22; legacy e2e 80 checks pass with zero
FAILs (throws only at a PRE-EXISTING WS lobby-drift assertion, unrelated). Every
server file `node --check` clean.
Still on the branch — master untouched. Next: Phase 5 (pg backend + ~7 dialect
queries + data migration + Docker Postgres + cutover), then merge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On the db-migration branch only; master stays clean + deployable. Foundation
(adapter, pg schema, smoke harness) is already on master and safe.
Done:
- repos.js fully async (Phase 2, validated: node --check clean, no missed transforms).
- session.js currentUser/apiKeyFromReq async.
- Mechanical `await` prefix applied across routes/static/calls/signaling/reminders/
webhooks/push.
Remaining (does NOT compile yet — deterministic to finish):
1. Async cascade: helper fns that now contain `await` must be marked async and their
callers awaited. node --check points to each (namesFor, authAttachmentRaw/
authAttachment in static, the WS handlers in calls/signaling, reminders/webhooks
loops).
2. DTO builders are the real work: namesFor, avatarsFor, buildPollDTO, buildMsgDTO,
recDTO all became async — every `.map(x => buildMsgDTO(...))` etc. must become
`await Promise.all(arr.map(async x => ...))`.
3. Chained calls `R.x.y(...).map/.length/.includes` → `(await R.x.y(...)).method`.
4. Then: node --check all green → node test/db-smoke.js green → e2e → merge to master.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
server/dbx.js selects a backend by DB_BACKEND (default sqlite; pg added at cutover).
server/db/sqlite.js wraps the synchronous node:sqlite instance in the async
interface repos will call — prepare(sql).{get,all,run}, exec(sql), tx(fn), init().
Results come back as resolved Promises so identical repo code runs on synchronous
SQLite (dev/test) and asynchronous Postgres (prod).
tx() gives multi-statement atomicity that stays correct on both engines (sqlite is
single-connection; the pg backend will run it on one pooled client) — needed for the
account-merge transaction in repos.
Verified: get/all/run/tx all work end-to-end; confirmed no code reads
.changes/.lastInsertRowid, so the repo conversion is purely sync->Promise. Unwired —
nothing requires dbx.js yet; prod path untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Covers the DB-backed HTTP paths the async repo conversion touches — auth, users,
messages, attachments, conversations/groups, reactions, mentions, edit/delete,
polls, scheduled meetings (paginated), favorites, audit — asserting current API
shapes. Runs to completion with a pass/fail count and honours DB_BACKEND so it
doubles as the sqlite-vs-pg parity check at cutover. No WS/signaling (in-memory,
not the DB).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
server/db/schema.pg.sql — the full Postgres DDL, every column defined up front (no
ALTER-ordering fragility). SQLite→PG type mapping documented in-file (epoch-ms
INTEGER→BIGINT, 0/1 flags→SMALLINT kept numeric so app code is unchanged, sizes→
BIGINT, audit rowid→GENERATED IDENTITY). Mirrors the three existing FKs and adds a
new idx_messages_attachment (the /files auth scan we cached earlier becomes a keyed
lookup).
Validated against a throwaway Postgres 16: loads with no errors, 24 tables + 44
indexes created. Unwired — nothing uses it yet; the SQLite path is untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Migration prep. Two real fixes surfaced while building a regression harness:
1. db.js: the `guest_emails` and `lobby` ALTER TABLEs sat at lines 241/244, BEFORE
scheduled_meetings is CREATEd (line 299). On a FRESH database the ALTER fails
(no table yet), is swallowed by the try/catch, and the columns are never added —
so a brand-new deploy is missing them and scheduling with guests crashes. Prod
escaped it only by incremental deploy history. Moved both ALTERs to after the
CREATE. (The upcoming Postgres schema defines every column up front, so this
whole class of ordering bug goes away there.)
2. test/e2e.js: /api/meetings returns paginated `{list, pastTotal, page, pageSize}`
now, not a bare array — updated three `.data.find` → `.data.list.find`.
No prod behaviour change (prod already has the columns; ALTERs are idempotent).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The top-bar buttons looked heavy against the navy bar (thick grey X-circle, bold
"Send" pill). Swapped for light SF Symbols on the navy bar: a thin `xmark` for
cancel and a `paperplane.fill` for send (semibold, enables when ≥1 chat is picked).
Icon-only send matches the Teams reference — the radio checks already show what's
selected, so the "(N)" count text is dropped. Native-only — needs a build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two things from testing (the in-sheet picker with avatars + multi-select works):
1. Double "Send to": after the extension sent, opening the app ALSO popped the web
"Send to" modal for the same file. Cause: the extension wrote a safety-net
manifest up front, which the app then picked up. Now the manifest represents an
UNSENT share only — written solely when the extension can't send (no token) or a
send fails. A successful in-sheet send clears the staged files and leaves nothing,
so the app never re-offers it. Cancel also clears staged files (no orphans).
2. Layout aligned to the Teams reference:
- Preview strip of thumbnails for what's being shared (image → the image, video →
first frame via AVAssetImageGenerator, else a doc icon).
- Radio selectors on the right — an always-visible empty circle that fills to a
navy check when selected (clearer multi-select than an appear-on-select tick).
- "Recent chats" section header; subtitle under each name (Direct message /
Group · N members).
- Clearer branding: bold white "Share to Biz Connect" on the navy bar.
Native-only — NEEDS A NEW iOS BUILD. Balance + selectors checked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three follow-ups on the in-sheet picker:
- Profile photos: rows now show the real avatar (fetched from the conversations
API's `avatar` field with the bearer token, or a data-URL decoded inline),
rendered as a circle; coloured initials as the fallback — matching the app.
- Multi-select: tap toggles a checkmark instead of sending immediately; a "Send (N)"
button in the nav bar sends to every selected chat. Each file is uploaded ONCE and
its attachment id reused across all targets (the server allows the uploader to
reattach the same id), so multi-send doesn't re-upload.
- Branding: navy (#1F3B73) navigation bar with a white "Biz Connect" prompt over the
"Send to…" title and white controls.
Native-only — NEEDS A NEW iOS BUILD. Balance + selectors checked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the wrong approach (stage + try to bounce into the app, which iOS blocks)
with the one Teams/WhatsApp actually use: the picker and the send happen INSIDE the
share extension, so there's no app-open at all. Tap Share → Biz Connect → pick a
chat → it uploads and sends, right there in the sheet.
How the extension can send without the app: it's a separate process that can't see
the web app's HttpOnly cookie, so:
- server: GET /api/share/token mints a bearer token for the logged-in user.
- web: on every launch the app fetches that token and hands it to the extension via
the App Group (ShareInbox.setAuth writes token+base to the shared UserDefaults).
- extension: reads the token and calls the SAME API the native client uses —
GET /api/messages/conversations to list chats, POST /api/messages/upload for each
file, POST /api/messages to send. Native UITableView picker with search.
Robustness: it still stages the files + writes a manifest first, so if there's no
token yet (user never signed in) or the send fails, the file isn't lost — the app
collects it on next open, exactly as before. On success the manifest is cleared so
the app doesn't re-offer it.
Server + web are live now; the token endpoint is harmless until a build ships the
extension. NEEDS A NEW iOS BUILD for the picker itself.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Share Extension opening its host app is unsupported on modern iOS (restricted
~iOS 14), so the programmatic bizconnect://share open is silently blocked and the
user just saw a blank flash back to Photos — looking broken even though the files
staged fine.
The extension now shows a small native card after staging: "✓ Ready to send — Open
Biz Connect to choose a chat", with an "Open Biz Connect" button (user-initiated
open has the best chance of working) and a Done button. It still attempts the
auto-open first. Either way the app collects the staged files when next opened, so
the manual path that already works is unchanged — this just removes the "did it
even work?" confusion.
Renamed the local `staged` array to `collected` to free `staged` for the state
flag. Balance + selectors checked. NEEDS A NEW iOS BUILD (native change).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-ups from testing the share flow (which now works end-to-end):
- Send-to picker showed only initials — now shows the real profile photo when the
chat has one (matching the sidebar/forward avatars), coloured initials otherwise.
- During send it only said "Uploading 60%" with no idea WHO to — now the header and
progress name the target ("Sending to Manasa Rapolu · 60%").
- After sending, a video bubble sat blank (just a timestamp) for a second or two
while the poster generated on first view. media.js now warms the poster thumbnail
at UPLOAD (temp-then-rename), and the on-demand /thumbs handler also writes via a
temp, so the two can't serve a half-written JPEG. The bubble shows its poster
right away.
Web + server only — live on deploy. Does NOT address the share extension failing to
auto-open the app (an iOS limitation, handled next in the native build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sharing a photo into the app: the "Send to…" picker appeared and staging worked,
but picking a chat said "no file found". Cause: the app loads its UI from the
REMOTE origin (remote.bizgaze.com), and I read the staged bytes with
fetch(convertFileSrc(uri)). Capacitor's local `_capacitor_file_` serving isn't on
the remote origin, so that fetch is CORS-blocked / hits the remote server → 404.
Read through the native bridge instead: Filesystem.readFile returns base64 in
native code, never touching the webview network stack, so it reads the App Group
file the app has entitlement access to. Falls back to it.path, then to the old
webview path for local-asset builds. Web-only fix — no rebuild.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The archive keeps failing with only "Failed to archive" and exit 65 — no reason
shown. Cause: `xcode-project build-ipa` prints a prettified summary and swallows
xcodebuild's raw "error:" lines, which only survive in the /tmp/xcodebuild_logs
artifact. On failure we now grep that log for the signing/entitlement/compile
error and print it inline, so the next red build states WHY instead of just 65.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codemagic log: "xcodeproj not found at /Users/builder/clone/ios/App/App.xcodeproj"
— missing the `mobile/` segment. The script lives in mobile/scripts, so __dir__ is
mobile/scripts and `File.expand_path('../..', __dir__)` resolved to the REPO ROOT,
not mobile/. The project is at mobile/ios/App/App.xcodeproj. Changed to '..' so ROOT
= mobile, which fixes PROJECT, SRC_DIR, APP_DIR and the entitlements path together.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the reverse direction: share FROM Photos/Files/Safari INTO a Biz Connect
conversation. An app can only appear in the iOS share sheet as an app-extension
target, so this is real native work, not a web change.
Pieces:
- mobile/ios-share/ShareViewController.swift: a UI-less Share Extension. It stages
the shared items into the App Group container and opens bizconnect://share. It
deliberately does NOT reimplement the chat picker — that lives in the app, which
already has the chat list, search and upload progress. Appends to the manifest
(never overwrites), so sharing twice before opening the app loses nothing.
- mobile/scripts/add-share-extension.rb: injects the extension target into the
Capacitor-generated Xcode project on every CI build (Codemagic checks out fresh),
using the xcodeproj gem that ships with CocoaPods. Embeds it, sets the bundle id
<app>.share, and MERGES the App Group into the app's entitlements rather than
clobbering them (push's aps-environment must survive). Idempotent.
- mobile/plugins/share-inbox: getPending()/clear() to read that manifest — the App
Group container isn't one of Filesystem's known directories, so it needs a bridge.
- home.html: on bizconnect://share (and every resume, and cold-launch), read the
inbox and show a "Send to…" picker over the chat list; chosen files run the SAME
upload + /api/messages send as an in-app attachment. Reuses convertFileSrc to read
the staged bytes with no base64 marshalling.
- ios-patch.sh registers the bizconnect URL scheme; codemagic.yaml fetches a profile
for the .share bundle id too.
One-time manual gate (CI cannot toggle App capabilities): the App Group
group.com.bizgaze.connect must be created and enabled on both App IDs in the Apple
portal — documented in mobile/IOS_SETUP.md. Without it the two processes can't see
each other's files and sharing silently no-ops; everything else still works.
Validated cross-file: pod-name/jsName/method wiring for all three plugins, App
Group id identical in all 4 files, URL scheme consistent across extension/plist/web,
entitlement-merge preserves push. Needs a new iOS build (new targets + plugins).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Files folder is the app's own copy — it powers offline playback and Manage
storage — but Files is not where anyone looks for photos and videos. The Photos app
is, and only PhotoKit can write there; @capacitor/filesystem cannot, because an
app sandbox and the photo library are separate stores. So this adds a small native
plugin, mirroring the existing audio-route one.
- mobile/plugins/media-library: saveToAlbum({path, album, kind}) finds or creates
the album and adds the asset. Uses addResource(with:fileURL:), which is uniform
for photo and video and non-optional, unlike creationRequestForAssetFrom*, which
can silently no-op.
- Requests .readWrite, NOT .addOnly: addOnly can add an asset but cannot look up or
create an ALBUM, which is the whole point here. Both photo-library usage strings
are already set by ios-patch.sh.
- If the album can't be resolved (e.g. "limited" access), the asset is still saved
to the camera roll — landing somewhere beats failing outright. A racing create
from two simultaneous downloads re-looks-up instead of erroring.
- Podspec named MediaLibrary.podspec with s.name = 'MediaLibrary' to match
PascalCase of the package name — the same trap that broke the AudioRoute build.
Checked by a script: pod name, jsName and declared-vs-implemented methods.
Entirely best-effort from the web side: a denied permission or an older app build
never fails a download that is already safe in the app folder. Added a Settings
toggle since this does keep a second copy of the file.
Needs a new iOS build — new native plugin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The download button was a small corner overlay, which landed on top of the native
control bar's speaker icon. That was not the design asked for either: it should be
a single control in the CENTRE that turns into a play button once downloaded.
The tile is now the masked poster plus one centred button:
not downloaded → download icon (the element has no src at all, so nothing is
fetched until it is tapped)
downloading → live % inside that same button, in place
downloaded → play icon; tapping plays the LOCAL file
The native control bar is switched on only when playback starts, so there is
nothing for the control to collide with. Progress reports into the button rather
than the floating chip, so a video download no longer shows two indicators.
Also gives the tile a min-height so the thread doesn't jump while the poster loads.
Web/PWA is untouched — it has nowhere to download to, so it keeps streaming with
native controls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three connected pieces, so downloads stop being write-only.
1. A folder the user can actually find. ios-patch.sh now sets UIFileSharingEnabled
and LSSupportsOpeningDocumentsInPlace, and downloads go to the app's Documents
folder in typed subfolders. iOS shows it as:
Files -> On My iPhone -> Biz Connect -> Images | Videos | Files
Previously everything was written to CACHE (private, and iOS purges it whenever
it likes) and pushed straight at the share sheet, so nothing was ever really
"kept" by the app.
2. Manage storage (Settings -> Storage). Lists what this device has downloaded,
grouped by type with per-group and total sizes; each row can be shared to the OS
sheet (this is where "Save to Photos" now lives) or deleted. Plus Delete all.
Deleting removes ONLY the local copy — the attachment stays on the server, so
anything deleted can be downloaded again from the chat.
3. A downloaded video never downloads twice. Images and files have their own
download link, but a video's tile IS the player, so it had no control at all and
re-streamed on every play. It now carries a download button; once saved, the
button becomes a tick and the tile plays from the local file — no network.
The index is treated as a cache of the filesystem, never as truth, because the user
can delete these from the Files app behind our back: every listing re-stats and
forgets what is gone, the library is reconciled at startup, and a local file that
has vanished by play time falls straight back to streaming instead of showing a
broken player.
Unit-checked the path allocator: collisions between different attachments with the
same filename resolve to "name (2)", re-downloading the SAME attachment reuses its
path, and path traversal / illegal characters are neutralised.
Note: the folder and the save location need a new iOS build to take effect. The web
side degrades cleanly — none of this UI appears outside the native app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Uploads showed a bar + %; downloads showed nothing. On the native app that gap is
worse than on web, because there is no browser download UI behind it — the app
fetches the bytes itself, so a large video looked like a frozen tap.
- Adds the same chip an upload uses (name, bar, %) driven by Content-Length.
Falls back to an indeterminate "…" when the server sends no length.
- Streams the response and appends to the file in 3-byte-aligned blocks rather
than holding blob + base64 simultaneously. The old path peaked around 250 MB of
memory for a 75 MB video, which is enough to get a WebView killed on a phone.
Verified byte-exact against empty / 1 B / 2 B (base64 padding edges) / ragged
chunk sizes / 27 MB, reassembling identical bytes every time.
- Older WebViews without streams keep the previous one-shot path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reported: "the loading buffer is not the spinner, it still loads at the left of
the timer." Correct — and it was our bug, not a cosmetic preference.
The spinner was driven by a hand-picked event list (waiting/seeking/stalled). But
a cold start from preload="none" NEVER fires `waiting`: it runs
loadstart -> loadedmetadata -> loadeddata -> canplay -> playing straight through.
Since the bandwidth fix, that cold start is the only stall left — so the spinner
sat out the exact moment it existed for, leaving just the OS control bar's own
small indicator where the play button sits, i.e. left of the timer.
Now the spinner is derived from the element's real state rather than guessed from
events: busy = seeking || (!paused && !ended && readyState < HAVE_FUTURE_DATA),
recomputed on every relevant media event. Simulated against the real event
sequences before shipping — cold start, mid-stream stall and seek all spin;
paused/ended/idle never do.
Also dim the frame to 72% brightness while buffering so the spinner reads
instantly against a bright poster, and give it a dark backing disc.
Note: the small indicator inside the native control bar belongs to the OS's own
video controls and cannot be suppressed while we use them. Ours is now the loud,
central one; removing the OS indicator entirely would mean custom controls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
THE ANSWER to "why does an already-downloaded video still buffer?" — it was never
the download, and it was not the server. Probing the real uploads on the box:
d0e49e58… 1920x1080 19.4 Mbps 75 MB / 31 s
ad929d0b… 1920x1080 19.0 Mbps 27 MB / 11 s
9f4e0865… 720x1584 3.6 Mbps 14 MB / 31 s
To play a 19 Mbps file the client has to SUSTAIN a 19 Mbps download for the whole
clip. No mobile link does, so the <video> buffer drains every few seconds: buffers,
plays, buffers, plays. Server-side disk read was instant and load was 1.7 on 20
cores throughout — the bottleneck is the media itself, not the delivery path.
Second, independent defect: phone MP4s store `moov` AFTER `mdat` (verified on two
uploads), so the player must fetch the file's tail before it can start at all.
Fix — keep the original bytes untouched (that is what the download button serves,
full quality) and build <id>.web.mp4 beside it: longest side capped at 1280,
~2.5 Mbps ceiling, +faststart. Measured on the 19 Mbps file:
27.3 MB @ 19.0 Mbps -> 2.55 MB @ 1.78 Mbps (10.7x less bandwidth)
transcode took 2.4 s for an 11.5 s clip
- server/media.js (new): probe, decide, 2-at-a-time background queue. Already
light + correctly sized + faststart => no rendition at all. Light but wrong atom
order => remux -c copy (seconds, no re-encode). Otherwise re-encode. A rendition
that lands bigger than the original is discarded. MP4 box-walker for the
faststart test is unit-checked against known fast/slow files, both directions.
- /stream/<id> serves the rendition, falling back to the original while it is still
transcoding, so a video is never unplayable. /files/<id> is unchanged and still
serves the pristine original for download.
- Renditions are queued at upload, and backfilled 15 s after boot for the videos
that predate this. Range serving is now one shared helper for both routes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of a downloaded/streaming video buffering repeatedly: every /files Range request
(a playing video fires dozens) re-ran the full attachment authorization, which scans the
messages table by attachment_id (un-indexed) — a per-chunk table scan = stutter. Now the
auth decision is cached per user+attachment for 60s (module-level, bounded), so range
requests after the first are ~free.
Also: preload='none' (nothing about a video downloads until the user taps play — only the
small poster loads), per 'no auto-download'. And the buffering spinner no longer hides on
canplay/loadeddata (they fire mid-buffer), so it reliably spins whenever it's buffering.
build batch159.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The native controls' buffering indicator sits by the timer and is easy to miss. Wrap the
<video> and overlay a centered spinner shown while it buffers (waiting/seeking/stalled ->
show; playing/canplay/seeked/loadeddata -> hide). Media events don't bubble, so the
listeners run in the capture phase; the overlay is pointer-events:none so it never blocks
the native controls. build batch158.
- Dockerfile: add ffmpeg (Alpine).
- static.js: new /thumbs/<id> — ffmpeg extracts the first frame (0.5s), caches it next to
the file, serves as the video poster (cosmetic; 404s gracefully if ffmpeg unavailable).
- static.js: /files now supports HTTP Range (206 Partial Content) + Accept-Ranges, which
iOS requires to stream/seek video reliably (fixes the buffer-before-play / multi-tap);
media (image/video/audio) now served inline, other files still download. Shared
attachment auth refactored into one helper used by /files and /thumbs.
- home.html: video poster points at /thumbs/<id>. build batch157.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Swipe-back: the finger-following version is dropped (parked for the future) per request —
back to the reliable release-triggered swipe (rightward edge release runs bzcBack's slide).
Video: the custom download->play overlay caused layout 'dancing' on load and flaky
multi-tap playback. Replaced with a plain native <video controls playsinline preload=
metadata> (poster via #t=0.1) at a fixed box size — poster + OS play button, plays inline
on one tap, streams once and is cached (no re-downloads). build batch156.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug 1: .ptr-ind used top:8px so the pull-to-refresh spinner sat under the notch/Dynamic
Island. Now top:calc(var(--sat)+8px) clears the safe-area inset.
Bug 2: video attachments rendered as a plain download link that re-downloaded on every
tap. Server now sends isVideo/isAudio on message attachments; videos render as an in-chat
player — masked poster with a DOWNLOAD button that loads the file ONCE (preload=none ->
load on tap), then becomes a PLAY button; playing hands off to native inline controls, so
no repeat downloads. build batch155.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The conversation pane now tracks your finger from the left edge and reveals the chat
list behind it, completing the back past ~35% width or springing back otherwise. Reuses
the existing body.chat-dragging layout (already defined, identical to chat-closing that
showWelcome uses): content z-index:2 at translateX(0), list .chatcol absolute behind at
z-index:0 — so the pane starts at the correct on-screen origin (the earlier attempt's
'one screen-width off' bug was a different setup). Vertical drags still scroll; popup/
search edge-release still closes via bzcBack. build batch154.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Investigations are closed, so strip the probes: bzDbg + __perfProbe definitions, the
older/render/slide/pwaFocus call sites, and the server-side /api/dbg (MDBG) sink. Kept
the functional code around each probe (renderThread's innerHTML build, the older-page
re-anchor, the slide fade). No behavior change. build batch153.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of 'tapping stops the Bluetooth audio': bzUnlockAudio fires on EVERY tap
during a call and called bzApplyRoute -> setSpeaker(false) -> configureDevice, whose
setCategory+setActive(true) reconfigures the AVAudioSession mid-call and interrupts
WebKit's audio unit, dropping the call audio. Fix: bzApplyRoute on iOS no longer touches
the session at all (only wires the route-icon listener); iOS keeps auto-routing. build batch152.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Telemetry confirmed the ceiling: a single speaker override holds ~1s over active BT then
WebKit reverts. So stop trying to control the route on iOS — always use the default port
(iOS auto-routes: BT/wired if connected, else loudspeaker), and make the button a live
INDICATOR of the real output; tapping shows a toast (connect/disconnect a headset to
change). Removes the temp nroute/sptap probes. Pure web change; plugin v1.1.2 already
supports it. build batch151.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1.1.1's route observer re-forced speaker on every change, and WebKit re-added Bluetooth
each time -> the audio flapped speaker<->BT many times/sec (telemetry: dozens of route
flips from 3 taps), which read as 'sound doesn't switch'. WKWebView won't let the app
hold the built-in speaker over an active BT device. So: one override per tap, observer
only REPORTS the output (no fighting). Also stop dimming the iOS button (it's a live
output indicator, not on/off; the dim read as 'disabled' on BT). Probe now carries the
native marker so we can confirm the binary. plugin v1.1.2-stable, web batch150.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
overrideOutputAudioPort(.speaker) alone can't beat a connected BT headset (BT is higher
priority), so 'Speaker' snapped back to BT. Now setSpeaker(true) sets category options
[.defaultToSpeaker] (no allowBluetooth) so BT isn't an eligible output and the speaker
wins; setSpeaker(false) restores [.allowBluetooth,.allowBluetoothA2DP] and uses the
default port (routes to the headset). Observer re-holds speaker if a BT connect steals it.
Adds a TEMP web probe (nroute/sptap) to verify from telemetry. plugin v1.1.1, web batch149.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BT audio already routes on iOS, but the web UI can't see it (iOS hides audio outputs
from enumerateDevices), so the icon was stuck on speaker. Plugin v1.1.0 now exposes the
active output: getRoute() + a 'routeChange' event ('speaker'|'bluetooth'|'wired'|
'receiver'|'airplay'). Web subscribes and drives the icon/label from the real route
(bluetooth/headphones/speaker), and the iOS toggle becomes a 2-state Speaker <-> Device
cycle (JS can't enumerate outputs there). Also strips the earpiece-investigation debug
logging from the plugin. Native needs one Codemagic build; web is live (batch148).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Proven via device telemetry: inside a WKWebView, WebKit owns the WebRTC audio unit and
forces the loudspeaker; overrideOutputAudioPort(.none) is a no-op (route settles on
Speaker 1.2s later), so the built-in earpiece cannot be selected. Present only what
actually works on iOS: Speaker, and Bluetooth/wired headset when connected. bzApplyRoute
now maps only 'speaker' to the loudspeaker override; bt/headset use the default port.
Coerce any stale 'earpiece' pref to speaker on iOS. Also strips the route debug telemetry.
Other platforms keep the earpiece option. build batch147.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1.0.3 telemetry showed mode already == .voiceChat when earpiece still landed on
Speaker, and setSpeaker read the route synchronously (stale) right after the override.
So the mode re-pin alone may be insufficient and the sync read is unreliable.
1.0.5: keeps the .voiceChat re-pin, but stamps live mode into every route-change log
line and re-reads the SETTLED port+mode at +0.4s and +1.2s after each toggle (reported
in the next toggle's trail). This definitively answers whether WebKit flips to .videoChat
and where the route truly settles. native marker 1.0.5-settle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause (WebKit source MediaSessionManagerCocoa.mm + Apple DTS, confirmed by
telemetry): WKWebView's WebRTC re-pins the session to mode .videoChat while capture
is active, and .videoChat auto-implies .defaultToSpeaker. So override(.none) reverts
to the mode default = LOUDSPEAKER, and .none alone can never reach the earpiece once
WebKit flips the mode. Our first override won only because .voiceChat was still active.
Fix: for earpiece, setMode(.voiceChat) (its default route IS the receiver) before
override(.none) in both setSpeaker and the debounced route-change re-assert. Add an
accessory guard so a connected BT/wired headset isn't yanked to the built-in receiver.
Reconcile the launch patch: drop .defaultToSpeaker from inject-audio.js so it stops
contradicting the plugin. native marker 1.0.4-mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Telemetry proof: overrideOutputAudioPort(.none) routes to Receiver once, then every
later toggle latches to built-in Speaker despite opts=36 (no .defaultToSpeaker) —
WebKit's WebRTC engine re-forces the loudspeaker after our override. The prior build
ignored .override-reason route changes and never corrected it.
Now: react to ALL route changes, debounced 0.25s, and re-assert the chosen port only
on a genuine mismatch (self-terminating, capped at 6/toggle to avoid thrash). setSpeaker
returns a reason->port route-change trail so the log shows whether WebKit is one-shot
or persistent. native marker 1.0.3-reassert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Telemetry only showed 'override didn't throw', and the web __BUILD tag can't tell
native binaries apart, so we couldn't see WHERE iOS actually routed the audio or
which plugin build ran. setSpeaker now resolves with the real currentRoute output
port (Receiver/Speaker/Bluetooth), the live AVAudioSession category/mode/options,
and a native-build marker (1.0.2-diag) so the route log is unambiguous.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Speaker worked but the earpiece was silent, and call audio flickered on speaker
then dropped. Causes: (1) the category set .defaultToSpeaker, so overrideOutputAudioPort(.none)
fell back to the loudspeaker instead of the receiver; (2) setSpeaker re-ran
setCategory+setActive on every toggle mid-call, tearing down the audio unit WebKit's
WebRTC engine was using and silencing the earpiece route.
Fix: drop .defaultToSpeaker (drive the port explicitly), make setSpeaker flip ONLY
overrideOutputAudioPort, and observe routeChangeNotification to re-assert the chosen
route when WebKit reconfigures the session at call start / device change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capacitor derives the pod name from the npm package name (audio-route -> AudioRoute)
and writes 'pod AudioRoute, :path => ../../plugins/audio-route' into the generated
Podfile. CocoaPods then requires a file literally named AudioRoute.podspec whose
s.name is 'AudioRoute'. The old AudioRoutePlugin.podspec (s.name AudioRoutePlugin)
caused the Codemagic build to fail with 'No podspec found for AudioRoute'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device telemetry proved the app-embedded AudioRoute class never registered (not in
Capacitor.Plugins) — appending a CAPBridgedPlugin to AppDelegate.swift gets stripped/undiscovered
in release builds. The plugins that DO register (Share, Camera, Filesystem) are all npm packages
wired by cap sync. So AudioRoute is now a local plugin package (mobile/plugins/audio-route,
file: dep in mobile/package.json) with a podspec + CAPBridgedPlugin Swift — cap sync adds its pod
and Capacitor registers it like the others. load() sets the launch speaker default; setSpeaker({on})
overrides the output port. inject-audio.js no longer injects the plugin class (would duplicate);
it keeps only the AppDelegate launch default as a fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Server streams the upload body straight to /data/uploads (a .part temp file, atomic rename on
success), backpressure-aware, so a 1 GB file never buffers in RAM. MAX_UPLOAD_MB env (default
1024 = 1 GB) controls the cap; error message reflects it.
- Client size guard raised 25 MB -> 1 GB.
- docker-compose documents MAX_UPLOAD_MB and the required Nginx Proxy Manager client_max_body_size.
NOTE: the actual bottleneck for the user's 9.7 MB reject is almost certainly NPM's client_max_body_size
(nginx default 1 MB) — that must be raised in the NPM admin; the app change alone can't lift it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ios-patch.sh now injects an AudioRoutePlugin (CAPBridgedPlugin, Capacitor 7 auto-registers it)
into AppDelegate.swift with setSpeaker({on}) -> AVAudioSession.overrideOutputAudioPort. Tolerant/
build-safe: if it doesn't register, the web call just no-ops (can't crash or fail the build).
- web: nativeAudioRoute()/bzApplyRoute() drive the plugin; toggleSpeakerphone + the on-join/on-tap
unlock now actually switch the route on iOS (setSinkId can't). canRouteAudio() shows the toggle
when the native plugin is present. Dormant until the next Codemagic build ships the plugin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>