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("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" {
|
||||
|
||||
Reference in New Issue
Block a user