mirror of
https://git.sr.ht/~rjarry/aerc
synced 2026-02-26 15:11:34 +01:00
When a message is deleted, it is completely removed from store.Messages. When the store "knows" about the UID but does not have any header information about it, there will be a "nil" entry in the store.Messages map. Take that into account in MessageList.Draw to avoid fetching headers for messages that are being deleted. Signed-off-by: Robin Jarry <robin@jarry.cc> Reviewed-by: Simon Martin <simon@nasilyan.com>
599 lines
14 KiB
Go
599 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"math"
|
|
"strings"
|
|
|
|
sortthread "github.com/emersion/go-imap-sortthread"
|
|
"github.com/emersion/go-message/mail"
|
|
"github.com/mattn/go-runewidth"
|
|
|
|
"git.sr.ht/~rjarry/aerc/config"
|
|
"git.sr.ht/~rjarry/aerc/lib"
|
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
|
"git.sr.ht/~rjarry/aerc/lib/state"
|
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
|
"git.sr.ht/~rjarry/aerc/models"
|
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
|
"git.sr.ht/~rockorager/vaxis"
|
|
)
|
|
|
|
type MessageList struct {
|
|
Scrollable
|
|
height int
|
|
width int
|
|
spinner *Spinner
|
|
store *lib.MessageStore
|
|
isInitalizing bool
|
|
}
|
|
|
|
func NewMessageList(account *AccountView) *MessageList {
|
|
ml := &MessageList{
|
|
spinner: NewSpinner(account.UiConfig()),
|
|
isInitalizing: true,
|
|
}
|
|
// TODO: stop spinner, probably
|
|
ml.spinner.Start()
|
|
return ml
|
|
}
|
|
|
|
func (ml *MessageList) Invalidate() {
|
|
ui.Invalidate()
|
|
}
|
|
|
|
type messageRowParams struct {
|
|
uid models.UID
|
|
needsHeaders bool
|
|
err error
|
|
uiConfig *config.UIConfig
|
|
styles []config.StyleObject
|
|
headers *mail.Header
|
|
}
|
|
|
|
// AlignMessage aligns the selected message to position pos.
|
|
func (ml *MessageList) AlignMessage(pos AlignPosition) {
|
|
store := ml.Store()
|
|
if store == nil {
|
|
return
|
|
}
|
|
idx := 0
|
|
iter := store.UidsIterator()
|
|
for i := 0; iter.Next(); i++ {
|
|
if store.SelectedUid() == iter.Value().(models.UID) {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
ml.Align(idx, pos)
|
|
}
|
|
|
|
func (ml *MessageList) Draw(ctx *ui.Context) {
|
|
ml.height = ctx.Height()
|
|
ml.width = ctx.Width()
|
|
uiConfig := SelectedAccountUiConfig()
|
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
|
|
uiConfig.GetStyle(config.STYLE_MSGLIST_DEFAULT))
|
|
|
|
acct := SelectedAccount()
|
|
store := ml.Store()
|
|
if store == nil || acct == nil || len(store.Uids()) == 0 {
|
|
if ml.isInitalizing {
|
|
ml.spinner.Draw(ctx)
|
|
} else {
|
|
ml.spinner.Stop()
|
|
ml.drawEmptyMessage(ctx)
|
|
}
|
|
return
|
|
}
|
|
|
|
ml.SetOffset(uiConfig.MsglistScrollOffset)
|
|
ml.UpdateScroller(ml.height, len(store.Uids()))
|
|
iter := store.UidsIterator()
|
|
for i := 0; iter.Next(); i++ {
|
|
if store.SelectedUid() == iter.Value().(models.UID) {
|
|
ml.EnsureScroll(i)
|
|
break
|
|
}
|
|
}
|
|
|
|
store.UpdateScroll(ml.Scroll(), ml.height)
|
|
|
|
textWidth := ctx.Width()
|
|
if ml.NeedScrollbar() {
|
|
textWidth -= 1
|
|
}
|
|
if textWidth <= 0 {
|
|
return
|
|
}
|
|
|
|
var needsHeaders []models.UID
|
|
|
|
data := state.NewDataSetter()
|
|
data.SetAccount(acct.acct)
|
|
data.SetFolder(acct.Directories().SelectedDirectory())
|
|
|
|
customDraw := func(t *ui.Table, r int, c *ui.Context) bool {
|
|
row := &t.Rows[r]
|
|
params, _ := row.Priv.(messageRowParams)
|
|
if params.err != nil {
|
|
var style vaxis.Style
|
|
if params.uid == store.SelectedUid() {
|
|
style = uiConfig.GetStyle(config.STYLE_ERROR)
|
|
} else {
|
|
style = uiConfig.GetStyleSelected(config.STYLE_ERROR)
|
|
}
|
|
ctx.Printf(0, r, style, "error: %s", params.err)
|
|
return true
|
|
}
|
|
if params.needsHeaders {
|
|
needsHeaders = append(needsHeaders, params.uid)
|
|
ml.spinner.Draw(ctx.Subcontext(0, r, c.Width(), 1))
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
getRowStyle := func(t *ui.Table, r int) vaxis.Style {
|
|
var style vaxis.Style
|
|
row := &t.Rows[r]
|
|
params, _ := row.Priv.(messageRowParams)
|
|
if params.uid == store.SelectedUid() {
|
|
style = params.uiConfig.MsgComposedStyleSelected(
|
|
config.STYLE_MSGLIST_DEFAULT, params.styles,
|
|
params.headers)
|
|
} else {
|
|
style = params.uiConfig.MsgComposedStyle(
|
|
config.STYLE_MSGLIST_DEFAULT, params.styles,
|
|
params.headers)
|
|
}
|
|
return style
|
|
}
|
|
|
|
table := ui.NewTable(
|
|
ml.height,
|
|
uiConfig.IndexColumns,
|
|
uiConfig.ColumnSeparator,
|
|
customDraw,
|
|
getRowStyle,
|
|
)
|
|
|
|
showThreads := store.ThreadedView()
|
|
threadView := newThreadView(store)
|
|
iter = store.UidsIterator()
|
|
for i := 0; iter.Next(); i++ {
|
|
if i < ml.Scroll() {
|
|
continue
|
|
}
|
|
uid := iter.Value().(models.UID)
|
|
if showThreads {
|
|
threadView.Update(data, uid)
|
|
}
|
|
if addMessage(store, uid, &table, data, uiConfig) {
|
|
break
|
|
}
|
|
}
|
|
|
|
table.Draw(ctx.Subcontext(0, 0, textWidth, ctx.Height()))
|
|
|
|
if ml.NeedScrollbar() {
|
|
scrollbarCtx := ctx.Subcontext(textWidth, 0, 1, ctx.Height())
|
|
ml.drawScrollbar(scrollbarCtx)
|
|
}
|
|
|
|
if len(store.Uids()) == 0 {
|
|
if store.Sorting {
|
|
ml.spinner.Start()
|
|
ml.spinner.Draw(ctx)
|
|
return
|
|
} else {
|
|
ml.drawEmptyMessage(ctx)
|
|
}
|
|
}
|
|
|
|
if len(needsHeaders) != 0 {
|
|
store.FetchHeaders(needsHeaders, nil)
|
|
ml.spinner.Start()
|
|
} else {
|
|
ml.spinner.Stop()
|
|
}
|
|
}
|
|
|
|
func addMessage(
|
|
store *lib.MessageStore, uid models.UID,
|
|
table *ui.Table, data state.DataSetter,
|
|
uiConfig *config.UIConfig,
|
|
) bool {
|
|
msg, ok := store.Messages[uid]
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
cells := make([]string, len(table.Columns))
|
|
params := messageRowParams{uid: uid, uiConfig: uiConfig}
|
|
|
|
if msg == nil || (msg.Envelope == nil && msg.Error == nil) {
|
|
params.needsHeaders = true
|
|
return table.AddRow(cells, params)
|
|
} else if msg.Error != nil {
|
|
params.err = msg.Error
|
|
return table.AddRow(cells, params)
|
|
}
|
|
|
|
if msg.Flags.Has(models.SeenFlag) {
|
|
params.styles = append(params.styles, config.STYLE_MSGLIST_READ)
|
|
} else {
|
|
params.styles = append(params.styles, config.STYLE_MSGLIST_UNREAD)
|
|
}
|
|
if msg.Flags.Has(models.AnsweredFlag) {
|
|
params.styles = append(params.styles, config.STYLE_MSGLIST_ANSWERED)
|
|
}
|
|
if msg.Flags.Has(models.ForwardedFlag) {
|
|
params.styles = append(params.styles, config.STYLE_MSGLIST_FORWARDED)
|
|
}
|
|
if msg.Flags.Has(models.FlaggedFlag) {
|
|
params.styles = append(params.styles, config.STYLE_MSGLIST_FLAGGED)
|
|
}
|
|
// deleted message
|
|
if _, ok := store.Deleted[msg.Uid]; ok {
|
|
params.styles = append(params.styles, config.STYLE_MSGLIST_DELETED)
|
|
}
|
|
// search result
|
|
if store.IsResult(msg.Uid) {
|
|
params.styles = append(params.styles, config.STYLE_MSGLIST_RESULT)
|
|
}
|
|
// folded thread
|
|
templateData, ok := data.(models.TemplateData)
|
|
if ok {
|
|
if templateData.ThreadFolded() {
|
|
params.styles = append(params.styles, config.STYLE_MSGLIST_THREAD_FOLDED)
|
|
}
|
|
if templateData.ThreadContext() {
|
|
params.styles = append(params.styles, config.STYLE_MSGLIST_THREAD_CONTEXT)
|
|
}
|
|
if templateData.ThreadOrphan() {
|
|
params.styles = append(params.styles, config.STYLE_MSGLIST_THREAD_ORPHAN)
|
|
}
|
|
}
|
|
// marked message
|
|
marked := store.Marker().IsMarked(msg.Uid)
|
|
if marked {
|
|
params.styles = append(params.styles, config.STYLE_MSGLIST_MARKED)
|
|
}
|
|
|
|
data.SetInfo(msg, len(table.Rows), marked)
|
|
|
|
for c, col := range table.Columns {
|
|
var buf bytes.Buffer
|
|
err := col.Def.Template.Execute(&buf, data.Data())
|
|
if err != nil {
|
|
log.Errorf("<%s> %s", msg.Envelope.MessageId, err)
|
|
cells[c] = err.Error()
|
|
} else {
|
|
cells[c] = buf.String()
|
|
}
|
|
}
|
|
|
|
params.headers = msg.RFC822Headers
|
|
|
|
return table.AddRow(cells, params)
|
|
}
|
|
|
|
func (ml *MessageList) drawScrollbar(ctx *ui.Context) {
|
|
uiConfig := SelectedAccountUiConfig()
|
|
gutterStyle := uiConfig.GetStyle(config.STYLE_MSGLIST_GUTTER)
|
|
pillStyle := uiConfig.GetStyle(config.STYLE_MSGLIST_PILL)
|
|
|
|
// gutter
|
|
ctx.Fill(0, 0, 1, ctx.Height(), ' ', gutterStyle)
|
|
|
|
// pill
|
|
pillSize := int(math.Ceil(float64(ctx.Height()) * ml.PercentVisible()))
|
|
pillOffset := int(math.Floor(float64(ctx.Height()) * ml.PercentScrolled()))
|
|
ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle)
|
|
}
|
|
|
|
func (ml *MessageList) MouseEvent(localX int, localY int, event vaxis.Event) {
|
|
if event, ok := event.(vaxis.Mouse); ok {
|
|
switch event.Button {
|
|
case vaxis.MouseLeftButton:
|
|
selectedMsg, ok := ml.Clicked(localX, localY)
|
|
if ok {
|
|
ml.Select(selectedMsg)
|
|
acct := SelectedAccount()
|
|
if acct == nil || acct.Messages().Empty() {
|
|
return
|
|
}
|
|
store := acct.Messages().Store()
|
|
msg := acct.Messages().Selected()
|
|
if msg == nil {
|
|
return
|
|
}
|
|
lib.NewMessageStoreView(msg, acct.UiConfig().AutoMarkRead,
|
|
store, CryptoProvider(), DecryptKeys,
|
|
func(view lib.MessageView, err error) {
|
|
if err != nil {
|
|
PushError(err.Error())
|
|
return
|
|
}
|
|
viewer, err := NewMessageViewer(acct, view)
|
|
if err != nil {
|
|
PushError(err.Error())
|
|
return
|
|
}
|
|
NewTab(viewer, msg.Envelope.Subject)
|
|
})
|
|
}
|
|
case vaxis.MouseWheelDown:
|
|
if ml.store != nil {
|
|
ml.store.Next()
|
|
}
|
|
ml.Invalidate()
|
|
case vaxis.MouseWheelUp:
|
|
if ml.store != nil {
|
|
ml.store.Prev()
|
|
}
|
|
ml.Invalidate()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (ml *MessageList) Clicked(x, y int) (int, bool) {
|
|
nmsgs := 0
|
|
if store := ml.Store(); store != nil {
|
|
nmsgs = len(store.Uids())
|
|
}
|
|
if nmsgs == 0 || y >= nmsgs {
|
|
return 0, false
|
|
}
|
|
return y + ml.Scroll(), true
|
|
}
|
|
|
|
func (ml *MessageList) Height() int {
|
|
return ml.height
|
|
}
|
|
|
|
func (ml *MessageList) Width() int {
|
|
return ml.width
|
|
}
|
|
|
|
func (ml *MessageList) storeUpdate(store *lib.MessageStore) {
|
|
if ml.Store() != store {
|
|
return
|
|
}
|
|
ml.Invalidate()
|
|
}
|
|
|
|
func (ml *MessageList) SetStore(store *lib.MessageStore) {
|
|
if ml.Store() != store {
|
|
ml.Scrollable = Scrollable{}
|
|
}
|
|
ml.store = store
|
|
if store != nil {
|
|
ml.spinner.Stop()
|
|
store.OnUpdate(ml.storeUpdate)
|
|
} else {
|
|
ml.spinner.Start()
|
|
}
|
|
ml.Invalidate()
|
|
}
|
|
|
|
func (ml *MessageList) SetInitDone() {
|
|
ml.isInitalizing = false
|
|
}
|
|
|
|
func (ml *MessageList) Store() *lib.MessageStore {
|
|
return ml.store
|
|
}
|
|
|
|
func (ml *MessageList) Empty() bool {
|
|
store := ml.Store()
|
|
return store == nil || len(store.Uids()) == 0
|
|
}
|
|
|
|
func (ml *MessageList) Selected() *models.MessageInfo {
|
|
return ml.Store().Selected()
|
|
}
|
|
|
|
func (ml *MessageList) Select(index int) {
|
|
// Note that the msgstore.Select function expects a uid as argument
|
|
// whereas the msglist.Select expects the message number
|
|
store := ml.Store()
|
|
uids := store.Uids()
|
|
if len(uids) == 0 {
|
|
store.Select(lib.MagicUid)
|
|
return
|
|
}
|
|
|
|
iter := store.UidsIterator()
|
|
|
|
var uid models.UID
|
|
if index < 0 {
|
|
uid = uids[iter.EndIndex()]
|
|
} else {
|
|
uid = uids[iter.StartIndex()]
|
|
for i := 0; iter.Next(); i++ {
|
|
if i >= index {
|
|
uid = iter.Value().(models.UID)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
store.Select(uid)
|
|
|
|
ml.Invalidate()
|
|
}
|
|
|
|
func (ml *MessageList) drawEmptyMessage(ctx *ui.Context) {
|
|
uiConfig := SelectedAccountUiConfig()
|
|
msg := uiConfig.EmptyMessage
|
|
ctx.Printf((ctx.Width()/2)-(len(msg)/2), 0,
|
|
uiConfig.GetStyle(config.STYLE_MSGLIST_DEFAULT), "%s", msg)
|
|
}
|
|
|
|
func countThreads(thread *types.Thread) (ctr int) {
|
|
if thread == nil {
|
|
return
|
|
}
|
|
_ = thread.Walk(func(t *types.Thread, _ int, _ error) error {
|
|
ctr++
|
|
return nil
|
|
})
|
|
return
|
|
}
|
|
|
|
func unreadInThread(thread *types.Thread, store *lib.MessageStore) (ctr int) {
|
|
if thread == nil {
|
|
return
|
|
}
|
|
_ = thread.Walk(func(t *types.Thread, _ int, _ error) error {
|
|
msg := store.Messages[t.Uid]
|
|
if msg != nil && !msg.Flags.Has(models.SeenFlag) {
|
|
ctr++
|
|
}
|
|
return nil
|
|
})
|
|
return
|
|
}
|
|
|
|
func threadPrefix(t *types.Thread, reverse bool, msglist bool) string {
|
|
uiConfig := SelectedAccountUiConfig()
|
|
var tip, prefix, firstChild, lastSibling, orphan, dummy string
|
|
if msglist {
|
|
tip = uiConfig.ThreadPrefixTip
|
|
} else {
|
|
threadPrefixSibling := "├─"
|
|
threadPrefixReverse := "┌─"
|
|
threadPrefixEnd := "└─"
|
|
threadStem := "│"
|
|
threadIndent := strings.Repeat(" ", runewidth.StringWidth(threadPrefixSibling)-1)
|
|
|
|
switch {
|
|
case t.Parent != nil && t.NextSibling != nil:
|
|
prefix += threadPrefixSibling
|
|
case t.Parent != nil && reverse:
|
|
prefix += threadPrefixReverse
|
|
case t.Parent != nil:
|
|
prefix += threadPrefixEnd
|
|
}
|
|
|
|
for n := t.Parent; n != nil && n.Parent != nil; n = n.Parent {
|
|
if n.NextSibling != nil {
|
|
prefix = threadStem + threadIndent + prefix
|
|
} else {
|
|
prefix = " " + threadIndent + prefix
|
|
}
|
|
}
|
|
|
|
return prefix
|
|
}
|
|
|
|
if reverse {
|
|
firstChild = uiConfig.ThreadPrefixFirstChildReverse
|
|
lastSibling = uiConfig.ThreadPrefixLastSiblingReverse
|
|
orphan = uiConfig.ThreadPrefixOrphanReverse
|
|
dummy = uiConfig.ThreadPrefixDummyReverse
|
|
} else {
|
|
firstChild = uiConfig.ThreadPrefixFirstChild
|
|
lastSibling = uiConfig.ThreadPrefixLastSibling
|
|
orphan = uiConfig.ThreadPrefixOrphan
|
|
dummy = uiConfig.ThreadPrefixDummy
|
|
}
|
|
|
|
var hiddenOffspring bool = t.FirstChild != nil && t.FirstChild.Hidden > 0
|
|
var parentAndSiblings bool = t.Parent != nil && t.NextSibling != nil
|
|
var hasSiblings string = uiConfig.ThreadPrefixHasSiblings
|
|
if t.Parent != nil && t.Parent.Hidden > 0 && t.Hidden == 0 {
|
|
hasSiblings = dummy
|
|
}
|
|
|
|
switch {
|
|
case parentAndSiblings && hiddenOffspring:
|
|
prefix = hasSiblings +
|
|
uiConfig.ThreadPrefixFolded
|
|
case parentAndSiblings && t.FirstChild != nil:
|
|
prefix = hasSiblings +
|
|
firstChild + tip
|
|
case parentAndSiblings:
|
|
prefix = hasSiblings +
|
|
uiConfig.ThreadPrefixLimb +
|
|
uiConfig.ThreadPrefixUnfolded + tip
|
|
case t.Parent != nil && hiddenOffspring:
|
|
prefix = lastSibling + uiConfig.ThreadPrefixFolded
|
|
case t.Parent != nil && t.FirstChild != nil:
|
|
prefix = lastSibling + firstChild + tip
|
|
case t.Parent != nil && t.FirstChild == nil:
|
|
prefix = lastSibling + uiConfig.ThreadPrefixLimb + tip
|
|
case t.Parent != nil:
|
|
prefix = lastSibling + uiConfig.ThreadPrefixUnfolded +
|
|
uiConfig.ThreadPrefixTip
|
|
case t.Parent == nil && hiddenOffspring:
|
|
prefix = uiConfig.ThreadPrefixFolded
|
|
case t.Parent == nil && t.Dummy:
|
|
prefix = dummy + tip
|
|
case t.Parent == nil && t.FirstChild != nil:
|
|
prefix = orphan
|
|
case t.Parent == nil && t.FirstChild == nil:
|
|
prefix = uiConfig.ThreadPrefixLone
|
|
}
|
|
|
|
for n := t.Parent; n != nil && n.Parent != nil; n = n.Parent {
|
|
if n.NextSibling != nil {
|
|
prefix = uiConfig.ThreadPrefixStem +
|
|
uiConfig.ThreadPrefixIndent + prefix
|
|
} else {
|
|
prefix = " " + uiConfig.ThreadPrefixIndent + prefix
|
|
}
|
|
}
|
|
|
|
return prefix
|
|
}
|
|
|
|
func sameParent(left, right *types.Thread) bool {
|
|
return left.Root() == right.Root()
|
|
}
|
|
|
|
func isParent(t *types.Thread) bool {
|
|
return t == t.Root()
|
|
}
|
|
|
|
func threadSubject(store *lib.MessageStore, thread *types.Thread) string {
|
|
msg, found := store.Messages[thread.Uid]
|
|
if !found || msg == nil || msg.Envelope == nil {
|
|
return ""
|
|
}
|
|
subject, _ := sortthread.GetBaseSubject(msg.Envelope.Subject)
|
|
return subject
|
|
}
|
|
|
|
type threadView struct {
|
|
store *lib.MessageStore
|
|
reverse bool
|
|
prev *types.Thread
|
|
prevSubj string
|
|
}
|
|
|
|
func newThreadView(store *lib.MessageStore) *threadView {
|
|
return &threadView{
|
|
store: store,
|
|
reverse: store.ReverseThreadOrder(),
|
|
}
|
|
}
|
|
|
|
func (t *threadView) Update(data state.DataSetter, uid models.UID) {
|
|
thread, err := t.store.Thread(uid)
|
|
info := state.ThreadInfo{}
|
|
if thread != nil && err == nil {
|
|
info.Prefix = threadPrefix(thread, t.reverse, true)
|
|
subject := threadSubject(t.store, thread)
|
|
info.SameSubject = subject == t.prevSubj && sameParent(thread, t.prev) && !isParent(thread)
|
|
t.prev = thread
|
|
t.prevSubj = subject
|
|
info.Count = countThreads(thread)
|
|
info.Unread = unreadInThread(thread, t.store)
|
|
info.Folded = thread.FirstChild != nil && thread.FirstChild.Hidden != 0
|
|
info.Context = thread.Context
|
|
info.Orphan = thread.Parent != nil && thread.Parent.Hidden > 0 && thread.Hidden == 0
|
|
}
|
|
data.SetThreading(info)
|
|
}
|