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:
etalon
2026-06-13 11:43:11 +02:00
parent df08682bed
commit b4e919502f
4 changed files with 196 additions and 8 deletions
+19 -4
View File
@@ -628,8 +628,8 @@ body[data-nav=side] #tab-audit {
}
/* ---- chat ---- */
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; min-height: 70vh; }
.chat-sidebar { overflow: auto; }
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; height: calc(100vh - 150px); }
.chat-sidebar { overflow: auto; min-height: 0; }
.conv-list { list-style: none; margin: 8px 0 0; padding: 0; }
.conv-list li { border-bottom: var(--hairline); }
.conv-list li.active .conv-item {
@@ -643,8 +643,10 @@ body[data-nav=side] #tab-audit {
border-radius: var(--radius);
}
.conv-item:hover { background: var(--surface2); }
.chat-main { display: flex; flex-direction: column; }
.chat-messages { flex: 1; overflow: auto; padding: 8px 0; min-height: 320px; }
.chat-main { display: flex; flex-direction: column; min-height: 0; }
/* flex:1 + min-height:0 lets the message list scroll internally instead of
growing the whole page */
.chat-messages { flex: 1; overflow-y: auto; padding: 8px 0; min-height: 0; }
.chat-msg {
max-width: 75%; margin: 10px 0; padding: 9px 13px;
background: var(--surface2);
@@ -815,6 +817,19 @@ legend {
#nc-results .nc-empty { color: var(--muted); padding: 10px; font-size: 0.88rem; }
#nc-selected { min-height: 30px; display: flex; gap: 6px; flex-wrap: wrap; }
/* members dialog: fixed width, vertically-stacked results (no horizontal growth) */
#members-dialog > .stack { width: 440px; max-width: 86vw; }
#mm-list li { justify-content: space-between; }
#mm-results { max-height: 200px; overflow: auto; margin-top: 6px; }
#mm-results .nc-row {
display: block; width: 100%; text-align: left;
font: inherit; font-size: 0.92rem;
background: none; border: none; color: var(--text);
padding: 8px 10px; cursor: pointer;
border-bottom: var(--hairline); border-radius: var(--radius);
}
#mm-results .nc-row:hover { background: var(--surface2); }
/* ---- floating messages bubble + mini panel ---- */
.chat-fab {
position: fixed; bottom: 16px; right: 16px; z-index: 2147483645;
+63 -4
View File
@@ -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();
+2
View File
@@ -6,7 +6,9 @@
<h2>Messages</h2>
<button class="btn small primary" id="conv-new"> New</button>
</div>
<input type="search" id="conv-search" class="mt" placeholder="Search messages…" aria-label="Search messages">
<ul id="conv-list" class="conv-list"></ul>
<ul id="search-results" class="conv-list" hidden></ul>
</aside>
<section class="chat-main card">
<div class="spread">