diff options
Diffstat (limited to 'app/account.go')
-rw-r--r-- | app/account.go | 649 |
1 files changed, 649 insertions, 0 deletions
diff --git a/app/account.go b/app/account.go new file mode 100644 index 00000000..3ab7fc8f --- /dev/null +++ b/app/account.go @@ -0,0 +1,649 @@ +package app + +import ( + "bytes" + "errors" + "fmt" + "sync" + "time" + + "github.com/gdamore/tcell/v2" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/hooks" + "git.sr.ht/~rjarry/aerc/lib/marker" + "git.sr.ht/~rjarry/aerc/lib/sort" + "git.sr.ht/~rjarry/aerc/lib/state" + "git.sr.ht/~rjarry/aerc/lib/templates" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +var _ ProvidesMessages = (*AccountView)(nil) + +type AccountView struct { + sync.Mutex + acct *config.AccountConfig + aerc *Aerc + dirlist DirectoryLister + labels []string + grid *ui.Grid + host TabHost + tab *ui.Tab + msglist *MessageList + worker *types.Worker + state state.AccountState + newConn bool // True if this is a first run after a new connection/reconnection + uiConf *config.UIConfig + + split *MessageViewer + splitSize int + splitDebounce *time.Timer + splitDir string + + // Check-mail ticker + ticker *time.Ticker + checkingMail bool +} + +func (acct *AccountView) UiConfig() *config.UIConfig { + if dirlist := acct.Directories(); dirlist != nil { + return dirlist.UiConfig("") + } + return acct.uiConf +} + +func NewAccountView( + aerc *Aerc, acct *config.AccountConfig, + host TabHost, deferLoop chan struct{}, +) (*AccountView, error) { + acctUiConf := config.Ui.ForAccount(acct.Name) + + view := &AccountView{ + acct: acct, + aerc: aerc, + host: host, + uiConf: acctUiConf, + } + + view.grid = ui.NewGrid().Rows([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: func() int { + return view.UiConfig().SidebarWidth + }}, + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + + worker, err := worker.NewWorker(acct.Source, acct.Name) + if err != nil { + host.SetError(fmt.Sprintf("%s: %s", acct.Name, err)) + log.Errorf("%s: %v", acct.Name, err) + return view, err + } + view.worker = worker + + view.dirlist = NewDirectoryList(acct, worker) + if acctUiConf.SidebarWidth > 0 { + view.grid.AddChild(ui.NewBordered(view.dirlist, ui.BORDER_RIGHT, acctUiConf)) + } + + view.msglist = NewMessageList(aerc, view) + view.grid.AddChild(view.msglist).At(0, 1) + + view.dirlist.OnVirtualNode(func() { + view.msglist.SetStore(nil) + view.Invalidate() + }) + + go func() { + defer log.PanicHandler() + + if deferLoop != nil { + <-deferLoop + } + + worker.Backend.Run() + }() + + worker.PostAction(&types.Configure{Config: acct}, nil) + worker.PostAction(&types.Connect{}, nil) + view.SetStatus(state.ConnectionActivity("Connecting...")) + if acct.CheckMail.Minutes() > 0 { + view.CheckMailTimer(acct.CheckMail) + } + + return view, nil +} + +func (acct *AccountView) SetStatus(setters ...state.SetStateFunc) { + for _, fn := range setters { + fn(&acct.state, acct.SelectedDirectory()) + } + acct.UpdateStatus() +} + +func (acct *AccountView) UpdateStatus() { + if acct.isSelected() { + acct.host.UpdateStatus() + } +} + +func (acct *AccountView) PushStatus(status string, expiry time.Duration) { + acct.aerc.PushStatus(fmt.Sprintf("%s: %s", acct.acct.Name, status), expiry) +} + +func (acct *AccountView) PushError(err error) { + acct.aerc.PushError(fmt.Sprintf("%s: %v", acct.acct.Name, err)) +} + +func (acct *AccountView) PushWarning(warning string) { + acct.aerc.PushWarning(fmt.Sprintf("%s: %s", acct.acct.Name, warning)) +} + +func (acct *AccountView) AccountConfig() *config.AccountConfig { + return acct.acct +} + +func (acct *AccountView) Worker() *types.Worker { + return acct.worker +} + +func (acct *AccountView) Name() string { + return acct.acct.Name +} + +func (acct *AccountView) Invalidate() { + ui.Invalidate() +} + +func (acct *AccountView) Draw(ctx *ui.Context) { + acct.grid.Draw(ctx) +} + +func (acct *AccountView) MouseEvent(localX int, localY int, event tcell.Event) { + acct.grid.MouseEvent(localX, localY, event) +} + +func (acct *AccountView) Focus(focus bool) { + // TODO: Unfocus children I guess +} + +func (acct *AccountView) Directories() DirectoryLister { + return acct.dirlist +} + +func (acct *AccountView) Labels() []string { + return acct.labels +} + +func (acct *AccountView) Messages() *MessageList { + return acct.msglist +} + +func (acct *AccountView) Store() *lib.MessageStore { + if acct.msglist == nil { + return nil + } + return acct.msglist.Store() +} + +func (acct *AccountView) SelectedAccount() *AccountView { + return acct +} + +func (acct *AccountView) SelectedDirectory() string { + return acct.dirlist.Selected() +} + +func (acct *AccountView) SelectedMessage() (*models.MessageInfo, error) { + if acct.msglist == nil || acct.msglist.Store() == nil { + return nil, errors.New("init in progress") + } + if len(acct.msglist.Store().Uids()) == 0 { + return nil, errors.New("no message selected") + } + msg := acct.msglist.Selected() + if msg == nil { + return nil, errors.New("message not loaded") + } + return msg, nil +} + +func (acct *AccountView) MarkedMessages() ([]uint32, error) { + if store := acct.Store(); store != nil { + return store.Marker().Marked(), nil + } + return nil, errors.New("no store available") +} + +func (acct *AccountView) SelectedMessagePart() *PartInfo { + return nil +} + +func (acct *AccountView) isSelected() bool { + return acct == acct.aerc.SelectedAccount() +} + +func (acct *AccountView) newStore(name string) *lib.MessageStore { + uiConf := acct.dirlist.UiConfig(name) + store := lib.NewMessageStore(acct.worker, + acct.sortCriteria(uiConf), + uiConf.ThreadingEnabled, + uiConf.ForceClientThreads, + uiConf.ClientThreadsDelay, + uiConf.ReverseOrder, + uiConf.ReverseThreadOrder, + uiConf.SortThreadSiblings, + func(msg *models.MessageInfo) { + err := hooks.RunHook(&hooks.MailReceived{ + Account: acct.Name(), + Folder: name, + MsgInfo: msg, + }) + if err != nil { + msg := fmt.Sprintf("mail-received hook: %s", err) + acct.aerc.PushError(msg) + } + }, func() { + if uiConf.NewMessageBell { + acct.host.Beep() + } + }, + acct.updateSplitView, + acct.dirlist.UiConfig(name).ThreadContext, + ) + store.SetMarker(marker.New(store)) + return store +} + +func (acct *AccountView) onMessage(msg types.WorkerMessage) { + msg = acct.worker.ProcessMessage(msg) + switch msg := msg.(type) { + case *types.Done: + switch resp := msg.InResponseTo().(type) { + case *types.Connect, *types.Reconnect: + acct.SetStatus(state.ConnectionActivity("Listing mailboxes...")) + log.Infof("[%s] connected.", acct.acct.Name) + acct.SetStatus(state.SetConnected(true)) + log.Tracef("Listing mailboxes...") + acct.worker.PostAction(&types.ListDirectories{}, nil) + case *types.Disconnect: + acct.dirlist.ClearList() + acct.msglist.SetStore(nil) + log.Infof("[%s] disconnected.", acct.acct.Name) + acct.SetStatus(state.SetConnected(false)) + case *types.OpenDirectory: + acct.dirlist.Update(msg) + if store, ok := acct.dirlist.SelectedMsgStore(); ok { + // If we've opened this dir before, we can re-render it from + // memory while we wait for the update and the UI feels + // snappier. If not, we'll unset the store and show the spinner + // while we download the UID list. + acct.msglist.SetStore(store) + acct.Store().Update(msg.InResponseTo()) + } else { + acct.msglist.SetStore(nil) + } + case *types.CreateDirectory: + store := acct.newStore(resp.Directory) + acct.dirlist.SetMsgStore(&models.Directory{ + Name: resp.Directory, + }, store) + acct.dirlist.Update(msg) + case *types.RemoveDirectory: + acct.dirlist.Update(msg) + case *types.FetchMessageHeaders: + if acct.newConn { + acct.checkMailOnStartup() + } + case *types.ListDirectories: + acct.dirlist.Update(msg) + if dir := acct.dirlist.Selected(); dir != "" { + acct.dirlist.Select(dir) + return + } + // Nothing selected, select based on config + dirs := acct.dirlist.List() + var dir string + for _, _dir := range dirs { + if _dir == acct.acct.Default { + dir = _dir + break + } + } + if dir == "" && len(dirs) > 0 { + dir = dirs[0] + } + if dir != "" { + acct.dirlist.Select(dir) + } + acct.msglist.SetInitDone() + acct.newConn = true + } + case *types.Directory: + store, ok := acct.dirlist.MsgStore(msg.Dir.Name) + if !ok { + store = acct.newStore(msg.Dir.Name) + } + acct.dirlist.SetMsgStore(msg.Dir, store) + case *types.DirectoryInfo: + acct.dirlist.Update(msg) + case *types.DirectoryContents: + if store, ok := acct.dirlist.SelectedMsgStore(); ok { + if acct.msglist.Store() == nil { + acct.msglist.SetStore(store) + } + store.Update(msg) + acct.SetStatus(state.Threading(store.ThreadedView())) + } + if acct.newConn && len(msg.Uids) == 0 { + acct.checkMailOnStartup() + } + case *types.DirectoryThreaded: + if store, ok := acct.dirlist.SelectedMsgStore(); ok { + if acct.msglist.Store() == nil { + acct.msglist.SetStore(store) + } + store.Update(msg) + acct.SetStatus(state.Threading(store.ThreadedView())) + } + if acct.newConn && len(msg.Threads) == 0 { + acct.checkMailOnStartup() + } + case *types.FullMessage: + if store, ok := acct.dirlist.SelectedMsgStore(); ok { + store.Update(msg) + } + case *types.MessageInfo: + if store, ok := acct.dirlist.SelectedMsgStore(); ok { + store.Update(msg) + } + case *types.MessagesDeleted: + if dir := acct.dirlist.SelectedDirectory(); dir != nil { + dir.Exists -= len(msg.Uids) + } + if store, ok := acct.dirlist.SelectedMsgStore(); ok { + store.Update(msg) + } + case *types.MessagesCopied: + acct.updateDirCounts(msg.Destination, msg.Uids) + case *types.MessagesMoved: + acct.updateDirCounts(msg.Destination, msg.Uids) + case *types.LabelList: + acct.labels = msg.Labels + case *types.ConnError: + log.Errorf("[%s] connection error: %v", acct.acct.Name, msg.Error) + acct.SetStatus(state.SetConnected(false)) + acct.PushError(msg.Error) + acct.msglist.SetStore(nil) + acct.worker.PostAction(&types.Reconnect{}, nil) + case *types.Error: + log.Errorf("[%s] unexpected error: %v", acct.acct.Name, msg.Error) + acct.PushError(msg.Error) + } + acct.UpdateStatus() + acct.setTitle() +} + +func (acct *AccountView) updateDirCounts(destination string, uids []uint32) { + // Only update the destination destDir if it is initialized + if destDir := acct.dirlist.Directory(destination); destDir != nil { + var recent, unseen int + var accurate bool = true + for _, uid := range uids { + // Get the message from the originating store + msg, ok := acct.Store().Messages[uid] + if !ok { + continue + } + // If message that was not yet loaded is copied + if msg == nil { + accurate = false + break + } + seen := msg.Flags.Has(models.SeenFlag) + if msg.Flags.Has(models.RecentFlag) { + recent++ + } + if !seen { + unseen++ + } + } + if accurate { + destDir.Recent += recent + destDir.Unseen += unseen + destDir.Exists += len(uids) + } else { + destDir.Exists += len(uids) + } + } +} + +func (acct *AccountView) sortCriteria(uiConf *config.UIConfig) []*types.SortCriterion { + if uiConf == nil { + return nil + } + if len(uiConf.Sort) == 0 { + return nil + } + criteria, err := sort.GetSortCriteria(uiConf.Sort) + if err != nil { + acct.PushError(fmt.Errorf("ui sort: %w", err)) + return nil + } + return criteria +} + +func (acct *AccountView) GetSortCriteria() []*types.SortCriterion { + return acct.sortCriteria(acct.UiConfig()) +} + +func (acct *AccountView) CheckMail() { + acct.Lock() + defer acct.Unlock() + if acct.checkingMail { + return + } + // Exclude selected mailbox, per IMAP specification + exclude := append(acct.AccountConfig().CheckMailExclude, acct.dirlist.Selected()) //nolint:gocritic // intentional append to different slice + dirs := acct.dirlist.List() + dirs = acct.dirlist.FilterDirs(dirs, acct.AccountConfig().CheckMailInclude, false) + dirs = acct.dirlist.FilterDirs(dirs, exclude, true) + log.Debugf("Checking for new mail on account %s", acct.Name()) + acct.SetStatus(state.ConnectionActivity("Checking for new mail...")) + msg := &types.CheckMail{ + Directories: dirs, + Command: acct.acct.CheckMailCmd, + Timeout: acct.acct.CheckMailTimeout, + } + acct.checkingMail = true + + var cb func(types.WorkerMessage) + cb = func(response types.WorkerMessage) { + dirsMsg, ok := response.(*types.CheckMailDirectories) + if ok { + checkMailMsg := &types.CheckMail{ + Directories: dirsMsg.Directories, + Command: acct.acct.CheckMailCmd, + Timeout: acct.acct.CheckMailTimeout, + } + acct.worker.PostAction(checkMailMsg, cb) + } else { // Done + acct.SetStatus(state.ConnectionActivity("")) + acct.Lock() + acct.checkingMail = false + acct.Unlock() + } + } + acct.worker.PostAction(msg, cb) +} + +// CheckMailReset resets the check-mail timer +func (acct *AccountView) CheckMailReset() { + if acct.ticker != nil { + d := acct.AccountConfig().CheckMail + acct.ticker = time.NewTicker(d) + } +} + +func (acct *AccountView) checkMailOnStartup() { + if acct.AccountConfig().CheckMail.Minutes() > 0 { + acct.newConn = false + acct.CheckMail() + } +} + +func (acct *AccountView) CheckMailTimer(d time.Duration) { + acct.ticker = time.NewTicker(d) + go func() { + defer log.PanicHandler() + for range acct.ticker.C { + if !acct.state.Connected { + continue + } + acct.CheckMail() + } + }() +} + +func (acct *AccountView) closeSplit() { + if acct.split != nil { + acct.split.Close() + } + acct.splitSize = 0 + acct.splitDir = "" + acct.split = nil + acct.grid = ui.NewGrid().Rows([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: func() int { + return acct.UiConfig().SidebarWidth + }}, + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + + acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.uiConf)) + acct.grid.AddChild(acct.msglist).At(0, 1) + ui.Invalidate() +} + +func (acct *AccountView) updateSplitView(msg *models.MessageInfo) { + if acct.splitSize == 0 { + return + } + if acct.splitDebounce != nil { + acct.splitDebounce.Stop() + } + fn := func() { + if acct.split != nil { + acct.grid.RemoveChild(acct.split) + acct.split.Close() + } + lib.NewMessageStoreView(msg, false, acct.Store(), acct.aerc.Crypto, acct.aerc.DecryptKeys, + func(view lib.MessageView, err error) { + if err != nil { + acct.aerc.PushError(err.Error()) + return + } + acct.split = NewMessageViewer(acct, view) + switch acct.splitDir { + case "split": + acct.grid.AddChild(acct.split).At(1, 1) + case "vsplit": + acct.grid.AddChild(acct.split).At(0, 2) + } + }) + } + acct.splitDebounce = time.AfterFunc(100*time.Millisecond, func() { + ui.QueueFunc(fn) + }) +} + +func (acct *AccountView) SplitSize() int { + return acct.splitSize +} + +func (acct *AccountView) SetSplitSize(n int) { + if n == 0 { + acct.closeSplit() + } + acct.splitSize = n +} + +// Split splits the message list view horizontally. The message list will be n +// rows high. If n is 0, any existing split is removed +func (acct *AccountView) Split(n int) error { + acct.SetSplitSize(n) + if acct.splitDir == "split" || n == 0 { + return nil + } + acct.splitDir = "split" + acct.grid = ui.NewGrid().Rows([]ui.GridSpec{ + // Add 1 so that the splitSize is the number of visible messages + {Strategy: ui.SIZE_EXACT, Size: func() int { return acct.SplitSize() + 1 }}, + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: func() int { + return acct.UiConfig().SidebarWidth + }}, + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + + acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.uiConf)).Span(2, 1) + acct.grid.AddChild(ui.NewBordered(acct.msglist, ui.BORDER_BOTTOM, acct.uiConf)).At(0, 1) + acct.split = NewMessageViewer(acct, nil) + acct.grid.AddChild(acct.split).At(1, 1) + acct.updateSplitView(acct.msglist.Selected()) + return nil +} + +// Vsplit splits the message list view vertically. The message list will be n +// rows wide. If n is 0, any existing split is removed +func (acct *AccountView) Vsplit(n int) error { + acct.SetSplitSize(n) + if acct.splitDir == "vsplit" || n == 0 { + return nil + } + acct.splitDir = "vsplit" + acct.grid = ui.NewGrid().Rows([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: func() int { + return acct.UiConfig().SidebarWidth + }}, + {Strategy: ui.SIZE_EXACT, Size: acct.SplitSize}, + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + + acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.uiConf)).At(0, 0) + acct.grid.AddChild(ui.NewBordered(acct.msglist, ui.BORDER_RIGHT, acct.uiConf)).At(0, 1) + acct.split = NewMessageViewer(acct, nil) + acct.grid.AddChild(acct.split).At(0, 2) + acct.updateSplitView(acct.msglist.Selected()) + return nil +} + +// setTitle executes the title template and sets the tab title +func (acct *AccountView) setTitle() { + if acct.tab == nil { + return + } + + data := state.NewDataSetter() + data.SetAccount(acct.acct) + data.SetFolder(acct.Directories().SelectedDirectory()) + data.SetRUE(acct.dirlist.List(), acct.dirlist.GetRUECount) + + var buf bytes.Buffer + err := templates.Render(acct.uiConf.TabTitleAccount, &buf, data.Data()) + if err != nil { + acct.PushError(err) + return + } + acct.tab.SetTitle(buf.String()) +} |