Files
mail/model.go

408 lines
9.6 KiB
Go
Raw Permalink Normal View History

2023-06-13 23:31:19 -04:00
package main
import (
"os"
"strings"
2023-06-15 10:52:27 -04:00
"time"
2023-06-13 23:31:19 -04:00
"charm.land/bubbles/v2/filepicker"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
2023-07-31 10:32:02 -04:00
"github.com/charmbracelet/x/exp/ordered"
2023-06-15 10:30:20 -04:00
"github.com/resendlabs/resend-go"
2023-06-13 23:31:19 -04:00
)
2023-07-31 10:32:02 -04:00
// State is the current state of the application.
2023-06-13 23:31:19 -04:00
type State int
const (
editingFrom State = iota
editingTo
editingCc
editingBcc
2023-06-13 23:31:19 -04:00
editingSubject
editingBody
editingAttachments
2023-06-15 14:38:57 -04:00
hoveringSendButton
2023-06-13 23:31:19 -04:00
pickingFile
sendingEmail
)
2023-07-31 10:32:02 -04:00
// DeliveryMethod is the method of delivery for the email.
type DeliveryMethod int
const (
// None is the default delivery method.
None DeliveryMethod = iota
// Resend uses https://resend.com to send an email.
Resend
// SMTP uses an SMTP server to send an email.
SMTP
// Unknown is set when the user has not chosen a single delivery method.
// i.e. multiple delivery methods are set.
Unknown
2023-07-31 10:32:02 -04:00
)
// Model is Pop's application model.
2023-06-13 23:31:19 -04:00
type Model struct {
// state represents the current state of the application.
state State
2023-07-31 10:32:02 -04:00
// DeliveryMethod is whether we are using DeliveryMethod or Resend.
DeliveryMethod DeliveryMethod
2023-06-13 23:31:19 -04:00
// From represents the sender's email address.
From textinput.Model
// To represents the recipient's email address.
// This can be a comma-separated list of addresses.
To textinput.Model
// Subject represents the email's subject.
Subject textinput.Model
// Body represents the email's body.
// This can be written in markdown and will be converted to HTML.
Body textarea.Model
// Attachments represents the email's attachments.
// This is a list of file paths which are picked with a filepicker.
Attachments list.Model
showCc bool
Cc textinput.Model
Bcc textinput.Model
2023-07-31 10:32:02 -04:00
2023-06-13 23:31:19 -04:00
// filepicker is used to pick file attachments.
2023-06-13 23:53:24 -04:00
filepicker filepicker.Model
loadingSpinner spinner.Model
help help.Model
keymap KeyMap
quitting bool
abort bool
2023-06-15 09:45:14 -04:00
err error
2023-06-13 23:31:19 -04:00
}
2023-07-31 10:32:02 -04:00
// NewModel returns a new model for the application.
func NewModel(defaults resend.SendEmailRequest, deliveryMethod DeliveryMethod) Model {
2023-06-13 23:31:19 -04:00
from := textinput.New()
from.Prompt = "From "
from.Placeholder = "me@example.com"
2023-06-15 10:30:20 -04:00
from.SetValue(defaults.From)
2023-06-13 23:31:19 -04:00
to := textinput.New()
to.Prompt = "To "
to.Placeholder = "you@example.com"
2023-07-31 10:32:02 -04:00
to.SetValue(strings.Join(defaults.To, ToSeparator))
2023-06-13 23:31:19 -04:00
cc := textinput.New()
cc.Prompt = "Cc "
cc.Placeholder = "cc@example.com"
cc.SetValue(strings.Join(defaults.Cc, ToSeparator))
bcc := textinput.New()
bcc.Prompt = "Bcc "
bcc.Placeholder = "bcc@example.com"
bcc.SetValue(strings.Join(defaults.Bcc, ToSeparator))
2023-06-13 23:31:19 -04:00
subject := textinput.New()
subject.Prompt = "Subject "
subject.Placeholder = "Hello!"
2023-06-15 10:30:20 -04:00
subject.SetValue(defaults.Subject)
2023-06-13 23:31:19 -04:00
body := textarea.New()
2023-06-13 23:53:24 -04:00
body.Placeholder = "# Email"
2023-06-15 10:30:20 -04:00
body.SetValue(defaults.Text)
body.CharLimit = 4000
2023-06-13 23:31:19 -04:00
body.Blur()
2023-06-15 10:42:22 -04:00
// Decide which input to focus.
var state State
switch {
case defaults.From == "":
state = editingFrom
case len(defaults.To) == 0:
state = editingTo
case defaults.Subject == "":
state = editingSubject
case defaults.Text == "":
state = editingBody
}
2023-06-13 23:31:19 -04:00
attachments := list.New([]list.Item{}, attachmentDelegate{}, 0, 3)
attachments.DisableQuitKeybindings()
attachments.SetShowTitle(true)
attachments.Title = "Attachments"
attachments.SetShowHelp(false)
attachments.SetShowStatusBar(false)
attachments.SetStatusBarItemName("attachment", "attachments")
attachments.SetShowPagination(false)
for _, a := range defaults.Attachments {
attachments.InsertItem(0, attachment(a.Filename))
}
2023-06-13 23:31:19 -04:00
picker := filepicker.New()
picker.CurrentDirectory, _ = os.UserHomeDir()
2023-06-15 09:45:14 -04:00
loadingSpinner := spinner.New()
2023-06-13 23:53:24 -04:00
loadingSpinner.Spinner = spinner.Dot
2023-06-15 10:42:22 -04:00
m := Model{
state: state,
2023-06-13 23:53:24 -04:00
From: from,
To: to,
showCc: len(cc.Value()) > 0 || len(bcc.Value()) > 0,
Cc: cc,
Bcc: bcc,
2023-06-13 23:53:24 -04:00
Subject: subject,
Body: body,
Attachments: attachments,
filepicker: picker,
help: help.New(),
keymap: DefaultKeybinds(),
loadingSpinner: loadingSpinner,
2023-07-31 10:32:02 -04:00
DeliveryMethod: deliveryMethod,
2023-06-13 23:31:19 -04:00
}
2023-06-15 10:42:22 -04:00
m.focusActiveInput()
return m
2023-06-13 23:31:19 -04:00
}
2023-07-31 10:32:02 -04:00
// Init initializes the model.
2023-06-13 23:31:19 -04:00
func (m Model) Init() tea.Cmd {
return nil
2023-06-13 23:31:19 -04:00
}
2023-06-15 10:52:27 -04:00
type clearErrMsg struct{}
func clearErrAfter(d time.Duration) tea.Cmd {
return tea.Tick(d, func(t time.Time) tea.Msg {
return clearErrMsg{}
})
}
2023-07-31 10:32:02 -04:00
// Update is the update loop for the model.
2023-06-13 23:31:19 -04:00
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
2023-06-15 09:45:14 -04:00
case sendEmailSuccessMsg:
m.quitting = true
return m, tea.Quit
case sendEmailFailureMsg:
2023-06-15 10:59:25 -04:00
m.blurInputs()
2023-06-15 09:45:14 -04:00
m.state = editingFrom
2023-06-15 10:52:27 -04:00
m.focusActiveInput()
2023-06-15 09:45:14 -04:00
m.err = msg
return m, clearErrAfter(10 * time.Second)
2023-06-15 10:52:27 -04:00
case clearErrMsg:
m.err = nil
2023-06-13 23:31:19 -04:00
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keymap.NextInput):
m.blurInputs()
switch m.state {
case editingFrom:
m.state = editingTo
m.To.Focus()
case editingTo:
if m.showCc {
m.state = editingCc
} else {
m.state = editingSubject
}
case editingCc:
m.state = editingBcc
case editingBcc:
2023-06-13 23:31:19 -04:00
m.state = editingSubject
case editingSubject:
m.state = editingBody
case editingBody:
m.state = editingAttachments
case editingAttachments:
2023-06-15 14:38:57 -04:00
m.state = hoveringSendButton
case hoveringSendButton:
2023-06-13 23:31:19 -04:00
m.state = editingFrom
}
m.focusActiveInput()
case key.Matches(msg, m.keymap.PrevInput):
m.blurInputs()
switch m.state {
case editingFrom:
2023-06-15 14:38:57 -04:00
m.state = hoveringSendButton
2023-06-13 23:31:19 -04:00
case editingTo:
m.state = editingFrom
case editingCc:
2023-06-13 23:31:19 -04:00
m.state = editingTo
case editingBcc:
m.state = editingCc
case editingSubject:
if m.showCc {
m.state = editingBcc
} else {
m.state = editingTo
}
2023-06-13 23:31:19 -04:00
case editingBody:
m.state = editingSubject
case editingAttachments:
m.state = editingBody
2023-06-15 14:38:57 -04:00
case hoveringSendButton:
m.state = editingAttachments
2023-06-13 23:31:19 -04:00
}
m.focusActiveInput()
2023-06-15 09:52:28 -04:00
case key.Matches(msg, m.keymap.Back):
m.state = editingAttachments
m.updateKeymap()
return m, nil
2023-06-13 23:31:19 -04:00
case key.Matches(msg, m.keymap.Send):
2023-06-13 23:53:24 -04:00
m.state = sendingEmail
return m, tea.Batch(
m.loadingSpinner.Tick,
2023-06-15 09:45:14 -04:00
m.sendEmailCmd(),
2023-06-13 23:53:24 -04:00
)
2023-06-13 23:31:19 -04:00
case key.Matches(msg, m.keymap.Attach):
m.state = pickingFile
2023-06-15 09:52:28 -04:00
return m, m.filepicker.Init()
2023-06-13 23:31:19 -04:00
case key.Matches(msg, m.keymap.Unattach):
m.Attachments.RemoveItem(m.Attachments.Index())
2023-07-31 10:32:02 -04:00
m.Attachments.SetHeight(ordered.Max(len(m.Attachments.Items()), 1) + 2)
2023-06-13 23:31:19 -04:00
case key.Matches(msg, m.keymap.Quit):
m.quitting = true
m.abort = true
2023-06-13 23:31:19 -04:00
return m, tea.Quit
}
}
m.updateKeymap()
var cmds []tea.Cmd
var cmd tea.Cmd
m.From, cmd = m.From.Update(msg)
cmds = append(cmds, cmd)
m.To, cmd = m.To.Update(msg)
cmds = append(cmds, cmd)
if m.showCc {
m.Cc, cmd = m.Cc.Update(msg)
cmds = append(cmds, cmd)
m.Bcc, cmd = m.Bcc.Update(msg)
cmds = append(cmds, cmd)
}
2023-06-13 23:31:19 -04:00
m.Subject, cmd = m.Subject.Update(msg)
cmds = append(cmds, cmd)
m.Body, cmd = m.Body.Update(msg)
cmds = append(cmds, cmd)
m.filepicker, cmd = m.filepicker.Update(msg)
cmds = append(cmds, cmd)
2023-06-13 23:53:24 -04:00
switch m.state {
case pickingFile:
2023-06-13 23:31:19 -04:00
if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect {
m.Attachments.InsertItem(0, attachment(path))
m.Attachments.SetHeight(len(m.Attachments.Items()) + 2)
m.state = editingAttachments
m.updateKeymap()
}
2023-06-13 23:53:24 -04:00
case editingAttachments:
2023-06-13 23:31:19 -04:00
m.Attachments, cmd = m.Attachments.Update(msg)
cmds = append(cmds, cmd)
2023-06-13 23:53:24 -04:00
case sendingEmail:
m.loadingSpinner, cmd = m.loadingSpinner.Update(msg)
cmds = append(cmds, cmd)
2023-06-13 23:31:19 -04:00
}
2023-06-13 23:53:24 -04:00
2023-06-13 23:31:19 -04:00
m.help, cmd = m.help.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *Model) blurInputs() {
m.From.Blur()
m.To.Blur()
m.Subject.Blur()
m.Body.Blur()
if m.showCc {
m.Cc.Blur()
m.Bcc.Blur()
}
2023-06-13 23:31:19 -04:00
m.Attachments.SetDelegate(attachmentDelegate{false})
}
func (m *Model) focusActiveInput() {
switch m.state {
case editingFrom:
m.From.Focus()
m.From.CursorEnd()
case editingTo:
m.To.Focus()
m.To.CursorEnd()
case editingCc:
m.Cc.Focus()
m.Cc.CursorEnd()
case editingBcc:
m.Bcc.Focus()
m.Bcc.CursorEnd()
2023-06-13 23:31:19 -04:00
case editingSubject:
m.Subject.Focus()
m.Subject.CursorEnd()
case editingBody:
m.Body.Focus()
m.Body.CursorEnd()
case editingAttachments:
m.Attachments.SetDelegate(attachmentDelegate{true})
}
}
2023-07-31 10:32:02 -04:00
// View displays the application.
func (m Model) View() tea.View {
2023-06-13 23:31:19 -04:00
if m.quitting {
return tea.NewView("")
2023-06-13 23:31:19 -04:00
}
2023-06-13 23:53:24 -04:00
switch m.state {
case pickingFile:
return tea.NewView("\n" + activeLabelStyle.Render("Attachments") + " " + commentStyle.Render(m.filepicker.CurrentDirectory) +
"\n\n" + m.filepicker.View())
2023-06-13 23:53:24 -04:00
case sendingEmail:
return tea.NewView("\n " + m.loadingSpinner.View() + "Sending email")
2023-06-13 23:31:19 -04:00
}
var s strings.Builder
s.WriteString(m.From.View())
s.WriteString("\n")
s.WriteString(m.To.View())
s.WriteString("\n")
if m.showCc {
s.WriteString(m.Cc.View())
s.WriteString("\n")
s.WriteString(m.Bcc.View())
s.WriteString("\n")
}
2023-06-13 23:31:19 -04:00
s.WriteString(m.Subject.View())
s.WriteString("\n\n")
s.WriteString(m.Body.View())
s.WriteString("\n\n")
s.WriteString(m.Attachments.View())
s.WriteString("\n")
2023-06-15 14:38:57 -04:00
if m.state == hoveringSendButton && m.canSend() {
s.WriteString(sendButtonActiveStyle.Render("Send"))
} else if m.state == hoveringSendButton {
s.WriteString(sendButtonInactiveStyle.Render("Send"))
} else {
s.WriteString(sendButtonStyle.Render("Send"))
}
s.WriteString("\n\n")
2023-06-13 23:31:19 -04:00
s.WriteString(m.help.View(m.keymap))
2023-06-15 09:45:14 -04:00
if m.err != nil {
s.WriteString("\n\n")
s.WriteString(errorStyle.Render(m.err.Error()))
}
return tea.NewView(paddedStyle.Render(s.String()))
2023-06-13 23:53:24 -04:00
}