diff --git a/server/db.js b/server/db.js index a267a97..9957a55 100644 --- a/server/db.js +++ b/server/db.js @@ -216,6 +216,8 @@ try { db.exec('ALTER TABLE messages ADD COLUMN msg_type TEXT'); } catch (e) { /* // Deleted ("delete for everyone"): the row stays so threads/ordering hold, but body+attachment // are cleared and clients render a "This message was deleted" placeholder. try { db.exec('ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ } +// A message can be edited by its sender; edited_at marks it (shows an "edited" label). +try { db.exec('ALTER TABLE messages ADD COLUMN edited_at INTEGER'); } catch (e) { /* exists */ } // User-set presence status: 'active' | 'away' | 'onleave'. ('incall' is derived live, not stored.) try { db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"); } catch (e) { /* exists */ } // BizGaze person-id (s.userId): the SAME value whether the person signs in with their email or diff --git a/server/public/connect.html b/server/public/connect.html index c95ab12..9e7612d 100644 --- a/server/public/connect.html +++ b/server/public/connect.html @@ -76,7 +76,7 @@ Home
-
Biz Connect
+
Biz Connect
diff --git a/server/public/dashboard.html b/server/public/dashboard.html index 4957ae5..e5f8e67 100644 --- a/server/public/dashboard.html +++ b/server/public/dashboard.html @@ -92,7 +92,7 @@
-
Biz Connect · Dashboard
+
Biz Connect · Dashboard
diff --git a/server/public/home.html b/server/public/home.html index 7ab0b56..d065eee 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -301,8 +301,9 @@ .sys-msg{align-self:center;font-size:.76rem;color:var(--muted);background:rgba(0,0,0,.04);padding:.25rem .7rem;border-radius:99px;margin:.2rem 0;max-width:90%;text-align:center;} .bubble .t{display:flex;align-items:center;justify-content:flex-end;gap:.25rem;font-size:.64rem;opacity:.65;margin-top:.15rem;} .bubble .rcpt{display:inline-flex;} - .bubble.mine .rcpt{opacity:.8;} - .bubble.mine .rcpt.seen{color:#8fd3ff;opacity:1;} + .bubble.mine .rcpt{opacity:.85;} + /* Read receipt on my own (blue) bubble: brand yellow so the double tick actually stands out. */ + .bubble.mine .rcpt.seen{color:var(--brand);opacity:1;} .att-img{cursor:zoom-in;} .fmt-bar{display:flex;align-items:center;gap:.05rem;padding:.3rem .4rem .1rem;flex-wrap:wrap;border-bottom:1px dashed var(--line);} .fmt-bar button{border:none;background:transparent;color:var(--muted);cursor:pointer;width:30px;height:30px;border-radius:7px;display:grid;place-items:center;} @@ -470,6 +471,9 @@ .bubble:hover .react-btn,.bubble.show-actions .react-btn{opacity:1;pointer-events:auto;} .del-btn{position:absolute;top:-9px;right:58px;background:var(--card);color:var(--red);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);display:grid;place-items:center;} .bubble:hover .del-btn,.bubble.show-actions .del-btn{opacity:1;pointer-events:auto;} + .edit-btn{position:absolute;top:-9px;right:84px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);display:grid;place-items:center;} + .bubble:hover .edit-btn,.bubble.show-actions .edit-btn{opacity:1;pointer-events:auto;} + .bubble .t .edited{font-style:italic;opacity:.8;} .bubble.deleted{opacity:.85;} .bubble.deleted .del-msg{font-style:italic;color:var(--muted);font-size:.9rem;display:inline-flex;align-items:center;gap:.3rem;} .bubble.mine.deleted .del-msg{color:rgba(255,255,255,.85);} @@ -662,6 +666,9 @@ .authcard .sub{color:var(--muted);font-size:.9rem;text-align:center;margin-bottom:1.2rem;} /* Blue card + gold CTA so the sign-in screen carries BOTH brand colours (never mono-blue). */ .authcard .gobtn{background:var(--brand);color:var(--blue-d);} + .gobtn:disabled{opacity:.9;cursor:default;} + .btn-spin{display:inline-block;width:15px;height:15px;border:2px solid rgba(22,41,79,.3);border-top-color:var(--blue-d);border-radius:50%;animation:btnspin .6s linear infinite;vertical-align:-3px;margin-right:7px;} + @keyframes btnspin{to{transform:rotate(360deg);}} .authcard .gobtn:hover{filter:brightness(.95);} .authtabs{display:flex;gap:.5rem;margin-bottom:1.1rem;} .authtabs button{flex:1;background:#eef1f6;color:var(--muted);font-weight:600;border:none;border-radius:9px;padding:.5rem;cursor:pointer;font-size:.9rem;} @@ -682,8 +689,11 @@ /* ---- Loading / toast ---- */ .loading{position:fixed;inset:0;display:grid;place-items:center;background:#1F3B73;z-index:9000;color:rgba(255,255,255,.82);font-size:.9rem;} - .loading .ld-inner{display:flex;flex-direction:column;align-items:center;gap:1rem;} + .loading .ld-inner{display:flex;flex-direction:column;align-items:center;gap:.85rem;} .loading .ld-inner img{width:72px;height:72px;} + .loading .ld-wm{font-size:1.35rem;font-weight:800;color:#fff;letter-spacing:.3px;margin-top:.15rem;} + .loading .ld-wm b{color:var(--brand);font-weight:800;} + .loading .ld-sub{color:rgba(255,255,255,.66);font-size:.82rem;} /* Chat thread loading (light pane) and call-connecting (dark stage) — branded, centered. */ .thread-loading{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.7rem;color:var(--muted);font-size:.86rem;} .call-connecting{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1rem;background:#16294F;color:rgba(255,255,255,.82);font-size:.95rem;} @@ -757,16 +767,16 @@ - -
Loading…
+
Biz Connect
Loading…
- +
Biz Connect
@@ -1283,8 +1293,9 @@ function bubbleHTML(m){ + sender + quote + att + renderMsgBody(m) + pollHTML(m) + '' + '' + + ((mine && !m.deleted && m.body && !m.poll)?'':'') + (mine?'':'') - + ''+pEsc(fmtClock(m.created_at))+rcpt+'' + + ''+pEsc(fmtClock(m.created_at))+(m.edited_at?' · edited':'')+rcpt+'' + reacts + seen + ''; } // reply + emoji state/helpers @@ -1313,6 +1324,36 @@ function setReply(m){ const inp=document.getElementById('msgInput'); if(inp) inp.focus(); } function clearReply(){ replyTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } } +// Edit an existing message (sender only): load its text into the composer, show an "Editing" bar; +// the next send saves the edit instead of posting a new message. +let editTarget=null; +function startEdit(m){ + if(!m) return; clearReply(); editTarget=m; + const bar=document.getElementById('replyBar'); if(bar){ bar.innerHTML=''+ic('edit',13)+' Editing message'+ic('x',15)+''; bar.style.display='flex'; const x=document.getElementById('editCancel'); if(x) x.onclick=cancelEdit; } + const inp=document.getElementById('msgInput'); if(inp){ inp.value=m.body||''; autoGrow(inp); inp.focus(); } +} +function cancelEdit(){ editTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } const inp=document.getElementById('msgInput'); if(inp){ inp.value=''; autoGrow(inp); } } +async function saveEdit(text){ + const m=editTarget; if(!m) return; + if(!text){ cancelEdit(); return; } // empty → treat as cancel (delete is a separate action) + if(text===(m.body||'')){ cancelEdit(); return; } // no change + editTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } + const inp=document.getElementById('msgInput'); if(inp){ inp.value=''; autoGrow(inp); } + try{ + await postJSON('/api/messages/edit',{ id:m.id, body:text }); + const t=THREAD.find(x=>x.id===m.id); if(t){ t.body=text; t.edited_at=Date.now(); updateBubble(t); } + THREAD_CACHE.forEach(arr=>arr.forEach(x=>{ if(x.id===m.id){ x.body=text; x.edited_at=Date.now(); } })); + // keep the chat-list preview in sync if this was the last message + const it=rowFor(selected.kind, selected.id); if(it && it.last_from_me){ const last=THREAD[THREAD.length-1]; if(last && last.id===m.id){ it.last_body=text; renderChats(searchVal()); } } + }catch(e){ toast(e.message||'Could not edit'); } +} +// A message I can see was edited by its sender → update it in place (thread + cache + list preview). +function onChatEdited(d){ + if(!d||!d.id) return; + const t=THREAD.find(x=>x.id===d.id); if(t){ t.body=d.body; t.edited_at=d.edited_at||Date.now(); updateBubble(t); } + THREAD_CACHE.forEach(arr=>arr.forEach(x=>{ if(x.id===d.id){ x.body=d.body; x.edited_at=d.edited_at||Date.now(); } })); + try{ loadSidebar(); }catch(_){} // refresh last-message previews +} // attachments let pendingAttach=null; function fmtSize(b){ b=+b||0; if(b<1024) return b+' B'; if(b<1048576) return Math.round(b/1024)+' KB'; return (b/1048576).toFixed(1)+' MB'; } @@ -1628,7 +1669,7 @@ async function openConvo(kind,id){ el.ondrop=(e)=>{ const f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0]; if(f){ e.preventDefault(); el.classList.remove('drag-over'); uploadFile(f); } }; const back=document.getElementById('convoBack'); if(back) back.onclick=showWelcome; const form=document.getElementById('composer'); if(form) form.addEventListener('submit',(e)=>{ e.preventDefault(); sendMessage(); }); - clearReply(); pendingAttach=null; hideAttach(); + clearReply(); cancelEdit(); pendingAttach=null; hideAttach(); const ab2=document.getElementById('attachBtn'); if(ab2) ab2.onclick=()=>{ const fi=document.getElementById('fileInput'); if(fi) fi.click(); }; const fi=document.getElementById('fileInput'); if(fi) fi.onchange=()=>{ if(fi.files&&fi.files[0]) uploadFile(fi.files[0]); }; const eb=document.getElementById('emojiBtn'); if(eb) eb.onclick=(e)=>{ e.stopPropagation(); emojiOpen?closeEmoji():openEmoji('compose', eb); }; @@ -1657,6 +1698,7 @@ async function openConvo(kind,id){ const po=e.target.closest('.poll-opt'); if(po){ if(!po.disabled) votePoll(po.dataset.poll, +po.dataset.idx); return; } const pcl=e.target.closest('.poll-close'); if(pcl){ closePoll(pcl.dataset.poll); return; } const rb=e.target.closest('.reply-btn'); if(rb){ const mm=THREAD.find(x=>x.id===rb.dataset.id); if(mm) setReply(mm); return; } + const ed=e.target.closest('.edit-btn'); if(ed){ const mm=THREAD.find(x=>x.id===ed.dataset.edit); if(mm) startEdit(mm); return; } const dl=e.target.closest('.del-btn'); if(dl){ deleteMessage(dl.dataset.del); return; } const ab=e.target.closest('.react-btn'); if(ab){ openEmojiForReact(ab.dataset.id, ab); return; } const ch=e.target.closest('.react-chip'); if(ch){ reactToMessage(ch.dataset.id, ch.dataset.emoji); return; } @@ -1857,7 +1899,9 @@ async function selectChat(kind,id){ } async function sendMessage(){ const inp=document.getElementById('msgInput'); if(!inp) return; - const text=inp.value.trim(); if((!text&&!pendingAttach)||!selected) return; + const text=inp.value.trim(); + if(editTarget){ saveEdit(text); return; } // in edit mode → save the edit instead of sending new + if((!text&&!pendingAttach)||!selected) return; inp.value=''; inp.style.height='auto'; setDraft(selected.kind, selected.id, ''); // sent → clear the draft const replyTo=replyTarget?replyTarget.id:null; const attachmentId=pendingAttach?pendingAttach.id:null; @@ -1868,7 +1912,7 @@ async function sendMessage(){ // Dedup by id: the server echoes our message over WS (to sync other tabs) and that echo // can arrive BEFORE this POST resolves, so onChatMessage may have already appended it. if(!THREAD.some(x=>x.id===m.id)){ THREAD.push(m); appendBubble(m); } - clearReply(); pendingAttach=null; hideAttach(); + clearReply(); cancelEdit(); pendingAttach=null; hideAttach(); { const ck=selected.kind+':'+selected.id; if(THREAD_CACHE.has(ck)){ const a=THREAD_CACHE.get(ck); if(!a.some(x=>x.id===m.id)) a.push(m); } } const it=rowFor(selected.kind,selected.id); if(it){ it.last_body=m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):''); it.last_at=m.created_at; it.last_from_me=true; it.last_status='sent'; it.last_msg_id=m.id; it.unread=0; } @@ -2012,16 +2056,14 @@ function notify(title, body, kind, id){ }).catch(()=>{}); return; } - // Web / PWA: standard Notification. Icon = the sender's/group's DP (falling back to their - // initials, then the brand app icon) — never the old wordmark. badge = brand mark. + // Web / PWA: standard Notification. Use the sender's/group's DP URL DIRECTLY as the icon — + // the browser fetches notification icons cross-origin fine, whereas drawing an external DP to a + // canvas taints it and silently fell back to initials (the bug). Brand icon only if no DP. if(!('Notification' in window) || Notification.permission!=='granted') return; - notifAvatarDataUrl(kind, id, String(title||'').replace(/^[^\p{L}\p{N}]+/u,'')).then(avatar=>{ - const n=new Notification(title, { body, icon: avatar || '/icon-192.png', badge:'/icon-192.png' }); - n.onclick=()=>{ n.close(); openFromNotif(kind, id); }; - setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000); - }).catch(()=>{ - try{ const n=new Notification(title, { body, icon:'/icon-192.png' }); n.onclick=()=>{ n.close(); openFromNotif(kind, id); }; }catch(_){} - }); + const _row=rowFor(kind,id)||{}; + const n=new Notification(title, { body, icon: _row.avatar || '/icon-192.png', badge:'/icon-192.png' }); + n.onclick=()=>{ n.close(); openFromNotif(kind, id); }; + setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000); }catch(_){} } let _audioCtx=null; @@ -2108,7 +2150,7 @@ function connectChatWs(){ try{ chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws'); chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} }; - chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='presence') onPresence(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); }; + chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='presence') onPresence(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); }; chatWs.onclose=()=>{ setTimeout(connectChatWs, 3000); }; // auto-reconnect }catch(_){} } @@ -2850,7 +2892,7 @@ async function renderLogin(){ `:''}
- Email + Email or phone Password${pwField('li_pw','password')} @@ -2877,10 +2919,12 @@ async function renderLogin(){ } async function doLogin(){ clearErr('li_err'); + const btn=document.getElementById('li_btn'); const orig=btn?btn.innerHTML:''; + if(btn){ btn.disabled=true; btn.innerHTML='Signing in…'; } // show progress on Enter/click try{ await postJSON('/api/login',{email:document.getElementById('li_email').value,password:document.getElementById('li_pw').value,remember:document.getElementById('li_remember').checked}); - location.reload(); - }catch(e){ showErr('li_err', /invalid credentials/i.test(e.message)?'Incorrect email or password. Please try again.':e.message); } + location.reload(); // the branded splash takes over from here + }catch(e){ if(btn){ btn.disabled=false; btn.innerHTML=orig; } showErr('li_err', /invalid credentials/i.test(e.message)?'Incorrect email or password. Please try again.':e.message); } } async function doRegister(){ clearErr('rg_err'); @@ -2905,15 +2949,18 @@ async function doRegister(){ setupPush(); // register the notification service worker + subscribe to Web Push (if granted) { const cl=document.getElementById('chatlist'); if(cl) enablePullRefresh(cl, loadSidebar); } // pull-to-refresh the chat list setTimeout(maybeNotifPrompt, 1500); // gentle "enable notifications" prompt if still undecided (key for iOS PWA) - document.getElementById('loading').style.display='none'; // Fast-path: when opened from a notification, show the chat immediately (only needs the // thread fetch) and load the sidebar in the background — don't make the reload wait on it. let oid=null, okind='dm'; try{ const q=new URLSearchParams(location.search); oid=q.get('openId'); okind=q.get('openKind')||'dm'; if(oid) history.replaceState(null,'','/home'); }catch(_){} if(oid){ selectChat(okind, oid); // open the chat right away (only waits on the thread fetch) + document.getElementById('loading').style.display='none'; // the chat pane shows its own thread loader loadSidebar().then(()=>{ try{ const it=rowFor(okind,oid); const nm=document.querySelector('#chatPanel .convo-head .nm'); if(it&&nm) nm.textContent=it.name; }catch(_){} }); - } else { await loadSidebar(); renderChatPanel(); } + } else { + await loadSidebar(); renderChatPanel(); + document.getElementById('loading').style.display='none'; // keep the branded splash up until the list is ready (no blank/stuck gap) + } })(); diff --git a/server/public/index.html b/server/public/index.html index e2abd0a..d2fecaf 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -55,7 +55,7 @@
- +
Biz Connect
diff --git a/server/public/logo.png b/server/public/logo.png deleted file mode 100644 index aba2c5c..0000000 Binary files a/server/public/logo.png and /dev/null differ diff --git a/server/public/mark-light.png b/server/public/mark-light.png new file mode 100644 index 0000000..ae0bb46 Binary files /dev/null and b/server/public/mark-light.png differ diff --git a/server/public/share.html b/server/public/share.html index 5ea765d..dfc9b57 100644 --- a/server/public/share.html +++ b/server/public/share.html @@ -67,7 +67,7 @@ Home
- +
Biz Connect
Secure, instant remote support — no downloads, you stay in control.
diff --git a/server/repos.js b/server/repos.js index 2fc92dc..e97f44d 100644 --- a/server/repos.js +++ b/server/repos.js @@ -188,6 +188,7 @@ const messages = { byAttachment: (attachmentId) => db.prepare('SELECT * FROM messages WHERE attachment_id=? LIMIT 1').get(attachmentId), setPoll: (messageId, pollId) => db.prepare('UPDATE messages SET poll_id=? WHERE id=?').run(pollId, messageId), markDelivered: (id) => db.prepare('UPDATE messages SET delivered_at=? WHERE id=? AND delivered_at IS NULL').run(now(), id), + editBody: (id, body) => db.prepare('UPDATE messages SET body=?, edited_at=? WHERE id=?').run(body, now(), id), // Delete-for-everyone: clear the content but keep the row (renders as a placeholder). markDeleted: (id) => db.prepare("UPDATE messages SET deleted=1, body='', attachment_id=NULL, poll_id=NULL WHERE id=?").run(id), // Shared media/files in a conversation (group) or DM — newest first. diff --git a/server/routes.js b/server/routes.js index 0466ea6..133536b 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1282,6 +1282,26 @@ route('POST', '/api/messages/delete', async (req, res) => { else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} } json(res, 200, { ok: true }); }); +// Edit a message (sender only, text only) — updates the body + marks it edited, and pushes the +// change live to the other side / other tabs (mirrors the delete broadcast). +route('POST', '/api/messages/edit', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { id, body } = await readBody(req); + if (!id || typeof body !== 'string') return json(res, 400, { error: 'id and body required' }); + const text = body.trim(); + if (!text) return json(res, 400, { error: 'message cannot be empty' }); + const m = R.messages.byId(id); + if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); + if (m.sender_id !== u.id) return json(res, 403, { error: 'you can only edit your own messages' }); + if (m.deleted) return json(res, 400, { error: 'cannot edit a deleted message' }); + R.messages.editBody(id, text); + const edited = R.messages.byId(id); + const evt = { type: 'chat-edited', id, body: text, edited_at: edited.edited_at, conversation_id: m.conversation_id || null }; + if (m.conversation_id) { for (const mid of R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } + else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} } + json(res, 200, { ok: true, edited_at: edited.edited_at }); +}); // Favourite/unfavourite a conversation (per user). target = 'dm:' or 'group:'. route('POST', '/api/favorites', async (req, res) => { const u = currentUser(req);