drafts.go

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