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:
2026-07-07 15:21:14 +05:30
parent 4fd323fff4
commit eb79823fd2
3 changed files with 101 additions and 26 deletions
+60 -17
View File
@@ -251,6 +251,8 @@
.csh-nav:hover{background:#dbe6fb;} .csh-nav:hover{background:#dbe6fb;}
mark.search-hit{background:#fde68a;color:inherit;border-radius:2px;padding:0 1px;} mark.search-hit{background:#fde68a;color:inherit;border-radius:2px;padding:0 1px;}
mark.search-current{background:#f59e0b;color:#1f2430;} 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{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:hover{background:#eef2f8;}
.gi-media-row .gmr-l{display:flex;align-items:center;gap:.6rem;} .gi-media-row .gmr-l{display:flex;align-items:center;gap:.6rem;}
@@ -788,7 +790,7 @@
<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-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 // 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>
@@ -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; }); 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(_){} } 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). // #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(){ 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(_){} }); } 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){ function _highlightIn(root, ql){
const hits=[]; 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); } }); if(last>0){ if(last<txt.length) frag.appendChild(document.createTextNode(txt.slice(last))); n.parentNode.replaceChild(frag,n); } });
return hits; 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(); clearSearchHighlights();
const box=document.getElementById('msgs'), cnt=document.getElementById('convoSearchCount'); if(!box) return; const cnt=document.getElementById('convoSearchCount');
q=(q||'').trim(); if(!q){ if(cnt) cnt.textContent=''; return; } q=(q||'').trim(); _searchQ=q; _searchHits=[]; _searchIdx=-1;
_searchHits=_highlightIn(box, q.toLowerCase()); if(!q){ if(cnt) cnt.textContent=''; return; }
if(!_searchHits.length){ if(cnt) cnt.textContent='0/0'; return; } if(!selected) return;
gotoHit(_searchHits.length-1); // start at the most recent match 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){ 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+'"]'); }
if(!_searchHits.length) return; async function gotoHit(i){
_searchIdx=((i%_searchHits.length)+_searchHits.length)%_searchHits.length; const hits=_searchHits; if(!hits.length) return;
_searchHits.forEach(m=>m.classList.remove('search-current')); _searchIdx=((i%hits.length)+hits.length)%hits.length;
const m=_searchHits[_searchIdx]; m.classList.add('search-current'); try{ m.scrollIntoView({block:'center'}); }catch(_){} const hit=hits[_searchIdx];
const cnt=document.getElementById('convoSearchCount'); if(cnt) cnt.textContent=(_searchIdx+1)+'/'+_searchHits.length; 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=''; } 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. // #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. // Floating date pill (updates to the day at the top of the viewport) + jump-to-latest button.
function onMsgsScroll(){ function onMsgsScroll(){
const box=document.getElementById('msgs'); if(!box) return; 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 nearBottom=(box.scrollHeight - box.scrollTop - box.clientHeight) < 120;
const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display=nearBottom?'none':'grid'; const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display=nearBottom?'none':'grid';
const fd=document.getElementById('floatDate'); if(!fd) return; 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'}); } 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" 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 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; const box=document.getElementById('msgs'); if(!box) return;
rendered.clear(); _lastDay=''; _lastMineId=lastMineId(); rendered.clear(); _lastDay=''; _lastMineId=lastMineId();
if(!THREAD.length){ box.innerHTML='<div class="empty-thread">No messages yet — say hello 👋</div>'; return; } if(!THREAD.length){ box.innerHTML='<div class="empty-thread">No messages yet — say hello 👋</div>'; return; }
let html=''; 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); } 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){ function appendBubble(m){
if(rendered.has(m.id)) return; rendered.add(m.id); if(rendered.has(m.id)) return; rendered.add(m.id);
@@ -1809,6 +1851,7 @@ async function openConvo(kind,id){
} }
THREAD=msgs; THREAD=msgs;
THREAD_CACHE.set(ckey, THREAD.slice()); THREAD_CACHE.set(ckey, THREAD.slice());
_hasMoreOlder = msgs.length >= PAGE; _loadingOlder=false; // a full page back means there may be older history
renderThread(); renderThread();
// #3: if there were unread messages, drop a "New messages" divider, scroll to the first unread, // #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. // and show the "jump to newest" arrow so the user can return to the bottom.
+14 -5
View File
@@ -207,12 +207,18 @@ const messages = {
// Full 1:1 (DM) thread between two users (both directions). Take the NEWEST `limit` 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 // (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". // 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 ( db.prepare(`SELECT * FROM (
SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)) 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 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) => 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') 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), .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 ?') 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), .all(teamId, userId, userId, limit),
// Group conversation helpers. // Group conversation helpers.
threadByConversation: (conversationId, limit = 500) => threadByConversation: (conversationId, limit = 500, before = null) =>
db.prepare(`SELECT * FROM ( db.prepare(`SELECT * FROM (
SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC 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, 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) => lastInConversation: (conversationId) =>
db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId), db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId),
unreadInConversation: (conversationId, userId, since) => unreadInConversation: (conversationId, userId, since) =>
+27 -4
View File
@@ -761,12 +761,13 @@ route('GET', '/api/messages/thread', async (req, res) => {
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const q = new URLSearchParams(req.url.split('?')[1] || ''); const q = new URLSearchParams(req.url.split('?')[1] || '');
const peek = !!q.get('peek'); // prefetch only — do NOT mark the conversation read 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 names = namesFor(u.team_id);
const group = q.get('group'); const group = q.get('group');
if (group) { if (group) {
if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this 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); const rows = R.messages.threadByConversation(group, 500, before);
if (!peek) { if (!peek && !before) {
R.conversations.markRead(group, u.id); R.conversations.markRead(group, u.id);
const evt = { type: 'group-read', group, by: u.id, byName: names[u.id] || u.email, at: now() }; 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 (_) {} } } 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 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 (!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' }); 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); const rows = R.messages.thread(u.team_id, u.id, other, 500, before);
if (!peek) { R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} } 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); 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; })); 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). // Create a group conversation with the given members (creator is always added).
route('POST', '/api/groups', async (req, res) => { route('POST', '/api/groups', async (req, res) => {
const u = currentUser(req); const u = currentUser(req);