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}