Commit Graph
245 Commits
Author SHA1 Message Date
SravanandClaude Opus 4.8 db6c00cbcf fix(meet): send peerId (not user id) to setTileZoom so screen zoom works
Pinch-to-zoom on a received shared screen was a no-op on Android (and iOS).
syncVideoTiles keys each native renderer by the tile's peerId (uid=id, the
LiveKit identity since #12), but the pinch handler forwarded
meetPeerUids.get(id) — the USER id — to setTileZoom. The plugin looked up the
renderer by that mismatched key and found nothing, so the zoom transform never
applied. Send `id` (the peerId), matching syncVideoTiles. Web-only fix; the
native setTileZoom was already correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-11-03 22:49:02 +05:30
SravanandClaude Opus 4.8 c9bb9c75d1 fix(home): close callConnected arrow body — syntax error broke app boot
Production outage: after login the app hung forever on the splash. Chrome
showed "Uncaught SyntaxError: Unexpected token ')' at home:4398". The
callConnected listener's arrow body `()=>{ ... }` was missing its closing
brace (brace balance +1 on the line), so the whole inline <script> block
failed to parse — every boot function (loadSidebar, the boot IIFE) was never
defined, so the splash never hid. The login screen still rendered because it
lives in an earlier script block; only this block died.

The bug rode in with the Increment-2a callConnected wiring and was masked for
a while by service-worker caching (users' "quit and reopen fixed it"); once
the broken home.html was served fresh everywhere it became a total login hang,
on web and desktop alike. Verified all inline blocks parse after the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-11-03 21:24:57 +05:30
SravanandClaude Opus 4.8 ae607f44b5 fix(android-call): no hole-punch without native video (white meeting bg)
On an Android native call the WebView turned on hole-punch (transparent
meeting grid, so native video can show BEHIND the WebView) whenever the
plugin exposed setHolePunch — but on the 2a audio-only build there is no
native video behind it, so the transparent grid revealed the white Android
window (reported: white meeting background; tiles stayed visible as they're
opaque web elements). iOS never showed this because CallKit covers the view.

Gate hole-punch on native video actually existing: new ANDROID_NATIVE_VIDEO
server flag (mirrors ANDROID_NATIVE_MEDIA, default off) exposed via
/api/meetings/config. Web: bzNativeVideoOn() — iOS always, Android only when
the flag is on (Increment 2b APK). While off, hole-punch stays off and the
grid keeps its dark background; the native-tile poll doesn't run. iOS
unchanged. Flip the flag on once the 2b native-video APK is installed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-11-03 20:12:21 +05:30
SravanandClaude Opus 4.8 aa4aeeaba1 android(call): Increment 2a — native LiveKit call audio, background service, speaker/BT routing
Replaces WebView call media on Android with a native LiveKit connection (iOS
parity), so call audio survives backgrounding and the output can be switched
to Speaker/Bluetooth/Earpiece — the #3 (missing speaker control) and #4
(background audio) items.

Gated behind a new, default-off server flag ANDROID_NATIVE_MEDIA (independent
of ANDROID_CALLS, which only governs the native ring). Flag off = today's
WebView media, video included; flag on = native audio (video is Increment 2b,
so enable it for VOICE testing first). No behavior change until flipped.

Native (mobile/plugins/native-call/android):
- build.gradle: add kotlin-android + io.livekit:livekit-android 2.29.2 (+ twilio
  audioswitch, coroutines) + JitPack repo; minSdk 24 (LiveKit floor).
- CallMedia.kt: LiveKit Room wrapper — connect/mic/disconnect + AudioSwitchHandler
  routing (auto + selectDevice) and route-change events.
- CallForegroundService.java: microphone-type foreground service for background
  audio (no-ops without RECORD_AUDIO so it never crashes the call).
- NativeCallPlugin.java: real bodies for reportOutgoingCall/reconnectRoom/setMuted
  /endCall + new setAudioRoute/getAudioRoute; fires callConnected/callError/audioRoute.
- AndroidManifest: FG service + FOREGROUND_SERVICE(_MICROPHONE)/RECORD_AUDIO/
  MODIFY_AUDIO_SETTINGS/BLUETOOTH_CONNECT.
- android-patch.js: bump the generated app minSdk to 24.

Server:
- config.js/routes.js: ANDROID_NATIVE_MEDIA → /api/meetings/config.androidNativeMedia.

Web (home.html):
- Android flips to the native-media path when androidNativeMedia is set; the
  existing outgoing/answer/mic/end code then drives the plugin unchanged.
- Speaker button wired for Android native: cycles speaker/earpiece/bluetooth via
  NC.setAudioRoute and reflects the real output from the audioRoute event.

Video stays no-op on the native path (Increment 2b).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-30 17:25:18 +05:30
SravanandClaude Opus 4.8 08eff043ec call: stop the ring on a user's other devices when one answers (answered-elsewhere)
A user signed in on two devices (iPhone + Android) rang on both. When they
answered on Android, the iPhone kept ringing: markDmAnswered only emitted a
WS 'call-taken', which never reaches a backgrounded/killed device ringing on
CallKit (no live socket).

Now markDmAnswered also pushes a native cancel to the answerer's OTHER
devices. The device that actually answered is skipped via exceptToken (its own
call-push token) — critical on iOS, where a cancel would end the just-answered
CallKit call; Android's cancel branch never tears down a live call anyway.

- push.sendCallCancel(userId, uuid, { exceptToken, reason }) — skips the
  answering device's token; existing 2-arg callers unchanged.
- calls.markDmAnswered(room, userId, exceptToken) — fires the cancel.
- POST /api/calls/answered now takes deviceToken.
- home.html: the native answerCall handler POSTs /api/calls/answered with this
  device's own token (ios-voip or fcm), so native/LiveKit calls (which bypass
  the mesh) are marked answered and siblings stop ringing.

