// 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) }