diff --git a/internal/http/messages.go b/internal/http/messages.go index 9b376ab..dc2aa75 100644 --- a/internal/http/messages.go +++ b/internal/http/messages.go @@ -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("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/messages/search", s.requireAuth(http.HandlerFunc(s.handleSearchMessages))) } // 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}) } +// 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). func (s *Server) HandleInboundWS(userID string, msg ws.Message) { if msg.Channel != "chat" || msg.Event != "typing" { diff --git a/web/static/css/app.css b/web/static/css/app.css index 02b57e7..b6fccd2 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -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; diff --git a/web/static/js/messages.js b/web/static/js/messages.js index 1cd8ddf..db4e08e 100644 --- a/web/static/js/messages.js +++ b/web/static/js/messages.js @@ -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 = `${esc(r.conversationTitle)} + ${esc(r.snippet)}`; + 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 = '