lmcli/pkg/tui/conversations.go

218 lines
5.4 KiB
Go

package tui
import (
"fmt"
"slices"
"strings"
"time"
models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model"
"git.mlow.ca/mlow/lmcli/pkg/util"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type loadedConversation struct {
conv models.Conversation
lastReply models.Message
}
type (
// sent when conversation list is loaded
msgConversationsLoaded ([]loadedConversation)
// sent when each coversation's 'last reply' is loaded
)
type conversationsModel struct {
basemodel
conversations []loadedConversation
content viewport.Model
}
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
}
func (m *conversationsModel) handleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
switch msg.String() {
case "enter":
// how to notify chats model
return true, func() tea.Msg {
return msgChangeState(stateChat)
}
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
}
return false, nil
}
func (m conversationsModel) Init() tea.Cmd {
return nil
}
func (m *conversationsModel) handleResize(width, height int) {
m.width, m.height = width, height
m.content.Width = width
}
func (m conversationsModel) Update(msg tea.Msg) (conversationsModel, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case msgChangeState:
cmds = append(cmds, m.loadConversations())
case tea.WindowSizeMsg:
m.handleResize(msg.Width, msg.Height)
case msgConversationsLoaded:
m.conversations = msg
m.content.SetContent(m.renderConversationList())
}
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()
m.views.footer = "" // TODO: show /something/
m.views.error = errorBanner(m.err, m.width)
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()
}
return m, tea.Batch(cmds...)
}
func (m *conversationsModel) loadConversations() tea.Cmd {
return func() tea.Msg {
conversations, err := m.ctx.Store.Conversations()
if err != nil {
return msgError(fmt.Errorf("Could not load conversations: %v", err))
}
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)
})
return msgConversationsLoaded(loaded)
}
}
func (m *conversationsModel) headerView() string {
titleStyle := lipgloss.NewStyle().Bold(true)
header := titleStyle.Render("Conversations")
return headerStyle.Width(m.width).Render(header)
}
func (m *conversationsModel) renderConversationList() string {
type timeCategory struct {
name string
cutoff time.Duration
}
type listItem struct {
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())
categories := []timeCategory{
{"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)
conversationHeadingStyle := lipgloss.NewStyle().
MarginBottom(1).
PaddingLeft(2)
ageStyle := lipgloss.NewStyle().Faint(true)
titleStyle := lipgloss.NewStyle().Bold(true)
untitledStyle := titleStyle.Copy().Italic(true).Faint(true).SetString("(untitled)")
var currentCategory string
sb := &strings.Builder{}
for _, c := range m.conversations {
lastReplyAge := now.Sub(c.lastReply.CreatedAt)
var category string
for _, g := range categories {
if lastReplyAge < g.cutoff {
category = g.name
break
}
}
if category != currentCategory {
currentCategory = category
fmt.Fprintf(sb, "%s\n", categoryStyle.Render(currentCategory))
}
tstyle := titleStyle
if c.conv.Title == "" {
tstyle = untitledStyle
}
heading := fmt.Sprintf(
"%s\n%s",
tstyle.Render(c.conv.Title),
ageStyle.Render(util.HumanTimeElapsedSince(lastReplyAge)),
)
sb.WriteString(conversationHeadingStyle.Render(heading))
sb.WriteRune('\n')
}
return sb.String()
}