aboutsummaryrefslogblamecommitdiffstats
path: root/app/msglist.go
blob: 42f6125d66d5a375a5aa525d45815f02b1f7fe2a (plain) (tree)
1
2
3
4
5
6
7
8
9
           

        
               
              
                 
 
                                                           
                                             
                                       
 

                                       
                                        
                                          

                                       
                                             
                                     

 
                         
                  
                         
                         



                                       

 
                                                        
                           
                                                              
                                    
         




                                       
                                     
                       

 
                              
                               
                         
                          

                                         
                                 

 








                                                            
                                                                     






                               
                                              
                                
                              
                                             
                                                      
                                                                
 
                                 
                           
                                                                  

                                            


                                                
                 
                      

         
                                                  
                                                       

                                      
                                                                     

                                          
                 

         

                                                  
                                
                               

                              

                           

         
                                     
 
                                     
                                  
                                                              



                                                                    









                                                                                     

                                                                       
                                                                           




                                   

                                                             


                                                        


                                                                            
                        


                                                                            
                 
                            

         
                             
                          





                                         





                                           
                 
                                                




                                                                   
                 

         
                                                                 
 
                               
                                                                             
                                              

         
                                   






                                                

         
                                   
                                                     



                                  

 
                
                                                
                                               

                                  

                                  
                                                   
                                                                
 
                                                                    

                                                  


                                                  
         
 
                                           
                                                                                
                
                                                                                  
         


                                                                                    


                                                                                     
                                              
                                                                                   
         

                                                
                                                                                   
         

                                    
                                                                                  
         

                                                      






                                                                                                  


                                                                                                 
         
                         


                                                                                  

         
                                                  
 

                                           
                                                                  
                               
                                                                          



                                               

         
                                          

                                          

 
                                                       
                                             

                                                                     




                                                         

                                                                                   


                                                            
                                                                              


                                                 


                                                                     
                                                         
                                                                           






                                                                 
                                                                                          
                                                                             

                                                                               
                                                                              

                                                              




                                                                                           
                                                                                    
                                          
                         
                                          


                                               
                                       
                                        


                                               
                                       








                                                           
                                    

 



                                     



                                    
                                                             
                                

                      
                       

 
                                                          
                                
                                            
         
                        

                                 

                                    
                                              





                                                                    




                                  
 



                                      
                                                  
                       

 
                                     
                           
                                                     

 
                                                       
                                    

 
                                          

                                                                           
                           

                            
                                          

                      


                                    
                          





                                              
                                                               


                                     
         
                         
 
                       

 
                                                          
                                             
                                    
                                                   
                                                                           
 
 










                                                                     













                                                                              

                                                                       
                                                                      
































                                                                                                 
                                                         



                                                              
                                                  



                                                                                 



                                                                    

                
                                                  
                                      

                                                      
                                      

                                        
                                      







                                                                      
                             



                                                                      

                                        



                                                    



                                                                      

                                                                    
                        
                                                                           

                 
 
                     
 







                                                 








                                                                          














                                                         
                                                                    
                                          
                                  
                                        
                                                                   
                                                         
                                                                                                           

                                    




                                                                                                    
         
                               
 
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
	nmsgs         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) {
	store := ml.Store()
	if store == nil || ml.nmsgs == 0 || y >= ml.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()
		uids := store.Uids()
		ml.nmsgs = len(uids)
		store.OnUpdate(ml.storeUpdate)
		store.OnFilterChange(func(store *lib.MessageStore) {
			if ml.Store() != store {
				return
			}
			ml.nmsgs = len(store.Uids())
		})
	} 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)
}