This commit is contained in:
avinash
2026-07-02 17:58:14 +05:30
10 changed files with 99 additions and 29 deletions
+2
View File
@@ -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 // 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. // 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 */ } 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.) // 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 */ } 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 // BizGaze person-id (s.userId): the SAME value whether the person signs in with their email or
+1 -1
View File
@@ -76,7 +76,7 @@
<script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script> <script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
<a href="/home" id="homeLink"><span data-ic="arrowLeft" data-sz="16"></span> Home</a> <a href="/home" id="homeLink"><span data-ic="arrowLeft" data-sz="16"></span> Home</a>
<div class="topbar" id="topbar"> <div class="topbar" id="topbar">
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">Biz <span>Connect</span></div></div> <div class="brandrow"><img src="/mark-light.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">Biz <span>Connect</span></div></div>
<div class="agentchip" id="agentChip"></div> <div class="agentchip" id="agentChip"></div>
</div> </div>
<div class="topbar2" id="bar"><span id="barStatus"></span><button id="endBtn">End session</button></div> <div class="topbar2" id="bar"><span id="barStatus"></span><button id="endBtn">End session</button></div>
+1 -1
View File
@@ -92,7 +92,7 @@
</head> </head>
<body> <body>
<header> <header>
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">Biz <span class="y">Connect</span> <span class="tag">· Dashboard</span></div></div> <div class="brandrow"><img src="/mark-light.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">Biz <span class="y">Connect</span> <span class="tag">· Dashboard</span></div></div>
<div class="row" id="hdrRight"></div> <div class="row" id="hdrRight"></div>
</header> </header>
<main id="app"></main> <main id="app"></main>
+70 -23
View File
@@ -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;} .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 .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 .rcpt{display:inline-flex;}
.bubble.mine .rcpt{opacity:.8;} .bubble.mine .rcpt{opacity:.85;}
.bubble.mine .rcpt.seen{color:#8fd3ff;opacity:1;} /* 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;} .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{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;} .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;} .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;} .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;} .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{opacity:.85;}
.bubble.deleted .del-msg{font-style:italic;color:var(--muted);font-size:.9rem;display:inline-flex;align-items:center;gap:.3rem;} .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);} .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;} .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). */ /* 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);} .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);} .authcard .gobtn:hover{filter:brightness(.95);}
.authtabs{display:flex;gap:.5rem;margin-bottom:1.1rem;} .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;} .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 / 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{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-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. */ /* 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;} .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;} .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 @@
<body> <body>
<script src="/icons.js?v=4"></script> <script src="/icons.js?v=4"></script>
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
<script>window.__BUILD='2026-07-02-batch33';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-02-batch37';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load // Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
// (emojis stay as plain Unicode). (#5) // (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script> function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
<div class="loading" id="loading"><div class="ld-inner"><img src="/loaders/loader-orbit-dark.svg" alt="" width="72" height="72"><span>Loading…</span></div></div> <div class="loading" id="loading"><div class="ld-inner"><img src="/loaders/loader-orbit-dark.svg" alt="" width="72" height="72"><div class="ld-wm">Biz <b>Connect</b></div><span class="ld-sub">Loading…</span></div></div>
<header> <header>
<div class="brandrow"> <div class="brandrow">
<button class="navtoggle" id="navToggle" title="Toggle menu" aria-label="Toggle menu"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button> <button class="navtoggle" id="navToggle" title="Toggle menu" aria-label="Toggle menu"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
<img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"> <img src="/mark-light.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))">
<div class="brand">Biz <span class="y">Connect</span></div> <div class="brand">Biz <span class="y">Connect</span></div>
</div> </div>
<div id="hdrRight"></div> <div id="hdrRight"></div>
@@ -1283,8 +1293,9 @@ function bubbleHTML(m){
+ sender + quote + att + renderMsgBody(m) + pollHTML(m) + sender + quote + att + renderMsgBody(m) + pollHTML(m)
+ '<button class="reply-btn" data-id="'+pEsc(m.id)+'" title="Reply">'+ic('reply',14)+'</button>' + '<button class="reply-btn" data-id="'+pEsc(m.id)+'" title="Reply">'+ic('reply',14)+'</button>'
+ '<button class="react-btn" data-id="'+pEsc(m.id)+'" title="React">'+ic('smilePlus',14)+'</button>' + '<button class="react-btn" data-id="'+pEsc(m.id)+'" title="React">'+ic('smilePlus',14)+'</button>'
+ ((mine && !m.deleted && m.body && !m.poll)?'<button class="edit-btn" data-edit="'+pEsc(m.id)+'" title="Edit message">'+ic('edit',13)+'</button>':'')
+ (mine?'<button class="del-btn" data-del="'+pEsc(m.id)+'" title="Delete message">'+ic('trash',13)+'</button>':'') + (mine?'<button class="del-btn" data-del="'+pEsc(m.id)+'" title="Delete message">'+ic('trash',13)+'</button>':'')
+ '<span class="t">'+pEsc(fmtClock(m.created_at))+rcpt+'</span>' + '<span class="t">'+pEsc(fmtClock(m.created_at))+(m.edited_at?'<span class="edited"> · edited</span>':'')+rcpt+'</span>'
+ reacts + seen + '</div>'; + reacts + seen + '</div>';
} }
// reply + emoji state/helpers // reply + emoji state/helpers
@@ -1313,6 +1324,36 @@ function setReply(m){
const inp=document.getElementById('msgInput'); if(inp) inp.focus(); 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=''; } } 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='<span>'+ic('edit',13)+' Editing message</span><span class="rx" id="editCancel">'+ic('x',15)+'</span>'; 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 // attachments
let pendingAttach=null; 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'; } 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); } }; 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 back=document.getElementById('convoBack'); if(back) back.onclick=showWelcome;
const form=document.getElementById('composer'); if(form) form.addEventListener('submit',(e)=>{ e.preventDefault(); sendMessage(); }); 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 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 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); }; 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 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 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 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 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 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; } 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(){ async function sendMessage(){
const inp=document.getElementById('msgInput'); if(!inp) return; 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 inp.value=''; inp.style.height='auto'; setDraft(selected.kind, selected.id, ''); // sent → clear the draft
const replyTo=replyTarget?replyTarget.id:null; const replyTo=replyTarget?replyTarget.id:null;
const attachmentId=pendingAttach?pendingAttach.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 // 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. // 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); } 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 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); 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; } 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(()=>{}); }).catch(()=>{});
return; return;
} }
// Web / PWA: standard Notification. Icon = the sender's/group's DP (falling back to their // Web / PWA: standard Notification. Use the sender's/group's DP URL DIRECTLY as the icon —
// initials, then the brand app icon) — never the old wordmark. badge = brand mark. // 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; if(!('Notification' in window) || Notification.permission!=='granted') return;
notifAvatarDataUrl(kind, id, String(title||'').replace(/^[^\p{L}\p{N}]+/u,'')).then(avatar=>{ const _row=rowFor(kind,id)||{};
const n=new Notification(title, { body, icon: avatar || '/icon-192.png', badge:'/icon-192.png' }); const n=new Notification(title, { body, icon: _row.avatar || '/icon-192.png', badge:'/icon-192.png' });
n.onclick=()=>{ n.close(); openFromNotif(kind, id); }; n.onclick=()=>{ n.close(); openFromNotif(kind, id); };
setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000); 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(_){}
});
}catch(_){} }catch(_){}
} }
let _audioCtx=null; let _audioCtx=null;
@@ -2108,7 +2150,7 @@ function connectChatWs(){
try{ try{
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws'); chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} }; 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 chatWs.onclose=()=>{ setTimeout(connectChatWs, 3000); }; // auto-reconnect
}catch(_){} }catch(_){}
} }
@@ -2850,7 +2892,7 @@ async function renderLogin(){
<button id="tabReg">Register team</button> <button id="tabReg">Register team</button>
</div>`:''} </div>`:''}
<form id="loginForm"> <form id="loginForm">
<span class="lbl">Email</span><input id="li_email" type="email" placeholder="you@bizgaze.com" autocomplete="username"> <span class="lbl">Email or phone</span><input id="li_email" type="text" inputmode="email" placeholder="you@bizgaze.com or mobile number" autocomplete="username">
<span class="lbl">Password</span>${pwField('li_pw','password')} <span class="lbl">Password</span>${pwField('li_pw','password')}
<label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="li_remember" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label> <label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="li_remember" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
<button class="gobtn" id="li_btn" type="submit">Sign in</button> <button class="gobtn" id="li_btn" type="submit">Sign in</button>
@@ -2877,10 +2919,12 @@ async function renderLogin(){
} }
async function doLogin(){ async function doLogin(){
clearErr('li_err'); clearErr('li_err');
const btn=document.getElementById('li_btn'); const orig=btn?btn.innerHTML:'';
if(btn){ btn.disabled=true; btn.innerHTML='<span class="btn-spin"></span>Signing in…'; } // show progress on Enter/click
try{ try{
await postJSON('/api/login',{email:document.getElementById('li_email').value,password:document.getElementById('li_pw').value,remember:document.getElementById('li_remember').checked}); await postJSON('/api/login',{email:document.getElementById('li_email').value,password:document.getElementById('li_pw').value,remember:document.getElementById('li_remember').checked});
location.reload(); location.reload(); // the branded splash takes over from here
}catch(e){ showErr('li_err', /invalid credentials/i.test(e.message)?'Incorrect email or password. Please try again.':e.message); } }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(){ async function doRegister(){
clearErr('rg_err'); clearErr('rg_err');
@@ -2905,15 +2949,18 @@ async function doRegister(){
setupPush(); // register the notification service worker + subscribe to Web Push (if granted) 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 { 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) 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 // 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. // thread fetch) and load the sidebar in the background — don't make the reload wait on it.
let oid=null, okind='dm'; 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(_){} 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){ if(oid){
selectChat(okind, oid); // open the chat right away (only waits on the thread fetch) 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(_){} }); 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)
}
})(); })();
</script> </script>
</body> </body>
+1 -1
View File
@@ -55,7 +55,7 @@
<body> <body>
<header> <header>
<div class="brandrow"> <div class="brandrow">
<img src="/logo.png" alt="" style="height:40px;width:auto;max-width:170px;border-radius:8px;object-fit:contain;background:#fff;padding:4px 10px" onerror="this.style.display='none'"> <img src="/mark-light.png" alt="" style="height:40px;width:auto;max-width:170px;border-radius:8px;object-fit:contain" onerror="this.style.display='none'">
<div class="brand">Biz <span>Connect</span></div> <div class="brand">Biz <span>Connect</span></div>
</div> </div>
<div class="hdr-right"> <div class="hdr-right">
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1 -1
View File
@@ -67,7 +67,7 @@
<a href="/" id="homeLink" style="position:fixed;top:14px;left:16px;z-index:50;display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.92);color:#1F3B73;text-decoration:none;font-weight:600;font-size:.86rem;padding:.45rem .8rem;border-radius:10px;box-shadow:0 4px 12px rgba(0,0,0,.15)"><span data-ic="arrowLeft" data-sz="16"></span> Home</a> <a href="/" id="homeLink" style="position:fixed;top:14px;left:16px;z-index:50;display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.92);color:#1F3B73;text-decoration:none;font-weight:600;font-size:.86rem;padding:.45rem .8rem;border-radius:10px;box-shadow:0 4px 12px rgba(0,0,0,.15)"><span data-ic="arrowLeft" data-sz="16"></span> Home</a>
<div class="stage"> <div class="stage">
<div class="brandpanel"> <div class="brandpanel">
<img src="/logo.png" alt="" style="width:88px;height:88px;border-radius:22px;object-fit:contain;margin-bottom:1.2rem;background:#fff;padding:8px;box-shadow:0 12px 30px rgba(0,0,0,.25)" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'mark',textContent:'B'}))"> <img src="/icon-512.png" alt="" style="width:88px;height:88px;border-radius:22px;object-fit:contain;margin-bottom:1.2rem;background:#fff;padding:8px;box-shadow:0 12px 30px rgba(0,0,0,.25)" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'mark',textContent:'B'}))">
<div class="wordmark">Biz <span>Connect</span></div> <div class="wordmark">Biz <span>Connect</span></div>
<div class="tagline">Secure, instant remote support — no downloads, you stay in control.</div> <div class="tagline">Secure, instant remote support — no downloads, you stay in control.</div>
</div> </div>
+1
View File
@@ -188,6 +188,7 @@ const messages = {
byAttachment: (attachmentId) => db.prepare('SELECT * FROM messages WHERE attachment_id=? LIMIT 1').get(attachmentId), 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), 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), 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). // 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), 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. // Shared media/files in a conversation (group) or DM — newest first.
+20
View File
@@ -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 (_) {} } else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} }
json(res, 200, { ok: true }); 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:<userId>' or 'group:<groupId>'. // Favourite/unfavourite a conversation (per user). target = 'dm:<userId>' or 'group:<groupId>'.
route('POST', '/api/favorites', async (req, res) => { route('POST', '/api/favorites', async (req, res) => {
const u = currentUser(req); const u = currentUser(req);