Server + web only — no native build needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-30 16:38:51 +05:30
SravanandClaude Opus 4.8 cab0c79f57 Android calls: single-source ring (fix double-ring), drop battery dialog, add wake logging
On-device (OnePlus, build 5cb3d0c): new ring UI confirmed; backgrounded rang
twice (channel sound + activity ringtone, and both wake paths raised it);
battery dialog deemed unprofessional (WhatsApp/Teams don't prompt it).

- Double-ring: notification channel is now SILENT (new id bzc_incoming_calls_v2,
  no sound/vibration, onlyAlertOnce) so IncomingCallActivity is the single ring
  source (looping ringtone + vibrator). showIncoming dedups atomically
  (if(!active.add(uuid)) return) so the FCM push and WebSocket paths can't both
  ring the same call.
- Battery dialog removed: dropped requestCallReliability (web call + plugin
  method) and REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission.
- Logging: Log.i(bzcall, ...) across the wake chain to diagnose swiped-away
  (OnePlus force-kills on swipe and blocks FCM cold-start).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-28 18:53:06 +05:30
SravanandClaude Opus 4.8 5cb3d0c122 Android calls: professional incoming UI + killed-state ring reliability
Addresses on-device feedback from Increment 1:
- #2 Professional incoming screen: IncomingCallActivity redesigned — brand-
  blue backdrop, circular avatar with the caller's initial, name + call
  type, and two round icon buttons (red hang-up = Decline, green phone =
  Accept) instead of the full-screen colored buttons. New vector drawables
  ic_call_answer / ic_call_decline (also used on the notification actions).
- #1 Killed-state ring: the missed-call notification arriving while killed
  proves FCM reaches the app, so the ring (high-priority DATA) is being
  held by OEM battery management (OnePlus/Oppo Doze) until the call is over.
  Added REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + a requestCallReliability()
  plugin method that prompts the "allow background activity" dialog once;
  the web calls it on Android call setup. (OEM auto-launch is a manual
  toggle with no API — guide the user.)

#3 (speaker/Bluetooth) and #4 (background audio) ride Increment 2 (native
LiveKit Android media + foreground service) — kept separate so the LiveKit
dependency can't block these UI/reliability fixes (the iOS coupling lesson).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-27 15:15:18 +05:30
SravanandClaude Opus 4.8 b60ce6cda3 Android native calling — Increment 1: full-screen incoming ring + answer
First slice of Android Phase C (full LiveKit parity), mirroring how iOS
started: native full-screen incoming call UI that wakes the app from
closed, with call MEDIA still in the WebView (foreground). Native LiveKit
media + a foreground service for true background audio are Increment 2.

Native (new Android side of the native-call plugin, Java to match the
proven official-plugin module layout):
- NativeCallPlugin: registered as window.Capacitor.Plugins.NativeCall
  (same jsName as iOS, so the shared web bridge drives both). getToken
  (FCM token), reportIncomingCall (ring), reportOutgoingCall, endCall,
  setMuted; video/screen/hole-punch/transcript methods are safe no-ops
  until later increments so the shared bridge never rejects.
