fix: unstick scrolling, group toast icon, seen separator, autocorrect (batch65)

1. Scroll felt "stuck": the seen-by card had TWO nested scroll containers
   (.modal overflow-y:auto + inner .seen-scroll) fighting each other — card is
   now overflow:hidden so only the inner list scrolls. Global scrollbar widened
   6→11px, dropped the inset border, added a min thumb length so it's easy to
   grab and drags smoothly everywhere.
2. Group message toast now carries a generated group icon (initials on a brand
   disc) when the group has no photo — was iconless.
3. Seen / Not-seen split by a visual divider in the message-info popup.
4. Auto-correct: curated common-typo dictionary applied as you type (on a word
   boundary) — corrections happen automatically, no right-click. Web/desktop/mobile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 13:56:18 +05:30
parent adafc8c960
commit 1dfb6b16cb
+78 -11
View File
@@ -139,11 +139,13 @@
/* Modern thin scrollbars for every scroll area (chat, modals, lists, …). Without this,
Electron on Windows falls back to the classic scrollbar with up/down stepper arrows. */
::-webkit-scrollbar{width:6px;height:6px;}
::-webkit-scrollbar{width:11px;height:11px;}
::-webkit-scrollbar-button,::-webkit-scrollbar-button:start:decrement,::-webkit-scrollbar-button:end:increment,::-webkit-scrollbar-button:vertical:start,::-webkit-scrollbar-button:vertical:end,::-webkit-scrollbar-button:horizontal:start,::-webkit-scrollbar-button:horizontal:end{display:none!important;width:0!important;height:0!important;}
::-webkit-scrollbar-track{background:transparent;}
::-webkit-scrollbar-thumb{background:#c7d0dd;border-radius:9px;border:1px solid transparent;background-clip:content-box;}
::-webkit-scrollbar-thumb:hover{background:#aab6c8;}
/* Was 6px with a 1px inset border (~4px grabbable) and no min height → the thumb was tiny and felt
"stuck" when dragged. Wider, solid, with a min length so it's easy to grab and drags smoothly. */
::-webkit-scrollbar-thumb{background:#b9c4d4;border-radius:8px;min-height:44px;}
::-webkit-scrollbar-thumb:hover{background:#9fadc2;}
::-webkit-scrollbar-corner{background:transparent;}
*{scrollbar-width:thin;scrollbar-color:#c7d0dd transparent;}
@@ -151,7 +153,8 @@
.chatlist:hover{scrollbar-color:#c7d0dd transparent;}
.chatlist::-webkit-scrollbar{width:6px;}
.chatlist::-webkit-scrollbar-button,.chatlist::-webkit-scrollbar-button:start:decrement,.chatlist::-webkit-scrollbar-button:end:increment{display:none!important;height:0!important;width:0!important;}
.convo-msgs::-webkit-scrollbar{width:6px;} .convo-msgs::-webkit-scrollbar-button{display:none!important;height:0!important;width:0!important;}
.convo-msgs::-webkit-scrollbar{width:10px;} .convo-msgs::-webkit-scrollbar-button{display:none!important;height:0!important;width:0!important;}
.convo-msgs::-webkit-scrollbar-thumb{min-height:44px;}
.chatlist::-webkit-scrollbar-thumb{background:transparent;border-radius:8px;}
.chatlist:hover::-webkit-scrollbar-thumb{background:#c7d0dd;}
/* Pull-to-refresh indicator (mobile) */
@@ -553,10 +556,13 @@
.modal input#grpName:focus,.modal input#giName:focus{outline:none;border-color:var(--brand);}
/* Branded confirm dialog (replaces the OS/Electron window.confirm). */
.bz-confirm .bzc-msg{margin:.2rem 0 1.1rem;color:var(--ink);font-size:.92rem;line-height:1.5;}
.seen-modal{max-width:360px;display:flex;flex-direction:column;max-height:80vh;}
/* overflow:hidden on the CARD so it never becomes a 2nd scroll container — .modal defaults to
overflow-y:auto, which nested against .seen-scroll and made the wheel/drag feel "stuck". */
.seen-modal{max-width:360px;display:flex;flex-direction:column;max-height:80vh;overflow:hidden;}
.seen-modal>h3{flex:0 0 auto;}
/* ONE scroll region for the whole list; the two section headers stick to the top as you scroll */
.seen-modal .seen-scroll{flex:1 1 auto;overflow-y:auto;margin:.2rem -.4rem 0;padding:0 .4rem;min-height:0;}
/* THE single scroll region for the whole list; the two section headers stick as you scroll */
.seen-modal .seen-scroll{flex:1 1 auto;overflow-y:auto;overscroll-behavior:contain;margin:.2rem -.4rem 0;padding:0 .4rem;min-height:0;}
.seen-modal .seen-div{height:1px;background:var(--line,#e3e8f2);margin:.5rem .2rem;}
.seen-modal .seen-row{display:flex;align-items:center;gap:.6rem;padding:.34rem .2rem;}
/* dark lettering on the pastel avatar (white was invisible on the light background), unless a real DP covers it */
.seen-modal .seen-row .mini-av{width:34px;height:34px;flex:0 0 auto;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.76rem;overflow:hidden;position:relative;}
@@ -850,7 +856,7 @@
<body>
<script src="/icons.js?v=5"></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-10-batch64';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-10-batch65';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
// (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
@@ -941,6 +947,51 @@ function firstName(name){return String(name||'').trim().split(/\s+/)[0]||'there'
const AV_COLORS=['#dbeafe','#e0e7ff','#dcfce7','#cffafe','#fae8ff','#fce7f3','#fee2e2','#fef3c7','#ecfccb','#fde4cf'];
function avColor(name){let h=0;for(const c of String(name))h=(h*31+c.charCodeAt(0))>>>0;return AV_COLORS[h%AV_COLORS.length];}
// Auto-correct: curated high-confidence common-typo → fix map. Applied as you type (on a word
// boundary), so corrections happen automatically — no right-click menu. Works on web/desktop/mobile.
// Kept conservative (only unambiguous typos) to avoid "fighting" the user. Ctrl+Z reverts a fix.
const AUTOCORRECT={
teh:'the',hte:'the',thier:'their',recieve:'receive',recieved:'received',beleive:'believe',beleived:'believed',
seperate:'separate',definately:'definitely',occured:'occurred',occuring:'occurring',untill:'until',wich:'which',
becuase:'because',becasue:'because',accross:'across',adress:'address',adressed:'addressed',alot:'a lot',
arguement:'argument',calender:'calendar',cemetary:'cemetery',changable:'changeable',collegue:'colleague',
comming:'coming',commited:'committed',concious:'conscious',definetly:'definitely',dissapoint:'disappoint',
embarass:'embarrass',enviroment:'environment',existance:'existence',familier:'familiar',finaly:'finally',
foriegn:'foreign',fourty:'forty',freind:'friend',gaurd:'guard',goverment:'government',grammer:'grammar',
happend:'happened',harrass:'harass',immediatly:'immediately',independant:'independent',infomation:'information',
knowlege:'knowledge',liason:'liaison',libary:'library',lisence:'license',maintainance:'maintenance',
mispell:'misspell',neccessary:'necessary',necesary:'necessary',noticable:'noticeable',occassion:'occasion',
occurance:'occurrence',ocurrence:'occurrence',paralell:'parallel',parliment:'parliament',particurly:'particularly',
perhasp:'perhaps',persistant:'persistent',posession:'possession',prefered:'preferred',privelege:'privilege',
probaly:'probably',probelm:'problem',proffesional:'professional',promiss:'promise',pronounciation:'pronunciation',
publically:'publicly',realy:'really',reccomend:'recommend',recomend:'recommend',refered:'referred',
relevent:'relevant',religous:'religious',rember:'remember',remeber:'remember',resturant:'restaurant',
rythm:'rhythm',secratary:'secretary',sieze:'seize',similiar:'similar',succesful:'successful',succesfully:'successfully',
suprise:'surprise',suprised:'surprised',tommorow:'tomorrow',tommorrow:'tomorrow',tounge:'tongue',truely:'truly',
unfortunatly:'unfortunately',usualy:'usually',vaccum:'vacuum',wierd:'weird',writen:'written',yeild:'yield',
yuo:'you',youre:"you're",cant:"can't",dont:"don't",doesnt:"doesn't",didnt:"didn't",wasnt:"wasn't",werent:"weren't",
isnt:"isn't",wouldnt:"wouldn't",couldnt:"couldn't",shouldnt:"shouldn't",hasnt:"hasn't",havent:"haven't",
ive:"I've",thats:"that's",whats:"what's",lets:"let's",
aer:'are',adn:'and',nad:'and',wnat:'want',waht:'what',knwo:'know',
woudl:'would',shoudl:'should',coudl:'could',funtion:'function',retrun:'return',
};
// Apply an auto-correction if the caret just crossed a word boundary onto a known typo.
function maybeAutocorrect(el){
try{
if(!el||el.selectionStart==null||el.selectionStart!==el.selectionEnd) return;
const pos=el.selectionStart, val=el.value, ch=val[pos-1];
if(!ch || !/[\s.,!?;:]/.test(ch)) return; // only right after a boundary char
const before=val.slice(0,pos-1); const m=before.match(/([A-Za-z']+)$/); if(!m) return;
const word=m[1], lower=word.toLowerCase(); const rep=AUTOCORRECT[lower];
if(!rep) return; // not a known typo (native squiggles still flag others)
const fixed=/^[A-Z]/.test(word)?(rep.charAt(0).toUpperCase()+rep.slice(1)):rep;
if(fixed===word) return;
const start=pos-1-word.length;
el.value=val.slice(0,start)+fixed+val.slice(pos-1);
const np=start+fixed.length+1; el.setSelectionRange(np,np);
}catch(_){}
}
// All in-app toasts route through the branded BZToast (bizconnect-toast.js). We keep the single
// toast(msg) entry point so every existing call site is unchanged, and classify by wording so
// errors/successes get the right colour+icon; everything else shows as a neutral message.
@@ -1732,7 +1783,7 @@ function showSeenByModal(msg){
let inner='';
if(seenNames.length) inner+='<div class="seen-sec"><span class="dot ok"></span>Seen · '+seenNames.length+'</div>'+rowsFromNames(seenNames);
else inner+='<div class="gi-noresult" style="padding:.6rem .2rem">No one has seen this yet.</div>';
if(convoIsGroup && notSeen.length) inner+='<div class="seen-sec dim"><span class="dot"></span>Not seen yet · '+notSeen.length+'</div>'+rowsFromMembers(notSeen);
if(convoIsGroup && notSeen.length) inner+='<div class="seen-div"></div><div class="seen-sec dim"><span class="dot"></span>Not seen yet · '+notSeen.length+'</div>'+rowsFromMembers(notSeen);
// ONE scroll container for the whole list (section headers stick as you scroll) — not a scrollbar
// per section, which split the popup confusingly.
const body='<div class="seen-scroll">'+inner+'</div>';
@@ -2019,7 +2070,7 @@ async function openConvo(kind,id){
if(inpEl){
const _d=getDraft(kind,id); if(_d){ inpEl.value=_d; autoGrow(inpEl); } // restore unsent draft
inpEl.addEventListener('paste', onPaste);
inpEl.addEventListener('input', ()=>{ autoGrow(inpEl); setDraft(kind,id,inpEl.value); noteTyping(); }); // keep the draft in sync + emit "typing…"
inpEl.addEventListener('input', ()=>{ maybeAutocorrect(inpEl); autoGrow(inpEl); setDraft(kind,id,inpEl.value); noteTyping(); }); // auto-correct common typos, keep the draft in sync + emit "typing…"
inpEl.addEventListener('blur', stopTyping);
inpEl.addEventListener('keydown', (e)=>{ if(e.key==='Enter' && !e.shiftKey){ if(mentionItems && mentionItems.length) return; e.preventDefault(); sendMessage(); } }); // Enter sends, Shift+Enter = newline
}
@@ -2469,13 +2520,29 @@ function onNotifClear(d){
// Drop matching activity-center entries so the bell badge stays in sync.
try{ if(Array.isArray(NOTIFS)){ const before=NOTIFS.length; NOTIFS=NOTIFS.filter(x=>!(x.link&&x.link.kind===d.kind&&x.link.id===d.id)); if(NOTIFS.length!==before){ saveNotifs&&saveNotifs(); updateBellBadge&&updateBellBadge(); } } }catch(_){}
}
// Draw a round initials avatar to a PNG data URL (synchronous — no external image, so it never taints
// the canvas). Used as the desktop toast icon when a chat/group has no photo, so every toast shows one.
function initialsAvatarDataUrl(name, isGroup){
try{
const s=96, c=document.createElement('canvas'); c.width=s; c.height=s; const g=c.getContext('2d');
g.beginPath(); g.arc(s/2,s/2,s/2,0,2*Math.PI); g.fillStyle=isGroup?'#1F3B73':avColor(name||'?'); g.fill();
g.fillStyle=isGroup?'#fff':'#334155'; g.font='bold 40px system-ui,Segoe UI,sans-serif'; g.textAlign='center'; g.textBaseline='middle';
g.fillText(initials(name||'?'), s/2, s/2+2);
return c.toDataURL('image/png');
}catch(_){ return null; }
}
function notify(title, body, kind, id, opts){
const persistent=!!(opts&&opts.persistent);
try{
// Desktop app: native toast with the sender/group DP. Pass the DP URL directly — the shell
// downloads it for the icon (drawing an external DP to a canvas here tainted it → initials).
if(window.bizConnectNative && window.bizConnectNative.replyNotify){
const _av=(rowFor(kind,id)||{}).avatar||null;
const _row=rowFor(kind,id)||{};
// Groups (and contacts) with no photo URL had NO toast icon at all. Draw a generated icon
// (initials on a brand-tinted disc — no external image, so no canvas taint) so every toast,
// especially group messages, carries a recognizable avatar.
let _av=_row.avatar||null;
if(!_av){ try{ _av=initialsAvatarDataUrl(_row.name||title, kind==='group'); }catch(_){ _av=null; } }
Promise.resolve(window.bizConnectNative.replyNotify({title, body, kind, id, avatar:_av, persistent}))
.then(r=>{ if(!r) return;
if(r.text) sendReplyTo(kind, id, r.text); // replied from the toast