Commit Graph

367 Commits

Author SHA1 Message Date
Sravan e39828ed56 Android CI: fail the build on a Gradle error + surface the real cause
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>
2026-09-08 16:35:39 +05:30
Sravan 35ce641046 Android CI: run on mac_mini_m2 (linux_x2 not on the billing plan)
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>
2026-09-08 16:08:52 +05:30
Sravan 1efb2e4314 Fix: message text not selectable on desktop
#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>
2026-08-22 19:23:21 +05:30
Sravan 677d418ed0 Fix: dragging multiple files into a chat only sent the first
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>
2026-08-22 18:22:57 +05:30
Sravan 07c728bf6a Android CI build + delete-chat (self-only) + SFU copy refresh
- 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>
2026-08-22 17:12:42 +05:30
Sravan 948eae249f App Store: add 13-inch iPad screenshots (universal build requires them)
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>
2026-08-22 16:13:55 +05:30
Sravan 88a6b6a21e App Store: add screen-sharing screenshot (6 total)
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>
2026-08-22 14:51:15 +05:30
Sravan 620039a2ff iOS remote support: Share Screen -> Connect Screen over LiveKit (core)
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>
2026-08-22 13:16:06 +05:30
Sravan 224a2800c5 Revert "iOS "Share Screen": route to a screen-share meeting (remote-support on iPhone)"
This reverts commit eb685b42ca.
2026-08-22 12:21:36 +05:30
Sravan ba51a3c5d7 Revert "iOS Share Screen: show the code and wait; start sharing when the helper joins"
This reverts commit 1a089c0349.
2026-08-22 12:21:36 +05:30
Sravan 1a089c0349 iOS Share Screen: show the code and wait; start sharing when the helper joins
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>
2026-08-22 12:00:14 +05:30
Sravan eb685b42ca iOS "Share Screen": route to a screen-share meeting (remote-support on iPhone)
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>
2026-08-22 11:36:48 +05:30
Sravan 467a8b9c6e iOS meeting screen share: publish ReplayKit broadcast into the SFU room
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>
2026-08-22 01:01:42 +05:30
Sravan 14eb99edaf App Store: add group-chat + schedule-meeting screenshots (5 total)
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>
2026-08-22 00:37:03 +05:30
Sravan 2948e0cfcc App Store assets: 6.9" screenshots + wired privacy/support URLs into submission pack
- 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>
2026-08-21 12:46:12 +05:30
Sravan a9d95ed8c6 Add public /privacy and /support pages (App Store required URLs)
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>
2026-08-21 12:35:06 +05:30
Sravan 18edc5dbfb Moderation UX: Block on DM contact info + visible report-resolved notice
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>
2026-08-20 22:39:13 +05:30
Sravan 928119725e Moderation follow-ups: block on contact profile, z-index, account suspend, reporter notify
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>
2026-08-20 11:50:55 +05:30
Sravan 10b2251efe Scheduled meetings: deliver a background push (fixes no iOS notification)
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>
2026-08-19 19:51:25 +05:30
Sravan e4d361f298 Chat moderation: Report message + Block user (App Store guideline 1.2)
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>
2026-08-19 19:46:22 +05:30
Sravan 7e3a94b04c iOS splash: quality-branded, crop-safe navy launch screen
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>
2026-08-18 21:06:50 +05:30
Sravan 343724df0f group chat: profile photos missing on a cached re-open (only initials showed)
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>
2026-08-17 14:22:00 +05:30
Sravan 3f41ca857a chat list: clear the stale "message deleted" label when a newer message arrives
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>
2026-08-17 14:08:31 +05:30
Sravan 87a76c09f3 transcript: fix duplicate copy on multi-device + iOS download hijacking the app
- 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>
2026-08-14 19:47:23 +05:30
Sravan e46ac1e7cc #5 transcript: cover scheduled/web meetings on iOS too (not just native calls)
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>
2026-08-14 17:05:56 +05:30
Sravan 68ff3a878b #12 multi-device tiles + #5 native iOS transcript
#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>
2026-08-14 16:36:18 +05:30
Sravan c322448774 #14 edit: PARK the edit on leave and RESUME it on return (Send updates the original)
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>
2026-08-14 12:52:38 +05:30
Sravan 354e65acdf #14 follow-up: keep in-progress edit text as a draft when leaving a chat mid-edit
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>
2026-08-14 12:38:05 +05:30
Sravan 785eeb7fbc #14 draft ROOT CAUSE: openConvo's cancelEdit() wiped the just-opened chat's draft
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>
2026-08-14 11:50:20 +05:30
Sravan 25b0b39c49 #14 draft: guarantee the save at leave-time (fixes switch-chats-and-return loss)
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>
2026-08-13 22:39:16 +05:30
Sravan 6ee424db16 Round 4: hidden-msg pagination, iOS audio unlock, iOS long-press callout, draft hardening, pinned-by
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>
2026-08-13 22:29:34 +05:30
Sravan c3c4178227 #8 root cause: listByTenant omitted last_seen/status → contacts/conversations sent lastSeen:null
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>
2026-08-13 17:28:39 +05:30
Sravan a677675b8f Round 3: session longevity, in-chat tone, pin audit, older-msg loader, long-press sheet, draft fix
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>
2026-08-13 16:38:58 +05:30
Sravan eaea1ccc3f test: wait for the server to LISTEN instead of a fixed 300ms
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>
2026-08-13 00:29:55 +05:30
Sravan 971a6fdf22 Round 2 fixes from on-device testing (#2,#3,#6,#8,#9,#13,#14,#18 + call re-ring)
#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>
2026-08-13 00:22:50 +05:30
Sravan ad48829337 Retire the SQLite backend — Postgres is the only engine
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>
2026-08-12 08:20:26 +05:30
Sravan f3b6e67c19 HOTFIX: add #18/#13 schema to Postgres (chat history returned empty)
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>
2026-08-12 07:53:13 +05:30
Sravan d0863351d2 #13: pin a message
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>
2026-08-11 23:00:06 +05:30
Sravan 9017d2ff25 #9: mobile — single tap acts, long-press reveals message actions
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>
2026-08-11 22:51:35 +05:30
Sravan c1b67d38d3 #16: trim the schedule form when scheduling from a group
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>
2026-08-11 22:47:15 +05:30
Sravan 276c5e0929 Fix #1 (input-focus zoom), #6 (last-image arrow), #7 (join sound)
#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>
2026-08-11 22:44:00 +05:30
Sravan ecc8be3dba Fix #14 (edit leaves a draft) and #8 (last-seen flapping)
#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>
2026-08-11 22:37:06 +05:30
Sravan e359271157 #10 deleted-last-message preview + #18 delete for me / for everyone
#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>
2026-08-11 22:31:17 +05:30
Sravan 9e80aee1c6 Fix notification bugs #2 (open+backgrounded chat) and #3 (reactions)
#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>
2026-08-11 22:23:16 +05:30
Sravan a0f9ab10f2 #15: adding a 3rd person to a 1:1 call promotes it to a persistent group call
When someone is added to a 1:1 (DM) call, promoteDmToGroup() now creates a
real group conversation (named after the participants) and migrates the live
call from dmCalls -> groupCalls, keeping the same room/uuid/history so media
and the transcript continue uninterrupted. Two wins:
 - the call survives anyone leaving (group calls only end when the room empties)
 - an added person who drops can rejoin from the group's active-call banner
   (they're now a member, so replayActiveCalls / group-call resurface it)

/api/calls/invite promotes on a DM call and lets the group-call broadcast ring
the invitees in; it only sends the plain call-invite when NOT promoted. Guarded
so inviting an existing pair-member (memberIds < 3) stays a 1:1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 22:13:41 +05:30
Sravan 100a092ff9 Fix call-cluster bugs #11, #4a, #4b
#11: adding a 3rd person to a 1:1 call then having one participant close
the app disconnected the call for everyone. leaveMeeting() ended a DM room
for ALL peers on any leave; now it only tears down when <2 people remain,
otherwise it falls through to the normal peer-left path (call continues).

#4b: after someone left a call they never reappeared under "Add people".
meeting-peer-left cleaned meetPeers/tiles but not meetPeerUids/meetNames,
so the departed uid stayed in hereUids and was filtered out. Now deleted.

#4a: a guest who enabled mic/cam on the pre-join screen had to re-tap after
being admitted — the choices were applied on a blind 900ms timer that fired
while still in the lobby. Now applied in the meeting-joined handler, after
admission + media connect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 21:51:49 +05:30
Sravan 0cee72e73c Add a clear "Stop sharing" banner while sharing your screen
The only way to stop a screen share was toggling the share button again —
buried in the ⋮ More menu on mobile. Now a floating "You're sharing your
screen · Stop" banner appears at the top whenever you're sharing (driven by
meetScreen via updateScreenBtn), with a red Stop button that calls
toggleScreen. Works on iOS/desktop/web. Web-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 16:56:00 +05:30
Sravan c24d5d828a Hole-punch attempt 3: set WebView transparent at startup + scrollView.isOpaque
Previous hole-punch builds showed no video because WKWebView can IGNORE
isOpaque=false when it's set AFTER the page has already rendered (during the
call). Now:
- makeWebViewTransparent() runs once at plugin load (startup): isOpaque=false,
  backgroundColor=.clear, and crucially scrollView.isOpaque=false +
  scrollView.backgroundColor=.clear (an opaque scrollView occludes content
  behind the WebView).
