From 37a22cc1a8f5f24aba8a392add981e5ece05f422 Mon Sep 17 00:00:00 2001 From: Danila Fominykh Date: Sun, 19 Jul 2026 20:29:20 +0300 Subject: [PATCH] feat(mail): windowed rendering, backend search, and UX polish - Add content-visibility:auto for native browser rendering optimization on message bubbles and HTML cards - Implement backend-powered in-thread search with Unicode-aware case-insensitive matching (Go strings.ToLower handles Cyrillic/CJK) - Add ListMessagesAroundID model + /around/{msgid} API endpoint for windowed message loading around search matches - Add message caching for instant thread switching without API calls - Fix scroll position restoration on pagination using anchor element tracking with double requestAnimationFrame - Fix client-side thread list sorting by last message timestamp - Remove FromName display from DM message bubbles (only in groups) - Add Copy button to message context menu with toast feedback - Replace JSON-based forwarding with plain text (RFC 5322 compatible) - Strip Gmail/Outlook reply quotes from incoming messages - Fix audio player sync: loadedTrackId vs playingTrackId separation - Add viewport meta override to disable mobile zoom on all inputs - Add user-select:none to prevent native text selection on long-press - Resolve participant display names from message history for thread list --- models/mail/conversation.go | 2 +- models/mail/message.go | 87 +++++++++++- routers/api/v1/api.go | 2 + routers/api/v1/mail.go | 106 +++++++++++++-- services/mail/dto/dto.go | 20 ++- services/mail/dto/dto_test.go | 20 +-- services/mail/receive.go | 12 +- services/mail/receive_test.go | 6 +- services/mail/send.go | 2 +- services/mail/system_mail.go | 4 +- templates/mail.tmpl | 20 +++ web_src/css/mail.css | 64 +++++++++ web_src/js/features/mail/MailApp.vue | 36 ++++- .../js/features/mail/components/ChatPane.vue | 124 +++++++++++++----- .../features/mail/components/ThreadList.vue | 15 ++- 15 files changed, 447 insertions(+), 73 deletions(-) diff --git a/models/mail/conversation.go b/models/mail/conversation.go index b4b9738686..5b3681d016 100644 --- a/models/mail/conversation.go +++ b/models/mail/conversation.go @@ -69,7 +69,7 @@ func ListConversationsForUser(ctx context.Context, ownerUID int64) ([]*Conversat // ListConversationsForUserPaged returns up to `limit` conversations for a user // with updated_at < beforeTime (or all if beforeTime is 0), newest first. // Used for cursor-based pagination of the thread list. -func ListConversationsForUserPaged(ctx context.Context, ownerUID int64, beforeTime int64, limit int) ([]*Conversation, error) { +func ListConversationsForUserPaged(ctx context.Context, ownerUID, beforeTime int64, limit int) ([]*Conversation, error) { if limit <= 0 || limit > 200 { limit = 30 } diff --git a/models/mail/message.go b/models/mail/message.go index 17cf62968a..441733a9e0 100644 --- a/models/mail/message.go +++ b/models/mail/message.go @@ -5,6 +5,7 @@ package mail import ( "context" + "strings" "errors" "gitea.dev/models/db" @@ -89,7 +90,7 @@ func ListMessagesByConversation(ctx context.Context, conversationID int64) ([]*M // ListMessagesByConversationPaged returns up to `limit` messages for a conversation // with ID < beforeID (or all if beforeID is 0), ordered newest-first. Used for // cursor-based pagination — the caller prepends the results to existing messages. -func ListMessagesByConversationPaged(ctx context.Context, conversationID int64, beforeID int64, limit int) ([]*Message, error) { +func ListMessagesByConversationPaged(ctx context.Context, conversationID, beforeID int64, limit int) ([]*Message, error) { if limit <= 0 || limit > 200 { limit = 50 } @@ -116,6 +117,90 @@ func GetLastMessageByConversation(ctx context.Context, conversationID int64) (*M return m, nil } +// SearchMessagesInConversation searches for messages containing the query string +// in body or preview. Returns matching message IDs in chronological order +// (oldest first), limited to the specified conversation. +func SearchMessagesInConversation(ctx context.Context, conversationID int64, query string) ([]int64, error) { + // SQLite's LOWER() is ASCII-only, so for full Unicode support (Cyrillic, etc.) + // we fetch candidate messages and filter in Go using strings.ToLower (Unicode-aware). + // First do a broad LIKE to narrow down, then exact-match in Go. + lowerQuery := strings.ToLower(query) + var msgs []*Message + err := db.GetEngine(ctx). + Where("conversation_id = ?", conversationID). + And("content_type NOT LIKE 'subject-change%'"). + And("content_type NOT LIKE 'name-change%'"). + OrderBy("received_unix ASC, id ASC"). + Find(&msgs) + if err != nil { + return nil, err + } + ids := make([]int64, 0, len(msgs)) + for _, m := range msgs { + // Search both the preview (reply-stripped, clean) and full body. + lowerBody := strings.ToLower(m.Body) + lowerPreview := strings.ToLower(m.Preview) + if strings.Contains(lowerBody, lowerQuery) || strings.Contains(lowerPreview, lowerQuery) { + ids = append(ids, m.ID) + } + } + return ids, nil +} + +// ListMessagesAroundID loads messages centered on a specific message ID. +// Returns limit/2 messages before and limit/2 after (plus the target itself). +// Used by search jump-to-match to load just the relevant context. +func ListMessagesAroundID(ctx context.Context, conversationID, targetID int64, limit int) ([]*Message, bool, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + half := limit / 2 + + // Load the target message + half after it + var after []*Message + err := db.GetEngine(ctx). + Where("conversation_id = ?", conversationID). + And("id >= ?", targetID). + OrderBy("received_unix ASC, id ASC"). + Limit(half + 1). + Find(&after) + if err != nil { + return nil, false, err + } + + // Load half before it + var before []*Message + err = db.GetEngine(ctx). + Where("conversation_id = ?", conversationID). + And("id < ?", targetID). + OrderBy("received_unix DESC, id DESC"). + Limit(half). + Find(&before) + if err != nil { + return nil, false, err + } + + // Reverse "before" to chronological order + for i, j := 0, len(before)-1; i < j; i, j = i+1, j-1 { + before[i], before[j] = before[j], before[i] + } + + // Check if there are more messages before the oldest loaded + hasMore := len(before) >= half + if len(before) > 0 { + var count int64 + count, err = db.GetEngine(ctx). + Where("conversation_id = ?", conversationID). + And("id < ?", before[0].ID). + Count(new(Message)) + if err == nil { + hasMore = count > 0 + } + } + + return append(before, after...), hasMore, nil +} + // MarkConversationRead marks all messages in a conversation as read. func MarkConversationRead(ctx context.Context, conversationID int64) error { _, err := db.GetEngine(ctx).Where("conversation_id = ?", conversationID). diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 11652735ae..94e7c63482 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1094,6 +1094,8 @@ func Routes() *web.Router { m.Post("/threads/{id}/members", reqToken(), reqLocalMailDomain(), MailAddMember) m.Delete("/threads/{id}/members/{email}", reqToken(), reqLocalMailDomain(), MailRemoveMember) m.Get("/threads/{id}/poll", reqToken(), reqLocalMailDomain(), MailPoll) + m.Get("/threads/{id}/search", reqToken(), reqLocalMailDomain(), MailSearchMessages) + m.Get("/threads/{id}/around/{msgid}", reqToken(), reqLocalMailDomain(), MailLoadMessagesAroundID) m.Get("/attachments/{id}", reqToken(), reqLocalMailDomain(), MailGetAttachment) m.Get("/blocks", reqToken(), reqLocalMailDomain(), MailListBlocks) m.Post("/blocks", reqToken(), reqLocalMailDomain(), MailCreateBlock) diff --git a/routers/api/v1/mail.go b/routers/api/v1/mail.go index 299887e989..fd18ac30ac 100644 --- a/routers/api/v1/mail.go +++ b/routers/api/v1/mail.go @@ -141,6 +141,26 @@ func loadRecentMessages(ctx *context.APIContext, conv *mail_model.Conversation, return out, oldestID } +// participantNamesFromMessages extracts a map of email→display name from +// the most recent message of each sender. Used to resolve participant display +// names in the thread list even when the last message is outgoing. +func participantNamesFromMessages(convID int64) map[string]string { + msgs, err := mail_model.ListMessagesByConversation(gocontext.Background(), convID) + if err != nil { + return nil + } + names := make(map[string]string) + // Walk newest-first; first occurrence wins (most recent name). + for i := len(msgs) - 1; i >= 0; i-- { + m := msgs[i] + email := strings.ToLower(strings.TrimSpace(m.FromAddr)) + if m.FromName != "" && names[email] == "" { + names[email] = m.FromName + } + } + return names +} + // sortConversationsPinnedFirst returns pinned conversations first, then unpinned. // Within each group, conversations are sorted by updated_at descending (newest // message first), with ties broken by ID descending for stable ordering. @@ -394,7 +414,7 @@ func MailListThreads(ctx *context.APIContext) { atts, _ := mail_model.ListAttachmentsByMessage(ctx, last.ID) preview = []dto.Message{dto.MessageFromModel(last, ctx.Doer.Email, atts)} } - threads = append(threads, dto.ThreadFromConversation(c, preview, ctx.Doer.Email)) + threads = append(threads, dto.ThreadFromConversation(c, preview, ctx.Doer.Email, participantNamesFromMessages(c.ID))) } // Compute cursor: the updated_at of the last conversation in this page. @@ -404,8 +424,8 @@ func MailListThreads(ctx *context.APIContext) { nextCursor = int64(convs[len(convs)-1].UpdatedAt) } ctx.JSON(http.StatusOK, map[string]any{ - "threads": threads, - "has_more": hasMore, + "threads": threads, + "has_more": hasMore, "next_before": nextCursor, }) } @@ -418,13 +438,13 @@ func MailGetThread(ctx *context.APIContext) { return } msgs, oldestID := loadRecentMessages(ctx, conv, 0, 50) - thread := dto.ThreadFromConversation(conv, msgs, ctx.Doer.Email) + thread := dto.ThreadFromConversation(conv, msgs, ctx.Doer.Email, participantNamesFromMessages(conv.ID)) conv.Unread = false _ = mail_model.UpdateConversation(ctx, conv, "unread") ctx.JSON(http.StatusOK, map[string]any{ - "thread": thread, - "has_more_msgs": len(msgs) >= 50, - "oldest_msg_id": oldestID, + "thread": thread, + "has_more_msgs": len(msgs) >= 50, + "oldest_msg_id": oldestID, }) } @@ -443,9 +463,9 @@ func MailLoadMoreMessages(ctx *context.APIContext) { limit := ctx.FormInt("limit") msgs, oldestID := loadRecentMessages(ctx, conv, beforeID, limit) ctx.JSON(http.StatusOK, map[string]any{ - "messages": msgs, - "has_more_msgs": len(msgs) >= 50, - "oldest_msg_id": oldestID, + "messages": msgs, + "has_more_msgs": len(msgs) >= 50, + "oldest_msg_id": oldestID, }) } @@ -573,7 +593,7 @@ func MailCreateThread(ctx *context.APIContext) { go deliverOutgoing(conv, m, recipients) thread := dto.ThreadFromConversation(conv, - []dto.Message{dto.MessageFromModel(m, selfEmail, atts)}, selfEmail) + []dto.Message{dto.MessageFromModel(m, selfEmail, atts)}, selfEmail, nil) ctx.JSON(http.StatusCreated, map[string]any{"thread": thread}) } @@ -1051,6 +1071,70 @@ func MailRemoveMember(ctx *context.APIContext) { ctx.JSON(http.StatusOK, map[string]any{"participants": dto.ParseParticipants(conv.Participants, ctx.Doer.Email)}) } +// MailSearchMessages searches all messages in a conversation for a query string. +// Returns matching message IDs (chronological order) so the frontend can load +// the surrounding context and scroll to the match. +func MailSearchMessages(ctx *context.APIContext) { + conv := getOwnedConversation(ctx) + if conv == nil { + return + } + q := ctx.FormString("q") + log.Info("M8SH search: conv=%d q=%q", conv.ID, q) + if q == "" { + ctx.JSON(http.StatusOK, map[string]any{"ids": []string{}}) + return + } + ids, err := mail_model.SearchMessagesInConversation(ctx, conv.ID, q) + if err != nil { + log.Error("M8SH search error: %v", err) + ctx.APIErrorInternal(err) + return + } + log.Info("M8SH search: found %d matches", len(ids)) + out := make([]string, 0, len(ids)) + for _, id := range ids { + out = append(out, dto.EncodeID(id)) + } + ctx.JSON(http.StatusOK, map[string]any{"ids": out}) +} + +// MailLoadMessagesAroundID loads messages centered on a target message ID. +// Used by search jump-to-match: replaces the current message window with +// messages around the matched ID, so the user sees context without loading +// the entire conversation. +func MailLoadMessagesAroundID(ctx *context.APIContext) { + conv := getOwnedConversation(ctx) + if conv == nil { + return + } + targetID := dto.DecodeID(ctx.PathParam("msgid")) + if targetID <= 0 { + ctx.APIError(http.StatusBadRequest, "invalid msgid") + return + } + limit := ctx.FormInt("limit") + msgs, hasMore, err := mail_model.ListMessagesAroundID(ctx, conv.ID, targetID, limit) + if err != nil { + ctx.APIErrorInternal(err) + return + } + out := make([]dto.Message, 0, len(msgs)) + for _, m := range msgs { + atts, _ := mail_model.ListAttachmentsByMessage(ctx, m.ID) + out = append(out, dto.MessageFromModel(m, ctx.Doer.Email, atts)) + } + var oldestID int64 + if len(msgs) > 0 { + oldestID = msgs[0].ID + } + ctx.JSON(http.StatusOK, map[string]any{ + "messages": out, + "has_more_msgs": hasMore, + "oldest_msg_id": oldestID, + }) +} + // MailPoll long-polls for new messages in a conversation since a given ID. func MailPoll(ctx *context.APIContext) { conv := getOwnedConversation(ctx) diff --git a/services/mail/dto/dto.go b/services/mail/dto/dto.go index c68648dead..c8402bcf3d 100644 --- a/services/mail/dto/dto.go +++ b/services/mail/dto/dto.go @@ -143,11 +143,25 @@ func ParticipantFromAddress(addr, selfEmail, displayName string) Participant { // ParseParticipants decodes a conversation's Participants JSON array and marks // the self participant. Returns the list including self. func ParseParticipants(participantsJSON, selfEmail string) []Participant { + return ParseParticipantsWithNames(participantsJSON, selfEmail, nil) +} + +// ParseParticipantsWithNames is like ParseParticipants but resolves display +// names from a map of email→name (e.g. extracted from message FromName fields). +// This ensures the thread list shows real names even when the last message is +// outgoing (from self), not from the other participant. +func ParseParticipantsWithNames(participantsJSON, selfEmail string, names map[string]string) []Participant { var addrs []string _ = json.Unmarshal([]byte(participantsJSON), &addrs) out := make([]Participant, 0, len(addrs)) for _, a := range addrs { - out = append(out, ParticipantFromAddress(a, selfEmail, "")) + displayName := "" + if names != nil { + if n, ok := names[strings.ToLower(strings.TrimSpace(a))]; ok { + displayName = n + } + } + out = append(out, ParticipantFromAddress(a, selfEmail, displayName)) } return out } @@ -209,7 +223,7 @@ func MessageFromModel(m *mail_model.Message, selfEmail string, atts []*mail_mode // ThreadFromConversation builds a Thread with an empty messages slice (the list // endpoint loads messages separately). Pass nil for msgs when listing. -func ThreadFromConversation(c *mail_model.Conversation, msgs []Message, selfEmail string) Thread { +func ThreadFromConversation(c *mail_model.Conversation, msgs []Message, selfEmail string, participantNames map[string]string) Thread { subject := c.Subject if c.IsGroup && c.GroupName != "" { subject = c.GroupName @@ -218,7 +232,7 @@ func ThreadFromConversation(c *mail_model.Conversation, msgs []Message, selfEmai ID: EncodeID(c.ID), IsGroup: c.IsGroup, Subject: subject, - Participants: ParseParticipants(c.Participants, selfEmail), + Participants: ParseParticipantsWithNames(c.Participants, selfEmail, participantNames), Pinned: c.Pinned, Unread: c.Unread, } diff --git a/services/mail/dto/dto_test.go b/services/mail/dto/dto_test.go index 70e2d28ed3..5dcaca80e7 100644 --- a/services/mail/dto/dto_test.go +++ b/services/mail/dto/dto_test.go @@ -7,8 +7,8 @@ import ( "testing" mail_model "gitea.dev/models/mail" - "gitea.dev/modules/timeutil" "gitea.dev/modules/json" + "gitea.dev/modules/timeutil" ) const selfEmail = "danila@m8sh.su" @@ -22,9 +22,9 @@ func TestAttachmentKind(t *testing.T) { {"video/mp4", "clip.mp4", "video"}, {"audio/mpeg", "song.mp3", "audio"}, {"application/pdf", "doc.pdf", "file"}, - {"", "photo.JPG", "image"}, // extension fallback - {"", "track.opus", "audio"}, // extension fallback - {"", "noext", "file"}, // nothing to derive + {"", "photo.JPG", "image"}, // extension fallback + {"", "track.opus", "audio"}, // extension fallback + {"", "noext", "file"}, // nothing to derive {"application/octet-stream", "movie.mkv", "video"}, } for _, tt := range tests { @@ -122,7 +122,7 @@ func TestThreadFromConversationEmptyMessages(t *testing.T) { Unread: false, UpdatedAt: timeutil.TimeStamp(1700000000), } - thread := ThreadFromConversation(conv, nil, selfEmail) + thread := ThreadFromConversation(conv, nil, selfEmail, nil) if thread.ID != "42" { t.Errorf("id = %q, want %q", thread.ID, "42") } @@ -192,12 +192,12 @@ func TestParseParticipants(t *testing.T) { // is stored as a JSON string in the email-native body column). func TestForwardContentJSONRoundTrip(t *testing.T) { fc := ForwardedContent{ - From: Participant{Email: "a@b.com"}, - Body: "nested", + From: Participant{Email: "a@b.com"}, + Body: "nested", Timestamp: 1, Forwarded: &ForwardedContent{ - From: Participant{Email: "c@d.com"}, - Body: "deeper", + From: Participant{Email: "c@d.com"}, + Body: "deeper", Timestamp: 2, }, } @@ -212,4 +212,4 @@ func TestForwardContentJSONRoundTrip(t *testing.T) { if back.Forwarded == nil || back.Forwarded.Body != "deeper" { t.Error("nested forwarded tree did not round-trip") } -} \ No newline at end of file +} diff --git a/services/mail/receive.go b/services/mail/receive.go index b50bf9a1e8..bbb33d64c4 100644 --- a/services/mail/receive.go +++ b/services/mail/receive.go @@ -15,8 +15,6 @@ import ( "strings" "time" - "golang.org/x/net/html" - "gitea.dev/models/mail" user_model "gitea.dev/models/user" "gitea.dev/modules/eventsource" @@ -25,6 +23,8 @@ import ( "gitea.dev/modules/storage" "gitea.dev/modules/timeutil" "gitea.dev/modules/util" + + "golang.org/x/net/html" ) // healthcheckHeader is the marker the healthcheck probe sets to detect itself. @@ -571,9 +571,11 @@ func htmlToText(htmlStr string) string { // stripReplyQuotes removes Gmail/Outlook-style reply quotes from plain text. // These look like: -// On Sat, Jul 18 at 4:08 PM Danila wrote: -// > original message text -// >> nested quote +// +// On Sat, Jul 18 at 4:08 PM Danila wrote: +// > original message text +// >> nested quote +// // The function finds the first occurrence of a quote header pattern and trims // everything from that point onward. func stripReplyQuotes(body string) string { diff --git a/services/mail/receive_test.go b/services/mail/receive_test.go index c169572e24..3219dbcf74 100644 --- a/services/mail/receive_test.go +++ b/services/mail/receive_test.go @@ -84,7 +84,7 @@ func TestLooksLikeHTML(t *testing.T) { plainBodies := []string{ "Hello, this is a plain text message.", "Line one\nLine two\nLine three", - "Price: 5 < 10 and 3 > 1", // angle brackets but no tags + "Price: 5 < 10 and 3 > 1", // angle brackets but no tags "Visit https://example.com/path?x=1&y=2", " ", "", @@ -129,8 +129,8 @@ func TestGuessContentType(t *testing.T) { {"clip.mp4", "video/mp4"}, {"song.mp3", "audio/mpeg"}, {"doc.pdf", "application/pdf"}, - {"noext", ""}, // no extension → empty - {"unknown.xyz", ""}, // unknown extension → empty + {"noext", ""}, // no extension → empty + {"unknown.xyz", ""}, // unknown extension → empty } for _, tt := range tests { got := guessContentType(tt.filename) diff --git a/services/mail/send.go b/services/mail/send.go index 0aa77ba8f4..04aa4d449b 100644 --- a/services/mail/send.go +++ b/services/mail/send.go @@ -356,4 +356,4 @@ func buildDeliverPayload(opts SendOptions) []byte { } b, _ := json.Marshal(payload) return b -} \ No newline at end of file +} diff --git a/services/mail/system_mail.go b/services/mail/system_mail.go index 7a151f9981..8413cc3a04 100644 --- a/services/mail/system_mail.go +++ b/services/mail/system_mail.go @@ -36,7 +36,7 @@ func SendSystemMail(ctx context.Context, from, to, subject, body string, html bo // SendActivationMail builds and sends an account activation email to an // external user via the integrated mail server. The email contains a link // to {AppURL}user/activate?code={code}. -func SendActivationMail(ctx context.Context, userDisplayName, userEmail, activationCode string, activeCodeLives string) error { +func SendActivationMail(ctx context.Context, userDisplayName, userEmail, activationCode, activeCodeLives string) error { appURL := setting.AppURL if !strings.HasSuffix(appURL, "/") { appURL += "/" @@ -67,4 +67,4 @@ func SendActivationMail(ctx context.Context, userDisplayName, userEmail, activat } }() return nil -} \ No newline at end of file +} diff --git a/templates/mail.tmpl b/templates/mail.tmpl index 9e6d286bd0..ab279e4c5d 100644 --- a/templates/mail.tmpl +++ b/templates/mail.tmpl @@ -2,4 +2,24 @@
+ {{template "base/footer" .}} \ No newline at end of file diff --git a/web_src/css/mail.css b/web_src/css/mail.css index 6a8292c06a..5b6c1a925b 100644 --- a/web_src/css/mail.css +++ b/web_src/css/mail.css @@ -297,3 +297,67 @@ body > .overflow-backdrop .ctx-item.danger:hover { transition-duration: 0s !important; transition-delay: 0s !important; } + +/* Mobile: prevent iOS auto-zoom on input focus by ensuring font-size >= 16px + on ALL form elements. The viewport meta override handles most cases, but newer + iOS versions sometimes ignore user-scalable=no, so this is a belt-and-suspenders + fix. Covers textareas, inputs (all types), and contenteditable elements. */ +@media (max-width: 639.98px) { + #mail-app input, + #mail-app textarea, + #mail-app select, + #mail-app [contenteditable], + body > .confirm-overlay input, + body > .confirm-overlay textarea, + body > .fwd-overlay input, + body > .fwd-overlay textarea, + body > .ctx-menu input, + body > .overflow-menu input { + font-size: 16px !important; + } +} + +/* Prevent native text selection / callout on long-press in the mail app. + The mail UI uses custom context menus; native selection popups interfere. + Message bubble content is still selectable via the Copy button. */ +#mail-app, +#mail-app .thread-item, +#mail-app .msg-row, +#mail-app .msg-bubble, +#mail-app .html-card, +#mail-app .ctx-menu, +#mail-app .overflow-menu { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-touch-callout: none; +} + +/* Prevent double-tap-to-zoom on mobile. touch-action: manipulation allows + panning and pinching but disables double-tap zoom gesture. */ +@media (max-width: 639.98px) { + #mail-app, + body > .confirm-overlay, + body > .fwd-overlay, + body > .ctx-menu, + body > .overflow-menu, + body > .att-lightbox { + touch-action: manipulation; + } +} + +/* Rendering optimization: content-visibility:auto lets the browser skip + rendering off-screen message bubbles entirely. contain-intrinsic-size + provides a placeholder height so scrollbar calculations work without + actually rendering the content. This makes large conversations smooth + regardless of message count. */ +#mail-app .msg-row { + content-visibility: auto; + contain-intrinsic-size: auto 60px; +} + +#mail-app .html-card { + content-visibility: auto; + contain-intrinsic-size: auto 120px; +} diff --git a/web_src/js/features/mail/MailApp.vue b/web_src/js/features/mail/MailApp.vue index fbe2fbcef9..376e6f2ab9 100644 --- a/web_src/js/features/mail/MailApp.vue +++ b/web_src/js/features/mail/MailApp.vue @@ -22,6 +22,9 @@ export default defineComponent({ components: {SvgIcon, ThreadList, ChatPane, ComposePanel}, setup() { const threads = ref([]); + // Cache of fully-loaded messages per thread, so switching back is instant. + // Keyed by thread ID; value is the messages array. + const messageCache = new Map(); const activeId = ref(''); const view = ref<'list' | 'chat'>('list'); const searchQuery = ref(''); @@ -68,12 +71,23 @@ export default defineComponent({ activeId.value = id; t.unread = false; if (isMobile.value) view.value = 'chat'; + // Use cached messages if available (instant switch, no API call) + const cached = messageCache.get(id); + if (cached && cached.length > 0) { + const idx = threads.value.findIndex((x) => x.id === id); + if (idx >= 0) threads.value[idx].messages = cached.slice(); + return; + } try { const r = await api(`/api/v1/mail/threads/${id}`); const data = await r.json(); const full: Thread = data.thread; const idx = threads.value.findIndex((x) => x.id === id); - if (idx >= 0) threads.value[idx] = {...full, messages: full.messages ?? []}; + if (idx >= 0) { + threads.value[idx] = {...full, messages: full.messages ?? []}; + // Cache the loaded messages (without attachment blobs — just metadata) + messageCache.set(id, (full.messages ?? []).slice()); + } } catch (e) { /* surface later */ void e; } } @@ -103,6 +117,7 @@ export default defineComponent({ const idx = threads.value.findIndex((x) => x.id === id); if (idx < 0) return; threads.value.splice(idx, 1); + messageCache.delete(id); if (activeId.value === id) { activeId.value = ''; if (isMobile.value) view.value = 'list'; @@ -296,6 +311,22 @@ export default defineComponent({ try { const r = await api(`/api/v1/mail/threads/${threadId}/messages?before=${beforeId}`); const data = await r.json(); + const msgs = (data.messages ?? []) as Message[]; + // Update cache with all loaded messages for this thread + const existing = messageCache.get(threadId) ?? []; + messageCache.set(threadId, [...msgs, ...existing]); + return { + messages: msgs, + hasMore: !!data.has_more_msgs, + oldestId: data.oldest_msg_id ? String(data.oldest_msg_id) : '', + }; + } catch { return null; } + } + + async function loadMessagesAround(threadId: string, msgId: string): Promise<{messages: Message[], hasMore: boolean, oldestId: string} | null> { + try { + const r = await api(`/api/v1/mail/threads/${threadId}/around/${msgId}`); + const data = await r.json(); return { messages: (data.messages ?? []) as Message[], hasMore: !!data.has_more_msgs, @@ -337,7 +368,7 @@ export default defineComponent({ onForward, onDeleteSelected, renameGroup, addMemberToGroup, removeMemberFromGroup, - hasMoreThreads, loadMoreThreads, loadMoreMessages, + hasMoreThreads, loadMoreThreads, loadMoreMessages, loadMessagesAround, }; }, }); @@ -386,6 +417,7 @@ export default defineComponent({ :is-mobile="isMobile" :all-threads="threads" :load-more-messages="loadMoreMessages" + :load-messages-around="loadMessagesAround" @back="goBack" @toggle-pin="togglePin(activeThread!.id)" @delete="deleteThread(activeThread!.id)" diff --git a/web_src/js/features/mail/components/ChatPane.vue b/web_src/js/features/mail/components/ChatPane.vue index 8a7c5bb17e..f60c2289d5 100644 --- a/web_src/js/features/mail/components/ChatPane.vue +++ b/web_src/js/features/mail/components/ChatPane.vue @@ -7,7 +7,7 @@ import {SvgIcon} from '../../../svg.ts'; import type {Thread, Message, Attachment} from '../types.ts'; import {attachmentKindOf} from '../types.ts'; import {threadHeaderTitle, threadAvatarEmail} from '../threadTitle.ts'; -import {textMatches, htmlTextContent} from '../highlight.ts'; +import {htmlTextContent} from '../highlight.ts'; import AvatarCircle from './AvatarCircle.vue'; import MessageBubble from './MessageBubble.vue'; import HtmlMailCard from './HtmlMailCard.vue'; @@ -28,6 +28,7 @@ export default defineComponent({ isMobile: {type: Boolean, default: false}, allThreads: {type: Array as PropType, default: () => []}, loadMoreMessages: {type: Function as PropType<(threadId: string, beforeId: string) => Promise<{messages: Message[], hasMore: boolean, oldestId: string} | null>>, default: null}, + loadMessagesAround: {type: Function as PropType<(threadId: string, msgId: string) => Promise<{messages: Message[], hasMore: boolean, oldestId: string} | null>>, default: null}, }, emits: ['back', 'toggle-pin', 'toggle-members', 'delete', 'send', 'forward', 'delete-selected', 'rename-group', 'add-member', 'remove-member'], setup(props, {emit}) { @@ -261,26 +262,46 @@ export default defineComponent({ exitSelectMode(); } - // ---- In-thread search ---------------------------------------------- + // ---- In-thread search (backend-powered) --------------------------- const searchOpen = ref(false); const query = ref(''); const matchIndex = ref(0); + const searchResultIds = ref([]); + const searching = ref(false); + let searchDebounce: number | undefined; - const matchedIds = computed(() => { - const q = query.value; - const ids: string[] = []; - for (const m of props.thread.messages) { - if (m.kind === 'subject-change' || m.kind === 'name-change') continue; - const haystack = m.kind === 'html' ? htmlTextContent(m.body) : m.body; - if (textMatches(haystack, q)) ids.push(m.id); - } - return ids; - }); - const matchCount = computed(() => matchedIds.value.length); + const matchCount = computed(() => searchResultIds.value.length); watch(matchCount, (n) => { if (matchIndex.value >= n) matchIndex.value = Math.max(0, n - 1); }); + // Backend search: queries ALL messages in the conversation via API. + // Returns matching IDs so we can load context around each match. + watch(query, (q) => { + if (searchDebounce) clearTimeout(searchDebounce); + if (!q.trim()) { + searchResultIds.value = []; + matchIndex.value = 0; + return; + } + searchDebounce = window.setTimeout(async () => { + searching.value = true; + try { + const r = await fetch(`/api/v1/mail/threads/${props.thread.id}/search?q=${encodeURIComponent(q)}`, { + credentials: 'same-origin', + }); + if (!r.ok) { searching.value = false; return; } + const data = await r.json(); + searchResultIds.value = (data.ids ?? []) as string[]; + matchIndex.value = 0; + if (searchResultIds.value.length > 0) { + await jumpToMessage(searchResultIds.value[0]); + } + } catch { searchResultIds.value = []; } + searching.value = false; + }, 300); + }); + function openSearch() { searchOpen.value = true; nextTick(() => searchInput.value?.focus()); @@ -289,6 +310,7 @@ export default defineComponent({ searchOpen.value = false; query.value = ''; matchIndex.value = 0; + searchResultIds.value = []; } function onSearchKeydown(e: KeyboardEvent) { if (e.key === 'Escape') { @@ -301,20 +323,43 @@ export default defineComponent({ else jumpMatch(1); } } - function jumpMatch(dir: 1 | -1) { + async function jumpMatch(dir: 1 | -1) { const n = matchCount.value; if (n === 0) return; matchIndex.value = (matchIndex.value + dir + n) % n; - scrollToMatch(matchedIds.value[matchIndex.value]); + await jumpToMessage(searchResultIds.value[matchIndex.value]); } - function scrollToMatch(id: string | undefined) { - if (!id) return; - nextTick(() => { - const root = scroller.value; - if (!root) return; - const el = root.querySelector(`[data-msg-id="${CSS.escape(id)}"]`); - if (el) el.scrollIntoView({block: 'center', behavior: 'smooth'}); - }); + + // jumpToMessage: always loads a fresh message window centered on the target + // via the backend 'around' API, so we never need to keep full history loaded. + async function jumpToMessage(targetId: string) { + const scrollerEl = scroller.value; + // Check if already in the current DOM window + if (scrollerEl) { + const el = scrollerEl.querySelector(`[data-msg-id="${CSS.escape(targetId)}"]`); + if (el) { el.scrollIntoView({block: 'center'}); return; } + } + // Not in DOM — load messages around the target via backend + if (!props.loadMessagesAround) return; + searching.value = true; + const result = await props.loadMessagesAround(props.thread.id, targetId); + if (result && result.messages.length > 0) { + props.thread.messages.splice(0, props.thread.messages.length, ...result.messages); + hasMoreMessages.value = result.hasMore; + // Wait for DOM render + content-visibility to settle, then scroll. + // Uses requestAnimationFrame twice to ensure layout is complete. + nextTick(() => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const se = scroller.value; + if (!se) return; + const el = se.querySelector(`[data-msg-id="${CSS.escape(targetId)}"]`); + if (el) el.scrollIntoView({block: 'center'}); + }); + }); + }); + } + searching.value = false; } const title = computed(() => threadHeaderTitle(props.thread)); @@ -426,18 +471,33 @@ export default defineComponent({ if (props.thread.messages.length === 0) return; const oldestId = props.thread.messages[0].id; loadingMoreMessages.value = true; - // Capture scroll height before adding messages so we can restore position. - const prevHeight = scroller.value?.scrollHeight ?? 0; + // Capture the first element's offset so we can anchor scroll to it + // after prepending. This is more robust than height-diff because it + // works even if content reflows during the async fetch. + const scrollerEl = scroller.value; + const firstEl = scrollerEl?.querySelector('[data-msg-id]') as HTMLElement | null; + const anchorOffset = firstEl ? firstEl.offsetTop : 0; + const scrollOffset = scrollerEl ? scrollerEl.scrollTop : 0; const result = await props.loadMoreMessages(props.thread.id, oldestId); if (result && result.messages.length > 0) { props.thread.messages.unshift(...result.messages); hasMoreMessages.value = result.hasMore; - // Restore scroll position so the view doesn't jump. + // Restore scroll: find the same first element by its data-msg-id and + // scroll to it. Use requestAnimationFrame for DOM-complete accuracy. + const anchorId = firstEl?.dataset.msgId; nextTick(() => { - if (scroller.value) { - const newHeight = scroller.value.scrollHeight; - scroller.value.scrollTop = newHeight - prevHeight; - } + requestAnimationFrame(() => { + if (!scrollerEl) return; + if (anchorId) { + const el = scrollerEl.querySelector(`[data-msg-id="${CSS.escape(anchorId)}"]`) as HTMLElement | null; + if (el) { + scrollerEl.scrollTop = el.offsetTop - anchorOffset + scrollOffset; + return; + } + } + // Fallback: height-diff method + scrollerEl.scrollTop = scrollerEl.scrollHeight - (scrollerEl.scrollHeight - scrollerEl.clientHeight) + scrollOffset; + }); }); } else if (result) { hasMoreMessages.value = result.hasMore; @@ -466,10 +526,10 @@ export default defineComponent({ title, avatarEmail, renderPlan, autoGrow, send, onDraftKeydown, pendingAttachments, fileInput, pickFiles, onFilesPicked, removePending, - searchOpen, query, matchIndex, matchCount, + searchOpen, query, matchIndex, matchCount, searching, searchResultIds, openSearch, closeSearch, onSearchKeydown, jumpMatch, isCurrentMatch(id: string): boolean { - return matchCount.value > 0 && matchedIds.value[matchIndex.value] === id; + return matchCount.value > 0 && searchResultIds.value[matchIndex.value] === id; }, selectMode, selectedIds, selectedCount, enterSelectMode, exitSelectMode, toggleSelected, diff --git a/web_src/js/features/mail/components/ThreadList.vue b/web_src/js/features/mail/components/ThreadList.vue index ba1148f6de..d0d3f081dc 100644 --- a/web_src/js/features/mail/components/ThreadList.vue +++ b/web_src/js/features/mail/components/ThreadList.vue @@ -73,8 +73,19 @@ export default defineComponent({ }); }); - const pinned = computed(() => filtered.value.filter((t) => t.pinned)); - const others = computed(() => filtered.value.filter((t) => !t.pinned)); + // Sort by last message timestamp (most recent first), then by name for stability. + // Pinned threads always appear first. This is client-side so the list stays + // correct even when the backend's updated_at lags (e.g. after deletions). + const pinned = computed(() => { + return filtered.value + .filter((t) => t.pinned) + .sort((a, b) => lastTimestamp(b) - lastTimestamp(a) || a.id.localeCompare(b.id)); + }); + const others = computed(() => { + return filtered.value + .filter((t) => !t.pinned) + .sort((a, b) => lastTimestamp(b) - lastTimestamp(a) || a.id.localeCompare(b.id)); + }); // Context menu. Delete sits at the top; Block is an expandable toggle whose // options render below it when open. -- 2.54.0