2024-03-30 19:02:07 -06:00
|
|
|
package tui
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"slices"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model"
|
|
|
|
"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-04-01 15:26:45 -06:00
|
|
|
type loadedConversation struct {
|
|
|
|
conv models.Conversation
|
|
|
|
lastReply models.Message
|
|
|
|
}
|
|
|
|
|
2024-03-30 20:03:53 -06:00
|
|
|
type (
|
2024-04-01 15:26:45 -06:00
|
|
|
// sent when conversation list is loaded
|
|
|
|
msgConversationsLoaded ([]loadedConversation)
|
2024-04-01 16:44:29 -06:00
|
|
|
// sent when a conversation is selected
|
|
|
|
msgConversationSelected models.Conversation
|
2024-03-30 20:03:53 -06:00
|
|
|
)
|
|
|
|
|
2024-03-31 17:51:45 -06:00
|
|
|
type conversationsModel struct {
|
|
|
|
basemodel
|
|
|
|
|
2024-04-01 15:26:45 -06:00
|
|
|
conversations []loadedConversation
|
2024-04-01 16:44:29 -06:00
|
|
|
cursor int // index of the currently selected message message
|
|
|
|
|
|
|
|
content viewport.Model
|
2024-03-31 17:51:45 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
func newConversationsModel(tui *model) conversationsModel {
|
|
|
|
m := conversationsModel{
|
|
|
|
basemodel: basemodel{
|
|
|
|
opts: tui.opts,
|
|
|
|
ctx: tui.ctx,
|
|
|
|
views: tui.views,
|
|
|
|
width: tui.width,
|
|
|
|
height: tui.height,
|
|
|
|
},
|
|
|
|
content: viewport.New(0, 0),
|
|
|
|
}
|
|
|
|
return m
|
|
|
|
}
|
|
|
|
|
2024-03-31 19:06:13 -06:00
|
|
|
func (m *conversationsModel) handleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
|
2024-03-30 19:02:07 -06:00
|
|
|
switch msg.String() {
|
|
|
|
case "enter":
|
2024-04-01 16:44:29 -06:00
|
|
|
if len(m.conversations) > 0 && m.cursor < len(m.conversations) {
|
|
|
|
return true, func() tea.Msg {
|
|
|
|
return msgConversationSelected(m.conversations[m.cursor].conv)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
case "j", "down":
|
|
|
|
if m.cursor < len(m.conversations)-1 {
|
|
|
|
m.cursor++
|
|
|
|
m.content.SetContent(m.renderConversationList())
|
2024-03-31 17:51:45 -06:00
|
|
|
}
|
2024-04-01 16:44:29 -06:00
|
|
|
return true, nil
|
|
|
|
case "k", "up":
|
|
|
|
if m.cursor > 0 {
|
|
|
|
m.cursor--
|
|
|
|
m.content.SetContent(m.renderConversationList())
|
|
|
|
}
|
|
|
|
return true, nil
|
2024-03-30 19:02:07 -06:00
|
|
|
case "n":
|
|
|
|
// new conversation
|
|
|
|
case "d":
|
|
|
|
// show prompt to delete conversation
|
|
|
|
case "c":
|
|
|
|
// copy/clone conversation
|
|
|
|
case "r":
|
|
|
|
// show prompt to rename conversation
|
|
|
|
case "shift+r":
|
|
|
|
// show prompt to generate name for conversation
|
|
|
|
}
|
2024-03-31 19:06:13 -06:00
|
|
|
return false, nil
|
2024-03-30 19:02:07 -06:00
|
|
|
}
|
|
|
|
|
2024-03-31 17:51:45 -06:00
|
|
|
func (m conversationsModel) Init() tea.Cmd {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2024-04-01 11:05:36 -06:00
|
|
|
func (m *conversationsModel) handleResize(width, height int) {
|
|
|
|
m.width, m.height = width, height
|
|
|
|
m.content.Width = width
|
|
|
|
}
|
|
|
|
|
2024-03-31 17:51:45 -06:00
|
|
|
func (m conversationsModel) Update(msg tea.Msg) (conversationsModel, tea.Cmd) {
|
2024-03-30 19:02:07 -06:00
|
|
|
var cmds []tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
2024-03-31 17:51:45 -06:00
|
|
|
case msgChangeState:
|
|
|
|
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-04-01 11:05:36 -06:00
|
|
|
m.handleResize(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:
|
|
|
|
m.conversations = msg
|
2024-03-30 20:29:35 -06:00
|
|
|
m.content.SetContent(m.renderConversationList())
|
2024-03-30 19:02:07 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
var cmd tea.Cmd
|
|
|
|
m.content, cmd = m.content.Update(msg)
|
|
|
|
if cmd != nil {
|
|
|
|
cmds = append(cmds, cmd)
|
|
|
|
}
|
|
|
|
|
|
|
|
if m.width > 0 {
|
|
|
|
m.views.header = m.headerView()
|
2024-03-31 17:51:45 -06:00
|
|
|
m.views.footer = "" // TODO: show /something/
|
|
|
|
m.views.error = errorBanner(m.err, m.width)
|
2024-03-30 19:02:07 -06:00
|
|
|
fixedHeight := height(m.views.header) + height(m.views.error) + height(m.views.footer)
|
|
|
|
m.content.Height = m.height - fixedHeight
|
|
|
|
m.views.content = m.content.View()
|
|
|
|
}
|
2024-03-31 17:51:45 -06:00
|
|
|
return m, tea.Batch(cmds...)
|
2024-03-30 19:02:07 -06:00
|
|
|
}
|
|
|
|
|
2024-03-31 17:51:45 -06:00
|
|
|
func (m *conversationsModel) loadConversations() tea.Cmd {
|
2024-03-30 19:02:07 -06:00
|
|
|
return func() tea.Msg {
|
2024-04-01 15:26:45 -06:00
|
|
|
conversations, err := m.ctx.Store.Conversations()
|
2024-03-30 19:02:07 -06:00
|
|
|
if err != nil {
|
|
|
|
return msgError(fmt.Errorf("Could not load conversations: %v", err))
|
|
|
|
}
|
|
|
|
|
2024-04-01 15:26:45 -06:00
|
|
|
loaded := make([]loadedConversation, len(conversations))
|
|
|
|
for i, c := range conversations {
|
|
|
|
lastMessage, err := m.ctx.Store.LastMessage(&c)
|
|
|
|
if err != nil {
|
|
|
|
return msgError(err)
|
|
|
|
}
|
|
|
|
loaded[i].conv = c
|
|
|
|
loaded[i].lastReply = *lastMessage
|
|
|
|
}
|
|
|
|
|
|
|
|
slices.SortFunc(loaded, func(a, b loadedConversation) int {
|
|
|
|
return b.lastReply.CreatedAt.Compare(a.lastReply.CreatedAt)
|
|
|
|
})
|
2024-03-31 17:51:45 -06:00
|
|
|
|
2024-04-01 15:26:45 -06:00
|
|
|
return msgConversationsLoaded(loaded)
|
|
|
|
}
|
|
|
|
}
|
2024-03-30 19:02:07 -06:00
|
|
|
|
2024-03-31 17:51:45 -06:00
|
|
|
func (m *conversationsModel) headerView() string {
|
|
|
|
titleStyle := lipgloss.NewStyle().Bold(true)
|
|
|
|
header := titleStyle.Render("Conversations")
|
|
|
|
return headerStyle.Width(m.width).Render(header)
|
2024-03-30 19:02:07 -06:00
|
|
|
}
|
|
|
|
|
2024-03-31 17:51:45 -06:00
|
|
|
func (m *conversationsModel) renderConversationList() string {
|
2024-04-01 15:26:45 -06:00
|
|
|
type timeCategory struct {
|
2024-03-30 19:02:07 -06:00
|
|
|
name string
|
|
|
|
cutoff time.Duration
|
|
|
|
}
|
|
|
|
|
2024-04-01 15:26:45 -06:00
|
|
|
type listItem struct {
|
2024-03-30 19:02:07 -06:00
|
|
|
id uint
|
|
|
|
short string
|
|
|
|
title string
|
|
|
|
elapsed string
|
|
|
|
lastReplyAge time.Duration
|
|
|
|
}
|
|
|
|
|
|
|
|
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-01 15:26:45 -06:00
|
|
|
categories := []timeCategory{
|
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{})},
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: pick nice color
|
|
|
|
categoryStyle := lipgloss.NewStyle().
|
|
|
|
MarginBottom(1).
|
|
|
|
Foreground(lipgloss.Color("170")).
|
|
|
|
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-01 15:26:45 -06:00
|
|
|
var currentCategory string
|
|
|
|
sb := &strings.Builder{}
|
2024-04-01 16:44:29 -06:00
|
|
|
for i, c := range m.conversations {
|
2024-04-01 15:26:45 -06:00
|
|
|
lastReplyAge := now.Sub(c.lastReply.CreatedAt)
|
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
|
|
|
|
fmt.Fprintf(sb, "%s\n", categoryStyle.Render(currentCategory))
|
|
|
|
}
|
|
|
|
|
2024-04-01 16:44:29 -06:00
|
|
|
tStyle := titleStyle.Copy()
|
|
|
|
padding := " "
|
2024-04-01 15:26:45 -06:00
|
|
|
if c.conv.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
|
|
|
|
|
|
|
title := tStyle.Width(m.width - 3).PaddingLeft(2).Render(c.conv.Title)
|
|
|
|
if i == m.cursor {
|
|
|
|
title = ">" + title[1:]
|
|
|
|
}
|
|
|
|
|
2024-04-01 15:26:45 -06:00
|
|
|
heading := fmt.Sprintf(
|
|
|
|
"%s\n%s",
|
2024-04-01 16:44:29 -06:00
|
|
|
title,
|
|
|
|
padding + ageStyle.Render(util.HumanTimeElapsedSince(lastReplyAge)),
|
2024-04-01 15:26:45 -06:00
|
|
|
)
|
2024-04-01 16:44:29 -06:00
|
|
|
sb.WriteString(itemStyle.Render(heading))
|
2024-04-01 15:26:45 -06:00
|
|
|
sb.WriteRune('\n')
|
|
|
|
}
|
2024-03-30 19:02:07 -06:00
|
|
|
return sb.String()
|
|
|
|
}
|