drafts.go

  1package tui
  2
  3import (
  4	"fmt"
  5	"strings"
  6	"time"
  7
  8	"github.com/charmbracelet/bubbles/key"
  9	"github.com/charmbracelet/bubbles/list"
 10	tea "github.com/charmbracelet/bubbletea"
 11	"github.com/charmbracelet/lipgloss"
 12	"github.com/floatpane/matcha/config"
 13)
 14
 15// draftItem represents a draft in the list
 16type draftItem struct {
 17	draft config.Draft
 18}
 19
 20func (i draftItem) Title() string {
 21	if i.draft.Subject != "" {
 22		return i.draft.Subject
 23	}
 24	return "(No subject)"
 25}
 26
 27func (i draftItem) Description() string {
 28	to := i.draft.To
 29	if to == "" {
 30		to = "(No recipient)"
 31	}
 32	timeAgo := formatTimeAgo(i.draft.UpdatedAt)
 33	return fmt.Sprintf("To: %s • %s", to, timeAgo)
 34}
 35
 36func (i draftItem) FilterValue() string {
 37	return i.draft.Subject + " " + i.draft.To + " " + i.draft.Body
 38}
 39
 40// formatTimeAgo returns a human-readable time difference
 41func formatTimeAgo(t time.Time) string {
 42	diff := time.Since(t)
 43	switch {
 44	case diff < time.Minute:
 45		return "just now"
 46	case diff < time.Hour:
 47		mins := int(diff.Minutes())
 48		if mins == 1 {
 49			return "1 minute ago"
 50		}
 51		return fmt.Sprintf("%d minutes ago", mins)
 52	case diff < 24*time.Hour:
 53		hours := int(diff.Hours())
 54		if hours == 1 {
 55			return "1 hour ago"
 56		}
 57		return fmt.Sprintf("%d hours ago", hours)
 58	case diff < 7*24*time.Hour:
 59		days := int(diff.Hours() / 24)
 60		if days == 1 {
 61			return "1 day ago"
 62		}
 63		return fmt.Sprintf("%d days ago", days)
 64	default:
 65		return t.Format("Jan 2, 2006")
 66	}
 67}
 68
 69// Drafts is the model for the drafts list view
 70type Drafts struct {
 71	list          list.Model
 72	drafts        []config.Draft
 73	width         int
 74	height        int
 75	confirmDelete bool
 76	selectedDraft *config.Draft
 77}
 78
 79// NewDrafts creates a new drafts list view
 80func NewDrafts(drafts []config.Draft) *Drafts {
 81	items := make([]list.Item, len(drafts))
 82	for i, d := range drafts {
 83		items[i] = draftItem{draft: d}
 84	}
 85
 86	l := list.New(items, list.NewDefaultDelegate(), 0, 0)
 87	l.Title = "Drafts"
 88	l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
 89	l.SetShowStatusBar(true)
 90	l.SetFilteringEnabled(true)
 91	l.SetStatusBarItemName("draft", "drafts")
 92	l.AdditionalShortHelpKeys = func() []key.Binding {
 93		return []key.Binding{
 94			key.NewBinding(key.WithKeys("enter"), key.WithHelp("\ue5fe enter", "open")),
 95			key.NewBinding(key.WithKeys("d"), key.WithHelp("\uea81 d", "delete")),
 96		}
 97	}
 98	l.KeyMap.Quit.SetEnabled(false)
 99
100	return &Drafts{
101		list:   l,
102		drafts: drafts,
103	}
104}
105
106func (m *Drafts) Init() tea.Cmd {
107	return nil
108}
109
110func (m *Drafts) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
111	switch msg := msg.(type) {
112	case tea.WindowSizeMsg:
113		m.width = msg.Width
114		m.height = msg.Height
115		m.list.SetWidth(msg.Width)
116		m.list.SetHeight(msg.Height - 4)
117		return m, nil
118
119	case tea.KeyMsg:
120		// Handle delete confirmation
121		if m.confirmDelete {
122			switch msg.String() {
123			case "y", "Y":
124				if m.selectedDraft != nil {
125					draftID := m.selectedDraft.ID
126					m.confirmDelete = false
127					m.selectedDraft = nil
128					return m, func() tea.Msg {
129						return DeleteSavedDraftMsg{DraftID: draftID}
130					}
131				}
132			case "n", "N", "esc":
133				m.confirmDelete = false
134				m.selectedDraft = nil
135			}
136			return m, nil
137		}
138
139		// Skip key handling during filtering
140		if m.list.FilterState() == list.Filtering {
141			break
142		}
143
144		switch msg.String() {
145		case "esc":
146			return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
147		case "enter":
148			if item, ok := m.list.SelectedItem().(draftItem); ok {
149				return m, func() tea.Msg {
150					return OpenDraftMsg{Draft: item.draft}
151				}
152			}
153		case "d":
154			if item, ok := m.list.SelectedItem().(draftItem); ok {
155				m.confirmDelete = true
156				m.selectedDraft = &item.draft
157				return m, nil
158			}
159		}
160
161	case DraftDeletedMsg:
162		if msg.Err == nil {
163			// Remove the deleted draft from the list
164			var newDrafts []config.Draft
165			for _, d := range m.drafts {
166				if d.ID != msg.DraftID {
167					newDrafts = append(newDrafts, d)
168				}
169			}
170			m.drafts = newDrafts
171
172			items := make([]list.Item, len(m.drafts))
173			for i, d := range m.drafts {
174				items[i] = draftItem{draft: d}
175			}
176			m.list.SetItems(items)
177		}
178		return m, nil
179	}
180
181	var cmd tea.Cmd
182	m.list, cmd = m.list.Update(msg)
183	return m, cmd
184}
185
186func (m *Drafts) View() string {
187	var b strings.Builder
188
189	if m.confirmDelete {
190		dialog := DialogBoxStyle.Render(
191			lipgloss.JoinVertical(lipgloss.Center,
192				"Delete this draft?",
193				HelpStyle.Render("\n(y/n)"),
194			),
195		)
196		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
197	}
198
199	if len(m.drafts) == 0 {
200		emptyMsg := lipgloss.NewStyle().
201			Foreground(lipgloss.Color("240")).
202			Render("No drafts saved.\n\nPress esc to go back.")
203		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, emptyMsg)
204	}
205
206	b.WriteString(m.list.View())
207	return b.String()
208}
209
210// SetDrafts updates the drafts list
211func (m *Drafts) SetDrafts(drafts []config.Draft) {
212	m.drafts = drafts
213	items := make([]list.Item, len(drafts))
214	for i, d := range drafts {
215		items[i] = draftItem{draft: d}
216	}
217	m.list.SetItems(items)
218}