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
+112
View File
@@ -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" {