Files
lazyntfy/internal/tui/model.go
T

845 lines
16 KiB
Go
Raw Normal View History

// Package tui is the lazyntfy terminal UI.
package tui
import (
"bytes"
"encoding/json"
"strings"
"time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"lazyntfy/internal/alert"
"lazyntfy/internal/config"
"lazyntfy/internal/history"
"lazyntfy/internal/ntfy"
"lazyntfy/internal/store"
"lazyntfy/internal/textutil"
)
type focus int
const (
focusTopics focus = iota
focusMessages
focusDetail
)
type mode int
const (
modeNormal mode = iota
modeFilter
modePublish
modeAdd
modeHelp
)
// Model is the Bubble Tea root model.
type Model struct {
app config.App
subs []config.Sub
notices <-chan ntfy.Notice
client *ntfy.Client
mgr *ntfy.Manager
hist *history.DB
store *store.Store
selected map[string]string
up map[string]bool
width int
height int
focus focus
mode mode
topicIdx int
detailTop int
filter string
raw bool
status string
restored int
savedTopic string
filterInput textinput.Model
addInput textinput.Model
form [4]textinput.Model
formIdx int
}
// New builds a model. notices, client, mgr, and hist may be nil in tests.
func New(app config.App, notices <-chan ntfy.Notice, client *ntfy.Client, mgr *ntfy.Manager, hist *history.DB) Model {
status := "connecting"
focusPane := focusMessages
if app.StartupStatus != "" {
status = app.StartupStatus
}
if len(app.Subs) == 0 {
focusPane = focusTopics
if app.StartupStatus == "" {
status = "no topics yet — press a to add one"
}
}
m := Model{
app: app,
subs: append([]config.Sub(nil), app.Subs...),
notices: notices,
client: client,
mgr: mgr,
hist: hist,
store: store.New(),
selected: map[string]string{},
up: map[string]bool{},
status: status,
focus: focusPane,
}
m.filterInput = newField("", "filter")
m.filterInput.CharLimit = 200
m.addInput = newField("", "topic or host/topic")
m.addInput.CharLimit = 200
placeholders := [4]string{"optional title", "3", "comma separated", "message"}
for i, placeholder := range placeholders {
m.form[i] = newField("", placeholder)
m.form[i].CharLimit = 256
}
m.form[3].CharLimit = 4000
m.resizeInputs()
if hist != nil {
for _, sub := range m.subs {
msgs, err := hist.Messages(sub.Name, 0)
if err != nil {
continue
}
for _, msg := range msgs {
m.store.Put(msg)
m.restored++
}
}
if name, ok := hist.State("topic"); ok && name != "" {
m.savedTopic = name
m = m.focusTopic(name)
}
}
return m
}
func newField(prompt, placeholder string) textinput.Model {
ti := textinput.New()
ti.Prompt = prompt
ti.Placeholder = placeholder
ti.SetWidth(48)
return ti
}
// Init starts the subscription listener.
func (m Model) Init() tea.Cmd {
if m.notices == nil {
return nil
}
return waitNotice(m.notices)
}
func waitNotice(ch <-chan ntfy.Notice) tea.Cmd {
return func() tea.Msg {
n, ok := <-ch
if !ok {
return nil
}
return n
}
}
// Update handles input, stream notices, and publish results.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.resizeInputs()
return m, nil
case ntfy.Notice:
m = m.apply(msg)
return m, waitNotice(m.notices)
case publishDone:
if msg.err != nil {
m.status = msg.err.Error()
} else {
m.status = "published"
}
return m, nil
case openDone:
if msg.err != nil {
m.status = msg.err.Error()
} else {
m.status = "opened"
}
return m, nil
case tea.KeyPressMsg:
if keyString(msg) == "ctrl+c" {
return m, tea.Quit
}
switch m.mode {
case modeFilter:
return m.updateFilter(msg)
case modePublish:
return m.updatePublish(msg)
case modeAdd:
return m.updateAdd(msg)
case modeHelp:
m.mode = modeNormal
return m, nil
default:
return m.updateNormal(msg)
}
}
return m, nil
}
func (m Model) updateNormal(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch keyString(msg) {
case "q":
return m, tea.Quit
case "j", "down":
return m.move(1), nil
case "k", "up":
return m.move(-1), nil
case "ctrl+d", "pgdown":
return m.move(m.page()), nil
case "ctrl+u", "pgup":
return m.move(-m.page()), nil
case "g", "home":
return m.moveEdge(true), nil
case "G", "end", "shift+g":
return m.moveEdge(false), nil
case "tab":
m.focus = (m.focus + 1) % 3
return m, nil
case "shift+tab":
m.focus = (m.focus + 2) % 3
return m, nil
case "1":
m.focus = focusTopics
return m, nil
case "2":
m.focus = focusMessages
return m, nil
case "3":
m.focus = focusDetail
return m, nil
case "enter":
switch m.focus {
case focusTopics:
m.focus = focusMessages
case focusMessages:
m.focus = focusDetail
default:
m.focus = focusMessages
}
return m, nil
case "/":
m.mode = modeFilter
m.filterInput.SetValue(m.filter)
return m, m.filterInput.Focus()
case "esc":
if m.filter != "" {
m.filter = ""
m.filterInput.SetValue("")
return m.ensureSelection(), nil
}
if m.focus == focusDetail {
m.focus = focusMessages
}
return m, nil
case "r":
if m.mgr != nil {
m.mgr.Reload()
}
m.status = "reconnecting"
return m, nil
case "p":
return m.beginPublish()
case "a":
return m.beginAdd()
case "v":
m.raw = !m.raw
m.detailTop = 0
return m, nil
case "?":
m.mode = modeHelp
return m, nil
case "o":
return m.openClick()
default:
return m, nil
}
}
func (m Model) updateFilter(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch keyString(msg) {
case "enter":
m.mode = modeNormal
m.filter = m.filterInput.Value()
m.filterInput.Blur()
return m.ensureSelection(), nil
case "esc":
m.mode = modeNormal
m.filter = ""
m.filterInput.SetValue("")
m.filterInput.Blur()
return m.ensureSelection(), nil
default:
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
m.filter = m.filterInput.Value()
return m.ensureSelection(), cmd
}
}
func (m Model) updateAdd(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch keyString(msg) {
case "enter":
return m.submitAdd(), nil
case "esc":
m.mode = modeNormal
m.addInput.Blur()
return m, nil
default:
var cmd tea.Cmd
m.addInput, cmd = m.addInput.Update(msg)
return m, cmd
}
}
func (m Model) updatePublish(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch keyString(msg) {
case "esc":
m.mode = modeNormal
m.blurForm()
return m, nil
case "tab", "down":
return m.formStep(1)
case "shift+tab", "up":
return m.formStep(-1)
case "enter":
if m.formIdx < len(m.form)-1 {
return m.formStep(1)
}
return m.submitPublish()
default:
var cmd tea.Cmd
m.form[m.formIdx], cmd = m.form[m.formIdx].Update(msg)
return m, cmd
}
}
func (m Model) formStep(delta int) (tea.Model, tea.Cmd) {
m.form[m.formIdx].Blur()
m.formIdx += delta
if m.formIdx < 0 {
m.formIdx = len(m.form) - 1
}
if m.formIdx >= len(m.form) {
m.formIdx = 0
}
return m, m.form[m.formIdx].Focus()
}
func (m Model) beginPublish() (Model, tea.Cmd) {
if _, ok := m.currentSub(); !ok {
m.status = "no topic to publish to"
return m, nil
}
if m.client == nil {
m.status = "publish unavailable"
return m, nil
}
m.mode = modePublish
m.formIdx = 0
for i := range m.form {
m.form[i].SetValue("")
m.form[i].Blur()
}
return m, m.form[0].Focus()
}
func (m Model) submitPublish() (Model, tea.Cmd) {
sub, ok := m.currentSub()
if !ok {
m.status = "no topic to publish to"
m.mode = modeNormal
return m, nil
}
priority, err := ntfy.ParsePriority(m.form[1].Value())
if err != nil {
m.status = err.Error()
return m, nil
}
message := strings.TrimSpace(m.form[3].Value())
if message == "" {
m.status = "message is empty"
return m, nil
}
req := ntfy.PublishRequest{
Title: strings.TrimSpace(m.form[0].Value()),
Priority: priority,
Tags: ntfy.ParseTags(m.form[2].Value()),
Message: message,
}
m.mode = modeNormal
m.blurForm()
m.status = "publishing"
return m, publishCmd(m.client, sub, req)
}
func (m Model) beginAdd() (Model, tea.Cmd) {
m.mode = modeAdd
m.addInput.SetValue("")
return m, m.addInput.Focus()
}
func (m Model) submitAdd() Model {
m.addInput.Blur()
m.mode = modeNormal
origin, name, err := config.Resolve(m.addInput.Value(), m.app.DefaultHost)
if err != nil {
m.status = err.Error()
return m
}
for _, sub := range m.subs {
if sub.Origin == origin && sub.Name == name {
m.status = name + " already open"
return m.focusTopic(name)
}
}
sub := config.Sub{Origin: origin, Name: name, Auth: m.app.Auth}
m.subs = append(m.subs, sub)
if m.mgr != nil {
m.mgr.Add(sub)
}
if m.hist != nil {
_ = m.hist.SaveTopic(config.Sub{Origin: origin, Name: name})
}
m.status = "subscribed to " + name
return m.focusTopic(name)
}
func (m Model) focusTopic(name string) Model {
names := m.topicNames()
for i, n := range names {
if n == name {
m.topicIdx = i
break
}
}
m.detailTop = 0
return m.ensureSelection().persistTopic()
}
func (m Model) blurForm() {
for i := range m.form {
m.form[i].Blur()
}
}
func (m Model) openClick() (Model, tea.Cmd) {
msg, ok := m.selectedMessage()
if !ok || msg.Click == "" {
m.status = "no click URL"
return m, nil
}
return m, openCmd(msg.Click)
}
func (m Model) apply(n ntfy.Notice) Model {
if n.UpSet {
for _, topic := range n.Topics {
for _, part := range strings.Split(topic, ",") {
part = strings.TrimSpace(part)
if part != "" {
m.up[part] = n.Up
}
}
}
}
if n.Status != "" {
m.status = n.Status
}
if n.Event != nil {
switch n.Event.Event {
case "message":
msg := toMessage(*n.Event)
m.store.Put(msg)
m.remember(msg)
case "message_delete":
m.store.Delete(n.Event.ID)
if m.hist != nil {
_ = m.hist.DeleteMessage(n.Event.ID)
}
case "message_clear":
m.store.Clear(n.Event.Topic)
if m.hist != nil {
_ = m.hist.ClearTopic(n.Event.Topic)
}
}
}
return m.ensureSelection()
}
func toMessage(ev ntfy.Event) store.Message {
msg := store.Message{
ID: ev.ID,
Time: ev.Time,
Expires: ev.Expires,
Topic: ev.Topic,
Title: ev.Title,
Body: ev.Message,
Tags: ev.Tags,
Priority: ev.Priority,
Click: ev.Click,
}
if ev.Attachment != nil {
msg.Attachment = &store.Attachment{
Name: ev.Attachment.Name,
URL: ev.Attachment.URL,
Type: ev.Attachment.Type,
Size: ev.Attachment.Size,
}
}
if len(ev.Actions) > 0 {
var buf bytes.Buffer
if err := json.Indent(&buf, ev.Actions, "", " "); err == nil {
msg.Actions = buf.String()
} else {
msg.Actions = string(ev.Actions)
}
}
return msg
}
func (m Model) originFor(topic string) string {
for _, sub := range m.subs {
if sub.Name == topic {
return sub.Origin
}
}
return ""
}
func (m Model) remember(msg store.Message) {
if m.hist == nil || msg.ID == "" || msg.Topic == "" {
return
}
display := ""
for _, sub := range m.subs {
if sub.Name == msg.Topic {
display = sub.Display
break
}
}
_ = m.hist.SaveTopic(config.Sub{Origin: m.originFor(msg.Topic), Name: msg.Topic, Display: display})
_ = m.hist.SaveMessage(m.originFor(msg.Topic), msg)
}
func (m Model) persistTopic() Model {
if m.hist == nil {
return m
}
name := m.currentName()
if name == "" || name == m.savedTopic {
return m
}
if err := m.hist.SetState("topic", name); err == nil {
m.savedTopic = name
}
return m
}
func (m Model) move(delta int) Model {
switch m.focus {
case focusTopics:
m.topicIdx += delta
m.clampTopic()
m.detailTop = 0
return m.ensureSelection().persistTopic()
case focusDetail:
m.detailTop += delta
return m.clampDetail()
default:
msgs := m.filtered()
if len(msgs) == 0 {
return m
}
name := m.currentName()
idx := indexOf(msgs, m.selected[name]) + delta
if idx < 0 {
idx = 0
}
if idx >= len(msgs) {
idx = len(msgs) - 1
}
m.selected[name] = msgs[idx].ID
m.detailTop = 0
return m
}
}
func (m Model) moveEdge(top bool) Model {
switch m.focus {
case focusTopics:
if top {
m.topicIdx = 0
} else {
m.topicIdx = len(m.topicNames()) - 1
}
m.clampTopic()
m.detailTop = 0
return m.ensureSelection().persistTopic()
case focusDetail:
if top {
m.detailTop = 0
return m
}
m.detailTop = 1 << 30
return m.clampDetail()
default:
msgs := m.filtered()
if len(msgs) == 0 {
return m
}
idx := 0
if !top {
idx = len(msgs) - 1
}
m.selected[m.currentName()] = msgs[idx].ID
m.detailTop = 0
return m
}
}
func (m Model) page() int {
n := m.bodyRows() - 1
if n < 1 {
return 1
}
return n
}
func (m Model) resizeInputs() {
w := m.width / 2
if w < 20 {
w = 20
}
if w > 80 {
w = 80
}
m.filterInput.SetWidth(w)
m.addInput.SetWidth(w)
for i := range m.form {
m.form[i].SetWidth(w)
}
}
func keyString(msg tea.KeyPressMsg) string {
if s := msg.String(); s != "" {
return s
}
return msg.Text
}
func formatTime(unix int64) string {
if unix == 0 {
return "?"
}
return time.Unix(unix, 0).Local().Format("2006-01-02 15:04:05")
}
func formatShort(unix int64) string {
if unix == 0 {
return " ? "
}
return time.Unix(unix, 0).Local().Format("01-02 15:04")
}
func priLabel(p int) string {
switch p {
case 1:
return "min"
case 2:
return "low"
case 3:
return "def"
case 4:
return "high"
case 5:
return "max"
default:
return "·"
}
}
func indexOf(msgs []store.Message, id string) int {
for i, msg := range msgs {
if msg.ID == id {
return i
}
}
return 0
}
func (m Model) topicNames() []string {
seen := map[string]bool{}
var names []string
for _, sub := range m.subs {
if sub.Name == "" || seen[sub.Name] {
continue
}
seen[sub.Name] = true
names = append(names, sub.Name)
}
for _, topic := range m.store.Topics() {
if topic == "" || seen[topic] {
continue
}
seen[topic] = true
names = append(names, topic)
}
return names
}
func (m Model) clampTopic() Model {
n := len(m.topicNames())
if n == 0 {
m.topicIdx = 0
return m
}
if m.topicIdx < 0 {
m.topicIdx = 0
}
if m.topicIdx >= n {
m.topicIdx = n - 1
}
return m
}
func (m Model) currentName() string {
names := m.topicNames()
if len(names) == 0 {
return ""
}
m = m.clampTopic()
return names[m.topicIdx]
}
func (m Model) currentSub() (config.Sub, bool) {
name := m.currentName()
for _, sub := range m.subs {
if sub.Name == name {
return sub, true
}
}
return config.Sub{}, false
}
func (m Model) filtered() []store.Message {
var out []store.Message
for _, msg := range m.store.List(m.currentName()) {
title, _ := alert.TitleSeverity(msg.Title, msg.Body)
if store.Match(msg, title, m.filter) {
out = append(out, msg)
}
}
return out
}
func (m Model) ensureSelection() Model {
msgs := m.filtered()
name := m.currentName()
if name == "" || len(msgs) == 0 {
delete(m.selected, name)
return m
}
id := m.selected[name]
for _, msg := range msgs {
if msg.ID == id {
return m
}
}
m.selected[name] = msgs[0].ID
m.detailTop = 0
return m
}
func (m Model) selectedMessage() (store.Message, bool) {
id := m.selected[m.currentName()]
if id == "" {
return store.Message{}, false
}
for _, msg := range m.filtered() {
if msg.ID == id {
return msg, true
}
}
return store.Message{}, false
}
func (m Model) bodyRows() int {
n := m.height - 4
if n < 1 {
return 1
}
return n
}
func (m Model) topicLabel(name string) string {
for _, sub := range m.subs {
if sub.Name == name {
return sub.Label()
}
}
return name
}
func (m Model) clampDetail() Model {
_, _, _, _, _, dw := m.layout()
width := dw - 1
if width < 8 {
width = 8
}
visible := m.bodyRows()
if visible < 1 {
visible = 1
}
maxTop := len(m.detailBlocks(width)) - visible
if maxTop < 0 {
maxTop = 0
}
if m.detailTop < 0 {
m.detailTop = 0
}
if m.detailTop > maxTop {
m.detailTop = maxTop
}
return m
}
func formatListTime(unix int64) string {
if unix == 0 {
return "—"
}
t := time.Unix(unix, 0).Local()
now := time.Now()
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return t.Format("15:04")
}
if t.Year() == now.Year() {
return t.Format("Jan 02")
}
return t.Format("2006-01-02")
}
func wrapText(s string, width int) []string {
if strings.TrimSpace(s) == "" {
return []string{""}
}
return textutil.Wrap(s, width)
}