feat(mail): windowed rendering, backend search, and UX polish #13
@@ -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
|
||||
}
|
||||
|
||||
+86
-1
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
+95
-11
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <d@m8sh.su> wrote:
|
||||
// > original message text
|
||||
// >> nested quote
|
||||
//
|
||||
// On Sat, Jul 18 at 4:08 PM Danila <d@m8sh.su> 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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -356,4 +356,4 @@ func buildDeliverPayload(opts SendOptions) []byte {
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,24 @@
|
||||
<div role="main" aria-label="{{.Title}}" class="page-content mail-page">
|
||||
<div id="mail-app"></div>
|
||||
</div>
|
||||
<script>
|
||||
// M8SH mail: completely disable zoom on mobile. The mail UI is designed for
|
||||
// mobile viewports and zoom only causes problems (auto-zoom on input focus,
|
||||
// accidental pinch-zoom). This runs immediately after head renders.
|
||||
(function () {
|
||||
var vp = document.querySelector('meta[name="viewport"]');
|
||||
if (vp) {
|
||||
vp.setAttribute('content', 'width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, viewport-fit=cover');
|
||||
}
|
||||
// Also prevent gesturestart (Safari pinch-zoom on iOS)
|
||||
document.addEventListener('gesturestart', function (e) { e.preventDefault(); });
|
||||
// Prevent double-tap zoom
|
||||
var lastTouch = 0;
|
||||
document.addEventListener('touchend', function (e) {
|
||||
var now = Date.now();
|
||||
if (now - lastTouch <= 300) { e.preventDefault(); }
|
||||
lastTouch = now;
|
||||
}, { passive: false });
|
||||
})();
|
||||
</script>
|
||||
{{template "base/footer" .}}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ export default defineComponent({
|
||||
components: {SvgIcon, ThreadList, ChatPane, ComposePanel},
|
||||
setup() {
|
||||
const threads = ref<Thread[]>([]);
|
||||
// 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<string, Message[]>();
|
||||
const activeId = ref<string>('');
|
||||
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)"
|
||||
|
||||
@@ -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<Thread[]>, 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<string[]>([]);
|
||||
const searching = ref(false);
|
||||
let searchDebounce: number | undefined;
|
||||
|
||||
const matchedIds = computed<string[]>(() => {
|
||||
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<HTMLElement>(`[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<HTMLElement>(`[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<HTMLElement>(`[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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user