mirror of
https://git.sr.ht/~rjarry/aerc
synced 2026-03-02 18:23:33 +01:00
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>
1101 lines
27 KiB
Go
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()
|
|
})
|
|
}
|