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}