feat: chat scroll/pagination, message search; fix members dialog growth
- Messages: the message list now scrolls inside a fixed-height pane instead of growing the page; new messages autoscroll only when already at the bottom, and scrolling near the top loads older history (40 at a time) with the position preserved. - Add a message search box in the conversations sidebar backed by a new GET /api/v1/messages/search endpoint (searches the caller's conversations, returns snippets); clicking a result opens that conversation. - Manage-members dialog no longer grows horizontally: results are stacked in a fixed-width, scrollable list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,7 @@ function renderConvList() {
|
||||
async function openConversation(c) {
|
||||
current = c;
|
||||
oldestCursor = '';
|
||||
noMoreOlder = false;
|
||||
document.getElementById('chat-title').textContent = c.title;
|
||||
const mbtn = document.getElementById('chat-members');
|
||||
mbtn.hidden = !(c.kind !== 'dm' && c.creatorId && c.creatorId === meId);
|
||||
@@ -74,19 +75,36 @@ async function openConversation(c) {
|
||||
history.replaceState(null, '', '/messages?c=' + c.id);
|
||||
}
|
||||
|
||||
const MSG_PAGE = 40;
|
||||
async function loadMessages(prepend) {
|
||||
const res = await api('GET',
|
||||
`/api/v1/conversations/${current.id}/messages?cursor=${prepend ? oldestCursor : ''}`);
|
||||
`/api/v1/conversations/${current.id}/messages?limit=${MSG_PAGE}&cursor=${prepend ? oldestCursor : ''}`);
|
||||
const msgs = res.messages;
|
||||
if (prepend && msgs.length < MSG_PAGE) noMoreOlder = true;
|
||||
if (msgs.length) oldestCursor = msgs[0].id;
|
||||
const nodes = await Promise.all(msgs.map(renderMessage));
|
||||
if (prepend) msgHost.prepend(...nodes);
|
||||
else {
|
||||
if (prepend) {
|
||||
// preserve the scroll position so the view doesn't jump when older
|
||||
// messages are inserted above the current viewport
|
||||
const before = msgHost.scrollHeight;
|
||||
const top = msgHost.scrollTop;
|
||||
msgHost.prepend(...nodes);
|
||||
msgHost.scrollTop = top + (msgHost.scrollHeight - before);
|
||||
} else {
|
||||
msgHost.append(...nodes);
|
||||
msgHost.scrollTop = msgHost.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// load older history when the user scrolls near the top
|
||||
let loadingOlder = false;
|
||||
let noMoreOlder = false;
|
||||
msgHost.addEventListener('scroll', async () => {
|
||||
if (msgHost.scrollTop > 60 || loadingOlder || noMoreOlder || !current) return;
|
||||
loadingOlder = true;
|
||||
try { await loadMessages(true); } finally { loadingOlder = false; }
|
||||
});
|
||||
|
||||
// ULID ids embed a 48-bit ms timestamp in the first 10 chars.
|
||||
function ulidTime(id) {
|
||||
const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
@@ -199,8 +217,9 @@ const typingNames = new Map();
|
||||
subscribe('chat', async (event, data) => {
|
||||
if (event === 'message' && data) {
|
||||
if (current && data.conversationId === current.id) {
|
||||
const nearBottom = msgHost.scrollHeight - msgHost.scrollTop - msgHost.clientHeight < 120;
|
||||
msgHost.appendChild(await renderMessage(data));
|
||||
msgHost.scrollTop = msgHost.scrollHeight;
|
||||
if (nearBottom) msgHost.scrollTop = msgHost.scrollHeight;
|
||||
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
|
||||
} else {
|
||||
loadConversations();
|
||||
@@ -299,6 +318,46 @@ document.getElementById('newconv-form').addEventListener('submit', async (e) =>
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
|
||||
// ---- message search ----
|
||||
const convSearch = document.getElementById('conv-search');
|
||||
let msgSearchTimer = null;
|
||||
async function runMessageSearch(q) {
|
||||
const list = document.getElementById('conv-list');
|
||||
const results = document.getElementById('search-results');
|
||||
if (q.length < 2) { results.hidden = true; results.replaceChildren(); list.hidden = false; return; }
|
||||
try {
|
||||
const res = await api('GET', `/api/v1/messages/search?q=${encodeURIComponent(q)}`);
|
||||
list.hidden = true; results.hidden = false;
|
||||
results.replaceChildren(...res.results.map((r) => {
|
||||
const li = document.createElement('li');
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'conv-item';
|
||||
b.style.cssText = 'flex-direction:column;align-items:flex-start;gap:2px';
|
||||
b.innerHTML = `<span>${esc(r.conversationTitle)}</span>
|
||||
<span class="muted" style="font-size:0.82rem">${esc(r.snippet)}</span>`;
|
||||
b.addEventListener('click', () => {
|
||||
const c = conversations.find((x) => x.id === r.conversationId);
|
||||
if (c) {
|
||||
convSearch.value = '';
|
||||
results.hidden = true; list.hidden = false;
|
||||
openConversation(c);
|
||||
}
|
||||
});
|
||||
li.appendChild(b);
|
||||
return li;
|
||||
}));
|
||||
if (!res.results.length) {
|
||||
results.innerHTML = '<li class="muted" style="padding:10px">No matching messages.</li>';
|
||||
}
|
||||
} catch (e) { /* ignore transient search errors */ }
|
||||
}
|
||||
convSearch.addEventListener('input', () => {
|
||||
clearTimeout(msgSearchTimer);
|
||||
const q = convSearch.value.trim();
|
||||
msgSearchTimer = setTimeout(() => runMessageSearch(q), 250);
|
||||
});
|
||||
|
||||
// ---- manage members (group creator only) ----
|
||||
const membersDlg = document.getElementById('members-dialog');
|
||||
let mmMemberIds = new Set();
|
||||
|
||||
Reference in New Issue
Block a user