lmcli/pkg/tui/conversations.go

228 lines
5.7 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 (
// send when conversation list is loaded
msgConversationsLoaded []models.Conversation
)
type conversationsModel struct {
basemodel
conversations []models.Conversation
lastReplies []models.Message
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) 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.content.Width = msg.Width
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 {
c, err := m.ctx.Store.Conversations()
if err != nil {
return msgError(fmt.Errorf("Could not load conversations: %v", err))
}
return msgConversationsLoaded(c)
}
}
//func (m *conversationsModel) loadConversationLastestReplies(conversations []models.Conversation) tea.Cmd {
// return func() tea.Msg {
// //lastMessage, err := m.ctx.Store.LastMessage(&conversation)
// return nil
// }
//}
//func (m *conversationsModel) setConversations(conversations []models.Conversation) {
//}
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 {
sb := &strings.Builder{}
type AgeGroup struct {
name string
cutoff time.Duration
}
type ConversationLine 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 := []AgeGroup{
{"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{})},
}
categorized := map[string][]ConversationLine{}
for _, conversation := range m.conversations {
lastMessage, err := m.ctx.Store.LastMessage(&conversation)
if lastMessage == nil || err != nil {
continue
}
messageAge := now.Sub(lastMessage.CreatedAt)
var category string
for _, c := range categories {
if messageAge < c.cutoff {
category = c.name
break
}
}
categorized[category] = append(
categorized[category],
ConversationLine{
id: conversation.ID,
title: conversation.Title,
short: conversation.ShortName.String,
lastReplyAge: messageAge,
},
)
}
// TODO: pick nice color
categoryStyle := lipgloss.NewStyle().
MarginBottom(1).
Foreground(lipgloss.Color("170")).
PaddingLeft(1).
Bold(true)
conversationHeadingStyle := lipgloss.NewStyle().
MarginBottom(1).
PaddingLeft(2)
for _, category := range categories {
conversationLines, ok := categorized[category.name]
if !ok {
continue
}
slices.SortFunc(conversationLines, func(a, b ConversationLine) int {
return int(a.lastReplyAge - b.lastReplyAge)
})
ageStyle := lipgloss.NewStyle().Faint(true)
titleStyle := lipgloss.NewStyle().Bold(true)
untitledStyle := titleStyle.Copy().Italic(true).SetString("(untitled)")
fmt.Fprintf(sb, "%s\n", categoryStyle.Render(category.name))
for _, c := range conversationLines {
tstyle := titleStyle
if c.title == "" {
tstyle = untitledStyle
}
heading := fmt.Sprintf(
"%s\n%s",
tstyle.Render(c.title),
ageStyle.Render(util.HumanTimeElapsedSince(c.lastReplyAge)),
)
sb.WriteString(conversationHeadingStyle.Render(heading))
sb.WriteRune('\n')
}
}
return sb.String()
}