- #2/#7/#19: openFromNotif opened chats via a full-page reload (location.assign),
which was slow AND dropped live state — the sidebar hadn't reloaded so the header
showed 'Conversation' with no DP, and an incoming call's Join/invite popup was lost.
Now it opens the chat IN-PLACE (selectChat) since the app is already running;
full-page nav only as a fallback when the app isn't initialised.
- #9: slimmer 6px scrollbar; explicitly hide all webkit stepper-arrow variants.
- #16: 1:1/small call no longer shows a stray scrollbar — meet-grid centers tiles
and clips overflow (call UI fits the viewport).
- #17: caller now hears a gentle ringback while waiting; stops on answer, on any
call exit, and auto-stops after 45s so it never rings forever.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pull-down-at-top called reloadThread (reload newest 500 + scroll to bottom), so
reaching the oldest message yanked the view to the newest — jarring, especially on a
single page. Now the pull gesture calls loadOlder: it pages in older history keeping
scroll position, or no-ops when there's nothing older. Never jumps to the bottom.
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>
THE disappearing-messages root cause (found via THREAD.length=300 vs 351 on server).
messages.thread and threadByConversation did 'ORDER BY created_at ASC LIMIT 300' —
the oldest 300. Once a DM/group passed 300 messages, every newer message was silently
dropped from the fetch, so anything sent after that point 'disappeared' (persisted
server-side, never returned to the client). Now: inner 'ORDER BY created_at DESC LIMIT'
takes the NEWEST N, outer ASC presents them oldest-first. Cap raised 300 -> 500.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
THE disappearing-messages root cause. openConvo did: let msgs=[]; try{ msgs=fetch()
}catch{}; THREAD = Array.isArray(msgs)?msgs:[]. On a flaky desktop network a failed
fetch left msgs=[] (still an array) → THREAD=[] AND THREAD_CACHE.set(ckey,[]) — so
reopening a chat blanked it AND overwrote the cache, making messages vanish and stay
gone even though they persist server-side (confirmed: 982 DMs, zero dangling ids).
Now msgs stays null on a failed/non-OK fetch; we only replace THREAD/cache on a real
array response, and otherwise keep the cached render instead of blanking.
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>
- Broken images: UPLOADS_DIR/REC_DIR/TRANS_DIR were server/<dir> INSIDE the image,
so every deploy.sh rebuild wiped uploaded files — old images 404'd ('broken
image') though their DB rows survived. Make them env-overridable and point prod
at /data/uploads|recordings|transcripts (persistent volume), matching DB/downloads.
NOTE: files already lost to prior rebuilds can't be recovered; new uploads persist.
- Duplicate notifications: harden connectChatWs — close/detach any prior socket
before opening a new one and keep a single pending reconnect timer, so a flaky
reconnect can't leave two live sockets delivering every event/notification twice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
batch42's resync-on-reconnect reopened the conversation via openConvo, which
replaces THREAD wholesale. On the desktop app (flaky WS), a reload racing with a
just-sent message could momentarily blank the chat ('messages disappear after
sending', seen on the Manasa chat). resync now MERGES: adds messages missed while
disconnected + refreshes read/delivered/seen/edited/deleted flags, but never
removes messages already on screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bundled livekit-client@2.20 uses signaling protocol 17 and the /rtc/v1 path,
which the v1.7 server didn't implement (404). The client fell back to the legacy
path, leaving the track publisher in a bad state so mic/cam publishing failed with
'InvalidAccessError: The sender was not created by this peer connection' — surfaced
to users as a misleading 'permission required' toast. v1.9 supports protocol 17.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #10/#18/#7: on WebSocket RE-connect, re-pull sidebar + re-open the current
thread so the desktop/mobile apps recover missed messages/ticks without a
refresh (the old onopen only re-sent chat-hello).
- #11: branded bzConfirm() dialog replaces window.confirm for delete (the OS/
Electron default dialog looked off-brand on desktop).
- #4: global Esc no longer closes the conversation while an image preview is
open — the preview closes first, the conversation on a second Esc.
- #6: live 'typing…' now also shows inside the conversation panel (animated),
not only the header/sidebar.
- #15 (diagnostic): mic/cam failures now report the REAL cause (permission vs
no-device vs in-use) with desktop-specific guidance, and log the raw error,
instead of always saying 'permission required'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prod box is behind NAT (private 192.168.88.61 behind public 118.95.33.89), so
LiveKit auto-IP-detection would pick the wrong (outbound) address. Pin
rtc.node_ip=118.95.33.89 and collapse media to one UDP port (50000) + TCP 7881 to
minimize the upstream gateway port-forward the network team must add. Docs updated
with the exact forward table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A normal deploy no longer starts the livekit container (it would crashloop with
empty keys before provisioning). Enable with 'docker compose --profile sfu up -d'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Step-by-step to enable meetings SFU: generate key/secret, .env vars, DNS record,
NPM proxy host for wss signaling, VPS firewall for UDP media + TCP fallback, deploy,
verify. Includes the one-line rollback to mesh (remove the LIVEKIT_* vars).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Meeting media flows through LiveKit when the server reports sfu:true; otherwise the
P2P mesh is unchanged. Reuses the ENTIRE existing meeting UI + WS control plane
(join/host/mute/screen/recording/transcript) and only swaps the transport:
- vendored livekit-client 2.20 UMD (lazy-loaded; no build step).
- sfuInit() reads /api/meetings/config at boot; sfuConnect() joins the LiveKit room
with the minted token after the WS meeting-join.
- remote tracks map back to the WS peerId via uid (LiveKit identity = app user id);
per-peer stream prefers screen over camera, driving the existing sharing/stage UI.
- toggleMic/Cam/Screen publish via LiveKit; local tracks reflected into meetLocalStream
so tiles, active-speaker meter, canvas recording and transcript keep working.
- meetMakePeer/peer-joined/peer-left/leaveMeeting branch on SFU.on; mesh path intact.
Needs a running LiveKit server + live test (phase 3 ops) to exercise end-to-end.
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>
Ephemeral over the chat WebSocket (no DB). Composer emits chat-typing on/off
(throttled 2.5s, auto-stop after 4s idle / on send / on leaving the chat).
Server relays to the DM peer or fans out to group members (membership-checked).
Receiver shows 'typing…' in the conversation header subtitle and the sidebar
row preview (brand-blue italic), with per-sender auto-expiry so a dropped 'off'
can't stick. Group shows names ('Alice is typing…', 'N people are typing…').
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors redeploy.sh conventions (pinned host key, DEPLOY_PASS/deploy.secret/
prompt password resolution, plink discovery). Uploads the three electron-builder
artifacts via pscp to a temp dir, then docker cp's them into the app container's
/data/downloads (volume-path-independent), and verifies the public feed serves
the new version. Keeps older versions; only latest.yml is overwritten.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/data is a named Docker volume, not a host bind-mount, so the prior
scp root@host:/data/downloads/ path was wrong. Document the real host
path (/var/lib/docker/volumes/bizgaze_support_data/_data/downloads) plus
the docker cp alternative, and note latest.yml is the only overwritten file.
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>
- #5: use the ACTUAL brand C (extracted transparent white C + gold dot from the master
app-icon) on the blue headers instead of a hand-drawn mark; login card keeps the app icon.
- #3: read-receipt double tick on my own (blue) bubble is now brand yellow (was blue-on-blue,
invisible).
- #2: Sign-in button shows a spinner + 'Signing in…' on submit.
- #1: web notification uses the sender/group DP URL directly as the icon (drawing an external
DP to canvas tainted it → silently fell back to initials).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The header is already blue, so the app-icon-in-a-white-pill looked like blue→white→blue→white
nesting. Use a transparent mark (mark-light.svg: white C + gold dot) placed directly on the blue
header (home/index/connect/dashboard), dropping the white background + padding. share.html keeps
the full app icon (it's a light card, not the blue bar).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Replace the leftover 'Your Virtual CXO' logo.png with the app icon (C mark) in every
header (home/index/connect/dashboard) + the app icon on share.html; delete logo.png.
- Loading screen now shows the orbit mark + 'Biz Connect' wordmark + 'Loading…' (was bare).
- Keep the branded splash up until the chat list finishes loading — no blank/stuck gap
after login (previously hidden before loadSidebar()).
- Login accepts email OR phone (field was type=email, which rejected phone numbers and so
blocked the mobile-login that triggers the account merge). Label → 'Email or phone'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #6: the server echoes your own sent message back over the socket; onChatMessage reset
last_status='sent', overwriting a delivered/read that arrived first → the chat-list tick
flipped back to single. Track last_msg_id and never downgrade the same message's tick.
- #3: pin the 'You' note-to-self chat in its own slot — tinted row + 'Note to self' tag +
a divider separating it from the conversation list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- docker-compose: DOWNLOADS_DIR=/data/downloads so uploaded installers/latest.yml
survive image rebuilds (deploy.sh) instead of being wiped.
- DEPLOY.md: step-by-step for publishing a desktop release (build → upload the
3 feed files → verify) so the Download button + auto-update go live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live presence (fixes stale in-call/status until refresh — impossible in apps):
- server broadcasts a user's status over the chat socket on connect/disconnect, call
join/leave, and status change (chat.js broadcastPresence; signaling + routes hooks).
- client onPresence() updates the contact dot + open-chat header live.
Chat delivery ticks (#6): chat-list row now mirrors the thread (delivered→double grey,
read→blue) via a new 'with' field on the delivered relay + onChatRead/onChatDelivered.
Call fixes: no bogus 'host handed over' when a 1:1 call ends (leaveMeeting forced);
branded call-connecting + chat-thread loaders; header subtitle tracks live call state.
Notifications: web notify + sw.js use sender/group DP + brand icon (not old wordmark);
desktop shell drops Web Push so only the single native toast fires (#5).
Brand: master icon/splash/loaders wired everywhere (PWA/favicon/apple-touch/.ico),
branded login (blue + gold CTA), branded toasts (BZToast) on all pages, Electron splash.
Desktop: dev auto-targets localhost (packaged→prod); version 0.1.3 with new multi-size
icon; dropped unused node-notifier; removed home-mockup.html.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From brand-assets/ masters (C + orbiting-dot on brand blue):
- Regenerated PWA icons (192/512/maskable), apple-touch (180), favicons
(32/16 + root favicon.ico) from the 1024 master; bumped manifest to v3.
- Rebuilt desktop/build/icon.ico as a proper multi-size ICO (16..256,
PNG-compressed) to fix the tiny/blurry taskbar icon.
- Updated mobile masters (resources/icon.png 1024, splash.png/splash-dark.png
2732) for capacitor-assets to regenerate native icons/splash.
- Wired the animated branded orbit loader into the app boot 'Loading…' screen;
added favicon/theme-color links to index + home heads.
- logo.png (horizontal wordmark) left untouched — masters have no wordmark.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Provisioning now keys the Biz Connect account on bz.bizgazeUserId (the same
value whether the person signs in with email or mobile) instead of the typed
identifier, so both logins resolve to a single contact. Legacy rows get the
person-id stamped on next login; an existing duplicate created under the same
identifier is folded in via a transactional users.mergeInto() that reassigns
all messages/memberships/reactions/votes/favorites/ownership to the survivor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Loads @twemoji/api (jsDelivr) and renders emojis as Twemoji images in messages, the emoji
picker, and reactions (twemojify at each render point). Picker inserts via data-emoji so it
survives the <img> swap. Falls back to plain Unicode if the CDN is unavailable. build batch25.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pinned 'You' chat at the top of the list (always available); messaging yourself works
(to===me), with no self push/echo notification and a 'Message yourself' header. Self is
filtered from the normal contacts and shows no status dot. build batch24.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Conversations DTO now returns last_status (sent/delivered/read for DMs; sent for groups) for
my last message. The list row shows the matching tick (single/double, blue when read) and a
red 'Draft:' indicator when there's unsent text. Live 'sent' on send/receive; upgrades to
delivered/read on refresh. e2e 119.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Unsent text is saved per conversation (survives switching chats and reloads) and restored
when you reopen the chat; cleared on send. (#1)
- Emoji picker: stopPropagation on tab/grid clicks so switching category no longer closes it
(the re-render was detaching the clicked node -> outside-click handler fired). (#6)
build batch23.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
node-notifier/SnoreToast click callback never fired without the crashing wait mode. Switch the
chat toast to Electron's built-in Notification: shows avatar + message and its 'click' event
reliably raises the app + opens the chat. No SnoreToast, no external tools.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bundled SnoreToast crashes (0xC0000409) handling a text reply, and that crash triggered
a second (fallback) toast. Removed the direct-SnoreToast reply path entirely; the chat toast
now reliably shows avatar + message via node-notifier and opens the chat on click. Inline
text reply needs a different toast engine (deferred).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Testing showed SnoreToast blocks/waits for the toast interaction when given -pipeName; the
-w flag actually fails on this build (exit -1) which forced the reply-less fallback. Without
-w it waits, captures the typed reply, and writes it to our pipe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
snoreExe returned the app.asar path (fs.existsSync lies about asar paths), so spawn failed
with ENOENT and always fell back to the reply-less WindowsToaster. Map to app.asar.unpacked
unconditionally so the real SnoreToast binary (with -w) runs and captures the reply.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause: node-notifier's toaster whitelist has no -w, so SnoreToast never waits and the
reply is lost. Now drive SnoreToast directly with -w + our own pipe (correct args, no
-application which had broken the toast). If the binary is missing or fails to show, fall
back to node-notifier's WindowsToaster so a toast always appears. Logs code+raw for diagnosis.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
node-notifier returned an empty result for text replies. Now spawn the bundled SnoreToast
with -tb -w -pipeName against our own pipe, read the raw UTF-16LE result, and parse the
reply (keeps spaces). Logs the raw pipe string to userData/toast-debug.log to pin the
exact reply field on real hardware.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- node-notifier's WindowsToaster forwards raw opts to SnoreToast, so inject -tb (reply box)
+ -p (image). Reuses its named-pipe + result parsing (exit 5 = TextEntered). No pwsh needed.
Logs the raw toast result to userData/toast-debug.log to confirm the reply field on real HW.
- home.html: notifAvatarDataUrl draws the DM sender's pic / group's DP (else colored initials)
to a round PNG and passes it as the toast image. Reply -> sendReplyTo; click -> open chat.
- dropped powertoast (ESM + needs pwsh 7, absent here). build batch22.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reply to a chat straight from the OS notification, no app switch:
- node-notifier (bundles SnoreToast) renders a native Windows toast with a reply box —
Electron's own Notification can't do Windows inline reply.
- main.js reply-notification handler resolves {text}|{open}|null; preload exposes replyNotify.
- home.html notify() routes chat toasts through it on desktop: a typed reply -> sendReplyTo()
POSTs to /api/messages without opening the app; a click opens the chat. Web/PWA path unchanged.
- Works only in the installed app (needs the installer's AppUserModelID). desktop 0.1.2, build batch21.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- dashboard.html: admin "App installs" card — table of user · platform · version · OS ·
first/last seen, from GET /api/v1/admin/installs (loadInstalls).
- index.html: replaced the long inline link with a compact white "Download app" button in
the blue top header (top-right); hidden when already inside the desktop app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Downloads/updates:
- config: DOWNLOADS_DIR (override to a mounted volume in prod).
- static.js: serves /downloads/* (installer, latest.yml, .blockmap) with range support for
resumable + differential auto-updates; /download/windows redirects to the current .exe
(stable link). Landing page gets a "Download the Windows desktop app" button (hidden in-app).
Install tracking (who installed the app):
- db app_installs + repos.appInstalls (upsert by install_id, fills in the user on sign-in).
- POST /api/v1/telemetry/install (records install + user once authenticated);
GET /api/v1/admin/installs (admin: list installs with user/version/os/last-seen).
- desktop main.js: stable per-install id in userData, exposed via preload
(bizConnectNative.installId/version/os); home.html reportInstall() posts it after login.
- e2e: +2 checks (telemetry recorded, admin sees it). 119/119. build batch20.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- home.html: login is now a real <form> with a submit button, so Enter (or click) both
submit natively — replaces the ad-hoc keydown handler. (build batch19; header stays removed)
- desktop/main.js: app.setAppUserModelId('com.bizgaze.connect.desktop') so Windows resolves
the installed shortcut and toasts read "Biz Connect" instead of "electron.app.BizConnect".
- desktop version → 0.1.1; installer rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The desktop app was hardcoded to /home, so it jumped straight to the login form and
skipped the 'before login' landing (no-login 'Share my screen' + sign-in). Now loads / —
same entry as the website; it redirects logged-in users to /home.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>