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);