From 9757752bf8c2c7960f1f940f1b4a534586353f26 Mon Sep 17 00:00:00 2001 From: youdie006 Date: Fri, 19 Jun 2026 15:23:51 +0900 Subject: [PATCH] fix(tui): sync unread badge after delete The delete/archive Update handlers mutated the unread stores but never recomputed the badge, so the unread counter only refreshed on the next fetch. Call m.syncUnreadBadge() after the store mutation in DeleteEmailMsg, ArchiveEmailMsg, BatchDeleteEmailsMsg and BatchArchiveEmailsMsg, mirroring the read/unread handlers. syncUnreadBadge now also caches the count on every OS so the behavior is testable. Closes #1404 --- main.go | 14 +++++++++- main_test.go | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index f371548c..1024f766 100644 --- a/main.go +++ b/main.go @@ -145,6 +145,9 @@ type mainModel struct { sendNotice string pendingAction *pendingEmailAction actionNotice string + // unreadBadge caches the unread count last pushed to the OS badge so the + // value the badge derives from is observable after email operations. + unreadBadge int } type logEntryMsg struct { @@ -296,10 +299,11 @@ func unreadBadgeCount(emailsByAcct, folderEmails map[string][]fetcher.Email) int } func (m *mainModel) syncUnreadBadge() { + count := unreadBadgeCount(m.emailsByAcct, m.folderEmails) + m.unreadBadge = count if runtime.GOOS != goosDarwin && loglevel.Get() < loglevel.LevelDebug { return } - count := unreadBadgeCount(m.emailsByAcct, m.folderEmails) loglevel.Debugf("unread badge count: %d", count) if runtime.GOOS != goosDarwin { return @@ -1936,6 +1940,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo go saveFolderEmailsToCache(folderName, filtered) } + m.syncUnreadBadge() + pa := &pendingEmailAction{ jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()), kind: actionKindDelete, @@ -1986,6 +1992,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo go saveFolderEmailsToCache(folderName, filtered) } + m.syncUnreadBadge() + pa := &pendingEmailAction{ jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()), kind: actionKindArchive, @@ -2065,6 +2073,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo go saveFolderEmailsToCache(folderName, filtered) } + m.syncUnreadBadge() + pa := &pendingEmailAction{ jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()), kind: actionKindDelete, @@ -2119,6 +2129,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo go saveFolderEmailsToCache(folderName, filtered) } + m.syncUnreadBadge() + pa := &pendingEmailAction{ jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()), kind: actionKindArchive, diff --git a/main_test.go b/main_test.go index a7ab1826..984c7801 100644 --- a/main_test.go +++ b/main_test.go @@ -6,7 +6,9 @@ import ( "testing" "unicode/utf8" + "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/tui" ) func TestSanitizeFilenameTruncatesCJKOnUTF8Boundary(t *testing.T) { @@ -61,6 +63,81 @@ func TestParseGlobalFlagsDoesNotConsumeSubcommandFlags(t *testing.T) { } } +// newBadgeTestModel builds a minimal mainModel seeded with a single unread +// email for one account, ready to receive delete/archive messages. +func newBadgeTestModel(uid uint32, accountID string) *mainModel { + email := fetcher.Email{UID: uid, AccountID: accountID, IsRead: false} + return &mainModel{ + current: tui.NewChoice(), + config: &config.Config{ + Accounts: []config.Account{{ID: accountID}}, + }, + emails: []fetcher.Email{email}, + emailsByAcct: map[string][]fetcher.Email{accountID: {email}}, + folderEmails: map[string][]fetcher.Email{folderInbox: {email}}, + } +} + +func TestDeleteEmailRefreshesUnreadBadge(t *testing.T) { + const acct = "acct-a" + m := newBadgeTestModel(7, acct) + m.syncUnreadBadge() + if m.unreadBadge != 1 { + t.Fatalf("setup: unreadBadge = %d, want 1", m.unreadBadge) + } + + m.Update(tui.DeleteEmailMsg{UID: 7, AccountID: acct}) + + if m.unreadBadge != 0 { + t.Fatalf("after delete: unreadBadge = %d, want 0 (badge not refreshed)", m.unreadBadge) + } +} + +func TestArchiveEmailRefreshesUnreadBadge(t *testing.T) { + const acct = "acct-a" + m := newBadgeTestModel(7, acct) + m.syncUnreadBadge() + if m.unreadBadge != 1 { + t.Fatalf("setup: unreadBadge = %d, want 1", m.unreadBadge) + } + + m.Update(tui.ArchiveEmailMsg{UID: 7, AccountID: acct}) + + if m.unreadBadge != 0 { + t.Fatalf("after archive: unreadBadge = %d, want 0 (badge not refreshed)", m.unreadBadge) + } +} + +func TestBatchDeleteEmailsRefreshesUnreadBadge(t *testing.T) { + const acct = "acct-a" + m := newBadgeTestModel(7, acct) + m.syncUnreadBadge() + if m.unreadBadge != 1 { + t.Fatalf("setup: unreadBadge = %d, want 1", m.unreadBadge) + } + + m.Update(tui.BatchDeleteEmailsMsg{UIDs: []uint32{7}, AccountID: acct}) + + if m.unreadBadge != 0 { + t.Fatalf("after batch delete: unreadBadge = %d, want 0 (badge not refreshed)", m.unreadBadge) + } +} + +func TestBatchArchiveEmailsRefreshesUnreadBadge(t *testing.T) { + const acct = "acct-a" + m := newBadgeTestModel(7, acct) + m.syncUnreadBadge() + if m.unreadBadge != 1 { + t.Fatalf("setup: unreadBadge = %d, want 1", m.unreadBadge) + } + + m.Update(tui.BatchArchiveEmailsMsg{UIDs: []uint32{7}, AccountID: acct}) + + if m.unreadBadge != 0 { + t.Fatalf("after batch archive: unreadBadge = %d, want 0 (badge not refreshed)", m.unreadBadge) + } +} + func TestUnreadBadgeCountDeduplicatesOverlappingStores(t *testing.T) { email := fetcher.Email{UID: 42, AccountID: "acct-a"} got := unreadBadgeCount(