Under Android edge-to-edge the status/nav bar icons default to light (white) and
vanished on the white app (clock/battery/nav invisible). Set SafeArea
statusBarStyle/navigationBarStyle to LIGHT (= dark content on a light background)
in capacitor.config.json, and add a runtime SafeArea.setSystemBarsStyle call in
home.html so already-installed APKs get dark icons on relaunch (no rebuild). iOS
unaffected (its status bar already shows dark content on white).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Base64 is a single env-safe token, so the FCM service-account key can live in
.env without the quoting/interpolation hazards of inline JSON. Falls back to the
existing FCM_SERVICE_ACCOUNT (inline JSON or file path). No behavior change when
neither is set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Commit the client Firebase config (bizgaze-connect, package com.bizgaze.connect)
and have the Codemagic Android step copy it into android/app/ so the google-services
Gradle plugin applies and FirebaseApp initializes — fixing the root cause of the
"Default FirebaseApp is not initialized" crash. google-services.json is client-side
(ships in the APK), safe for this private repo. The service-account key stays OUT of
git (.gitignore: *firebase-adminsdk*.json / *service-account*.json) — it goes on the
server as FCM_SERVICE_ACCOUNT separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The safe-area plugin logged: set SystemBars.insetsHandling to "disable". Under
Android edge-to-edge (targetSdk 36) this + offsetForKeyboardInsetBug were causing
a black strip between the input and the soft keyboard on the login view. Set
SystemBars.insetsHandling="disable" and SafeArea.offsetForKeyboardInsetBug=true.
Native config — takes effect on the next Android build; verify on device.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On Android, PushNotifications.register() calls FirebaseMessaging.getInstance(),
which throws "Default FirebaseApp is not initialized" when the build has no
google-services.json. Capacitor runs the plugin method on its own thread, so the
exception is an UNCAUGHT NATIVE crash a JS try/catch can't stop — the app died
right after sign-in and on every relaunch.
Gate Android push registration on a new server flag: /api/meetings/config now
returns fcm (push.fcmReady() = FCM_SERVICE_ACCOUNT present). setupNativePush skips
register() on Android unless fcm is true. Web-side fix — deploys without a rebuild;
push auto-enables once Firebase (client google-services.json + server FCM) is set up.
iOS/APNs unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@capacitor/android is ^8; Capacitor 8's Android toolchain (AGP 8.7 / Gradle 8.11)
requires JDK 21. The workflow used java: 17, which fails the Gradle build before an
APK is produced. Bump to java: 21.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The build step ended with `find … *.apk`, which exits 0 even when Gradle failed
and no APK exists — so Codemagic marked a failed Gradle run as a green build with
no artifact. Now: capture gradle output, use PIPESTATUS to detect failure, print
the "What went wrong" / FAILURE block, exit non-zero, and assert an APK exists.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The android-apk workflow requested linux_x2, which Codemagic rejected with
"selected instance type is not available with the current billing plan". Switch
to mac_mini_m2 — the same instance the iOS workflow already uses on this account;
Codemagic's macOS images include the Android SDK + JDK. Also make the SDK path
(ANDROID_SDK_ROOT -> ANDROID_HOME fallback) and the google-services base64 decode
(GNU --decode / BSD -D) portable to the macOS image.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#msgs .bubble had a blanket user-select:none (added to suppress iOS's long-press
magnifier), which also blocked mouse text-selection on desktop. Scope the
none to touch devices only (@media (hover:none)); keep it in multi-select mode.
Desktop can now highlight/copy message text. Verified: desktop=auto,
touch=none, sel-mode=none.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The conversation drop handler read dataTransfer.files[0] (a single file), so a
multi-file drag-and-drop uploaded just one. Iterate every dropped file, matching
the file-picker path. Verified in-browser: dropping 3 files queues + sends all 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- codemagic.yaml: add an Android workflow that builds an installable debug
APK (Linux instance, no signing needed to test) — the Android equivalent of
the iOS TestFlight loop. mobile/scripts/android-patch.js injects the
camera/mic/notification permissions into the CI-generated manifest (android/
is gitignored, same as ios/).
- Delete chat (self-only): new POST /api/messages/clear bulk-hides a whole DM
(or group) for the requester via the existing per-user message_hidden
mechanism — the other party keeps their copy entirely. "Delete chat" button
added to the DM contact-info panel; chat-cleared synced to my other devices.
Verified end-to-end on Postgres (A clears -> empty for A, B untouched).
- Meetings copy: SFU is live, so drop the "coming soon" / "small group (mesh)"
wording on the welcome card and the Meetings header.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Capacitor build is universal (iPhone + iPad), so App Store Connect requires
iPad screenshots. Added three 2064x2752 (13" iPad) shots rendered on the app's
two-pane iPad layout (chat sidebar + open conversation, meetings, schedule),
anonymized. No rebuild needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
06-screenshare.png — a meeting with a shared screen (a slide + chart on the
stage, "Sharing" badge, participant tiles, controls), rendered on the real call
UI at 1320x2868 and anonymized. Updated the submission pack's screenshot order to
include it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuild the iOS remote-support screen share the RIGHT way: keep the exact
Share/Connect UX + session (consent + symmetric session-ended), swap only the
media to LiveKit since WKWebView can't getDisplayMedia. (Replaces the earlier
"route to a meeting" detour, which was reverted.)
Flow: customer taps Share Screen -> gets a 6-digit code (unchanged UI) -> helper
enters it in Connect Screen -> on the customer's "Allow", the app publishes the
screen natively (ReplayKit -> LiveKit) into a per-session room and tells the
agent it's a LiveKit session -> connect.html joins that room and shows the screen
in its existing viewer (recording/controls intact). Chat runs over the session
socket (no P2P data channel in this mode). Either side ending fires the existing
session-ended -> both tear down (symmetric disconnect).
- signaling.js: relay 'rs-livekit' + 'rs-chat' between the two ends.
- home.html: parent bridge so the /share iframe can drive startMeetingScreenShare/
stop on the native plugin (+ a capability handshake).
- share.html (iOS): publish via native LiveKit instead of getDisplayMedia; chat
over WS; hide mic (voice = next iteration) + remote-control (impossible on iOS).
- connect.html: LiveKit viewer for iOS-shared sessions, reusing the P2P viewer.
Web-only, no new build (reuses the shipped startMeetingScreenShare). Desktop
Share/Connect P2P unchanged. Two-way voice is the planned follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fix the confusing flow: tapping Share Screen dumped the user straight into a
meeting and auto-shared, with the join code hard to find. Now it shows a clear
full-screen "Share your screen" step with the big 6-digit code + Copy + a
"Waiting for them to join…" spinner (and Cancel). The native screen broadcast
starts only when the helper actually joins with that code (meeting-peer-joined) —
matching the remote-support mental model.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The remote-support Share/Connect tabs use P2P WebRTC (screen + voice + chat) that
WKWebView can't capture, so "Share Screen" never worked on iPhone. Rather than
rebuild the whole P2P session on LiveKit, route iOS "Share Screen" to a
screen-share MEETING (chosen with the user): tapping Share Screen on iOS starts
an instant meeting, auto-starts the native ReplayKit screen share (the path just
verified on device), and toasts the join code. A helper joins by code (Meeting)
to watch — and gets voice + chat for free. Viewing already works in the webview,
so only the SHARE side is rerouted; desktop keeps the full P2P remote-support
flow, and iOS Safari (no app) is unchanged.
Web-only, no new build (reuses the shipped startMeetingScreenShare native method).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gap: on iOS, screen share only worked in native CallKit calls. In a scheduled /
code-joined SFU meeting the webview called setScreenShareEnabled() ->
getDisplayMedia(), which WKWebView does not implement, so it failed silently.
Production meetings are real LiveKit rooms, so we can publish natively instead.
Fix (native ReplayKit -> same LiveKit room as a dedicated screen participant):
- server: /api/meetings/token accepts screen:true -> mints a distinct
<peerId>-screen identity (LiveKit allows one connection per identity), so the
native publisher doesn't collide with the webview's own connection.
- native plugin: startMeetingScreenShare/stopMeetingScreenShare + connectScreenRoom
(a screen-only LiveKit connection: mic off, no camera, no callConnected) that
reuses the existing broadcast-extension publishing path.
- webview: toggleScreen routes to the native method on iOS SFU meetings; the
screenShareState listener now drives the SFU case too (and tears the screen
connection down on the system "Stop Broadcast"); sfuAttach/sfuDetach map the
'<peerId>-screen' participant's screen track onto the sharer's tile and suppress
the phantom person tile + the sharer's own self-view.
Web/server deploy now; the native method needs the next Codemagic build to test
on device. Verified: db-smoke 22/22; Swift braces balanced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two more anonymized 1320x2868 shots of features that work on iOS: a group
conversation (sender chips, read receipts) and the Schedule-a-call flow
(participant names anonymized). Deliberately did NOT screenshot Share/Connect:
webview screen capture (getDisplayMedia) is unavailable in WKWebView, so the
remote-support screen-share tab doesn't function on iOS — advertising it would
be inaccurate metadata. (Screen share works in native calls via ReplayKit and
fully on desktop.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- mobile/appstore-screenshots/: 3 anonymized 1320x2868 screenshots generated from
the live app (Chats, a Conversation, Meetings). Real names/faces replaced with
demo identities, notification banner dismissed, internal roadmap copy removed.
- APPSTORE_SUBMISSION.md: pre-submission checklist now all green; Privacy Policy
and Support URLs filled with the live /privacy and /support pages; demo login
and export-compliance noted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
App Store submission needs a public Privacy Policy URL and Support URL, reachable
without login. Added branded, self-contained pages served at /privacy and
/support (static.js path mappings, like /home). The privacy policy accurately
describes Biz Connect: BizGaze-account login, messages/media stored on our
servers, call media via LiveKit (only recorded on explicit user action),
on-device transcription (audio never leaves the device), push via APNs/FCM, no
sale of data / no ads, retention, security, and user/GDPR rights. Contact:
support@bizgaze.com (confirm/replace if different).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tester feedback:
1. "Block contact not on the contact info." The earlier Block button was only on
the group-sender mini-profile, which a 1:1 chat never opens. A DM's info panel
is openSharedItems('dm',…) (tap the conversation header). Added a full-width
"Block contact / Unblock contact" button there.
2. "Reporter gets no notification on resolution." Delivery was actually working
(verified: the reporter receives the 'report-resolved' event over the chat WS),
but the client only added a silent bell entry — easy to miss — and a self-
resolve (same admin reported + resolved) is intentionally skipped. Made it a
visible toast + ping for an online reporter (report-new likewise toasts admins).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses tester feedback on the Report/Block feature:
1. Block/Unblock is now on the CONTACT's profile card (openMiniProfile), not only
in a message's ⋮/long-press menu — you can block someone straight from their
info popup.
2. Admin Delete/confirm popups were appearing BEHIND the Reports window. The
moderation modals used z-index 100000 (above bzConfirm's .modal-ov at 9800);
lowered them to 9750 so the confirm dialog sits on top.
3. Clarified admin action. "Block" is a PERSONAL mute (per guideline 1.2) and does
not touch login. The report view now offers a real account action instead:
"Suspend account" (deactivate -> signed out + cannot log in) with a confirm,
toggling to "Reactivate account" — both reversible in-place, driven by a new
reportedActive flag on /api/reports. (Uses the existing /api/users/manage
deactivate/activate.)
4. Resolving a report now notifies the reporter (live 'report-resolved' event +
background push), and admins get a 'report-new' activity entry.
repos: reports.byId. Verified: moderation suite 18/18 (adds reportedActive +
suspend->login-blocked->reactivate->login-restored).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause: the scheduled-meeting INVITE (routes.js) and the ~10-min REMINDER
(reminders.js) both notified only via CHAT.pushToUser — the WebSocket channel,
which only reaches an OPEN tab with a live socket. Neither called
PUSH.sendToUser, the path that produces a background APNs/FCM/web-push alert.
On iOS the webview is suspended in the background, so the WS event was simply
missed and no notification appeared. (Chat messages already call PUSH.sendToUser,
which is why chat notices arrive on iOS but meeting ones didn't.)
Fix:
- schedule invite: also PUSH.sendToUser to every invited participant + group
members (kind:'meeting', id:roomCode) so a closed app is notified.
- reminders.js: also PUSH.sendToUser to all reminder recipients.
- client: a kind:'meeting' notification tap now opens the Meeting tab + its list
(both the live-tab open-chat handler and the cold-boot openKind path), instead
of calling selectChat with an unsupported kind.
Also: APPSTORE_SUBMISSION.md §8 updated — the UGC Report/Block gate (guideline
1.2) is now implemented, with a suggested reviewer note.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apple requires user-generated-content apps to offer a way to report
objectionable content and block abusive users. The chat had neither, which is
the #1 rejection cause for messaging apps. Added both, server-enforced.
Server:
- schema: message_reports + user_blocks tables.
- repos: reports {add,listForTeam,setStatus}, blocks {add,remove,has,listFor},
users.adminsOf(); thread + threadByConversation now exclude blocked senders in
SQL (like message_hidden) so the LIMIT counts only visible rows (no pagination
stall).
- routes: POST /api/messages/report, /api/users/block|unblock, GET
/api/users/blocked, GET /api/reports + POST /api/reports/resolve (admin only).
- enforcement: a blocked sender's DM/group messages are persisted but not
delivered (no live push, no background notification) to anyone who blocked
them; blocked users can't ring you (/api/calls/dm/start + /api/calls/invite);
admins can delete reported content (delete route now allows role=admin).
Client (home.html, all platforms via the web UI — no rebuild):
- message menu gains Report (canned-reason picker) + Block/Unblock.
- profile menu: "Blocked users" manager (list + unblock) for everyone;
"Reported messages" review (delete / block / resolve) for admins.
- blocked DMs hidden from the sidebar; block list loaded on boot.
- reports route to the workspace's OWN admins (org-internal moderation).
Verified: db-smoke 22/22 + a new moderation suite 12/12 (report+admin-list,
non-admin 403, block hides post-block history but sender still sees sent,
blocked call 403, unblock restores history + calling). New flag/ban icons added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The iOS splash read as unbranded for two concrete reasons:
1. The light-mode master (resources/splash.png) was on a WHITE background
while the SplashScreen plugin paints #16294F navy -> a white->navy->app
flash on light-mode devices.
2. The logo + "Biz Connect" wordmark spanned ~82% of the 2732 square, but the
launch storyboard scales it scaleAspectFill; on tall iPhones ~27% of each
side is cropped, clipping the wordmark edges.
New splash (both light + dark masters, identical navy so there is no flash):
brand navy gradient (#20396f -> #16294f) matching the plugin background, the
C-mark as hero, and the wordmark typeset in Corbel (closest installed match to
the brand geometric wordmark) sized to 1041px -> inside the ~1260px aspectFill
safe zone, so nothing clips on any device.
Also hardened codemagic.yaml: the asset step used `|| echo skipped`, which
silently shipped Capacitor's blank default splash if generation failed. Now it
hard-verifies the generated iOS Splash.imageset exists and fails the build
otherwise. Rides the next Codemagic build (native asset; no server redeploy).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The instant-paint work renders a group thread from cache BEFORE /api/groups/members
(convoMembers) loads, so senderAvatar had no avatar and drew initials only; the later
network render diffs/skips the unchanged messages, so the photos never came back until
a full reload. Platform-agnostic (desktop + iOS), group-only — matching the report.
Fix: senderAvatar now falls back to the global CONTACTS avatar so the cache paint is
already correct, and openConvo repaints the sender avatars (refreshSenderAvatars) once
convoMembers loads. Web-only; live on next app launch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sidebar shows "This message was deleted" whenever a row's last_deleted flag is
set, but the new-message handlers (incoming, my-sent, edit) updated the preview text
and never cleared last_deleted. So once a conversation's last message had been deleted
(server-computed last_deleted=true at load), a NEWER message left the flag stale and
the list kept showing "deleted"/"you deleted this" even though the actual last message
was a normal one. Remote deletes already self-corrected via onChatDeleted->loadSidebar;
this was the in-session new-message path. Now last_deleted is cleared wherever a fresh
non-deleted message becomes the row's last message. Web-only; live on next app launch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Duplicate ("Transcript shows two times"): finalizeTranscript had a race — for the
SAME user on two devices (#12 multi-device), both devices leaving at once each
passed the subscriber membership check across an await before either removed the
sub, so both wrote a private transcript. Now the subscriber is CLAIMED
SYNCHRONOUSLY (subs.delete filter) before any await, so only the first writer wins.
Verified with a concurrency simulation (2 concurrent leaves -> 1 write).
- iOS download: the /mrec transcript link had no `download` attribute, so WKWebView
NAVIGATED to the file and loaded it inline with no way back (had to force-quit the
app). Added download + data-mime so browsers download it and the existing native
click-interceptor catches it: it now saves to the Files folder and opens in native
Quick Look (view + its own share/save — into Files or Word) instead of hijacking
the WebView. recDTO now exposes the recording mime.
Web/server only — no native build needed; live on next app launch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In a scheduled meeting the WebView owns the mic (the plugin has no LiveKit room), so
the native-call AudioRenderer tap didn't apply. Now the WebView reads its OWN local
mic PCM via Web Audio (the same non-intrusive tap the app already uses for
active-speaker metering — NOT a 2nd getUserMedia, which would fight the call's mic on
iOS and yield silence), downsamples to 16 kHz mono Int16, and forwards it to the
plugin's SFSpeechRecognizer via feedAudio(). Native calls keep the LiveKit tap.
Muted -> the local track carries silence -> nothing transcribed; the feed re-inits
when the mic goes live on unmute.
- Plugin: startTranscription({external:true}) runs the recognizer without a track;
feedAudio({pcm,rate}) decodes base64 LE Int16 -> Float32 buffer -> recognizer.
- Web: startSR now uses the native recognizer for ALL iOS meetings (native call =
LiveKit tap, scheduled = PCM feed); desktop/browser unchanged (Web Speech API).
Web deploys now; the plugin's feedAudio/startExternal ride the next Codemagic build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#12 — same user on two devices now shows as two independent tiles (was: LiveKit
kicked the older connection, "audio jumps to whichever joined last"):
- LiveKit identity is now the per-connection mesh peerId, not the user id.
/api/meetings/token + guest-token mint identity=peerId when the client supplies
it (anti-hijack: never mint another live user's peerId). Web maps SFU tracks by
identity==peerId, keeping peerIdForUid as a fallback for the transition/native.
- Mesh dedup (dropDupPeers) now keys on a stable per-device clientId (persisted,
sent on meeting-join, echoed by the server) instead of user id — so two real
devices keep separate tiles while a same-device reconnect ghost still collapses.
Verified in a real browser: 2 devices -> 2 tiles; same-device reconnect -> 1.
- Native: plugin gains reconnectRoom(); after the native WebView joins the mesh it
re-homes the LiveKit media onto its peerId identity. syncVideoTiles keys by peerId.
Token-identity + anti-hijack + clientId echo verified by a server test.
#5 — iOS live transcript (WKWebView has no Web Speech API, so an iOS participant
was never transcribed; desktop already works):
- native-call plugin transcribes the local mic with SFSpeechRecognizer, fed by a
LiveKit AudioRenderer on the local mic track (reuses the call's open mic — no 2nd
AVAudioEngine). Finalized segments -> 'transcript' event -> web sends
meeting-transcript (same server assembly as desktop). startSR/stopSR use the
native recognizer on native calls; Web Speech API path unchanged elsewhere.
- NSSpeechRecognitionUsageDescription added to the iOS Info.plist.
Native pieces (#12 reconnect, #5 transcript) need a Codemagic build; the web+server
half is verified and deploys now (already fixes the reported laptop+phone case).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previous follow-up kept the in-progress edit text only as a plain draft, so sending
it after returning posted a NEW message instead of updating the original. Now leaving
a chat mid-edit parks {msgId, text, pre-edit draft} in _pendingEdits; reopening the
chat resumes edit MODE (editTarget + "Editing message" bar + the in-progress text)
once the thread is loaded (cache render, then network render as fallback). So Send
runs saveEdit → UPDATES the original message. If the message can't be found in the
loaded page (or was deleted) it falls back to a plain draft so the text isn't lost.
Verified with puppeteer against the live app:
- edit "hi bob" -> switch to Cara -> back -> edit mode resumes (composer "hi bob
EDITED", bar showing) -> Send -> thread count stays 1, message.edited_at set:
PASS (updated original, no new message).
- plain draft (no edit) switch-and-return still restores: PASS (no regression).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
persistCurrentDraft() no longer skips while editing — leaving a chat mid-edit
abandons the edit (openConvo clears editTarget) but now keeps whatever's in the
composer as that chat's draft, so your typing isn't lost. It returns as a normal
draft (sending posts a new message, not an edit). The edit-cancel (X) path still
restores the pre-edit draft, unchanged. Verified with puppeteer: edit "hi bob" ->
"hi bob EDITED" -> switch chats -> back -> composer holds "hi bob EDITED" (PASS).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reproduced with a real headless browser driving the live app: typing saved the draft
and it survived switching chats, but RETURNING to the chat deleted it. Stack trace
pinned it exactly:
setDraft(REMOVE) <- _restoreDraftAfterEdit <- cancelEdit <- openConvo:3484 <- selectChat
openConvo ran `clearReply(); cancelEdit(); hideAttach();` on every open. cancelEdit ->
_restoreDraftAfterEdit -> setDraft(selected,'') — and since `selected` is already the
chat being opened, it wiped THAT chat's draft (and blanked the composer) BEFORE the
draft-restore a few lines later could read it. So the draft never survived reopening —
this predated the recent rounds; my _restoreDraftAfterEdit change just made the wipe
explicit. Fix: drop cancelEdit() from openConvo (edit state is already reset at the top
via editTarget=null/_editSavedDraft='', and the shell was just rebuilt fresh). The
edit-cancel button + saveEdit still call cancelEdit normally.
Verified with puppeteer: type in chat A -> open B -> back to A -> "hello draft" restored
(PASS), no setDraft REMOVE on return.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-keystroke 'input' saves can be missed on mobile (predictive text or a fast
tap-away fires no final input event), which lost the whole draft when you switched
chats and came back. Added persistCurrentDraft() — snapshots the composer value
into the current chat's draft key the instant you leave it (selectChat before the
swap, and showWelcome/back). Restore on open was already correct. Skipped while
editing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Older-messages pagination (couple of chats wouldn't scroll back): the thread query
returned the latest 40 rows and JS filtered out hidden (delete-for-me) messages
AFTER the LIMIT, so a chat with a hidden recent message returned <40 → the client
read that as "no older history." Now excluded in SQL (repos.thread /
threadByConversation take the viewer id), so a page is always 40 VISIBLE rows.
Verified locally: hide 3 recent → page still returns 40 (older ones fill in).
#2 iOS in-chat tone was silent: WebAudio context is created suspended and only
resumes inside a user gesture. Added unlockAudio() on first tap/click (resume +
0-gain blip), re-armed each gesture so a background→foreground re-suspend recovers.
#9 Long-press "works once then stops" on images was iOS's native touch-callout
(Save Image / selection magnifier) hijacking the gesture. Disabled
-webkit-touch-callout/user-select on #msgs bubbles; added a Save action to the
sheet so image-saving isn't lost.
#13 Pin/unpin WAS being audited (verified: message.pin in /api/audit) — there's just
no in-app viewer. Surfaced "Pinned by X" in the pinned bar for immediate context.
#14 Hardened draft save: it now runs BEFORE maybeAutocorrect/autoGrow (wrapped) in the
input handler, so a throw there can't skip persisting the draft.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
repos.listByTenant selected id,email,name,role,active,avatar_url,created_at but NOT
last_seen or status. So /api/messages/contacts and /api/messages/conversations
always sent lastSeen:null (and status:'active'). Last-seen only ever appeared via
LIVE presence events (broadcastPresence reads the full row) — which is why it
"worked on desktop" (caught live), not on a fresh iOS load, and why round-2's
loadSidebar-on-focus then clobbered the live value → "Offline for all". Verified
locally: contacts now returns the real lastSeen timestamp; db-smoke 22/22.
Also lightened refreshPresenceOnResume: reconnect the socket if it's dead (its
onopen already resyncs the sidebar) but no longer force an unconditional
loadSidebar on every focus — that churn caused the #8 regression and could
momentarily reset an unread badge (#3). Session sliding (touchSession) stays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Session (New): stop the ~24h auto-logout. SESSION_TTL 24h -> 90d, and /api/me now
SLIDES the session forward + re-stamps the cookie on every app load / focus /
6h heartbeat — so an actively-used session never lapses; you only log out by
choosing to. Login no longer depends on "remember me".
#2 A new message in the chat you're actively viewing now plays a soft, distinct
in-chat tone (playMsgTone) — no popup — instead of being silent. A different
chat / a backgrounded chat still gets the alert ping + notification.
#13 Pin/unpin is now written to the audit log (actor + which message, and whose pin
was removed on an unpin) — the accountability gap when anyone can unpin.
Pagination: a floating "Loading earlier messages…" pill now shows while older
history is being fetched (loadOlder had no visible indicator).
#9 Mobile long-press now opens a dimmed + blurred bottom ACTION SHEET (quick
reactions + reply/edit/forward/copy/pin/delete) instead of the flaky hover-style
reveal that hid behind images and broke after the lightbox opened.
#14 editTarget is cleared on conversation switch — starting an edit then switching
chats used to leave editTarget set, which silently stopped ALL draft saving.
Also added Edit to the shared action list so mobile long-press can edit too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cold Postgres boot (connect + apply the full schema) takes a few seconds — the
fixed 300ms that was fine for SQLite's instant in-memory init raced the server
bind and the first fetch hit ECONNREFUSED. Poll BASE/ until it responds (≤30s).
Validated on local PostgreSQL 16: db-smoke 22/22 pass; e2e passes all DB-backed
checks (auth, messages, polls, groups, calls+invite, meetings) then stops at the
known pre-existing WS meeting-joined flake (unrelated to the DB).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#2 Don't ping/notify when you're ACTIVELY viewing a chat (app visible + chat
open). Alert only when a different chat, OR the open chat while the app is
minimised (the backgrounded case that used to stay silent).
#3 A reaction to my message now raises an unread badge on that conversation
(like a new message), not just a notification.
#6 Image lightbox pulls EVERY image in the conversation via /api/messages/media
(older images aren't in the DOM yet) — nav arrows reach them all. Nav buttons
always in the DOM; syncArrows shows/hides at the ends and for a single image.
#8 On app resume (visibilitychange / native appStateChange), reconnect the chat
socket if it isn't OPEN and re-pull the sidebar so online/last-seen refresh —
iOS freezes the WebView so the socket can be dead while its onclose lags,
leaving contacts stuck on a stale "Offline".
#9 Real cause was iOS "sticky :hover": a single tap latched :hover and popped the
action bar. Gate the hover-reveal behind @media (hover:hover) so touch reveals
actions ONLY via long-press; a plain tap performs the primary action.
#13 Pinned bar gains a "‹ 1 of n ›" pager to walk through multiple pinned messages
(shown only when more than one is pinned).
#14 Editing a message no longer eats a half-written draft — the real draft is set
aside on edit start and restored on save/cancel. edited_at is now in the message
DTO so the "edited" tag survives a reload.
#18 One "Delete" entry opens a branded dialog with "Delete for me" / "Delete for
everyone" (icons + descriptions) and a ✕/backdrop cancel, replacing the two
separate menu items.
New: a participant who LEAVES a still-running call is no longer auto-rung back in
on every socket reconnect. Track who left per call; replayActiveCalls sends
them noRing state (refreshes the Join affordance without ringing). An explicit
re-invite clears that and rings again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dual backend (SQLite via db.js + Postgres via schema.pg.sql) was a
maintenance foot-gun: a schema change could land on the SQLite path only and
silently 500 every read on prod (it just did, with #18/#13). Production has run
on Postgres for weeks, so SQLite is retired: ONE schema source of truth
(db/schema.pg.sql), no drift possible.
- dbx.js: default DB_BACKEND=pg; an unknown backend now fails loudly at require
time instead of silently selecting a stale engine.
- Deleted server/db.js, server/db/sqlite.js, server/db/migrate-sqlite-to-pg.js,
server/scripts/migrate-bizgaze-only.js (all SQLite-only, none in the runtime
path — the running server loads db/pg.js).
- Tests (e2e, db-smoke) target Postgres now and fail-fast (skip) unless
DATABASE_URL points at a disposable test DB — never SQLite, never prod.
- Removed the dead DB_PATH env + fixed misleading SQLite comments in the
Dockerfile / docker-compose (kept the /data volume: it holds
uploads/recordings/transcripts/downloads, not just the old data.db).
- CLAUDE.md: stack + repo-layout + run-locally updated for Postgres-only.
Runtime is unaffected (prod already sets DB_BACKEND=pg and pg is a prod dep);
this only removes the unused SQLite path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Production runs DB_BACKEND=pg, but the message_hidden table (#18) and
pinned_at/pinned_by columns (#13) were only added to the SQLite migrations in
db.js — never to schema.pg.sql. So thread/conversations/pinned queries hit a
missing relation/column and 500'd, which surfaced as "chat history removed"
(no data was ever deleted — the reads just errored).
pg.js init() runs schema.pg.sql on every boot. Added the message_hidden table
and, because CREATE TABLE IF NOT EXISTS can't add columns to the existing
messages table, idempotent ALTER TABLE ... ADD COLUMN IF NOT EXISTS for
pinned_at/pinned_by. Restores all chat history and re-enables pin + delete-for-me.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Any participant can pin/unpin a message from its ⋮ menu. Adds pinned_at/pinned_by
columns, /api/messages/pin (toggle, broadcasts chat-pinned) and
/api/messages/pinned (list, newest first, excludes deleted + delete-for-me).
The conversation shows a pinned strip under the header (latest pin + count);
tap it to jump to the message, × to unpin. Live-updates across participants and
devices. Added pin/pinOff Lucide icons.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On touch, tapping a message used to reveal the reply/react/more bar (so the
action needed a second tap). Now a single tap performs the primary action
(open image/file, jump to a reply), and the action bar is revealed by a
~420ms long-press (with a small haptic); movement or an early release cancels,
and a fired long-press suppresses the trailing tap. A tap elsewhere dismisses
the revealed bar. Desktop hover behaviour is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A group's members are automatically the meeting's participants, so the
Invite-participants, Invite-by-email and "Guests must be admitted by host"
sections are noise there. openScheduleModal now omits them when a group id is
present (inviteBlock is empty), and the email/participant/lobby handlers are
null-guarded so the save path still works without those fields.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#1: focusing a text box zoomed + stretched the whole page on iOS. Several
inputs were < 16px and the existing 16px rule lived in a width-based mobile
@media that misses iPad/landscape (and maximum-scale is unreliable on iOS).
Added a @media (pointer: coarse) rule forcing 16px on every focusable field
for ALL touch devices; desktop is untouched.
#6: the image lightbox used visibility:hidden for the end arrows (invisible but
still occupying space / reading as a ghost button). Switched to display toggling
so there's truly no right arrow at the last image (and no left at the first).
#7: play a soft two-note chime + a brief "<name> joined the call" toast when a
new participant joins the meeting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#14: after editing a message the edited text reappeared in the composer as a
draft and re-sent as a new message. The input listener saved a draft while
editing, but saveEdit/cancelEdit cleared only the input value, not the stored
draft — so openConvo restored it. Now editing never writes a draft, and
save/cancel clear it.
#8: a contact's subtitle flapped between "last seen …" and "Offline". A
loadSidebar rebuild replaced the row with the server's lastSeen, which is
sometimes null (the disconnect touchSeen is fire-and-forget and can lag the
presence broadcast). Now the client keeps last-seen sticky (never overwrites a
known value with null) and the server never broadcasts a null last-seen for an
offline user.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#10: deleting the last message showed "No messages yet" in the chat list.
The sidebar sent an empty last_body for a deleted (content-cleared) row; now
it sends a last_deleted flag and the row renders "This message was deleted"
(or "You deleted this message"), matching the in-thread placeholder.
#18: added "Delete for me" alongside "Delete for everyone". A new message_hidden
table records a per-user hide; the thread + sidebar (last message, unread) filter
out the requesting user's hidden messages, and the hide is echoed to their other
devices (chat-hidden). "Delete for me" is offered on any message; "Delete for
everyone" stays sender-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#2: a message arriving in the currently-OPEN chat produced no notification
when the app was minimized. onChatMessage marked the open chat read even
while document.hidden, which fired a notif-clear that closed the very
notification the service worker had just shown. Now the open chat is only
marked read while visible; markOpenChatRead() catches up on focus/visibility
return, and a message received while hidden stays unread with its alert intact.
#3: reacting to a message fired no notification. The react route only pushed
over the live socket (nothing for a closed app) and the client added a silent
bell entry. Now the server sends a native/web push to the message owner, and
onChatReaction pings + shows an OS/in-page popup (unless you're viewing that chat).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>