Files
aerc-fork-mirror/lib/msgstore.go
Robin Jarry e8e953aaa8 msgstore: add index field for incremental updates
Add an Index field to MessageInfo that indicates the message's position
in the current view. This allows workers to send incremental updates
with position information, enabling the UI to insert new messages at the
correct location without requiring a full refresh.

The JMAP push handler now includes index information when sending
unsolicited message updates, allowing the message list to stay
synchronized with server-side changes in real time.

Signed-off-by: Robin Jarry <robin@jarry.cc>
Reviewed-by: Simon Martin <simon@nasilyan.com>
2026-02-09 14:46:27 +01:00

1101 lines
27 KiB
Go

package lib
import (
"context"
"errors"
"io"
"slices"
"sync"
"time"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/iterator"
"git.sr.ht/~rjarry/aerc/lib/marker"
"git.sr.ht/~rjarry/aerc/lib/sort"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
// Accesses to fields must be guarded by MessageStore.Lock/Unlock
type MessageStore struct {
sync.Mutex
Name string
Deleted map[models.UID]any
Messages map[models.UID]*models.MessageInfo
Sorting bool
ui func() *config.UIConfig
// ctx is given by the directory lister
ctx context.Context
// Ordered list of known UIDs
uids []models.UID
threads []*types.Thread
// Visible UIDs
scrollOffset int
scrollLen int
selectedUid models.UID
bodyCallbacks map[models.UID][]func(*types.FullMessage)
// marking
marker marker.Marker
// Search/filter results
results []models.UID
resultIndex int
filter *types.SearchCriteria
sortCriteria []*types.SortCriterion
sortDefault []*types.SortCriterion
threadedView bool
threadContext bool
buildThreads bool
builder *ThreadBuilder
directoryContentsLoaded bool
// Map of uids we've asked the worker to fetch
onUpdate func(store *MessageStore) // TODO: multiple onUpdate handlers
onFilterChange func(store *MessageStore)
onUpdateDirs func()
pendingBodies map[models.UID]any
pendingHeaders map[models.UID]any
worker *types.Worker
needsFlags []models.UID
fetchFlagsDebounce *time.Timer
fetchFlagsDelay time.Duration
triggerNewEmail func(*models.MessageInfo)
triggerDirectoryChange func()
triggerMailDeleted func()
triggerMailAdded func(string)
triggerTagModified func([]string, []string, []string)
triggerFlagChanged func(string)
threadBuilderDebounce *time.Timer
threadCallback func()
// threads mutex protects the store.threads and store.threadCallback
threadsMutex sync.Mutex
iterFactory iterator.Factory
onSelect func(*models.MessageInfo)
}
const MagicUid = models.UID("")
func NewMessageStore(worker *types.Worker, name string,
ui func() *config.UIConfig,
triggerNewEmail func(*models.MessageInfo),
triggerDirectoryChange func(), triggerMailDeleted func(),
triggerMailAdded func(string), triggerTagModified func([]string, []string, []string),
triggerFlagChanged func(string),
onSelect func(*models.MessageInfo),
) *MessageStore {
return &MessageStore{
Name: name,
Deleted: make(map[models.UID]any),
Messages: make(map[models.UID]*models.MessageInfo),
ui: ui,
ctx: context.Background(),
selectedUid: MagicUid,
// default window height until account is drawn once
scrollLen: 25,
bodyCallbacks: make(map[models.UID][]func(*types.FullMessage)),
pendingBodies: make(map[models.UID]any),
pendingHeaders: make(map[models.UID]any),
worker: worker,
needsFlags: []models.UID{},
fetchFlagsDelay: 50 * time.Millisecond,
triggerNewEmail: triggerNewEmail,
triggerDirectoryChange: triggerDirectoryChange,
triggerMailDeleted: triggerMailDeleted,
triggerMailAdded: triggerMailAdded,
triggerTagModified: triggerTagModified,
triggerFlagChanged: triggerFlagChanged,
onSelect: onSelect,
}
}
func (store *MessageStore) Configure(
defaultSort []*types.SortCriterion,
) {
uiConf := store.ui()
store.buildThreads = uiConf.ForceClientThreads ||
!store.worker.Backend.Capabilities().Thread
store.iterFactory = iterator.NewFactory(uiConf.ReverseOrder)
// The following config values can be toggled by the user;
// reset to default values when reloading config
store.threadedView = uiConf.ThreadingEnabled
store.threadContext = uiConf.ThreadContext
// update the default sort criteria
store.sortDefault = defaultSort
if store.sortCriteria == nil {
store.sortCriteria = defaultSort
}
}
func (store *MessageStore) SetContext(ctx context.Context) {
store.ctx = ctx
}
func (store *MessageStore) UpdateScroll(offset, length int) {
store.scrollOffset = offset
store.scrollLen = length
}
func (store *MessageStore) FetchHeaders(uids []models.UID,
cb func(types.WorkerMessage),
) {
// TODO: this could be optimized by pre-allocating toFetch and trimming it
// at the end. In practice we expect to get most messages back in one frame.
var toFetch []models.UID
for _, uid := range uids {
if _, ok := store.pendingHeaders[uid]; !ok {
toFetch = append(toFetch, uid)
store.pendingHeaders[uid] = nil
}
}
if len(toFetch) > 0 {
store.worker.PostAction(store.ctx, &types.FetchMessageHeaders{
Uids: toFetch,
Directory: store.Name,
}, func(msg types.WorkerMessage) {
switch msg.(type) {
case *types.Error, *types.Done, *types.Cancelled:
for _, uid := range toFetch {
delete(store.pendingHeaders, uid)
}
}
if cb != nil {
cb(msg)
}
})
}
}
func (store *MessageStore) FetchFull(
ctx context.Context, uids []models.UID, cb func(*types.FullMessage),
) {
// TODO: this could be optimized by pre-allocating toFetch and trimming it
// at the end. In practice we expect to get most messages back in one frame.
var toFetch []models.UID
for _, uid := range uids {
if _, ok := store.pendingBodies[uid]; !ok {
toFetch = append(toFetch, uid)
store.pendingBodies[uid] = nil
if cb != nil {
if list, ok := store.bodyCallbacks[uid]; ok {
store.bodyCallbacks[uid] = append(list, cb)
} else {
store.bodyCallbacks[uid] = []func(*types.FullMessage){cb}
}
}
}
}
if len(toFetch) > 0 {
store.worker.PostAction(ctx, &types.FetchFullMessages{
Directory: store.Name,
Uids: toFetch,
}, func(msg types.WorkerMessage) {
if _, ok := msg.(*types.Error); ok {
for _, uid := range toFetch {
delete(store.pendingBodies, uid)
delete(store.bodyCallbacks, uid)
}
}
})
}
}
func (store *MessageStore) FetchBodyPart(
ctx context.Context, uid models.UID, part []int, cb func(io.Reader),
) {
store.worker.PostAction(ctx, &types.FetchMessageBodyPart{
Directory: store.Name,
Uid: uid,
Part: part,
}, func(resp types.WorkerMessage) {
msg, ok := resp.(*types.MessageBodyPart)
if !ok {
return
}
cb(msg.Part.Reader)
})
}
func merge(to *models.MessageInfo, from *models.MessageInfo) {
if from.BodyStructure != nil {
to.BodyStructure = from.BodyStructure
}
if from.Envelope != nil {
to.Envelope = from.Envelope
}
to.Flags = from.Flags
to.Labels = from.Labels
to.Error = from.Error
if from.Size != 0 {
to.Size = from.Size
}
var zero time.Time
if from.InternalDate != zero {
to.InternalDate = from.InternalDate
}
}
// pos is an index in a sorted list of len UIDs, that are by default displayed
// in discreasing order, but can also be reversed by configuration. Return the
// actual position according to that configuration.
func actualPositionInList(pos int, len int, reverse bool) int {
if !reverse {
return len - pos - 1
}
return pos
}
func (store *MessageStore) Update(msg types.WorkerMessage) {
var newUids []models.UID
update := false
updateThreads := false
directoryChange := false
directoryContentsWasLoaded := store.directoryContentsLoaded
reverseOrder := store.ui().ReverseOrder
start := store.scrollOffset
end := store.scrollOffset + store.scrollLen
switch msg := msg.(type) {
case *types.OpenDirectory:
store.Sort(store.sortCriteria, nil)
update = true
case *types.DirectoryContents:
if msg.Directory != store.Name {
break
}
nUids := len(msg.Uids)
newMap := make(map[models.UID]*models.MessageInfo, nUids)
for i, uid := range msg.Uids {
if msg, ok := store.Messages[uid]; ok {
newMap[uid] = msg
} else {
newMap[uid] = nil
directoryChange = true
pos := actualPositionInList(i, nUids, reverseOrder)
if pos >= start && pos < end {
newUids = append(newUids, uid)
}
}
}
store.Messages = newMap
store.uids = msg.Uids
if store.threadedView {
store.runThreadBuilderNow()
}
store.directoryContentsLoaded = true
case *types.DirectoryThreaded:
if msg.Directory != store.Name {
break
}
if store.builder == nil {
store.builder = NewThreadBuilder(store.iterFactory,
store.ui().ThreadingBySubject)
}
store.builder.RebuildUids(msg.Threads, store.ReverseThreadOrder())
store.uids = store.builder.Uids()
store.threads = msg.Threads
nUids := len(store.uids)
newMap := make(map[models.UID]*models.MessageInfo, nUids)
for i, uid := range store.uids {
if msg, ok := store.Messages[uid]; ok {
newMap[uid] = msg
} else {
newMap[uid] = nil
directoryChange = true
pos := actualPositionInList(i, nUids, reverseOrder)
if pos >= start && pos < end {
newUids = append(newUids, uid)
}
}
}
store.Messages = newMap
update = true
store.directoryContentsLoaded = true
case *types.MessageInfo:
if msg.Info.Directory != store.Name {
break
}
infoUpdated := msg.Info.Envelope != nil || msg.Info.Error != nil
if existing, ok := store.Messages[msg.Info.Uid]; ok && existing != nil {
merge(existing, msg.Info)
} else if infoUpdated {
store.Messages[msg.Info.Uid] = msg.Info
if store.selectedUid == msg.Info.Uid {
store.onSelect(msg.Info)
}
// Insert UID into uids list if it's a new message
if msg.InResponseTo() == nil && !slices.Contains(store.uids, msg.Info.Uid) {
store.insertUid(msg.Info.Uid, msg.Info.Index)
}
} else if msg.InResponseTo() == nil {
// We received an unsolicited update for a message we don't have
// in store; store that update as is to ensure we have the same
// "Unseen status" as the server. It will be properly replaced
// if/when we need to fetch the message.
store.Messages[msg.Info.Uid] = msg.Info
if !slices.Contains(store.uids, msg.Info.Uid) {
store.insertUid(msg.Info.Uid, msg.Info.Index)
}
}
if msg.NeedsFlags {
store.Lock()
store.needsFlags = append(store.needsFlags, msg.Info.Uid)
store.Unlock()
store.fetchFlags()
}
seen := msg.Info.Flags.Has(models.SeenFlag)
recent := msg.Info.Flags.Has(models.RecentFlag)
if !seen && recent && msg.Info.Envelope != nil {
store.triggerNewEmail(msg.Info)
}
if _, ok := store.pendingHeaders[msg.Info.Uid]; infoUpdated && ok {
delete(store.pendingHeaders, msg.Info.Uid)
}
if store.builder != nil {
store.builder.Update(msg.Info)
}
update = true
updateThreads = true
case *types.FullMessage:
if _, ok := store.pendingBodies[msg.Content.Uid]; ok {
delete(store.pendingBodies, msg.Content.Uid)
if cbs, ok := store.bodyCallbacks[msg.Content.Uid]; ok {
for _, cb := range cbs {
cb(msg)
}
delete(store.bodyCallbacks, msg.Content.Uid)
}
}
case *types.MessagesDeleted:
if msg.Directory != store.Name {
break
}
if len(store.uids) < len(msg.Uids) {
update = true
break
}
toDelete := make(map[models.UID]any)
for _, uid := range msg.Uids {
toDelete[uid] = nil
delete(store.Messages, uid)
delete(store.Deleted, uid)
}
uids := make([]models.UID, 0, len(store.uids)-len(msg.Uids))
for _, uid := range store.uids {
if _, deleted := toDelete[uid]; deleted {
continue
}
uids = append(uids, uid)
}
store.uids = uids
if len(uids) == 0 {
store.Select(MagicUid)
}
var newResults []models.UID
for _, res := range store.results {
if _, deleted := toDelete[res]; !deleted {
newResults = append(newResults, res)
}
}
store.results = newResults
for uid := range toDelete {
thread, err := store.Thread(uid)
if err != nil {
continue
}
thread.Deleted = true
}
update = true
updateThreads = true
}
if update {
store.update(updateThreads)
}
if directoryContentsWasLoaded && directoryChange && store.triggerDirectoryChange != nil {
store.triggerDirectoryChange()
}
if len(newUids) > 0 {
store.FetchHeaders(newUids, nil)
if directoryContentsWasLoaded && store.triggerDirectoryChange != nil {
if directoryChange {
// We already invoked the callback; no need to do it again.
} else {
store.triggerDirectoryChange()
}
}
}
}
func (store *MessageStore) OnUpdate(fn func(store *MessageStore)) {
store.onUpdate = fn
}
func (store *MessageStore) OnFilterChange(fn func(store *MessageStore)) {
store.onFilterChange = fn
}
func (store *MessageStore) OnUpdateDirs(fn func()) {
store.onUpdateDirs = fn
}
func (store *MessageStore) update(threads bool) {
if store.onUpdate != nil {
store.onUpdate(store)
}
if store.onUpdateDirs != nil {
store.onUpdateDirs()
}
if store.ThreadedView() && threads {
switch {
case store.BuildThreads():
store.runThreadBuilder()
default:
if store.builder == nil {
store.builder = NewThreadBuilder(store.iterFactory,
store.ui().ThreadingBySubject)
}
store.threadsMutex.Lock()
store.builder.RebuildUids(store.threads,
store.ReverseThreadOrder())
store.threadsMutex.Unlock()
}
}
}
func (store *MessageStore) ReverseThreadOrder() bool {
return store.ui().ReverseThreadOrder
}
func (store *MessageStore) SetThreadedView(thread bool) {
store.threadedView = thread
if store.buildThreads {
if store.threadedView {
store.runThreadBuilder()
} else if store.threadBuilderDebounce != nil {
store.threadBuilderDebounce.Stop()
}
return
}
store.Sort(store.sortCriteria, nil)
}
func (store *MessageStore) ThreadsIterator() iterator.Iterator {
store.threadsMutex.Lock()
defer store.threadsMutex.Unlock()
return store.iterFactory.NewIterator(store.threads)
}
func (store *MessageStore) ThreadedView() bool {
return store.threadedView
}
func (store *MessageStore) ToggleThreadContext() {
if !store.threadedView {
return
}
store.threadContext = !store.threadContext
store.Sort(store.sortCriteria, nil)
}
func (store *MessageStore) BuildThreads() bool {
return store.buildThreads
}
func (store *MessageStore) runThreadBuilder() {
if store.builder == nil {
store.builder = NewThreadBuilder(store.iterFactory,
store.ui().ThreadingBySubject)
for _, msg := range store.Messages {
store.builder.Update(msg)
}
}
if store.threadBuilderDebounce != nil {
store.threadBuilderDebounce.Stop()
}
store.threadBuilderDebounce = time.AfterFunc(store.ui().ClientThreadsDelay,
func() {
store.runThreadBuilderNow()
ui.Invalidate()
},
)
}
// runThreadBuilderNow runs the threadbuilder without any debounce logic
func (store *MessageStore) runThreadBuilderNow() {
if store.builder == nil {
store.builder = NewThreadBuilder(store.iterFactory,
store.ui().ThreadingBySubject)
for _, msg := range store.Messages {
store.builder.Update(msg)
}
}
// build new threads
th := store.builder.Threads(store.uids, store.ReverseThreadOrder(),
store.ui().SortThreadSiblings)
// save local threads to the message store variable and
// run callback if defined (callback should reposition cursor)
store.threadsMutex.Lock()
store.threads = th
if store.threadCallback != nil {
store.threadCallback()
}
store.threadsMutex.Unlock()
// invalidate message list
if store.onUpdate != nil {
store.onUpdate(store)
}
}
// Thread returns the thread for the given UId
func (store *MessageStore) Thread(uid models.UID) (*types.Thread, error) {
if store.builder == nil {
return nil, errors.New("no threads found")
}
return store.builder.ThreadForUid(uid)
}
// SelectedThread returns the thread with the UID from the selected message
func (store *MessageStore) SelectedThread() (*types.Thread, error) {
return store.Thread(store.SelectedUid())
}
func (store *MessageStore) Fold(uid models.UID, toggle bool) error {
return store.doThreadFolding(uid, true, toggle)
}
func (store *MessageStore) Unfold(uid models.UID, toggle bool) error {
return store.doThreadFolding(uid, false, toggle)
}
func (store *MessageStore) doThreadFolding(uid models.UID, hide bool, toggle bool) error {
thread, err := store.Thread(uid)
if err != nil {
return err
}
if len(thread.Uids()) == 1 {
return nil
}
folded := thread.FirstChild.Hidden > 0
if !toggle && hide && folded {
return nil
}
err = thread.Walk(func(t *types.Thread, _ int, __ error) error {
if t.Uid != uid {
switch {
case toggle:
if folded {
if t.Hidden > 1 {
t.Hidden--
} else {
t.Hidden = 0
}
} else {
t.Hidden++
}
case hide:
t.Hidden++
case t.Hidden > 1:
t.Hidden--
default:
t.Hidden = 0
}
}
return nil
})
if err != nil {
return err
}
if store.builder == nil {
return errors.New("No thread builder available")
}
store.Select(uid)
store.threadsMutex.Lock()
store.builder.RebuildUids(store.threads, store.ReverseThreadOrder())
store.threadsMutex.Unlock()
return nil
}
func (store *MessageStore) Delete(uids []models.UID, mfs *types.MultiFileStrategy,
cb func(msg types.WorkerMessage),
) {
for _, uid := range uids {
store.Deleted[uid] = nil
}
store.worker.PostAction(context.TODO(), &types.DeleteMessages{
Directory: store.Name,
Uids: uids,
MultiFileStrategy: mfs,
}, func(msg types.WorkerMessage) {
if _, ok := msg.(*types.Error); ok {
store.revertDeleted(uids)
}
if _, ok := msg.(*types.Unsupported); ok {
store.revertDeleted(uids)
}
if _, ok := msg.(*types.Done); ok {
store.triggerMailDeleted()
}
cb(msg)
})
}
func (store *MessageStore) revertDeleted(uids []models.UID) {
for _, uid := range uids {
delete(store.Deleted, uid)
}
}
func (store *MessageStore) Copy(uids []models.UID, dest string, createDest bool,
mfs *types.MultiFileStrategy, cb func(msg types.WorkerMessage),
) {
if createDest {
store.worker.PostAction(context.TODO(), &types.CreateDirectory{
Directory: dest,
Quiet: true,
}, cb)
}
store.worker.PostAction(context.TODO(), &types.CopyMessages{
Source: store.Name,
Destination: dest,
Uids: uids,
MultiFileStrategy: mfs,
}, func(msg types.WorkerMessage) {
if _, ok := msg.(*types.Done); ok {
store.triggerMailAdded(dest)
}
cb(msg)
})
}
func (store *MessageStore) Move(uids []models.UID, dest string, createDest bool,
mfs *types.MultiFileStrategy, cb func(msg types.WorkerMessage),
) {
for _, uid := range uids {
store.Deleted[uid] = nil
}
if createDest {
store.worker.PostAction(context.TODO(), &types.CreateDirectory{
Directory: dest,
Quiet: true,
}, nil) // quiet doesn't return an error, don't want the done cb here
}
store.worker.PostAction(context.TODO(), &types.MoveMessages{
Source: store.Name,
Destination: dest,
Uids: uids,
MultiFileStrategy: mfs,
}, func(msg types.WorkerMessage) {
switch msg.(type) {
case *types.Error:
store.revertDeleted(uids)
cb(msg)
case *types.Done:
store.triggerMailDeleted()
store.triggerMailAdded(dest)
cb(msg)
}
})
}
func (store *MessageStore) Append(dest string, flags models.Flags, date time.Time,
reader io.Reader, length int, cb func(msg types.WorkerMessage),
) {
store.worker.PostAction(context.TODO(), &types.CreateDirectory{
Directory: dest,
Quiet: true,
}, nil)
store.worker.PostAction(context.TODO(), &types.AppendMessage{
Destination: dest,
Flags: flags,
Date: date,
Reader: reader,
Length: length,
}, func(msg types.WorkerMessage) {
if _, ok := msg.(*types.Done); ok {
store.triggerMailAdded(dest)
}
cb(msg)
})
}
func (store *MessageStore) Flag(uids []models.UID, flags models.Flags,
enable bool, cb func(msg types.WorkerMessage),
) {
store.worker.PostAction(context.TODO(), &types.FlagMessages{
Directory: store.Name,
Enable: enable,
Flags: flags,
Uids: uids,
}, func(msg types.WorkerMessage) {
var flagName string
switch flags {
case models.SeenFlag:
flagName = "seen"
case models.AnsweredFlag:
flagName = "answered"
case models.ForwardedFlag:
flagName = "forwarded"
case models.FlaggedFlag:
flagName = "flagged"
case models.DraftFlag:
flagName = "draft"
}
if _, ok := msg.(*types.Done); ok {
store.triggerFlagChanged(flagName)
}
if cb != nil {
cb(msg)
}
})
}
func (store *MessageStore) Answered(uids []models.UID, answered bool,
cb func(msg types.WorkerMessage),
) {
store.worker.PostAction(context.TODO(), &types.AnsweredMessages{
Directory: store.Name,
Answered: answered,
Uids: uids,
}, cb)
}
func (store *MessageStore) Forwarded(uids []models.UID, forwarded bool,
cb func(msg types.WorkerMessage),
) {
store.worker.PostAction(context.TODO(), &types.ForwardedMessages{
Forwarded: forwarded,
Directory: store.Name,
Uids: uids,
}, cb)
}
func (store *MessageStore) Uids() []models.UID {
if store.ThreadedView() && store.builder != nil {
if uids := store.builder.Uids(); len(uids) > 0 {
return uids
}
}
return store.uids
}
// insertUid inserts a UID at the given index (0-based).
// If index is nil, the UID is appended to the end of the list.
func (store *MessageStore) insertUid(uid models.UID, index *int) {
if index == nil || *index <= 0 || *index >= len(store.uids) {
store.uids = append(store.uids, uid)
} else {
store.uids = slices.Insert(store.uids, *index, uid)
}
}
func (store *MessageStore) UidsIterator() iterator.Iterator {
return store.iterFactory.NewIterator(store.Uids())
}
func (store *MessageStore) Selected() *models.MessageInfo {
return store.Messages[store.selectedUid]
}
func (store *MessageStore) SelectedUid() models.UID {
if store.selectedUid == MagicUid && len(store.Uids()) > 0 {
iter := store.UidsIterator()
idx := iter.StartIndex()
if store.ui().SelectLast {
idx = iter.EndIndex()
}
store.Select(store.Uids()[idx])
}
return store.selectedUid
}
func (store *MessageStore) Select(uid models.UID) {
store.selectPriv(uid, false)
if store.onSelect != nil {
store.onSelect(store.Selected())
}
}
func (store *MessageStore) selectPriv(uid models.UID, lockHeld bool) {
if !lockHeld {
store.threadsMutex.Lock()
}
if store.threadCallback != nil {
store.threadCallback = nil
}
if !lockHeld {
store.threadsMutex.Unlock()
}
store.selectedUid = uid
if store.marker != nil {
store.marker.UpdateVisualMark()
}
}
func (store *MessageStore) NextPrev(delta int) {
uids := store.Uids()
if len(uids) == 0 {
return
}
iter := store.iterFactory.NewIterator(uids)
newIdx := store.FindIndexByUid(store.SelectedUid())
if newIdx < 0 {
store.Select(uids[iter.StartIndex()])
return
}
newIdx = iterator.MoveIndex(
newIdx,
delta,
iter,
iterator.FixBounds,
)
store.Select(uids[newIdx])
if store.BuildThreads() && store.ThreadedView() {
store.threadsMutex.Lock()
store.threadCallback = func() {
if uids := store.Uids(); len(uids) > newIdx {
store.selectPriv(uids[newIdx], true)
}
}
store.threadsMutex.Unlock()
}
if store.marker != nil {
store.marker.UpdateVisualMark()
}
store.updateResults()
}
func (store *MessageStore) Next() {
store.NextPrev(1)
}
func (store *MessageStore) Prev() {
store.NextPrev(-1)
}
func (store *MessageStore) Search(terms *types.SearchCriteria, cb func([]models.UID)) {
store.worker.PostAction(store.ctx, &types.SearchDirectory{
Directory: store.Name,
Criteria: terms,
}, func(msg types.WorkerMessage) {
if msg, ok := msg.(*types.SearchResults); ok {
allowedUids := store.Uids()
uids := make([]models.UID, 0, len(msg.Uids))
for _, uid := range msg.Uids {
if slices.Contains(allowedUids, uid) {
uids = append(uids, uid)
}
}
sort.SortBy(uids, allowedUids)
cb(uids)
}
})
}
func (store *MessageStore) ApplySearch(results []models.UID) {
store.results = results
store.resultIndex = -1
store.NextResult()
}
// IsResult returns true if uid is a search result
func (store *MessageStore) IsResult(uid models.UID) bool {
return slices.Contains(store.results, uid)
}
func (store *MessageStore) SetFilter(terms *types.SearchCriteria) {
store.filter = store.filter.Combine(terms)
}
func (store *MessageStore) ApplyClear() {
store.filter = nil
store.results = nil
if store.onFilterChange != nil {
store.onFilterChange(store)
}
store.Sort(store.sortDefault, nil)
}
func (store *MessageStore) updateResults() {
if len(store.results) == 0 || store.resultIndex < 0 {
return
}
uid := store.SelectedUid()
for i, u := range store.results {
if uid == u {
store.resultIndex = i
break
}
}
}
func (store *MessageStore) nextPrevResult(delta int) {
if len(store.results) == 0 {
return
}
iter := store.iterFactory.NewIterator(store.results)
if store.resultIndex < 0 {
store.resultIndex = iter.StartIndex()
} else {
store.resultIndex = iterator.MoveIndex(
store.resultIndex,
delta,
iter,
iterator.WrapBounds,
)
}
store.Select(store.results[store.resultIndex])
store.update(false)
}
func (store *MessageStore) NextResult() {
store.nextPrevResult(1)
}
func (store *MessageStore) PrevResult() {
store.nextPrevResult(-1)
}
func (store *MessageStore) ModifyLabels(uids []models.UID, add, remove, toggle []string,
cb func(msg types.WorkerMessage),
) {
store.worker.PostAction(context.TODO(), &types.ModifyLabels{
Uids: uids,
Add: add,
Remove: remove,
Toggle: toggle,
}, func(msg types.WorkerMessage) {
if _, ok := msg.(*types.Done); ok {
store.triggerTagModified(add, remove, toggle)
}
cb(msg)
})
}
func (store *MessageStore) Sort(criteria []*types.SortCriterion, cb func(types.WorkerMessage)) {
store.sortCriteria = criteria
store.Sorting = true
idx := len(store.Uids()) - (store.SelectedIndex() + 1)
handle_return := func(msg types.WorkerMessage) {
store.Select(store.SelectedUid())
if store.SelectedIndex() < 0 {
store.Select(MagicUid)
store.NextPrev(idx)
}
store.Sorting = false
if cb != nil {
cb(msg)
}
}
if store.threadedView && !store.buildThreads {
store.worker.PostAction(store.ctx, &types.FetchDirectoryThreaded{
Directory: store.Name,
SortCriteria: criteria,
Filter: store.filter,
ThreadContext: store.threadContext,
}, handle_return)
} else {
store.worker.PostAction(store.ctx, &types.FetchDirectoryContents{
Directory: store.Name,
SortCriteria: criteria,
Filter: store.filter,
}, handle_return)
}
}
func (store *MessageStore) GetCurrentSortCriteria() []*types.SortCriterion {
return store.sortCriteria
}
func (store *MessageStore) SetMarker(m marker.Marker) {
store.marker = m
}
func (store *MessageStore) Marker() marker.Marker {
if store.marker == nil {
store.marker = marker.New(store)
}
return store.marker
}
// FindIndexByUid returns the index in store.Uids() or -1 if not found
func (store *MessageStore) FindIndexByUid(uid models.UID) int {
for idx, u := range store.Uids() {
if u == uid {
return idx
}
}
return -1
}
// Capabilities returns a models.Capabilities struct or nil if not available
func (store *MessageStore) Capabilities() *models.Capabilities {
return store.worker.Backend.Capabilities()
}
// SelectedIndex returns the index of the selected message in the uid list or
// -1 if not found
func (store *MessageStore) SelectedIndex() int {
return store.FindIndexByUid(store.selectedUid)
}
func (store *MessageStore) fetchFlags() {
if store.fetchFlagsDebounce != nil {
store.fetchFlagsDebounce.Stop()
}
store.fetchFlagsDebounce = time.AfterFunc(store.fetchFlagsDelay, func() {
store.Lock()
store.worker.PostAction(store.ctx, &types.FetchMessageFlags{
Directory: store.Name,
Uids: store.needsFlags,
}, nil)
store.needsFlags = []models.UID{}
store.Unlock()
})
}