mirror of
https://git.sr.ht/~rjarry/aerc
synced 2025-10-29 15:29:11 +01:00
I noticed that clicking on a message in the message list only opens that message after I've navigated to another folder and came back. The problem is that we store the number of UIDs in the list in an attribute and screw up :-) It's easier to simply ask the store for the number of UIDs it has when needed, and it *also* makes clicks work on the initial message list load. Signed-off-by: Simon Martin <simon@nasilyan.com> Acked-by: Robin Jarry <robin@jarry.cc>
596 lines
14 KiB
Go
596 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 := store.Messages[uid]
|
|
|
|
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)
|
|
}
|