- 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>
#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>
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>
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>
#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>
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>
#10: deleting the last message showed "No messages yet" in the chat list.
The sidebar sent an empty last_body for a deleted (content-cleared) row; now
it sends a last_deleted flag and the row renders "This message was deleted"
(or "You deleted this message"), matching the in-thread placeholder.
#18: added "Delete for me" alongside "Delete for everyone". A new message_hidden
table records a per-user hide; the thread + sidebar (last message, unread) filter
out the requesting user's hidden messages, and the hide is echoed to their other
devices (chat-hidden). "Delete for me" is offered on any message; "Delete for
everyone" stays sender-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#2: a message arriving in the currently-OPEN chat produced no notification
when the app was minimized. onChatMessage marked the open chat read even
while document.hidden, which fired a notif-clear that closed the very
notification the service worker had just shown. Now the open chat is only
marked read while visible; markOpenChatRead() catches up on focus/visibility
return, and a message received while hidden stays unread with its alert intact.
#3: reacting to a message fired no notification. The react route only pushed
over the live socket (nothing for a closed app) and the client added a silent
bell entry. Now the server sends a native/web push to the message owner, and
onChatReaction pings + shows an OS/in-page popup (unless you're viewing that chat).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Push, native calling, and Photos save are all confirmed working, so strip
the diagnostic instrumentation: the pdbg() helper and all its call sites in
home.html (native-setup-*, registration-*, perm-*, nc-* call events,
photos-fail) and the matching /api/push-debug route in routes.js. Real
console.log/console.warn lines and all functional logic are kept; a couple
of pdbg-only error paths now log via console.warn instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Complete the WebView/server side of native LiveKit calls:
- routes.js: /api/calls/answered (markDmAnswered) + /api/calls/end (endCallByRoom) so
the server learns a NATIVE call was answered/ended (native media runs over LiveKit,
bypassing our mesh/WS lifecycle). Additive no-ops for WebView/mesh calls.
- home.html: for native calls the WebView no longer joins the room (one connection per
identity — the plugin holds it). answerCall -> POST /api/calls/answered + clear invite;
endCall -> /api/calls/end (answered) or /api/calls/decline (still ringing). Outgoing
DM/group calls fetch a LiveKit token and hand it to NativeCall.reportOutgoingCall
instead of enterMeeting. Removed the old callHandoff mic-repush.
Server deploys now; the plugin (native LiveKit) needs a Codemagic build. Still gated by
CALLKIT_ENABLED=0 — flip to 1 only after the build is installed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Increment 1 server side. Move livekitToken() into server/livekit.js (shared by routes.js
and calls.js). calls.js now mints a per-callee LiveKit join token and calls.js/push.js
put {livekitUrl, livekitToken} in the VoIP invite payload, so the native plugin can
connect the LiveKit room immediately on answer — even from a killed state, before the
WebView loads. No behaviour change while CALLKIT_ENABLED=0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MIC BROKEN with CallKit: a CallKit call reserves the microphone, so the WebView's
WebRTC can't capture it — calls are unusable until native LiveKit media lands. Add a
server kill-switch (CALLKIT_ENABLED, default OFF) so CallKit can be flipped without an
app rebuild: /api/meetings/config now returns callkit; setupNativeCall bails when off
(-> WebView calls, mic works); push.js only sends VoIP/CallKit pushes when enabled.
Deploying with the flag unset immediately restores working WebView calls.
RE-RING: a late cancel push for an already-declined call hit the plugin's 'unknown
uuid' path and re-reported a fresh incoming call (~1s re-ring). Track endedCalls and
make a late cancel for an already-ended call a no-op.
Server part deploys now (no rebuild); plugin re-ring fix ships with the native build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of the native call feature. Backward-compatible: with no VoIP tokens
registered yet it behaves exactly like today's banner push.
- push.js: sendApnsVoip() sends a PushKit VoIP push (apns-push-type 'voip', topic
<bundle>.voip, reusing the same .p8) to wake a killed app for CallKit; and
sendCallNotification() which PREFERS a VoIP push when the user has an 'ios-voip'
token, else falls back to the normal alert/banner push (Android/web/pre-CallKit iOS).
- routes.js: /api/devices now accepts platform 'ios-voip' (the PushKit token, stored
alongside the normal alert token in device_tokens).
- calls.js: each call now carries a stable crypto.randomUUID() (CallKit needs a UUID
to report + later cancel the call); DM and group call notifications route through
PUSH.sendCallNotification instead of the raw banner push.
Next: the native-call Capacitor plugin (PushKit + CallKit + LiveKit iOS SDK).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
device_tokens stays empty after reinstall+Allow, so the APNs token is never
obtained or never reaches the server, and there's no device console on Windows.
Add a /api/push-debug collector and breadcrumbs through setupNativePush (plugin
presence, permission state, register call, registration event/error, token POST
result) so the failing step is visible in server logs. Temporary — remove once
push is confirmed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The full sync→async conversion is complete and green on the SQLite backend. Every
DB call across the app now awaits the async adapter, so the identical code runs on
Postgres at cutover.
Converted (this commit finishes Phase 3):
- session.js: currentUser/apiKeyFromReq async → 63 route awaits + WS + static.
- routes.js: all ~250 R.* awaited; DTO helpers (namesFor, avatarsFor, buildMsgDTO,
buildPollDTO, reactionsForMessage, postSystemMessage, pushGroupUpdate,
issueRefreshToken, provisionFromBizgaze) made async; every `.map(x=>buildDTO(x))`
restructured to `await Promise.all(...map(async...))` preserving order; `.filter`
predicates that hit the DB moved to an `asyncFilter` helper; chained
`R.x.y(...).length/.map/.filter` wrapped as `(await R.x.y(...)).method`; stream
upload handlers (recording/transcript/attachment) made async.
- calls.js / signaling.js: all call/meeting fns async; leaveMeeting AWAITS
persistCallHistory + finalizeTranscript BEFORE endCallByRoom (ordering matters —
fire-and-forget would race the map teardown); WS handle()/cleanup() async with
.catch guards.
- static.js: authAttachment(Raw) async (the .some carrier check became a loop),
handleGet async; server.js dispatch catches handler rejections → 500 not a hang.
- media.js backfill, push.js, reminders.js, webhooks.js await their repo calls.
Validation on DB_BACKEND=sqlite: db-smoke 22/22; legacy e2e 80 checks pass with zero
FAILs (throws only at a PRE-EXISTING WS lobby-drift assertion, unrelated). Every
server file `node --check` clean.
Still on the branch — master untouched. Next: Phase 5 (pg backend + ~7 dialect
queries + data migration + Docker Postgres + cutover), then merge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On the db-migration branch only; master stays clean + deployable. Foundation
(adapter, pg schema, smoke harness) is already on master and safe.
Done:
- repos.js fully async (Phase 2, validated: node --check clean, no missed transforms).
- session.js currentUser/apiKeyFromReq async.
- Mechanical `await` prefix applied across routes/static/calls/signaling/reminders/
webhooks/push.
Remaining (does NOT compile yet — deterministic to finish):
1. Async cascade: helper fns that now contain `await` must be marked async and their
callers awaited. node --check points to each (namesFor, authAttachmentRaw/
authAttachment in static, the WS handlers in calls/signaling, reminders/webhooks
loops).
2. DTO builders are the real work: namesFor, avatarsFor, buildPollDTO, buildMsgDTO,
recDTO all became async — every `.map(x => buildMsgDTO(...))` etc. must become
`await Promise.all(arr.map(async x => ...))`.
3. Chained calls `R.x.y(...).map/.length/.includes` → `(await R.x.y(...)).method`.
4. Then: node --check all green → node test/db-smoke.js green → e2e → merge to master.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the wrong approach (stage + try to bounce into the app, which iOS blocks)
with the one Teams/WhatsApp actually use: the picker and the send happen INSIDE the
share extension, so there's no app-open at all. Tap Share → Biz Connect → pick a
chat → it uploads and sends, right there in the sheet.
How the extension can send without the app: it's a separate process that can't see
the web app's HttpOnly cookie, so:
- server: GET /api/share/token mints a bearer token for the logged-in user.
- web: on every launch the app fetches that token and hands it to the extension via
the App Group (ShareInbox.setAuth writes token+base to the shared UserDefaults).
- extension: reads the token and calls the SAME API the native client uses —
GET /api/messages/conversations to list chats, POST /api/messages/upload for each
file, POST /api/messages to send. Native UITableView picker with search.
Robustness: it still stages the files + writes a manifest first, so if there's no
token yet (user never signed in) or the send fails, the file isn't lost — the app
collects it on next open, exactly as before. On success the manifest is cleared so
the app doesn't re-offer it.
Server + web are live now; the token endpoint is harmless until a build ships the
extension. NEEDS A NEW iOS BUILD for the picker itself.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
THE ANSWER to "why does an already-downloaded video still buffer?" — it was never
the download, and it was not the server. Probing the real uploads on the box:
d0e49e58… 1920x1080 19.4 Mbps 75 MB / 31 s
ad929d0b… 1920x1080 19.0 Mbps 27 MB / 11 s
9f4e0865… 720x1584 3.6 Mbps 14 MB / 31 s
To play a 19 Mbps file the client has to SUSTAIN a 19 Mbps download for the whole
clip. No mobile link does, so the <video> buffer drains every few seconds: buffers,
plays, buffers, plays. Server-side disk read was instant and load was 1.7 on 20
cores throughout — the bottleneck is the media itself, not the delivery path.
Second, independent defect: phone MP4s store `moov` AFTER `mdat` (verified on two
uploads), so the player must fetch the file's tail before it can start at all.
Fix — keep the original bytes untouched (that is what the download button serves,
full quality) and build <id>.web.mp4 beside it: longest side capped at 1280,
~2.5 Mbps ceiling, +faststart. Measured on the 19 Mbps file:
27.3 MB @ 19.0 Mbps -> 2.55 MB @ 1.78 Mbps (10.7x less bandwidth)
transcode took 2.4 s for an 11.5 s clip
- server/media.js (new): probe, decide, 2-at-a-time background queue. Already
light + correctly sized + faststart => no rendition at all. Light but wrong atom
order => remux -c copy (seconds, no re-encode). Otherwise re-encode. A rendition
that lands bigger than the original is discarded. MP4 box-walker for the
faststart test is unit-checked against known fast/slow files, both directions.
- /stream/<id> serves the rendition, falling back to the original while it is still
transcoding, so a video is never unplayable. /files/<id> is unchanged and still
serves the pristine original for download.
- Renditions are queued at upload, and backfilled 15 s after boot for the videos
that predate this. Range serving is now one shared helper for both routes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug 1: .ptr-ind used top:8px so the pull-to-refresh spinner sat under the notch/Dynamic
Island. Now top:calc(var(--sat)+8px) clears the safe-area inset.
Bug 2: video attachments rendered as a plain download link that re-downloaded on every
tap. Server now sends isVideo/isAudio on message attachments; videos render as an in-chat
player — masked poster with a DOWNLOAD button that loads the file ONCE (preload=none ->
load on tap), then becomes a PLAY button; playing hands off to native inline controls, so
no repeat downloads. build batch155.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Investigations are closed, so strip the probes: bzDbg + __perfProbe definitions, the
older/render/slide/pwaFocus call sites, and the server-side /api/dbg (MDBG) sink. Kept
the functional code around each probe (renderThread's innerHTML build, the older-page
re-anchor, the slide fade). No behavior change. build batch153.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Server streams the upload body straight to /data/uploads (a .part temp file, atomic rename on
success), backpressure-aware, so a 1 GB file never buffers in RAM. MAX_UPLOAD_MB env (default
1024 = 1 GB) controls the cap; error message reflects it.
- Client size guard raised 25 MB -> 1 GB.
- docker-compose documents MAX_UPLOAD_MB and the required Nginx Proxy Manager client_max_body_size.
NOTE: the actual bottleneck for the user's 9.7 MB reject is almost certainly NPM's client_max_body_size
(nginx default 1 MB) — that must be raised in the NPM admin; the app change alone can't lift it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-add /api/dbg + a frame-gap/long-task probe over the chat-open slide, the innerHTML render time, and
before/after scroll for load-older — to measure on-device where the jank actually is (main-thread block
vs compositor) instead of guessing. Temporary; removed once fixed.
Build marker -> 2026-07-19-batch122.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slide (workflow-verified): the jump was the main thread building the message DOM + scroll writes DURING
the 300ms animation. Now: render off-screen first, then arm a transform-only slide on a promoted GPU
layer (translate3d/contain:layout paint) with all innerHTML/scrollTop work deferred to transitionend
(afterOpenSlide queue). armOpenSlide runs after the sync render; appendBubble defers live writes mid-slide.
Load-older jump: anchor on the specific oldest-loaded message element (not scrollHeight math, which broke
when prepended images loaded and shifted content) and re-anchor as those images load.
Removed all /api/dbg diagnostics (client reporter + server route + spike/diag probes).
Build marker -> 2026-07-18-batch121.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User's insight: the jank/jumps come from rendering ALL ~500 messages on open. Load only the latest 40
(server + client PAGE), and the existing loadOlder() pages in older history on scroll-up with a scroll
anchor (no jump). Whole-thread SEARCH is a separate endpoint, unaffected. Also #1: keyboard-show only
pins to newest when already near the bottom, so replying to an OLD message no longer yanks to latest.
Build marker -> 2026-07-18-batch120.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Temporary: the app POSTs its real runtime state to the server log so device-only bugs (budge source,
keyboard viewport numbers, chat-open scroll state) can be diagnosed from docker logs instead of
screenshots. Removed once mobile issues settle.
Build marker -> 2026-07-18-batch108.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Server: GET /api/gifs proxies GIPHY search/trending. The API key is read from the server
env only (config.GIPHY_API_KEY, from the gitignored .env) and NEVER reaches the browser;
the picker is hidden when it isn't configured.
- Emoji picker gains a GIF tab (separated from the emoji categories) with a search box + a 2-col
grid, "Powered by GIPHY" attribution. Clicking a GIF sends it immediately.
- GIFs are HOTLINKED to GIPHY's CDN (their terms require this — no re-hosting): the message body
is the GIF url, and a body that is a lone GIF url renders inline as the animated GIF (reusing
the image/lightbox path). Sidebar previews + notifications show "🎞️ GIF", not the raw url.
Key is NOT in git — set as GIPHY_API_KEY in the server .env.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Revised rule per user: a call is not a meeting. Past meetings now = real SCHEDULED meetings +
INSTANT meetings (Start-a-meeting, logged in call_history as adhoc). Ad-hoc CALLS — 1:1 direct,
group calls, and a 1:1 that a 3rd person joined — are shown ONLY if they produced a
recording/transcript. Dropped the earlier ">2 participants shows it" exception.
Also stop rendering a bare "Host: —" on logged call cards.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#3 The 136 "Direct Call" cards: calls.js writes a scheduled_meetings row for EVERY call
("Direct Call" / "Group call"), which is separate from the call_history table. Those rows
came through the scheduled-meetings path and my earlier filter never touched them. Now an
auto call-history row is shown in Past ONLY if it produced a recording/transcript OR the
call ever held >2 people (peak from the call log); plain 1:1s with neither are dropped.
Removed the now-redundant callRows synthesis. Recordings stay attached (rows WITH a
recording are always kept).
#4 Date range: from ≤ to enforced by disabling out-of-range days in each picker (can't pick a
from after to, or a to before from). Filter controls share one height/baseline so the
calendar icon, preset dropdown and date fields align cleanly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#1 Last seen still read "Offline": last_seen is a NEW column, so every existing user was NULL
until they happened to reconnect. Backfill it from the newest message each user sent (best
evidence we already have), and also stamp it on message send — not just on connect/disconnect
— so it stays fresh if a socket never closes cleanly.
#3 Past-meetings filter is a report filter now: presets (Today, Yesterday, Last 7/30 days, This
month, Year to date, All time) plus a Custom range that uses the SAME branded calendar popup
as the scheduler. The raw <input type=date> looked foreign and behaved differently per browser.
#4 Hover action row overflowed off-panel on SHORT received messages: it's wider than the bubble
and was anchored to the bubble's right edge, so it ran off the left. Received bubbles now
anchor it from the left (growing into the empty space); own messages keep the right anchor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#7 The past list was hard-capped at 12 (a .slice(0,12) in the client) and there was NO record
of how many people were ever in a finished call — so the "only calls with >2 people" rule
was impossible to apply. Added a call_history table: signaling tracks the HIGH-WATER
participant count per room and logs the call when the room tears down (scheduled meetings
are skipped — they already have their own row).
Past meetings now follow the rules asked for:
• a plain 1:1 direct call is NOT listed — unless it produced a recording/transcript
(those already surface as recording entries);
• a call that ever held MORE than 2 people IS listed (e.g. a 1:1 a third person joined),
showing its participant count and duration;
• entries are visible only to people who were actually in the call (or the group).
Server-side pagination (10/page) + a from/to date filter; nothing is double-listed.
#2 Last seen now shows the exact time/date, WhatsApp-style — "last seen today at 1:36 PM",
"last seen yesterday at 10:15 AM", "last seen 14/07/2026 at 9:00 AM" — instead of "10
minutes ago".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#6 Emoji were slow because Twemoji swapped EVERY emoji for an <img> fetched individually
from a CDN — opening the picker fired hundreds of image requests. Now uses the OS's own
colour emoji font: instant, zero network. twemojify() kept as a no-op.
#4 Message hover row reworked: three one-tap reactions (Like/Laugh/Surprised) + the emoji
picker + (own) Edit. Reply / Forward / Copy / Delete moved behind a ⋮ menu. Added Copy.
The row now sits FULLY above the bubble (was top:-14px, overlapping the first text line).
#9 Phone numbers linkify to tel: — mobile gets the OS "call this number?" prompt; desktop has
no dialer so it offers to copy. Regex kept conservative (10–15 digits, needs +/grouping) so
it won't grab amounts, dates or 6-digit meeting codes.
#3 Image preview zooms: wheel + pinch + double-click + ± buttons, drag to pan, keys (+/-/0),
cursor-anchored. Arrows hide while zoomed so panning isn't hijacked.
#8 Upload progress: fetch() can't report upload progress at all, so a large file just said
"uploading…". Switched to XHR (upload.onprogress) → real bar + %, and cancel aborts in flight.
#1 Clicking a sender in a group opens a mini profile card (photo, presence, last seen) with a
Message button that opens the 1:1 (and a view-photo button).
#2 Last seen: new users.last_seen column, stamped on connect and when the last socket drops;
carried on the presence broadcast, so an offline contact reads "Last seen 10 minutes ago"
instead of a bare "Offline".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DP bug — real cause found (client-side, not the DB):
loadSidebar builds a DM row for every contact you haven't messaged yet, but it copied only
{name, online} from the contact and DROPPED `avatar` (and status/email). So an un-messaged
contact always rendered initials in the 1:1, while the SAME person showed their photo in a
group (which reads /api/groups/members). Carry the whole contact through.
Kept a server-side safety net: avatarsFor() now indexes known photos under person-id, email
AND name, so a duplicate row missing a photo can match on any of them (the previous single
composite key missed twins with different emails).
Web updates are now completely silent: no banner, no toast. A web build is an implementation
detail — surfacing it makes users reason about "web build vs app version", which is exactly
the confusion to avoid. New code simply applies itself as soon as it's safe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auto-update (no manual step, no version confusion):
- New code now applies ITSELF. The client polls /api/build and, as soon as it's SAFE,
silently hard-reloads onto the new build. Safe = not in a call, no live screen session,
no dialog open, nothing half-typed; if the user is busy we wait and apply the moment
they're free. A brief "Updated to the latest version" toast confirms it.
- Removed the "web build" row from Settings: users must never have to reason about an app
version vs a web build. The only version surfaced is the desktop app's (auto-updater).
DP bug: a contact showed their photo in a GROUP but fell back to initials in the 1:1.
Two causes, both handled:
- Duplicate rows for one person (signed in by email once and by mobile another time before
the bizgaze_user_id merge landed) — only one row carries the DP, and the group happened to
reference the row WITH the photo. avatarsFor() now keys rows by stable person identity
(bizgaze person id → email → name) so a photo-less row borrows its twin's photo. Applied to
contacts, conversations, group members and group info.
- A DM whose counterparty was merged away is now keyed by the SURVIVING account, so the row
carries that account's name/photo/presence (and split threads collapse into one).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of "the fix works on mobile but not on desktop/web": nothing was wrong with
the code — the desktop app now CLOSES TO TRAY, so it can run for weeks on the page it
loaded on day one and never re-fetch after a deploy. There was also no way to force it
off a stale page (no menu bar → no reload accelerator).
- Server: GET /api/build returns home.html's __BUILD marker.
- Client: polls it (boot, on focus/visibility, every 10 min); when the server's build
differs from the running one, shows a branded "A new version is available — Refresh"
banner. Settings gains an always-available "Refresh app" with the current build shown.
- hardReloadApp(): in the browser it unregisters service workers + clears CacheStorage
then reloads cache-busted; in the shell it calls the native hard reload.
- Desktop: hard-reload IPC (clears the session HTTP cache + reloadIgnoringCache), wired to
Ctrl+R (reload), Ctrl+Shift+R / F5 (hard reload), and a "Refresh app (get latest)" tray
item. Previously there was literally no way to clear the cache from the app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
General #1/#2 (closed-app notifications): the desktop app now CLOSES TO TRAY instead
of quitting, keeping its chat WebSocket alive so calls/messages still notify. Tray
icon + menu (Open / Quit), single-instance lock, first-close hint.
Guest #4 (lobby/admit): meetings can require the host to admit guests joining by link.
Setting on the schedule form ("Guests must be admitted by the host", default on) +
ad-hoc default. Guests wait on a "waiting to be let in" screen; the host gets an
Admit/Deny prompt; auto-cleanup on leave. Logged-in members always join directly.
Guest #5 (speaker): headphones/speaker output picker in the meeting (setSinkId),
remembered and applied to every tile.
Guest #3 (link expiry): guest link/token dies ~2h after a scheduled meeting's end
(HTTP 410) with a clear message; live-room links expire when the room empties.
RC #4 (mobile): touch→mouse mapping so a phone/tablet viewer can control (tap=click,
drag=move). Uses the same letterbox-correct coordinate mapping.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remote control (connect.html viewer):
- Coordinate offset fixed: map clicks to the actual video CONTENT rect (object-fit
letterbox-aware), not the element rect — cursor now lands where you click.
- Keyboard: capture at document level while a session is live (the <video> lost
focus on bar clicks → keys did nothing). Skips the chat input.
- Control bar moved to bottom-right with tiny modern icons; video fills the viewport.
- Smoother cursor (mousemove ~60/s).
Guest meetings:
- Guest mic inaudible + guest invisible in the participant list + outsider screen
share not showing (#1/#8/#11): root cause was the SFU media→tile map keyed on
identity==uid, but guests had a random LiveKit identity and a null signaling uid.
Guests now carry ONE stable id across signaling (meeting-join guestId) and the
LiveKit token identity, so their media attaches and they appear to everyone.
- Guests can't add participants (#9) and don't see the transcript button (#12).
- Search box in the schedule participant list (#10).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#4: scheduled meetings can now invite people who aren't on Connect.
- SMTP config (config.js, env-gated: SMTP_HOST/PORT/USER/PASS/FROM/SECURE,
PUBLIC_BASE_URL) + a small nodemailer wrapper (mailer.js) with a branded
meeting-invite template carrying the guest join link. No-op until SMTP is set.
- /api/meetings/schedule + /update accept participantEmails; external emails are
persisted (scheduled_meetings.guest_emails migration) and emailed the guest
link (plus any invited Connect users with an email on file). Fire-and-forget —
a mail outage never fails scheduling.
- Meetings list DTO returns `link`; schedule form gains an "Invite by email"
chip input; each scheduled-meeting card gets a "Copy link" action.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Server: POST /api/meetings/guest-token issues an unauthenticated LiveKit token
for a room that is currently live or a valid scheduled meeting (throwaway guest
identity). Mesh signaling already tolerates anonymous peers.
- Client: opening /home?meet=CODE while signed out runs a lightweight GUEST mode —
name prompt, then straight into the meeting with the full meeting client and the
chat/sidebar chrome hidden. Signed-in users who open the link auto-join.
- sfuConnect uses the guest token for guests. Guests get a friendly "left the
meeting / rejoin" screen (no chat to fall back to).
- Meeting "Add people" panel gains a "Copy invite link" (copyMeetingLink) that
copies the guest link — foundation for the emailed invites in #4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #1: URLs in chat are now clickable links (open externally on desktop).
- #2: group 'Seen by X, Y +N more' now shows under the LATEST message to EVERYONE
(server computes reads for all messages; click shows the names).
- #5: tapping the conversation-header avatar previews the DP/group photo full-size.
- #4: in-call invite lists only people you've messaged, excludes those already in the
call/invited, and the Invite button is sticky.
- #3 (0.1.6 shell): auto-updater emits checking/available/downloading/ready/error to
the web UI, which shows a progress banner (+ Restart button) so updates aren't silent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #4: forward modal buttons now branded (bzc-* un-scoped from .bz-confirm); target
rows get real checkboxes + border highlight; selected MESSAGES highlight in green
with a filled tick (distinct from blue bubbles), checkbox on every message.
- #5: forwarded messages show an italic 'Forwarded from <origin>' label; new fwd_from
column preserves the true origin across re-forwards.
- #6: image-preview arrows are smaller (32px) chevron icons, clear of the image
(image max 82vw); icons.js?v=5.
- #10: image preview now opens ABOVE the media modal (z 9900) and Esc/close returns
to the media view instead of closing everything.
- #11: Media/Links/Docs active tab underline is brand-blue, not green.
- #2: chat-list + conversation scrollbars 6px, stepper arrows hidden; pagination adds
a cooldown + overflow-anchor:none so scrolling up no longer sticks/jumps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When you open/read a conversation, the server now also pushes a notif-clear to your
OWN other sockets. Each device tags notifications by conversation (kind:id), so on
notif-clear it closes the matching page Notification + any Service-Worker (Web Push)
notifications, and drops matching activity-center entries so the bell badge stays in
sync. (Desktop Electron native toasts are transient/auto-expire; the web+PWA surface
is covered.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Message action pill gains a Forward button; tapping it enters selection mode
(tap bubbles to multi-select, footer bar shows count + Forward/Cancel, Esc exits).
- Forward picker lists EXISTING conversations only (DMs + groups from the sidebar),
searchable, multi-target. POST /api/messages/forward copies body+attachment into
each target (authorized as participant/member), live-pushed like a normal send.
- /files auth now accepts ANY message carrying an attachment (allByAttachment), so
forwarded images stay viewable for the new recipients.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #8: reply quotes are now clickable — jump to the original message (paging older
history in if needed), then flash it. reply DTO carries the target's timestamp.
- #3: image lightbox now has ← / → arrows + keyboard nav to flip through all images
in the conversation.
- #5: the composer queues MULTIPLE files (file input is multiple; paste still works);
each is shown as a removable chip and sent as its own message (first carries the
typed text as caption), in order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Older messages (beyond the newest 500) now load as you scroll to the top — the
thread endpoint takes a ?before=<created_at> cursor, the client prepends the older
page and preserves scroll position (renderThread keepScroll). _hasMoreOlder stops
paging when a short page returns.
Search now covers the ENTIRE thread, not just the loaded window: new
/api/messages/search (DM + group, LIKE with escaped wildcards) returns all matching
message ids; the client debounces the query, and jumping to a hit older than the
loaded window pages history back (ensureLoadedBack) until the match is in view, then
highlights + flashes it. Cap raised to 500.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of 'messages to some contacts disappear (gone after reopen)': the #2
account merge deletes the merged-away user row. Any lingering reference to that old
id — a cached contact, an in-flight DM — then saved against a dead recipient / 404'd
on thread fetch, so messages silently vanished.
- New user_aliases table records old_id -> survivor on every merge (mergeInto).
- users.resolve(id) follows the redirect.
- DM send (recipient), thread fetch (with), and read now resolve() the peer id, so a
stale id transparently routes to the surviving account.
Fixes future merges fully. Contacts merged BEFORE this (no alias recorded) may need a
one-off data check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the server side of scaling meetings past the ~5-peer mesh:
- config.js: LIVEKIT_URL/API_KEY/API_SECRET + LIVEKIT_ENABLED flag. All optional;
when unset the app keeps the built-in P2P mesh (fully additive, like push).
- routes.js: GET /api/meetings/config (tells the client sfu on/off + wss url) and
POST /api/meetings/token (mints a per-user, per-room LiveKit join token — hand-rolled
HS256 JWT like the FCM/APNs tokens, no new dependency; secret stays server-side).
- docker-compose.yml: optional livekit service (single-node, no Redis), keys injected
via LIVEKIT_KEYS from the same .env; media over published UDP 50000-50100 + TCP 7881,
signaling proxied by NPM.
- livekit.yaml + .env.example documented.
Client (mesh->LiveKit media swap, behind the flag) lands in phase 2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Thread: every group message I send now carries a receipt tick — sent (1 grey)
-> seen-by-some (2 grey) -> seen-by-all (2 yellow) — computed from the per-message
seenBy the server already returns and kept live by onGroupRead. Tap the tick to see
exactly who ('Seen by X, Y') with an 'N of M' tooltip. Replaces the old last-message-
only 'Seen by' line with a universal, tappable per-message receipt.
Sidebar: the group row tick now reflects real read state (read/delivered/sent) via
memberReads vs member count, instead of a hardcoded 'sent' — and refreshes live when
the open group is read by all.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Message actions (reply/react/edit/delete) consolidated into ONE hover pill anchored to the
bubble's top-right, overlapping it so on short messages the icons no longer float off into
empty space and vanish before you can click.
- Desktop toast + mobile(FCM)/web-background push now show the sender's real DP:
* renderer passes the DP URL through; desktop shell downloads it for the toast icon
(canvas-drawing an external DP tainted it → initials). Desktop bumped to 0.1.4.
* DM push payload carries icon=sender avatar; sw.js already uses it (web background),
sendFcm sets notification.image (Android).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sender can edit their own text messages: a pencil action on the bubble loads the text into the
composer in an 'Editing' mode; saving updates the body, marks it 'edited', and pushes the change
live to the other side/tabs (chat-edited, mirroring delete). Adds messages.edited_at + editBody().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>