mirror of
https://github.com/ivabus/pop
synced 2024-11-10 02:25:19 +03:00
380 lines
9.3 KiB
Go
380 lines
9.3 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/filepicker"
|
|
"github.com/charmbracelet/bubbles/help"
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"github.com/charmbracelet/bubbles/list"
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
"github.com/charmbracelet/bubbles/textarea"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/resendlabs/resend-go"
|
|
"golang.org/x/exp/constraints"
|
|
)
|
|
|
|
type State int
|
|
|
|
const (
|
|
editingFrom State = iota
|
|
editingTo
|
|
editingSubject
|
|
editingBody
|
|
editingAttachments
|
|
hoveringSendButton
|
|
pickingFile
|
|
sendingEmail
|
|
)
|
|
|
|
type Model struct {
|
|
// state represents the current state of the application.
|
|
state State
|
|
|
|
// 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
|
|
|
|
// filepicker is used to pick file attachments.
|
|
filepicker filepicker.Model
|
|
loadingSpinner spinner.Model
|
|
help help.Model
|
|
keymap KeyMap
|
|
quitting bool
|
|
abort bool
|
|
err error
|
|
}
|
|
|
|
func NewModel(defaults resend.SendEmailRequest) Model {
|
|
from := textinput.New()
|
|
from.Prompt = "From "
|
|
from.Placeholder = "me@example.com"
|
|
from.PromptStyle = labelStyle.Copy()
|
|
from.PromptStyle = labelStyle
|
|
from.TextStyle = textStyle
|
|
from.Cursor.Style = cursorStyle
|
|
from.PlaceholderStyle = placeholderStyle
|
|
from.SetValue(defaults.From)
|
|
|
|
to := textinput.New()
|
|
to.Prompt = "To "
|
|
to.PromptStyle = labelStyle.Copy()
|
|
to.Cursor.Style = cursorStyle
|
|
to.PlaceholderStyle = placeholderStyle
|
|
to.TextStyle = textStyle
|
|
to.Placeholder = "you@example.com"
|
|
to.SetValue(strings.Join(defaults.To, TO_SEPARATOR))
|
|
|
|
subject := textinput.New()
|
|
subject.Prompt = "Subject "
|
|
subject.PromptStyle = labelStyle.Copy()
|
|
subject.Cursor.Style = cursorStyle
|
|
subject.PlaceholderStyle = placeholderStyle
|
|
subject.TextStyle = textStyle
|
|
subject.Placeholder = "Hello!"
|
|
subject.SetValue(defaults.Subject)
|
|
|
|
body := textarea.New()
|
|
body.Placeholder = "# Email"
|
|
body.ShowLineNumbers = false
|
|
body.FocusedStyle.CursorLine = activeTextStyle
|
|
body.FocusedStyle.Prompt = activeLabelStyle
|
|
body.FocusedStyle.Text = activeTextStyle
|
|
body.FocusedStyle.Placeholder = placeholderStyle
|
|
body.BlurredStyle.CursorLine = textStyle
|
|
body.BlurredStyle.Prompt = labelStyle
|
|
body.BlurredStyle.Text = textStyle
|
|
body.BlurredStyle.Placeholder = placeholderStyle
|
|
body.Cursor.Style = cursorStyle
|
|
body.CharLimit = 4000
|
|
body.SetValue(defaults.Text)
|
|
|
|
// Adjust for signature (if none, this is a no-op)
|
|
body.CursorUp()
|
|
body.CursorUp()
|
|
|
|
body.Blur()
|
|
|
|
// 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
|
|
}
|
|
|
|
attachments := list.New([]list.Item{}, attachmentDelegate{}, 0, 3)
|
|
attachments.DisableQuitKeybindings()
|
|
attachments.SetShowTitle(true)
|
|
attachments.Title = "Attachments"
|
|
attachments.Styles.Title = labelStyle
|
|
attachments.Styles.TitleBar = labelStyle
|
|
attachments.SetShowHelp(false)
|
|
attachments.SetShowStatusBar(false)
|
|
attachments.SetStatusBarItemName("attachment", "attachments")
|
|
attachments.SetShowPagination(false)
|
|
|
|
for _, a := range defaults.Attachments {
|
|
attachments.InsertItem(0, attachment(a.Filename))
|
|
}
|
|
|
|
picker := filepicker.New()
|
|
picker.CurrentDirectory, _ = os.UserHomeDir()
|
|
|
|
loadingSpinner := spinner.New()
|
|
loadingSpinner.Style = activeLabelStyle
|
|
loadingSpinner.Spinner = spinner.Dot
|
|
|
|
m := Model{
|
|
state: state,
|
|
From: from,
|
|
To: to,
|
|
Subject: subject,
|
|
Body: body,
|
|
Attachments: attachments,
|
|
filepicker: picker,
|
|
help: help.New(),
|
|
keymap: DefaultKeybinds(),
|
|
loadingSpinner: loadingSpinner,
|
|
}
|
|
|
|
m.focusActiveInput()
|
|
|
|
return m
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
m.From.Cursor.BlinkCmd(),
|
|
)
|
|
}
|
|
|
|
type clearErrMsg struct{}
|
|
|
|
func clearErrAfter(d time.Duration) tea.Cmd {
|
|
return tea.Tick(d, func(t time.Time) tea.Msg {
|
|
return clearErrMsg{}
|
|
})
|
|
}
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case sendEmailSuccessMsg:
|
|
m.quitting = true
|
|
return m, tea.Quit
|
|
case sendEmailFailureMsg:
|
|
m.blurInputs()
|
|
m.state = editingFrom
|
|
m.focusActiveInput()
|
|
m.err = msg
|
|
return m, clearErrAfter(5 * time.Second)
|
|
case clearErrMsg:
|
|
m.err = nil
|
|
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:
|
|
m.state = editingSubject
|
|
case editingSubject:
|
|
m.state = editingBody
|
|
case editingBody:
|
|
m.state = editingAttachments
|
|
case editingAttachments:
|
|
m.state = hoveringSendButton
|
|
case hoveringSendButton:
|
|
m.state = editingFrom
|
|
}
|
|
m.focusActiveInput()
|
|
|
|
case key.Matches(msg, m.keymap.PrevInput):
|
|
m.blurInputs()
|
|
switch m.state {
|
|
case editingFrom:
|
|
m.state = hoveringSendButton
|
|
case editingTo:
|
|
m.state = editingFrom
|
|
case editingSubject:
|
|
m.state = editingTo
|
|
case editingBody:
|
|
m.state = editingSubject
|
|
case editingAttachments:
|
|
m.state = editingBody
|
|
case hoveringSendButton:
|
|
m.state = editingAttachments
|
|
}
|
|
m.focusActiveInput()
|
|
|
|
case key.Matches(msg, m.keymap.Back):
|
|
m.state = editingAttachments
|
|
m.updateKeymap()
|
|
return m, nil
|
|
case key.Matches(msg, m.keymap.Send):
|
|
m.state = sendingEmail
|
|
return m, tea.Batch(
|
|
m.loadingSpinner.Tick,
|
|
m.sendEmailCmd(),
|
|
)
|
|
case key.Matches(msg, m.keymap.Attach):
|
|
m.state = pickingFile
|
|
return m, m.filepicker.Init()
|
|
case key.Matches(msg, m.keymap.Unattach):
|
|
m.Attachments.RemoveItem(m.Attachments.Index())
|
|
m.Attachments.SetHeight(max(len(m.Attachments.Items()), 1) + 2)
|
|
case key.Matches(msg, m.keymap.Quit):
|
|
m.quitting = true
|
|
m.abort = true
|
|
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)
|
|
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)
|
|
|
|
switch m.state {
|
|
case pickingFile:
|
|
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()
|
|
}
|
|
case editingAttachments:
|
|
m.Attachments, cmd = m.Attachments.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
case sendingEmail:
|
|
m.loadingSpinner, cmd = m.loadingSpinner.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
|
|
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()
|
|
m.From.PromptStyle = labelStyle
|
|
m.To.PromptStyle = labelStyle
|
|
m.Subject.PromptStyle = labelStyle
|
|
m.From.TextStyle = textStyle
|
|
m.To.TextStyle = textStyle
|
|
m.Subject.TextStyle = textStyle
|
|
m.Attachments.Styles.Title = labelStyle
|
|
m.Attachments.SetDelegate(attachmentDelegate{false})
|
|
}
|
|
|
|
func (m *Model) focusActiveInput() {
|
|
switch m.state {
|
|
case editingFrom:
|
|
m.From.PromptStyle = activeLabelStyle
|
|
m.From.TextStyle = activeTextStyle
|
|
m.From.Focus()
|
|
m.From.CursorEnd()
|
|
case editingTo:
|
|
m.To.PromptStyle = activeLabelStyle
|
|
m.To.TextStyle = activeTextStyle
|
|
m.To.Focus()
|
|
m.To.CursorEnd()
|
|
case editingSubject:
|
|
m.Subject.PromptStyle = activeLabelStyle
|
|
m.Subject.TextStyle = activeTextStyle
|
|
m.Subject.Focus()
|
|
m.Subject.CursorEnd()
|
|
case editingBody:
|
|
m.Body.Focus()
|
|
m.Body.CursorEnd()
|
|
case editingAttachments:
|
|
m.Attachments.Styles.Title = activeLabelStyle
|
|
m.Attachments.SetDelegate(attachmentDelegate{true})
|
|
}
|
|
}
|
|
|
|
func (m Model) View() string {
|
|
if m.quitting {
|
|
return ""
|
|
}
|
|
|
|
switch m.state {
|
|
case pickingFile:
|
|
return "\n" + activeLabelStyle.Render("Attachments") + " " + commentStyle.Render(m.filepicker.CurrentDirectory) +
|
|
"\n\n" + m.filepicker.View()
|
|
case sendingEmail:
|
|
return "\n " + m.loadingSpinner.View() + "Sending email"
|
|
}
|
|
|
|
var s strings.Builder
|
|
|
|
s.WriteString(m.From.View())
|
|
s.WriteString("\n")
|
|
s.WriteString(m.To.View())
|
|
s.WriteString("\n")
|
|
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")
|
|
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")
|
|
s.WriteString(m.help.View(m.keymap))
|
|
|
|
if m.err != nil {
|
|
s.WriteString("\n\n")
|
|
s.WriteString(errorStyle.Render(m.err.Error()))
|
|
}
|
|
|
|
return paddedStyle.Render(s.String())
|
|
}
|
|
|
|
func max[T constraints.Ordered](a, b T) T {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|