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:
@@ -29,6 +29,7 @@ func (s *Server) routesMessages(mux *http.ServeMux) {
|
|||||||
mux.Handle("DELETE /api/v1/conversations/{id}/members/{userId}", s.authed(s.handleRemoveMember))
|
mux.Handle("DELETE /api/v1/conversations/{id}/members/{userId}", s.authed(s.handleRemoveMember))
|
||||||
mux.Handle("POST /api/v1/files", s.authed(s.handleUploadFile))
|
mux.Handle("POST /api/v1/files", s.authed(s.handleUploadFile))
|
||||||
mux.Handle("GET /api/v1/users", s.requireAuth(http.HandlerFunc(s.handleSearchUsers)))
|
mux.Handle("GET /api/v1/users", s.requireAuth(http.HandlerFunc(s.handleSearchUsers)))
|
||||||
|
mux.Handle("GET /api/v1/messages/search", s.requireAuth(http.HandlerFunc(s.handleSearchMessages)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// conversation loads + authorizes participant access.
|
// conversation loads + authorizes participant access.
|
||||||
@@ -414,6 +415,117 @@ func (s *Server) handleSearchUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"users": out})
|
writeJSON(w, http.StatusOK, map[string]any{"users": out})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleSearchMessages finds messages containing the query within the caller's
|
||||||
|
// own conversations, newest first.
|
||||||
|
func (s *Server) handleSearchMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
me := CurrentUser(r.Context())
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
if len([]rune(q)) < 2 {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
convs, err := s.store.ListConversations(r.Context(), me.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internalError(w, r, "search: conversations", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(convs) == 0 {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(convs))
|
||||||
|
titles := make(map[string]string, len(convs))
|
||||||
|
for _, c := range convs {
|
||||||
|
ids = append(ids, c.ID)
|
||||||
|
title := c.Title
|
||||||
|
if c.Kind == "dm" {
|
||||||
|
for _, pid := range c.ParticipantIDs {
|
||||||
|
if pid != me.ID {
|
||||||
|
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
|
||||||
|
title = u.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
title = "Conversation"
|
||||||
|
}
|
||||||
|
titles[c.ID] = title
|
||||||
|
}
|
||||||
|
cur, err := s.store.DB.Collection("messages").Find(r.Context(), bson.M{
|
||||||
|
"conversationId": bson.M{"$in": ids},
|
||||||
|
"deletedAt": nil,
|
||||||
|
"body": bson.M{"$regex": regexEscape(q), "$options": "i"},
|
||||||
|
}, options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(30))
|
||||||
|
if err != nil {
|
||||||
|
s.internalError(w, r, "search messages", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var msgs []store.Message
|
||||||
|
if err := cur.All(r.Context(), &msgs); err != nil {
|
||||||
|
s.internalError(w, r, "decode search", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results := make([]map[string]any, 0, len(msgs))
|
||||||
|
for _, m := range msgs {
|
||||||
|
results = append(results, map[string]any{
|
||||||
|
"conversationId": m.ConversationID,
|
||||||
|
"conversationTitle": titles[m.ConversationID],
|
||||||
|
"messageId": m.ID,
|
||||||
|
"senderId": m.SenderID,
|
||||||
|
"snippet": snippetAround(stripTags(m.Body), q),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"results": results})
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripTags removes HTML tags and collapses whitespace for plain-text snippets.
|
||||||
|
func stripTags(html string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
inTag := false
|
||||||
|
for _, r := range html {
|
||||||
|
switch {
|
||||||
|
case r == '<':
|
||||||
|
inTag = true
|
||||||
|
case r == '>':
|
||||||
|
inTag = false
|
||||||
|
case !inTag:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(strings.Fields(b.String()), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// snippetAround returns a short text window centered on the first
|
||||||
|
// case-insensitive match of q.
|
||||||
|
func snippetAround(text, q string) string {
|
||||||
|
runes := []rune(text)
|
||||||
|
idx := strings.Index(strings.ToLower(text), strings.ToLower(q))
|
||||||
|
if idx < 0 {
|
||||||
|
if len(runes) > 100 {
|
||||||
|
return string(runes[:100]) + "…"
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
start := len([]rune(text[:idx]))
|
||||||
|
from := start - 30
|
||||||
|
if from < 0 {
|
||||||
|
from = 0
|
||||||
|
}
|
||||||
|
to := start + len([]rune(q)) + 50
|
||||||
|
if to > len(runes) {
|
||||||
|
to = len(runes)
|
||||||
|
}
|
||||||
|
out := string(runes[from:to])
|
||||||
|
if from > 0 {
|
||||||
|
out = "…" + out
|
||||||
|
}
|
||||||
|
if to < len(runes) {
|
||||||
|
out += "…"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// HandleInboundWS relays ephemeral client events (typing indicator).
|
// HandleInboundWS relays ephemeral client events (typing indicator).
|
||||||
func (s *Server) HandleInboundWS(userID string, msg ws.Message) {
|
func (s *Server) HandleInboundWS(userID string, msg ws.Message) {
|
||||||
if msg.Channel != "chat" || msg.Event != "typing" {
|
if msg.Channel != "chat" || msg.Event != "typing" {
|
||||||
|
|||||||
+19
-4
@@ -628,8 +628,8 @@ body[data-nav=side] #tab-audit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---- chat ---- */
|
/* ---- chat ---- */
|
||||||
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; min-height: 70vh; }
|
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; height: calc(100vh - 150px); }
|
||||||
.chat-sidebar { overflow: auto; }
|
.chat-sidebar { overflow: auto; min-height: 0; }
|
||||||
.conv-list { list-style: none; margin: 8px 0 0; padding: 0; }
|
.conv-list { list-style: none; margin: 8px 0 0; padding: 0; }
|
||||||
.conv-list li { border-bottom: var(--hairline); }
|
.conv-list li { border-bottom: var(--hairline); }
|
||||||
.conv-list li.active .conv-item {
|
.conv-list li.active .conv-item {
|
||||||
@@ -643,8 +643,10 @@ body[data-nav=side] #tab-audit {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.conv-item:hover { background: var(--surface2); }
|
.conv-item:hover { background: var(--surface2); }
|
||||||
.chat-main { display: flex; flex-direction: column; }
|
.chat-main { display: flex; flex-direction: column; min-height: 0; }
|
||||||
.chat-messages { flex: 1; overflow: auto; padding: 8px 0; min-height: 320px; }
|
/* 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 {
|
.chat-msg {
|
||||||
max-width: 75%; margin: 10px 0; padding: 9px 13px;
|
max-width: 75%; margin: 10px 0; padding: 9px 13px;
|
||||||
background: var(--surface2);
|
background: var(--surface2);
|
||||||
@@ -815,6 +817,19 @@ legend {
|
|||||||
#nc-results .nc-empty { color: var(--muted); padding: 10px; font-size: 0.88rem; }
|
#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; }
|
#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 ---- */
|
/* ---- floating messages bubble + mini panel ---- */
|
||||||
.chat-fab {
|
.chat-fab {
|
||||||
position: fixed; bottom: 16px; right: 16px; z-index: 2147483645;
|
position: fixed; bottom: 16px; right: 16px; z-index: 2147483645;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ function renderConvList() {
|
|||||||
async function openConversation(c) {
|
async function openConversation(c) {
|
||||||
current = c;
|
current = c;
|
||||||
oldestCursor = '';
|
oldestCursor = '';
|
||||||
|
noMoreOlder = false;
|
||||||
document.getElementById('chat-title').textContent = c.title;
|
document.getElementById('chat-title').textContent = c.title;
|
||||||
const mbtn = document.getElementById('chat-members');
|
const mbtn = document.getElementById('chat-members');
|
||||||
mbtn.hidden = !(c.kind !== 'dm' && c.creatorId && c.creatorId === meId);
|
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);
|
history.replaceState(null, '', '/messages?c=' + c.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MSG_PAGE = 40;
|
||||||
async function loadMessages(prepend) {
|
async function loadMessages(prepend) {
|
||||||
const res = await api('GET',
|
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;
|
const msgs = res.messages;
|
||||||
|
if (prepend && msgs.length < MSG_PAGE) noMoreOlder = true;
|
||||||
if (msgs.length) oldestCursor = msgs[0].id;
|
if (msgs.length) oldestCursor = msgs[0].id;
|
||||||
const nodes = await Promise.all(msgs.map(renderMessage));
|
const nodes = await Promise.all(msgs.map(renderMessage));
|
||||||
if (prepend) msgHost.prepend(...nodes);
|
if (prepend) {
|
||||||
else {
|
// 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.append(...nodes);
|
||||||
msgHost.scrollTop = msgHost.scrollHeight;
|
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.
|
// ULID ids embed a 48-bit ms timestamp in the first 10 chars.
|
||||||
function ulidTime(id) {
|
function ulidTime(id) {
|
||||||
const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||||
@@ -199,8 +217,9 @@ const typingNames = new Map();
|
|||||||
subscribe('chat', async (event, data) => {
|
subscribe('chat', async (event, data) => {
|
||||||
if (event === 'message' && data) {
|
if (event === 'message' && data) {
|
||||||
if (current && data.conversationId === current.id) {
|
if (current && data.conversationId === current.id) {
|
||||||
|
const nearBottom = msgHost.scrollHeight - msgHost.scrollTop - msgHost.clientHeight < 120;
|
||||||
msgHost.appendChild(await renderMessage(data));
|
msgHost.appendChild(await renderMessage(data));
|
||||||
msgHost.scrollTop = msgHost.scrollHeight;
|
if (nearBottom) msgHost.scrollTop = msgHost.scrollHeight;
|
||||||
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
|
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
loadConversations();
|
loadConversations();
|
||||||
@@ -299,6 +318,46 @@ document.getElementById('newconv-form').addEventListener('submit', async (e) =>
|
|||||||
} catch (err) { toast(err.message, 'err'); }
|
} 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) ----
|
// ---- manage members (group creator only) ----
|
||||||
const membersDlg = document.getElementById('members-dialog');
|
const membersDlg = document.getElementById('members-dialog');
|
||||||
let mmMemberIds = new Set();
|
let mmMemberIds = new Set();
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
<h2>Messages</h2>
|
<h2>Messages</h2>
|
||||||
<button class="btn small primary" id="conv-new">+ New</button>
|
<button class="btn small primary" id="conv-new">+ New</button>
|
||||||
</div>
|
</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="conv-list" class="conv-list"></ul>
|
||||||
|
<ul id="search-results" class="conv-list" hidden></ul>
|
||||||
</aside>
|
</aside>
|
||||||
<section class="chat-main card">
|
<section class="chat-main card">
|
||||||
<div class="spread">
|
<div class="spread">
|
||||||
|
|||||||
Reference in New Issue
Block a user