- BzCallMessagingService: extends @capacitor/push-notifications'
  MessagingService — handles high-priority call-wake DATA messages
  (data.bzcall==1) to ring when closed, forwards all other push via super.
- IncomingCallActivity: full-screen call screen over the lock screen
  (showWhenLocked/turnScreenOn), Accept/Decline, looping ringtone.
- CallStore: incoming-call notification (channel, full-screen-intent,
  actions) + UUID dedup so the FCM and WebSocket paths don't double-ring.
- Plugin manifest: BzCallMessagingService + IncomingCallActivity + call
  perms (USE_FULL_SCREEN_INTENT/VIBRATE/WAKE_LOCK).
- android-patch.js: removes Capacitor's MessagingService in the APP
  manifest (highest merge priority) so ours is the sole MESSAGING_EVENT
  receiver; ensures the tools namespace. Idempotent.
- package.json: capacitor.android.src wired.

Server (dormant until ANDROID_CALLS=1):
- push.js sendFcmData: high-priority data-only FCM (rings when closed).
  sendCallNotification rings 'android' tokens via it; sendCallCancel too.
- config.js ANDROID_CALLS flag; /api/meetings/config exposes androidCall.

Web (home.html) — platform-aware, iOS/web behavior byte-for-byte unchanged:
- setupNativeCall: Android gated on cfg.androidCall, sets _callWebViewMedia
  (native ring, WebView media); iOS keeps CallKit native-media path.
- answerCall + both outgoing sites branch on _callWebViewMedia so Android
  joins the meeting normally while the callee still rings via the server.

Safe to deploy now: dormant until ANDROID_CALLS=1 AND an APK carrying the
plugin is installed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 11:44:52 +05:30
SravanandClaude Opus 4.8 a8e0f549d4 Fix Android keyboard gap: don't double-offset the composer
Device logs showed Android's interactive-widget=resizes-content already shrinks
the layout viewport to above the keyboard (innerHeight 800->472 for a 328px
keyboard), yet the app also lifted the composer by --kb=328 → a keyboard-height
white gap. Now gate the manual --kb lift to iOS only (WKWebView doesn't resize);
on Android keep --kb=0 and just toggle kb-open so --sab collapses and the composer
sits flush to the keyboard. Also removes the temporary kbdiag logging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 10:49:45 +05:30
SravanandClaude Opus 4.8 1ece8e1061 System bars: lock in Option 1 (brand-blue bars) on iOS + Android
User picked the brand-blue look for both platforms. Apply .sysbar-blue on any
native platform; set white icons via the safe-area plugin on Android and via
@capacitor/status-bar on iOS. capacitor.config statusBarStyle/navigationBarStyle
-> DARK (light icons) so native builds match before JS runs. iOS is live, so this
web change reaches it immediately — verify on an iPhone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 18:13:55 +05:30
SravanandClaude Opus 4.8 03de639e1a Android system bars — Option 2 preview: white bars + brand separator line
Swap the class to sysbar-sep (thin 1.5px brand line under the status bar + above
the nav bar, bars stay white) and icons back to dark. Second of the two looks for
the user to compare; pick one after this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 18:09:39 +05:30
SravanandClaude Opus 4.8 133208ae3f Android system bars — Option 1 preview: brand-blue bars + white icons
Fill the status/nav inset regions with brand blue (#1F3B73) via html.sysbar-blue
pseudo-elements and switch the bar icons to light so they read on blue. This is
one of two looks for the user to compare on-device; Option 2 (white + separator)
is already in the CSS and swaps in by changing the class + icon style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 18:07:31 +05:30
SravanandClaude Opus 4.8 703cf1fcf0 TEMP: keyboard diagnostics for the Android repeat-open gap
Behavior-neutral console logging on keyboard show/hide (kb height, innerHeight,
clientHeight, visualViewport height, --kb) to determine whether the Android
WebView resizes (interactive-widget) or relies on the manual --kb lift, and to
spot any residual between the first and second open. Remove once fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 18:00:47 +05:30
SravanandClaude Opus 4.8 9d5af66a0e Android: dark system-bar icons (visible on the white app)
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>
2026-09-18 00:24:39 +05:30
SravanandClaude Opus 4.8 a4726dd10c Fix Android crash: don't register push without Firebase
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>
2026-09-15 21:55:09 +05:30
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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
SravanandClaude Opus 4.8 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