zap-go

Repositório zap-go
git clone git://git.sivaldodavi.com/zap-go.git
Log | Files | Refs | README

commit 5e54e6ca2635d889639fb5cf9a2317278fc42b48
parent 4b8f6dad647d8f0797546b11c7fe9a4d48852a93
Author: Sivaldo <sivaldodavi@disroot.org>
Date:   Mon, 30 Mar 2026 12:19:33 -0300

Melhoria de interatividade

Diffstat:
Mmain.go | 176++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 162 insertions(+), 14 deletions(-)

diff --git a/main.go b/main.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "time" @@ -68,6 +69,12 @@ var ( BorderTop(true). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("240")) + + styleUnreadBadge = lipgloss.NewStyle(). + Foreground(lipgloss.Color("0")). + Background(lipgloss.Color("9")). + Bold(true). + Padding(0, 1) ) // ─── Histórico persistente ──────────────────────────────────────────────────── @@ -79,10 +86,22 @@ type storedMsg struct { Time time.Time `json:"time"` } -func historyPath(phone string) string { +type chatMetadata struct { + LastRead time.Time `json:"last_read"` +} + +func dataDir() string { dir := filepath.Join(os.Getenv("HOME"), ".local", "share", "zap-tui") os.MkdirAll(dir, 0700) - return filepath.Join(dir, phone+".json") + return dir +} + +func historyPath(phone string) string { + return filepath.Join(dataDir(), phone+".json") +} + +func metadataPath(phone string) string { + return filepath.Join(dataDir(), phone+".meta.json") } func loadHistory(phone string) []storedMsg { @@ -100,6 +119,21 @@ func saveHistory(phone string, msgs []storedMsg) { os.WriteFile(historyPath(phone), data, 0600) } +func loadMetadata(phone string) chatMetadata { + data, err := os.ReadFile(metadataPath(phone)) + if err != nil { + return chatMetadata{} + } + var meta chatMetadata + json.Unmarshal(data, &meta) + return meta +} + +func saveMetadata(phone string, meta chatMetadata) { + data, _ := json.Marshal(meta) + os.WriteFile(metadataPath(phone), data, 0600) +} + // ─── Contatos (formato abook) ───────────────────────────────────────────────── type contact struct { @@ -142,6 +176,112 @@ func loadAbook() []contact { return contacts } +func cleanNumber(s string) string { + return strings.Map(func(r rune) rune { + if r >= '0' && r <= '9' { + return r + } + return -1 + }, s) +} + +// ─── Lista de Conversas ─────────────────────────────────────────────────────── + +type chatInfo struct { + contact contact + phone string + lastMsg time.Time + unreadCount int + preview string +} + +func getChats(contacts []contact) []chatInfo { + var chats []chatInfo + + for _, c := range contacts { + phone := cleanNumber(c.mobile) + history := loadHistory(phone) + if len(history) == 0 { + continue + } + + meta := loadMetadata(phone) + lastMsg := history[len(history)-1] + + unread := 0 + preview := lastMsg.Content + if len(preview) > 50 { + preview = preview[:50] + "..." + } + + for i := len(history) - 1; i >= 0; i-- { + if history[i].Time.After(meta.LastRead) && !history[i].IsMe { + unread++ + } + } + + chats = append(chats, chatInfo{ + contact: c, + phone: phone, + lastMsg: lastMsg.Time, + unreadCount: unread, + preview: preview, + }) + } + + sort.Slice(chats, func(i, j int) bool { + return chats[i].lastMsg.After(chats[j].lastMsg) + }) + + return chats +} + +func pickChatFzf(chats []chatInfo) (contact, bool) { + if len(chats) == 0 { + return contact{}, false + } + + var lines []string + for _, c := range chats { + badge := "" + if c.unreadCount > 0 { + badge = fmt.Sprintf("[%d] ", c.unreadCount) + } + when := c.lastMsg.Format("02/01 15:04") + line := fmt.Sprintf("%s%s\t%s\t%s\t%s", + badge, c.contact.name, c.contact.mobile, when, c.preview) + lines = append(lines, line) + } + + cmd := exec.Command("fzf", + "--prompt=Conversa: ", + "--delimiter=\t", + "--with-nth=1", + "--header=Enter para abrir Tab para novo Esc para sair", + "--bind=tab:abort", + ) + cmd.Stdin = strings.NewReader(strings.Join(lines, "\n")) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + + if err != nil { + if err.Error() == "exit status 130" { // Tab pressed + return pickContactFzf(loadAbook()) + } + return contact{}, false + } + + parts := strings.SplitN(strings.TrimSpace(string(out)), "\t", 4) + if len(parts) < 2 { + return contact{}, false + } + + name := strings.TrimLeft(parts[0], "0123456789[] ") + mobile := parts[1] + + return contact{name: name, mobile: mobile}, true +} + func pickContactFzf(contacts []contact) (contact, bool) { if len(contacts) == 0 { return contact{}, false @@ -152,7 +292,7 @@ func pickContactFzf(contacts []contact) (contact, bool) { } cmd := exec.Command("fzf", - "--prompt=Contato: ", + "--prompt=Novo contato: ", "--delimiter=\t", "--with-nth=1", "--header=Enter para abrir Esc para sair", @@ -170,15 +310,6 @@ func pickContactFzf(contacts []contact) (contact, bool) { return contact{name: parts[0], mobile: parts[1]}, true } -func cleanNumber(s string) string { - return strings.Map(func(r rune) rune { - if r >= '0' && r <= '9' { - return r - } - return -1 - }, s) -} - // ─── Modelo Bubble Tea ──────────────────────────────────────────────────────── type incomingMsg struct { @@ -208,6 +339,11 @@ func newModel(client *whatsmeow.Client, target contact) model { ti.Focus() ti.CharLimit = 1000 + // Marcar como lido ao abrir + meta := loadMetadata(phone) + meta.LastRead = time.Now() + saveMetadata(phone, meta) + return model{ client: client, target: target, @@ -247,6 +383,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyCtrlC: saveHistory(m.phone, m.history) + // Atualizar última leitura ao sair + meta := loadMetadata(m.phone) + meta.LastRead = time.Now() + saveMetadata(m.phone, meta) return m, tea.Quit case tea.KeyEnter: @@ -352,8 +492,17 @@ func (m model) View() string { func main() { contacts := loadAbook() + chats := getChats(contacts) + + var chosen contact + var ok bool + + if len(chats) > 0 { + chosen, ok = pickChatFzf(chats) + } else { + chosen, ok = pickContactFzf(contacts) + } - chosen, ok := pickContactFzf(contacts) if !ok { return } @@ -435,4 +584,3 @@ func main() { } client.Disconnect() } -