feat(chat): older-history pagination + full-thread server-side search
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>
This commit is contained in:
+60
-17
@@ -251,6 +251,8 @@
|
||||
.csh-nav:hover{background:#dbe6fb;}
|
||||
mark.search-hit{background:#fde68a;color:inherit;border-radius:2px;padding:0 1px;}
|
||||
mark.search-current{background:#f59e0b;color:#1f2430;}
|
||||
.bubble.search-flash{animation:searchFlash 1.4s ease;}
|
||||
@keyframes searchFlash{0%,20%{box-shadow:0 0 0 3px var(--brand);}100%{box-shadow:0 0 0 0 transparent;}}
|
||||
.gi-media-row{display:flex;align-items:center;justify-content:space-between;width:100%;background:#f6f8fb;border:1px solid var(--line);border-radius:10px;padding:.65rem .8rem;cursor:pointer;color:var(--ink);font-size:.92rem;font-weight:600;}
|
||||
.gi-media-row:hover{background:#eef2f8;}
|
||||
.gi-media-row .gmr-l{display:flex;align-items:center;gap:.6rem;}
|
||||
@@ -788,7 +790,7 @@
|
||||
<body>
|
||||
<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>window.__BUILD='2026-07-07-batch45';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-07-batch46';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>
|
||||
@@ -995,11 +997,33 @@ function enablePullRefresh(el, onRefresh){
|
||||
el.addEventListener('touchend',async()=>{ if(!pulling) return; pulling=false; if(dist>=TRIGGER){ busy=true; ind.classList.add('spin'); ind.style.opacity='1'; ind.style.transform='translateY(14px)'; try{ await onRefresh(); }catch(_){} await new Promise(r=>setTimeout(r,250)); ind.classList.remove('spin'); busy=false; } ind.style.opacity='0'; ind.style.transform='translateY(-48px)'; dist=0; });
|
||||
}
|
||||
async function reloadThread(){ if(!selected) return; const kind=selected.kind, id=selected.id; const url=kind==='group'?('/api/messages/thread?group='+encodeURIComponent(id)):('/api/messages/thread?with='+encodeURIComponent(id)); try{ const msgs=await fetch(url).then(r=>r.json()); if(selected&&selected.kind===kind&&selected.id===id&&Array.isArray(msgs)){ THREAD=msgs; THREAD_CACHE.set(kind+':'+id, THREAD.slice()); renderThread(); } }catch(_){} }
|
||||
// ---- Older-history pagination: the thread loads the newest ~500; scrolling to the top fetches the
|
||||
// previous page (messages older than the oldest one loaded) and prepends them, preserving scroll. ----
|
||||
const PAGE=500;
|
||||
let _hasMoreOlder=false, _loadingOlder=false;
|
||||
function threadUrl(kind,id,before){ return (kind==='group'?'/api/messages/thread?group='+encodeURIComponent(id):'/api/messages/thread?with='+encodeURIComponent(id))+(before?('&before='+before):''); }
|
||||
async function loadOlder(){
|
||||
if(_loadingOlder || !_hasMoreOlder || !selected || !THREAD.length) return;
|
||||
_loadingOlder=true;
|
||||
const kind=selected.kind, id=selected.id, before=THREAD[0].created_at;
|
||||
const box=document.getElementById('msgs'); const prevH=box?box.scrollHeight:0, prevTop=box?box.scrollTop:0;
|
||||
let older=null; try{ const r=await fetch(threadUrl(kind,id,before)); if(r.ok) older=await r.json(); }catch(_){}
|
||||
if(!selected||selected.kind!==kind||selected.id!==id){ _loadingOlder=false; return; }
|
||||
if(Array.isArray(older) && older.length){
|
||||
const have=new Set(THREAD.map(m=>m.id)); const add=older.filter(m=>!have.has(m.id));
|
||||
if(add.length){ THREAD=add.concat(THREAD); THREAD_CACHE.set(kind+':'+id, THREAD.slice()); }
|
||||
if(older.length<PAGE) _hasMoreOlder=false;
|
||||
renderThread(true); // keep scroll (don't jump to bottom)
|
||||
if(box) box.scrollTop = box.scrollHeight - prevH + prevTop; // anchor on the same message
|
||||
} else { _hasMoreOlder=false; }
|
||||
_loadingOlder=false;
|
||||
}
|
||||
// Keep loading older pages until a target timestamp is in the loaded window (used by search jump-to).
|
||||
async function ensureLoadedBack(at){ let guard=0; while(_hasMoreOlder && THREAD.length && THREAD[0].created_at > at && guard<60){ await loadOlder(); guard++; } }
|
||||
// #9: in-chat search — highlight every match and jump between them with up/down (no filtering).
|
||||
let _searchHits=[], _searchIdx=-1;
|
||||
let _searchHits=[], _searchIdx=-1, _searchQ='', _searchDebT=null; // _searchHits: server hits [{id,at}] oldest-first
|
||||
function clearSearchHighlights(){
|
||||
const box=document.getElementById('msgs'); if(box){ box.querySelectorAll('mark.search-hit').forEach(m=>{ const t=document.createTextNode(m.textContent); m.replaceWith(t); }); box.querySelectorAll('.bubble').forEach(b=>{ try{ b.normalize(); }catch(_){} }); }
|
||||
_searchHits=[]; _searchIdx=-1;
|
||||
}
|
||||
function _highlightIn(root, ql){
|
||||
const hits=[];
|
||||
@@ -1010,20 +1034,37 @@ function _highlightIn(root, ql){
|
||||
if(last>0){ if(last<txt.length) frag.appendChild(document.createTextNode(txt.slice(last))); n.parentNode.replaceChild(frag,n); } });
|
||||
return hits;
|
||||
}
|
||||
function runSearch(q){
|
||||
// Full-thread search: ask the server for ALL matches (not just the loaded window), debounced. Then
|
||||
// jump to any hit — paging older history in if the match is above what's currently loaded.
|
||||
function runSearch(q){ clearTimeout(_searchDebT); _searchDebT=setTimeout(()=>doSearch(q), 220); }
|
||||
async function doSearch(q){
|
||||
clearSearchHighlights();
|
||||
const box=document.getElementById('msgs'), cnt=document.getElementById('convoSearchCount'); if(!box) return;
|
||||
q=(q||'').trim(); if(!q){ if(cnt) cnt.textContent=''; return; }
|
||||
_searchHits=_highlightIn(box, q.toLowerCase());
|
||||
if(!_searchHits.length){ if(cnt) cnt.textContent='0/0'; return; }
|
||||
gotoHit(_searchHits.length-1); // start at the most recent match
|
||||
const cnt=document.getElementById('convoSearchCount');
|
||||
q=(q||'').trim(); _searchQ=q; _searchHits=[]; _searchIdx=-1;
|
||||
if(!q){ if(cnt) cnt.textContent=''; return; }
|
||||
if(!selected) return;
|
||||
const kind=selected.kind, id=selected.id;
|
||||
const url=(kind==='group'?'/api/messages/search?group='+encodeURIComponent(id):'/api/messages/search?with='+encodeURIComponent(id))+'&q='+encodeURIComponent(q);
|
||||
let hits=[]; try{ const r=await fetch(url); if(r.ok){ const j=await r.json(); hits=j.hits||[]; } }catch(_){}
|
||||
if(!selected||selected.kind!==kind||selected.id!==id||_searchQ!==q) return; // stale (switched chat / kept typing)
|
||||
_searchHits=hits;
|
||||
if(!hits.length){ if(cnt) cnt.textContent='0/0'; return; }
|
||||
gotoHit(hits.length-1); // jump to the most recent match first
|
||||
}
|
||||
function gotoHit(i){
|
||||
if(!_searchHits.length) return;
|
||||
_searchIdx=((i%_searchHits.length)+_searchHits.length)%_searchHits.length;
|
||||
_searchHits.forEach(m=>m.classList.remove('search-current'));
|
||||
const m=_searchHits[_searchIdx]; m.classList.add('search-current'); try{ m.scrollIntoView({block:'center'}); }catch(_){}
|
||||
const cnt=document.getElementById('convoSearchCount'); if(cnt) cnt.textContent=(_searchIdx+1)+'/'+_searchHits.length;
|
||||
function _bubbleEl(id){ const box=document.getElementById('msgs'); if(!box) return null; const esc=(window.CSS&&CSS.escape)?CSS.escape(id):id; return box.querySelector('.bubble[data-id="'+esc+'"]'); }
|
||||
async function gotoHit(i){
|
||||
const hits=_searchHits; if(!hits.length) return;
|
||||
_searchIdx=((i%hits.length)+hits.length)%hits.length;
|
||||
const hit=hits[_searchIdx];
|
||||
const cnt=document.getElementById('convoSearchCount'); if(cnt) cnt.textContent=(_searchIdx+1)+'/'+hits.length;
|
||||
let el=_bubbleEl(hit.id);
|
||||
if(!el){ await ensureLoadedBack(hit.at); if(_searchIdx!==((i%hits.length)+hits.length)%hits.length) return; el=_bubbleEl(hit.id); } // older than the loaded window → page back to it
|
||||
clearSearchHighlights();
|
||||
if(!el) return;
|
||||
_highlightIn(el, _searchQ.toLowerCase());
|
||||
const mk=el.querySelector('mark.search-hit');
|
||||
if(mk){ mk.classList.add('search-current'); try{ mk.scrollIntoView({block:'center'}); }catch(_){} } else { try{ el.scrollIntoView({block:'center'}); }catch(_){} }
|
||||
el.classList.add('search-flash'); setTimeout(()=>{ try{ el.classList.remove('search-flash'); }catch(_){} }, 1400);
|
||||
}
|
||||
function closeSearch(){ clearSearchHighlights(); const h=document.getElementById('convoSearchHead'); if(h) h.style.display='none'; const i=document.getElementById('convoSearchInput'); if(i) i.value=''; }
|
||||
// #10: "Shared" — Media (images) + Files tabs for a DM or group, opened by tapping the chat name.
|
||||
@@ -1691,6 +1732,7 @@ function updateBubble(m){
|
||||
// Floating date pill (updates to the day at the top of the viewport) + jump-to-latest button.
|
||||
function onMsgsScroll(){
|
||||
const box=document.getElementById('msgs'); if(!box) return;
|
||||
if(box.scrollTop < 120 && _hasMoreOlder && !_loadingOlder) loadOlder(); // near the top → pull in older history
|
||||
const nearBottom=(box.scrollHeight - box.scrollTop - box.clientHeight) < 120;
|
||||
const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display=nearBottom?'none':'grid';
|
||||
const fd=document.getElementById('floatDate'); if(!fd) return;
|
||||
@@ -1703,13 +1745,13 @@ function dayKey(ts){ return new Date(ts||Date.now()).toDateString(); }
|
||||
function dayLabel(ts){ const d=new Date(ts||Date.now()), n=new Date(); if(d.toDateString()===n.toDateString()) return 'Today'; const y=new Date(n); y.setDate(n.getDate()-1); if(d.toDateString()===y.toDateString()) return 'Yesterday'; return d.toLocaleDateString([], {weekday:'long', month:'long', day:'numeric', year:'numeric'}); }
|
||||
let _lastDay='', _lastMineId=''; // _lastMineId: only my newest message shows the group "Seen by"
|
||||
function lastMineId(){ for(let i=THREAD.length-1;i>=0;i--){ if(THREAD[i].from===ME.id && !THREAD[i].system) return THREAD[i].id; } return ''; }
|
||||
function renderThread(){
|
||||
function renderThread(keepScroll){
|
||||
const box=document.getElementById('msgs'); if(!box) return;
|
||||
rendered.clear(); _lastDay=''; _lastMineId=lastMineId();
|
||||
if(!THREAD.length){ box.innerHTML='<div class="empty-thread">No messages yet — say hello 👋</div>'; return; }
|
||||
let html='';
|
||||
for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='<div class="day-sep"><span>'+pEsc(dayLabel(m.created_at))+'</span></div>'; _lastDay=dk; } rendered.add(m.id); html+=bubbleHTML(m); }
|
||||
box.innerHTML=html; twemojify(box); box.scrollTop=box.scrollHeight;
|
||||
box.innerHTML=html; twemojify(box); if(!keepScroll) box.scrollTop=box.scrollHeight; // keepScroll: prepending older history — don't jump to bottom
|
||||
}
|
||||
function appendBubble(m){
|
||||
if(rendered.has(m.id)) return; rendered.add(m.id);
|
||||
@@ -1809,6 +1851,7 @@ async function openConvo(kind,id){
|
||||
}
|
||||
THREAD=msgs;
|
||||
THREAD_CACHE.set(ckey, THREAD.slice());
|
||||
_hasMoreOlder = msgs.length >= PAGE; _loadingOlder=false; // a full page back means there may be older history
|
||||
renderThread();
|
||||
// #3: if there were unread messages, drop a "New messages" divider, scroll to the first unread,
|
||||
// and show the "jump to newest" arrow so the user can return to the bottom.
|
||||
|
||||
Reference in New Issue
Block a user