- setHolePunch now just toggles a BLACK backing on the WebView's parent during
  the call (transparency is already applied); restored after.
- Tile frames use host.convert(rect, to: superview) instead of a manual origin
  offset — robust to any WebView inset.

Plugin-only — needs a Codemagic build. If this still shows no video, WKWebView
hole-punch is a dead end here and we revert to native-video-on-top.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 14:13:22 +05:30
Sravan d9ea104b73 Fix hole-punch: put native video BEHIND the WebView, not under its scrollView
Diagnosis: no native video rendered at all (bg went black, but camera AND
screen tiles were empty). WKWebView does NOT composite native subviews placed
under its scrollView through transparent web content — only the webView's own
layer background shows through. So the video was effectively invisible.

Fix (the standard hole-punch): insert the video tiles into the WebView's
SUPERVIEW, BEHIND the (fully transparent) WebView, so the web content paints
on top and the video shows through. Changes:
- makeTileView / syncVideoTiles: insert belowSubview: host (the WebView) in
  host.superview, and offset frames by the WebView's origin (getBoundingClientRect
  is viewport-relative).
- webView.backgroundColor = .clear (was .black — a black webView bg would have
  occluded the video behind it); the VC view's black background is the backing.

Plugin-only change — needs a Codemagic build. Web (transparent chain, zoom,
bz-hasvid) already correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 12:29:01 +05:30
Sravan c79d78485b Fix hole-punch regressions: transparency chain, black backing, keyboard
Three fixes for the hole-punch build:
1. White background + shared screen not showing: body and .content both use
   var(--bg) (a light colour) and were still opaque, occluding the native
   video behind the WebView. Make the WHOLE meeting chain transparent under
   .bz-hp (body + .content, on top of .meet-grid/tiles).
2. Backing: also set the view controller's view background to black (saved/
   restored) so any transparent gap reads black, not white — belt-and-braces
   with webView.backgroundColor.
3. Keyboard covering the in-call chat: keyboard resize is "none", so the
   absolutely-positioned meet panels don't move for the keyboard. Lift
   .meet-panel by the reported keyboard height (body.kb-open → bottom:
   calc(var(--kb)+12px)); --kb/kb-open are already set globally by the
   Keyboard listener.

Plugin change (VC background) needs a build; the CSS is web-deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 12:13:09 +05:30