feat(mail): windowed rendering, backend search, and UX polish #13

Merged
d merged 1 commits from email-messenger-improvements into main 2026-07-19 17:30:26 +00:00
15 changed files with 447 additions and 73 deletions
+1 -1
View File
@@ -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
View File
@@ -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).
+2
View File
@@ -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
View File
@@ -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)
+17 -3
View File
@@ -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,
}
+10 -10
View File
@@ -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")
}
}
}
+7 -5
View File
@@ -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 {
+3 -3
View File
@@ -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)
+1 -1
View File
@@ -356,4 +356,4 @@ func buildDeliverPayload(opts SendOptions) []byte {
}
b, _ := json.Marshal(payload)
return b
}
}
+2 -2
View File
@@ -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
}
}
+20
View File
@@ -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" .}}
+64
View File
@@ -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;
}
+34 -2
View File
@@ -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.