From eb79823fd2f02294273633bb7b0d94d59d22645d Mon Sep 17 00:00:00 2001 From: sravan Date: Tue, 7 Jul 2026 15:21:14 +0530 Subject: [PATCH] feat(chat): older-history pagination + full-thread server-side search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Older messages (beyond the newest 500) now load as you scroll to the top — the thread endpoint takes a ?before= 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 --- server/public/home.html | 77 ++++++++++++++++++++++++++++++++--------- server/repos.js | 19 +++++++--- server/routes.js | 31 ++++++++++++++--- 3 files changed, 101 insertions(+), 26 deletions(-) diff --git a/server/public/home.html b/server/public/home.html index 28fc5bf..609ca72 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -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 @@ - @@ -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 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(lastdoSearch(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='
No messages yet — say hello 👋
'; return; } let html=''; for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='
'+pEsc(dayLabel(m.created_at))+'
'; _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. diff --git a/server/repos.js b/server/repos.js index 8423436..d0de569 100644 --- a/server/repos.js +++ b/server/repos.js @@ -207,12 +207,18 @@ const messages = { // Full 1:1 (DM) thread between two users (both directions). Take the NEWEST `limit` messages // (inner DESC), then present them oldest-first. The old plain "ASC LIMIT" returned the OLDEST 300 // and silently dropped everything newer once a thread passed 300 — so new messages "disappeared". - thread: (teamId, a, b, limit = 500) => + thread: (teamId, a, b, limit = 500, before = null) => db.prepare(`SELECT * FROM ( SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)) + AND (? IS NULL OR created_at < ?) ORDER BY created_at DESC LIMIT ? - ) ORDER BY created_at ASC`).all(teamId, a, b, b, a, limit), + ) ORDER BY created_at ASC`).all(teamId, a, b, b, a, before, before, limit), + // Full-thread search (ALL messages, not just the loaded window). `like` is the escaped LIKE pattern. + searchThread: (teamId, a, b, like, limit = 300) => + db.prepare(`SELECT id, created_at, sender_id FROM messages WHERE team_id=? AND conversation_id IS NULL + AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)) + AND deleted=0 AND body LIKE ? ESCAPE '\\' ORDER BY created_at ASC LIMIT ?`).all(teamId, a, b, b, a, like, limit), markRead: (teamId, recipientId, senderId) => db.prepare('UPDATE messages SET read_at=? WHERE team_id=? AND conversation_id IS NULL AND recipient_id=? AND sender_id=? AND read_at IS NULL') .run(now(), teamId, recipientId, senderId), @@ -221,10 +227,13 @@ const messages = { db.prepare('SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL AND (sender_id=? OR recipient_id=?) ORDER BY created_at DESC LIMIT ?') .all(teamId, userId, userId, limit), // Group conversation helpers. - threadByConversation: (conversationId, limit = 500) => + threadByConversation: (conversationId, limit = 500, before = null) => db.prepare(`SELECT * FROM ( - SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT ? - ) ORDER BY created_at ASC`).all(conversationId, limit), + SELECT * FROM messages WHERE conversation_id=? AND (? IS NULL OR created_at < ?) ORDER BY created_at DESC LIMIT ? + ) ORDER BY created_at ASC`).all(conversationId, before, before, limit), + searchConversation: (conversationId, like, limit = 300) => + db.prepare(`SELECT id, created_at, sender_id FROM messages WHERE conversation_id=? AND deleted=0 + AND body LIKE ? ESCAPE '\\' ORDER BY created_at ASC LIMIT ?`).all(conversationId, like, limit), lastInConversation: (conversationId) => db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId), unreadInConversation: (conversationId, userId, since) => diff --git a/server/routes.js b/server/routes.js index a03cfbe..9ca251f 100644 --- a/server/routes.js +++ b/server/routes.js @@ -761,12 +761,13 @@ route('GET', '/api/messages/thread', async (req, res) => { if (!u) return json(res, 401, { error: 'unauthorized' }); const q = new URLSearchParams(req.url.split('?')[1] || ''); const peek = !!q.get('peek'); // prefetch only — do NOT mark the conversation read + const before = parseInt(q.get('before') || '', 10) || null; // pagination cursor: fetch messages OLDER than this created_at const names = namesFor(u.team_id); const group = q.get('group'); if (group) { if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); - const rows = R.messages.threadByConversation(group); - if (!peek) { + const rows = R.messages.threadByConversation(group, 500, before); + if (!peek && !before) { R.conversations.markRead(group, u.id); const evt = { type: 'group-read', group, by: u.id, byName: names[u.id] || u.email, at: now() }; for (const mid of R.conversations.members(group)) { if (mid !== u.id) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } @@ -782,12 +783,34 @@ route('GET', '/api/messages/thread', async (req, res) => { const other = R.users.resolve(q.get('with')); // follow a merge redirect so a stale peer id still loads the thread if (!other) return json(res, 400, { error: 'with or group required' }); if (!R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' }); - const rows = R.messages.thread(u.team_id, u.id, other); - if (!peek) { R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} } + const rows = R.messages.thread(u.team_id, u.id, other, 500, before); + if (!peek && !before) { R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} } const rxBy = groupReactions(R.reactions.forPair(u.team_id, u.id, other), u.id, names); return json(res, 200, rows.map((m) => { const d = buildMsgDTO(m, names, u.id); d.reactions = dtoReactions(rxBy, m.id); return d; })); }); +// Search the ENTIRE thread (not just the loaded window). Returns matching message ids + timestamps, +// oldest-first, so the client can jump to any hit and lazy-load the window around it. +route('GET', '/api/messages/search', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const q = new URLSearchParams(req.url.split('?')[1] || ''); + const term = String(q.get('q') || '').trim(); + if (term.length < 1) return json(res, 200, { hits: [] }); + const like = '%' + term.replace(/[\\%_]/g, '\\$&') + '%'; // escape LIKE wildcards + const group = q.get('group'); + let rows; + if (group) { + if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); + rows = R.messages.searchConversation(group, like); + } else { + const other = R.users.resolve(q.get('with')); + if (!other || !R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' }); + rows = R.messages.searchThread(u.team_id, u.id, other, like); + } + return json(res, 200, { hits: rows.map((m) => ({ id: m.id, at: m.created_at })) }); +}); + // Create a group conversation with the given members (creator is always added). route('POST', '/api/groups', async (req, res) => { const u = currentUser(req);