2024-05-30 00:44:40 -06:00
|
|
|
package conversations
|
2024-03-30 19:02:07 -06:00
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
2024-10-19 20:38:42 -06:00
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/conversation"
|
2024-07-08 00:40:52 -06:00
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/bubbles"
|
2024-09-15 18:48:45 -06:00
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/model"
|
2024-05-30 00:44:40 -06:00
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/shared"
|
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/styles"
|
|
|
|
tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util"
|
2024-03-30 19:02:07 -06:00
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/util"
|
2024-03-31 17:51:45 -06:00
|
|
|
"github.com/charmbracelet/bubbles/viewport"
|
2024-03-30 19:02:07 -06:00
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
)
|
|
|
|
|
2024-03-30 20:03:53 -06:00
|
|
|
type (
|
2024-04-01 15:26:45 -06:00
|
|
|
// sent when conversation list is loaded
|
2024-10-21 09:33:20 -06:00
|
|
|
msgConversationsLoaded conversation.ConversationList
|
|
|
|
// sent when a single conversation is loaded
|
2024-10-25 10:57:15 -06:00
|
|
|
msgConversationLoaded conversation.Conversation
|
2024-07-08 00:40:52 -06:00
|
|
|
// sent when a conversation is deleted
|
|
|
|
msgConversationDeleted struct{}
|
|
|
|
)
|
2024-09-16 09:40:04 -06:00
|
|
|
|
2024-05-30 00:44:40 -06:00
|
|
|
type Model struct {
|
2024-10-25 10:57:15 -06:00
|
|
|
App *model.AppModel
|
|
|
|
width int
|
2024-09-22 17:34:53 -06:00
|
|
|
height int
|
2024-04-01 16:44:29 -06:00
|
|
|
|
2024-09-22 17:34:53 -06:00
|
|
|
cursor int
|
2024-09-15 18:48:45 -06:00
|
|
|
itemOffsets []int // conversation y offsets
|
2024-07-08 00:40:52 -06:00
|
|
|
|
2024-09-15 18:48:45 -06:00
|
|
|
content viewport.Model
|
2024-07-08 00:40:52 -06:00
|
|
|
confirmPrompt bubbles.ConfirmPrompt
|
2024-03-31 17:51:45 -06:00
|
|
|
}
|
|
|
|
|
2024-09-22 17:34:53 -06:00
|
|
|
func Conversations(app *model.AppModel) *Model {
|
|
|
|
viewport.New(0, 0)
|
2024-05-30 00:44:40 -06:00
|
|
|
m := Model{
|
2024-09-22 17:34:53 -06:00
|
|
|
App: app,
|
|
|
|
content: viewport.New(0, 0),
|
2024-03-31 17:51:45 -06:00
|
|
|
}
|
2024-09-22 17:34:53 -06:00
|
|
|
return &m
|
2024-09-16 09:40:04 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd {
|
2024-07-08 00:40:52 -06:00
|
|
|
if m.confirmPrompt.Focused() {
|
|
|
|
var cmd tea.Cmd
|
|
|
|
m.confirmPrompt, cmd = m.confirmPrompt.Update(msg)
|
|
|
|
if cmd != nil {
|
2024-09-16 09:40:04 -06:00
|
|
|
return cmd
|
2024-07-08 00:40:52 -06:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-10-21 09:33:20 -06:00
|
|
|
conversations := m.App.Conversations.Items
|
|
|
|
|
2024-03-30 19:02:07 -06:00
|
|
|
switch msg.String() {
|
|
|
|
case "enter":
|
2024-10-21 09:33:20 -06:00
|
|
|
if len(conversations) > 0 && m.cursor < len(conversations) {
|
|
|
|
return m.loadConversation(conversations[m.cursor].ID)
|
2024-04-01 16:44:29 -06:00
|
|
|
}
|
|
|
|
case "j", "down":
|
2024-10-21 09:33:20 -06:00
|
|
|
if m.cursor < len(conversations)-1 {
|
2024-04-01 16:44:29 -06:00
|
|
|
m.cursor++
|
2024-10-21 09:33:20 -06:00
|
|
|
if m.cursor == len(conversations)-1 {
|
2024-04-02 00:53:29 -06:00
|
|
|
m.content.GotoBottom()
|
|
|
|
} else {
|
|
|
|
// this hack positions the *next* conversatoin slightly
|
|
|
|
// *off* the screen, ensuring the entire m.cursor is shown,
|
|
|
|
// even if its height may not be constant due to wrapping.
|
2024-05-30 00:44:40 -06:00
|
|
|
tuiutil.ScrollIntoView(&m.content, m.itemOffsets[m.cursor+1], -1)
|
2024-04-02 00:53:29 -06:00
|
|
|
}
|
2024-04-01 16:44:29 -06:00
|
|
|
m.content.SetContent(m.renderConversationList())
|
2024-04-02 00:53:29 -06:00
|
|
|
} else {
|
2024-10-21 09:33:20 -06:00
|
|
|
m.cursor = len(conversations) - 1
|
2024-04-02 00:53:29 -06:00
|
|
|
m.content.GotoBottom()
|
2024-03-31 17:51:45 -06:00
|
|
|
}
|
2024-09-16 09:40:04 -06:00
|
|
|
return shared.KeyHandled(msg)
|
2024-04-01 16:44:29 -06:00
|
|
|
case "k", "up":
|
|
|
|
if m.cursor > 0 {
|
|
|
|
m.cursor--
|
2024-04-02 00:53:29 -06:00
|
|
|
if m.cursor == 0 {
|
|
|
|
m.content.GotoTop()
|
|
|
|
} else {
|
2024-05-30 00:44:40 -06:00
|
|
|
tuiutil.ScrollIntoView(&m.content, m.itemOffsets[m.cursor], 1)
|
2024-04-02 00:53:29 -06:00
|
|
|
}
|
2024-04-01 16:44:29 -06:00
|
|
|
m.content.SetContent(m.renderConversationList())
|
2024-04-02 00:53:29 -06:00
|
|
|
} else {
|
|
|
|
m.cursor = 0
|
|
|
|
m.content.GotoTop()
|
2024-04-01 16:44:29 -06:00
|
|
|
}
|
2024-09-16 09:40:04 -06:00
|
|
|
return shared.KeyHandled(msg)
|
2024-03-30 19:02:07 -06:00
|
|
|
case "n":
|
2024-09-22 21:00:03 -06:00
|
|
|
m.App.NewConversation()
|
2024-09-20 20:47:03 -06:00
|
|
|
return shared.ChangeView(shared.ViewChat)
|
2024-03-30 19:02:07 -06:00
|
|
|
case "d":
|
2024-10-21 09:33:20 -06:00
|
|
|
if !m.confirmPrompt.Focused() && len(conversations) > 0 && m.cursor < len(conversations) {
|
|
|
|
title := conversations[m.cursor].Title
|
2024-07-08 00:40:52 -06:00
|
|
|
if title == "" {
|
|
|
|
title = "(untitled)"
|
|
|
|
}
|
|
|
|
m.confirmPrompt = bubbles.NewConfirmPrompt(
|
|
|
|
fmt.Sprintf("Delete '%s'?", title),
|
2024-10-21 09:33:20 -06:00
|
|
|
conversations[m.cursor],
|
2024-07-08 00:40:52 -06:00
|
|
|
)
|
|
|
|
m.confirmPrompt.Style = lipgloss.NewStyle().
|
|
|
|
Bold(true).
|
|
|
|
Foreground(lipgloss.Color("3"))
|
2024-09-16 09:40:04 -06:00
|
|
|
return shared.KeyHandled(msg)
|
2024-07-08 00:40:52 -06:00
|
|
|
}
|
2024-03-30 19:02:07 -06:00
|
|
|
case "c":
|
|
|
|
// copy/clone conversation
|
|
|
|
case "r":
|
|
|
|
// show prompt to rename conversation
|
|
|
|
case "shift+r":
|
|
|
|
// show prompt to generate name for conversation
|
|
|
|
}
|
2024-09-16 09:40:04 -06:00
|
|
|
return nil
|
2024-03-30 19:02:07 -06:00
|
|
|
}
|
|
|
|
|
2024-09-22 17:34:53 -06:00
|
|
|
func (m *Model) Init() tea.Cmd {
|
|
|
|
return nil
|
2024-03-31 17:51:45 -06:00
|
|
|
}
|
|
|
|
|
2024-09-22 17:34:53 -06:00
|
|
|
func (m *Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
|
2024-09-16 09:40:04 -06:00
|
|
|
isInput := false
|
|
|
|
inputHandled := false
|
|
|
|
|
2024-03-30 19:02:07 -06:00
|
|
|
var cmds []tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
2024-09-16 09:40:04 -06:00
|
|
|
case tea.KeyMsg:
|
|
|
|
isInput = true
|
|
|
|
cmd := m.handleInput(msg)
|
|
|
|
if cmd != nil {
|
|
|
|
cmds = append(cmds, cmd)
|
2024-09-22 17:34:53 -06:00
|
|
|
inputHandled = true
|
2024-09-16 09:40:04 -06:00
|
|
|
}
|
2024-05-30 00:44:40 -06:00
|
|
|
case shared.MsgViewEnter:
|
2024-03-31 17:51:45 -06:00
|
|
|
cmds = append(cmds, m.loadConversations())
|
2024-04-01 16:44:29 -06:00
|
|
|
m.content.SetContent(m.renderConversationList())
|
2024-03-31 17:51:45 -06:00
|
|
|
case tea.WindowSizeMsg:
|
2024-09-22 17:34:53 -06:00
|
|
|
m.width, m.height = msg.Width, msg.Height
|
2024-04-01 16:44:29 -06:00
|
|
|
m.content.SetContent(m.renderConversationList())
|
2024-03-30 19:02:07 -06:00
|
|
|
case msgConversationsLoaded:
|
2024-10-21 09:33:20 -06:00
|
|
|
m.App.Conversations = conversation.ConversationList(msg)
|
|
|
|
m.cursor = max(0, min(len(m.App.Conversations.Items), m.cursor))
|
2024-03-30 20:29:35 -06:00
|
|
|
m.content.SetContent(m.renderConversationList())
|
2024-10-21 09:33:20 -06:00
|
|
|
case msgConversationLoaded:
|
|
|
|
m.App.ClearConversation()
|
2024-10-25 10:57:15 -06:00
|
|
|
m.App.Conversation = conversation.Conversation(msg)
|
2024-10-21 09:33:20 -06:00
|
|
|
cmds = append(cmds, func() tea.Msg {
|
|
|
|
return shared.MsgViewChange(shared.ViewChat)
|
|
|
|
})
|
2024-07-08 00:40:52 -06:00
|
|
|
case bubbles.MsgConfirmPromptAnswered:
|
|
|
|
m.confirmPrompt.Blur()
|
|
|
|
if msg.Value {
|
2024-10-25 10:57:15 -06:00
|
|
|
conv, ok := msg.Payload.(conversation.ConversationListItem)
|
2024-09-15 18:48:45 -06:00
|
|
|
if ok {
|
|
|
|
cmds = append(cmds, m.deleteConversation(conv))
|
2024-07-08 00:40:52 -06:00
|
|
|
}
|
|
|
|
}
|
|
|
|
case msgConversationDeleted:
|
|
|
|
cmds = append(cmds, m.loadConversations())
|
2024-03-30 19:02:07 -06:00
|
|
|
}
|
|
|
|
|
2024-09-16 09:40:04 -06:00
|
|
|
if !isInput || !inputHandled {
|
2024-09-22 17:34:53 -06:00
|
|
|
content, cmd := m.content.Update(msg)
|
|
|
|
m.content = content
|
2024-09-16 09:40:04 -06:00
|
|
|
if cmd != nil {
|
|
|
|
cmds = append(cmds, cmd)
|
|
|
|
}
|
2024-03-30 19:02:07 -06:00
|
|
|
}
|
|
|
|
|
2024-09-16 09:40:04 -06:00
|
|
|
if len(cmds) > 0 {
|
|
|
|
return m, tea.Batch(cmds...)
|
|
|
|
}
|
|
|
|
|
|
|
|
return m, nil
|
2024-03-30 19:02:07 -06:00
|
|
|
}
|
|
|
|
|
2024-05-30 00:44:40 -06:00
|
|
|
func (m *Model) loadConversations() tea.Cmd {
|
2024-03-30 19:02:07 -06:00
|
|
|
return func() tea.Msg {
|
2024-10-21 09:33:20 -06:00
|
|
|
list, err := m.App.Ctx.Conversations.LoadConversationList()
|
2024-03-30 19:02:07 -06:00
|
|
|
if err != nil {
|
2024-09-22 21:37:01 -06:00
|
|
|
return shared.AsMsgError(fmt.Errorf("Could not load conversations: %v", err))
|
2024-03-30 19:02:07 -06:00
|
|
|
}
|
2024-10-21 09:33:20 -06:00
|
|
|
return msgConversationsLoaded(list)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Model) loadConversation(conversationID uint) tea.Cmd {
|
|
|
|
return func() tea.Msg {
|
|
|
|
conversation, err := m.App.Ctx.Conversations.GetConversationByID(conversationID)
|
|
|
|
if err != nil {
|
|
|
|
return shared.AsMsgError(fmt.Errorf("Could not load conversation %d: %v", conversationID, err))
|
|
|
|
}
|
2024-10-25 10:57:15 -06:00
|
|
|
return msgConversationLoaded(*conversation)
|
2024-04-01 15:26:45 -06:00
|
|
|
}
|
|
|
|
}
|
2024-03-30 19:02:07 -06:00
|
|
|
|
2024-10-25 10:57:15 -06:00
|
|
|
func (m *Model) deleteConversation(conv conversation.ConversationListItem) tea.Cmd {
|
2024-07-08 00:40:52 -06:00
|
|
|
return func() tea.Msg {
|
2024-10-25 10:57:15 -06:00
|
|
|
err := m.App.Ctx.Conversations.DeleteConversationById(conv.ID)
|
2024-07-08 00:40:52 -06:00
|
|
|
if err != nil {
|
2024-09-22 21:37:01 -06:00
|
|
|
return shared.AsMsgError(fmt.Errorf("Could not delete conversation: %v", err))
|
2024-07-08 00:40:52 -06:00
|
|
|
}
|
|
|
|
return msgConversationDeleted{}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-09-22 17:34:53 -06:00
|
|
|
func (m *Model) Header(width int) string {
|
|
|
|
titleStyle := lipgloss.NewStyle().Bold(true)
|
|
|
|
header := titleStyle.Render("Conversations")
|
|
|
|
return styles.Header.Width(width).Render(header)
|
|
|
|
}
|
2024-05-30 01:04:55 -06:00
|
|
|
|
2024-12-12 00:09:58 -07:00
|
|
|
func (m *Model) Content(width int, height int, errors string) string {
|
2024-09-22 17:34:53 -06:00
|
|
|
m.content.Width, m.content.Height = width, height
|
2024-12-12 00:09:58 -07:00
|
|
|
content := m.content.View()
|
|
|
|
if errors != "" {
|
|
|
|
content += errors
|
|
|
|
}
|
|
|
|
return content
|
2024-05-30 01:04:55 -06:00
|
|
|
}
|
|
|
|
|
2024-09-22 17:34:53 -06:00
|
|
|
func (m *Model) Footer(width int) string {
|
|
|
|
if m.confirmPrompt.Focused() {
|
|
|
|
return lipgloss.NewStyle().Width(width).Render(m.confirmPrompt.View())
|
|
|
|
}
|
|
|
|
return ""
|
2024-03-30 19:02:07 -06:00
|
|
|
}
|
|
|
|
|
2024-05-30 00:44:40 -06:00
|
|
|
func (m *Model) renderConversationList() string {
|
2024-03-30 19:02:07 -06:00
|
|
|
now := time.Now()
|
|
|
|
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
|
|
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
|
|
|
dayOfWeek := int(now.Weekday())
|
2024-04-03 01:10:41 -06:00
|
|
|
categories := []struct {
|
|
|
|
name string
|
|
|
|
cutoff time.Duration
|
|
|
|
}{
|
2024-03-30 19:02:07 -06:00
|
|
|
{"Today", now.Sub(midnight)},
|
|
|
|
{"Yesterday", now.Sub(midnight.AddDate(0, 0, -1))},
|
|
|
|
{"This week", now.Sub(midnight.AddDate(0, 0, -dayOfWeek))},
|
|
|
|
{"Last week", now.Sub(midnight.AddDate(0, 0, -(dayOfWeek + 7)))},
|
|
|
|
{"This month", now.Sub(monthStart)},
|
|
|
|
{"Last month", now.Sub(monthStart.AddDate(0, -1, 0))},
|
|
|
|
{"2 Months ago", now.Sub(monthStart.AddDate(0, -2, 0))},
|
|
|
|
{"3 Months ago", now.Sub(monthStart.AddDate(0, -3, 0))},
|
|
|
|
{"4 Months ago", now.Sub(monthStart.AddDate(0, -4, 0))},
|
|
|
|
{"5 Months ago", now.Sub(monthStart.AddDate(0, -5, 0))},
|
|
|
|
{"6 Months ago", now.Sub(monthStart.AddDate(0, -6, 0))},
|
|
|
|
{"Older", now.Sub(time.Time{})},
|
|
|
|
}
|
|
|
|
|
|
|
|
categoryStyle := lipgloss.NewStyle().
|
|
|
|
MarginBottom(1).
|
2024-04-03 01:06:25 -06:00
|
|
|
Foreground(lipgloss.Color("12")).
|
2024-03-30 19:02:07 -06:00
|
|
|
PaddingLeft(1).
|
|
|
|
Bold(true)
|
|
|
|
|
2024-04-01 16:44:29 -06:00
|
|
|
itemStyle := lipgloss.NewStyle().
|
|
|
|
MarginBottom(1)
|
2024-03-30 19:02:07 -06:00
|
|
|
|
2024-04-01 16:44:29 -06:00
|
|
|
ageStyle := lipgloss.NewStyle().Faint(true).SetString()
|
2024-04-01 15:26:45 -06:00
|
|
|
titleStyle := lipgloss.NewStyle().Bold(true)
|
2024-04-01 16:44:29 -06:00
|
|
|
untitledStyle := lipgloss.NewStyle().Faint(true).Italic(true)
|
|
|
|
selectedStyle := lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("6"))
|
2024-03-30 19:02:07 -06:00
|
|
|
|
2024-04-03 01:10:41 -06:00
|
|
|
var (
|
|
|
|
currentOffset int
|
|
|
|
currentCategory string
|
|
|
|
sb strings.Builder
|
|
|
|
)
|
|
|
|
|
2024-10-21 09:33:20 -06:00
|
|
|
m.itemOffsets = make([]int, len(m.App.Conversations.Items))
|
2024-04-02 00:53:29 -06:00
|
|
|
sb.WriteRune('\n')
|
|
|
|
currentOffset += 1
|
2024-04-03 01:10:41 -06:00
|
|
|
|
2024-10-21 09:33:20 -06:00
|
|
|
for i, c := range m.App.Conversations.Items {
|
|
|
|
lastReplyAge := now.Sub(c.LastMessageAt)
|
2024-03-30 19:02:07 -06:00
|
|
|
|
2024-04-01 15:26:45 -06:00
|
|
|
var category string
|
|
|
|
for _, g := range categories {
|
|
|
|
if lastReplyAge < g.cutoff {
|
|
|
|
category = g.name
|
|
|
|
break
|
2024-03-30 19:02:07 -06:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-04-01 16:44:29 -06:00
|
|
|
// print the category
|
2024-04-01 15:26:45 -06:00
|
|
|
if category != currentCategory {
|
|
|
|
currentCategory = category
|
2024-04-02 00:53:29 -06:00
|
|
|
heading := categoryStyle.Render(currentCategory)
|
|
|
|
sb.WriteString(heading)
|
2024-05-30 00:44:40 -06:00
|
|
|
currentOffset += tuiutil.Height(heading)
|
2024-04-02 00:53:29 -06:00
|
|
|
sb.WriteRune('\n')
|
2024-04-01 15:26:45 -06:00
|
|
|
}
|
|
|
|
|
2024-09-16 09:40:04 -06:00
|
|
|
tStyle := titleStyle
|
2024-10-21 09:33:20 -06:00
|
|
|
if c.Title == "" {
|
2024-04-01 16:44:29 -06:00
|
|
|
tStyle = tStyle.Inherit(untitledStyle).SetString("(untitled)")
|
|
|
|
}
|
|
|
|
if i == m.cursor {
|
|
|
|
tStyle = tStyle.Inherit(selectedStyle)
|
2024-04-01 15:26:45 -06:00
|
|
|
}
|
2024-04-01 16:44:29 -06:00
|
|
|
|
2024-10-21 09:33:20 -06:00
|
|
|
title := tStyle.Width(m.width - 3).PaddingLeft(2).Render(c.Title)
|
2024-04-01 16:44:29 -06:00
|
|
|
if i == m.cursor {
|
|
|
|
title = ">" + title[1:]
|
|
|
|
}
|
|
|
|
|
2024-04-02 00:53:29 -06:00
|
|
|
m.itemOffsets[i] = currentOffset
|
|
|
|
item := itemStyle.Render(fmt.Sprintf(
|
2024-04-03 01:06:25 -06:00
|
|
|
"%s\n %s",
|
2024-04-01 16:44:29 -06:00
|
|
|
title,
|
2024-04-03 01:06:25 -06:00
|
|
|
ageStyle.Render(util.HumanTimeElapsedSince(lastReplyAge)),
|
2024-04-02 00:53:29 -06:00
|
|
|
))
|
|
|
|
sb.WriteString(item)
|
2024-05-30 00:44:40 -06:00
|
|
|
currentOffset += tuiutil.Height(item)
|
2024-10-21 09:33:20 -06:00
|
|
|
if i < len(m.App.Conversations.Items)-1 {
|
2024-04-02 00:53:29 -06:00
|
|
|
sb.WriteRune('\n')
|
|
|
|
}
|
2024-04-01 15:26:45 -06:00
|
|
|
}
|
2024-03-30 19:02:07 -06:00
|
|
|
return sb.String()
|
|
|
|
}
|