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;}
|
.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
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user