From 3dc71b8448192786c7774d9f3272d17dbf18780e Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 26 Aug 2026 11:40:19 +0200 Subject: [PATCH 1/6] Open a bundle in the TUI as the threads inside it A bundle posting groups one contact's unseen mail, and its app_url names a topic only when it holds exactly one unseen thread. For any other bundle the TUI substituted the posting id for a topic id, which read a topic that does not exist -- the resource-not-found error screen -- or, on an id collision, silently opened an unrelated thread and marked the bundle seen. Enter on a bundle row now opens the bundle: its unseen threads as a list layered over the box list the way search results are, growing down its Link cursor, with enter opening a member thread and esc stepping back out. Reply and forward on a bundle row open the bundle too. The posting-id fallback is gone everywhere; a non-bundle row that names no thread gets an honest error. Reads go through the SDK's new Postings().BundleUnseenPage (hey-sdk v0.26.0), which models GET /postings/{id}/bundles/unseen. --- go.mod | 2 +- go.sum | 4 +- internal/mail/posting.go | 2 + internal/mail/posting_test.go | 3 +- internal/tui/collections_test.go | 1 + internal/tui/mail.go | 221 ++++++++++++++++++++++++++++--- internal/tui/mail_test.go | 112 ++++++++++++++++ internal/tui/tui_test.go | 2 + 8 files changed, 323 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index f218f7fd..bc5866a5 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655 - github.com/basecamp/hey-sdk/go v0.25.0 + github.com/basecamp/hey-sdk/go v0.26.0 github.com/charmbracelet/x/ansi v0.11.8 github.com/fsnotify/fsnotify v1.10.1 github.com/gofrs/flock v0.13.0 diff --git a/go.sum b/go.sum index 9f436717..bef710dd 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655 h1:zz0WUSEmjURj0T+soXuTtgX291nYouqa+UoyYY3Xxk8= github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/hey-sdk/go v0.25.0 h1:d1dHUXzMimExHeGqAp10/BsbK0j9b99m8D0WO3kbVkA= -github.com/basecamp/hey-sdk/go v0.25.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk= +github.com/basecamp/hey-sdk/go v0.26.0 h1:wOsb9rdcIG8cr6KPLZhhp3pCikUAawfw2GSLLiqlwrA= +github.com/basecamp/hey-sdk/go v0.26.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= diff --git a/internal/mail/posting.go b/internal/mail/posting.go index 506cc4fd..f4caf5d7 100644 --- a/internal/mail/posting.go +++ b/internal/mail/posting.go @@ -22,6 +22,7 @@ type Posting struct { Summary string AlternativeSenderName string Seen bool + Bundled bool BubbledUp bool Muted bool VisibleEntryCount int32 @@ -76,6 +77,7 @@ func NewPosting(posting generated.Posting) Posting { Summary: terminal.SanitizeLine(posting.Summary), AlternativeSenderName: terminal.SanitizeLine(posting.AlternativeSenderName), Seen: posting.Seen, + Bundled: posting.Bundled, BubbledUp: posting.BubbledUp, Muted: posting.Muted, VisibleEntryCount: posting.VisibleEntryCount, diff --git a/internal/mail/posting_test.go b/internal/mail/posting_test.go index 84f923bd..5afd0a5c 100644 --- a/internal/mail/posting_test.go +++ b/internal/mail/posting_test.go @@ -19,6 +19,7 @@ func TestNewPostingKeepsWhatARowShows(t *testing.T) { Summary: "Here is the revised quote for the cabinets", AlternativeSenderName: "Ryan at Fine Woodwork", Seen: true, + Bundled: true, BubbledUp: true, Muted: true, VisibleEntryCount: 3, @@ -34,7 +35,7 @@ func TestNewPostingKeepsWhatARowShows(t *testing.T) { if posting.ID != 4471829 || posting.TopicID != 501 || posting.Name != "Kitchen remodel quote" { t.Errorf("posting = %+v", posting) } - if !posting.Seen || !posting.BubbledUp || !posting.Muted || posting.VisibleEntryCount != 3 { + if !posting.Seen || !posting.Bundled || !posting.BubbledUp || !posting.Muted || posting.VisibleEntryCount != 3 { t.Errorf("posting state = %+v", posting) } if posting.Summary != "Here is the revised quote for the cabinets" || posting.AlternativeSenderName != "Ryan at Fine Woodwork" { diff --git a/internal/tui/collections_test.go b/internal/tui/collections_test.go index 89b2eac9..e3e9f64c 100644 --- a/internal/tui/collections_test.go +++ b/internal/tui/collections_test.go @@ -183,6 +183,7 @@ func TestMailViewCollectionMembershipFailureKeepsState(t *testing.T) { func TestMailViewCollectionMembershipRequiresTopicID(t *testing.T) { v, recorded := mailWithTestServer(t, http.StatusNoContent) v.boxes = append(v.boxes, mail.Source{ID: 12, Kind: mail.KindCollection, Name: "Kitchen remodel"}) + v.postingList.postings[0].TopicID = 0 if cmd := v.HandleContentKey(keyPress("n")); cmd != nil { t.Fatal("unresolved posting should not start a mutation") diff --git a/internal/tui/mail.go b/internal/tui/mail.go index ba3d54ba..7697caa9 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -36,6 +36,7 @@ const ( mailRequestReply mailRequestForward mailRequestSearch + mailRequestBundle mailRequestBulkReply ) @@ -120,6 +121,28 @@ type searchResultsAppendedMsg struct { err error } +// bundleLoadedMsg is the first page of the unseen threads inside a bundle posting, +// opened from its row the way a thread is. +type bundleLoadedMsg struct { + requestID uint64 + boxID int64 + postingID int64 + title string + nextPage string + postings []mail.Posting + err error +} + +// bundleAppendedMsg is the page of a bundle's threads below the ones on screen, read +// because the reader scrolled towards the bottom. Its own lane, like a box's. +type bundleAppendedMsg struct { + requestID uint64 + postingID int64 + nextPage string + postings []mail.Posting + err error +} + type attachmentSavedMsg struct { topicID int64 attachmentID string @@ -219,8 +242,14 @@ type mailView struct { searchList contentList searchActive bool searchQuery string - searchNextPage int // the page of matches after the ones on screen, zero at the last - searchLoadingMore bool // a page of matches is already on its way + searchNextPage int // the page of matches after the ones on screen, zero at the last + searchLoadingMore bool // a page of matches is already on its way + bundleList contentList + bundleActive bool + bundlePostingID int64 // the bundle row the open list belongs to + bundleTitle string // the bundled contact's name, sanitized + bundleNextPage string // the cursor for the page below, empty at the last + bundleLoadingMore bool // a page of the bundle is already on its way screenerCount int // senders waiting in The Screener lastBulkReplyID int64 // delayed delivery currently available for undo pendingMutations int // writes that must finish before changing the account context @@ -234,6 +263,7 @@ type mailView struct { liveRefreshDue bool // a re-read is already on its way moreRequestID uint64 // identifies the only page-below read allowed to grow the list searchMoreID uint64 // the same, for the search results + bundleMoreID uint64 // the same, for an open bundle's threads } func newMailView(vc *viewContext) *mailView { @@ -241,6 +271,7 @@ func newMailView(vc *viewContext) *mailView { vc: vc, topicViewport: viewport.New(viewport.WithWidth(0), viewport.WithHeight(0)), searchList: contentList{hideSeenState: true}, + bundleList: contentList{hideSeenState: true}, } if vc.loadCover != nil { view.cover = parseCoverPreset(vc.loadCover()) @@ -376,6 +407,38 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { } return v.loadMoreSearchResults(), true + case bundleLoadedMsg: + if msg.boxID != v.currentBoxID() { + return nil, true + } + if cmd, ok := v.requests.settle(newRequestResult(msg.requestID, msg.err)); !ok { + return cmd, true + } + v.bundleActive = true + v.bundlePostingID = msg.postingID + v.bundleTitle = msg.title + v.bundleNextPage = msg.nextPage + v.bundleLoadingMore = false + v.bundleList.setPostings(msg.postings) + return v.loadMoreBundlePostings(), true + + case bundleAppendedMsg: + if msg.requestID != v.bundleMoreID || !v.bundleActive || msg.postingID != v.bundlePostingID { + return nil, true + } + v.bundleLoadingMore = false + if msg.err != nil { + v.noteFailure("Could not load more mail", msg.err) + return nil, true + } + v.bundleList.growPostings(msg.postings) + if len(msg.postings) == 0 { + v.bundleNextPage = "" + } else { + v.bundleNextPage = msg.nextPage + } + return v.loadMoreBundlePostings(), true + case topicLoadedMsg: // A zero box identifies a topic opened directly rather than selected from // the current list. It remains valid while sources load or another section @@ -682,6 +745,12 @@ func (v *mailView) View() string { } return v.searchList.view() } + if v.bundleActive { + if v.notice != "" { + return v.vc.styles.title.Render(v.notice) + "\n" + v.bundleList.view() + } + return v.bundleList.view() + } return v.listView() } @@ -772,6 +841,9 @@ func (v *mailView) HelpBindings() []helpBinding { if v.searchActive { return []helpBinding{{"enter", "open"}, {"/", "new search"}} } + if v.bundleActive { + return []helpBinding{{"enter", "open"}, {"esc", "back"}} + } ignoreBinding := helpBinding{"-", "ignore"} if selected := v.postingList.selectedPosting(); selected != nil && selected.Muted { ignoreBinding = helpBinding{"+", "stop ignoring"} @@ -836,6 +908,13 @@ func (v *mailView) SubnavItems() ([]navItem, int, string, bool) { } return nil, 0, label, true } + if v.bundleActive { + label := "New from " + v.bundleTitle + if v.bundleLoadingMore { + label += " · loading more…" + } + return nil, 0, label, true + } label := "Mail" if v.boxIndex >= 0 && v.boxIndex < len(v.boxes) { label = terminal.SanitizeLine(v.boxes[v.boxIndex].Name) @@ -913,7 +992,7 @@ func (v *mailView) openCollections() { } func (v *mailView) SubnavLeft() tea.Cmd { - if v.searchActive || v.searchOpen() { + if v.searchActive || v.searchOpen() || v.bundleActive { return nil } tabIndexes := v.tabBoxIndexes() @@ -943,7 +1022,7 @@ func (v *mailView) SubnavLeft() tea.Cmd { } func (v *mailView) SubnavRight() tea.Cmd { - if v.searchActive || v.searchOpen() { + if v.searchActive || v.searchOpen() || v.bundleActive { return nil } switch v.currentSourceKind() { @@ -1049,6 +1128,27 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { return nil } + if v.bundleActive { + switch msg.Key().Code { + case tea.KeyUp: + v.bundleList.moveUp() + case tea.KeyDown: + v.bundleList.moveDown() + return v.loadMoreBundlePostings() + case tea.KeyEnter: + return v.openSelected() + default: + switch msg.String() { + case "k": + v.bundleList.moveUp() + case "j": + v.bundleList.moveDown() + return v.loadMoreBundlePostings() + } + } + return nil + } + switch msg.Key().Code { case tea.KeyUp: v.postingList.moveUp() @@ -1096,19 +1196,20 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { return nil } -func (v *mailView) InThread() bool { return v.inThread || v.searchActive } +func (v *mailView) InThread() bool { return v.inThread || v.searchActive || v.bundleActive } func (v *mailView) ExitDetail(key string) { - if key == "q" && v.searchActive && !v.inThread && (v.requests.kind == mailRequestTopic || v.requests.kind == mailRequestSearch) { + if key == "q" && (v.searchActive || v.bundleActive) && !v.inThread && (v.requests.kind == mailRequestTopic || v.requests.kind == mailRequestSearch) { v.requests.cancel() v.clearSearch() + v.clearBundle() return } v.ExitThread() } func (v *mailView) ExitThread() { - if v.searchActive && !v.inThread && (v.requests.kind == mailRequestTopic || v.requests.kind == mailRequestSearch) { + if (v.searchActive || v.bundleActive) && !v.inThread && (v.requests.kind == mailRequestTopic || v.requests.kind == mailRequestSearch) { v.requests.cancel() return } @@ -1119,6 +1220,11 @@ func (v *mailView) ExitThread() { v.requests.cancel() return } + if v.bundleActive { + v.clearBundle() + v.requests.cancel() + return + } v.clearSearch() v.requests.cancel() } @@ -1134,8 +1240,20 @@ func (v *mailView) clearSearch() { v.modal = nil } +func (v *mailView) clearBundle() { + v.bundleActive = false + v.bundlePostingID = 0 + v.bundleTitle = "" + v.bundleNextPage = "" + v.bundleLoadingMore = false + v.bundleMoreID++ + v.bundleList.setPostings(nil) + v.notice = "" + v.modal = nil +} + func (v *mailView) CancelPendingDetail() bool { - if v.requests.kind != mailRequestTopic && v.requests.kind != mailRequestReply && v.requests.kind != mailRequestForward && v.requests.kind != mailRequestSearch && v.requests.kind != mailRequestBulkReply { + if v.requests.kind != mailRequestTopic && v.requests.kind != mailRequestReply && v.requests.kind != mailRequestForward && v.requests.kind != mailRequestSearch && v.requests.kind != mailRequestBundle && v.requests.kind != mailRequestBulkReply { return false } v.requests.cancel() @@ -1164,6 +1282,7 @@ func (v *mailView) Resize(width, height int) { } v.postingList.setSize(width, height) v.searchList.setSize(width, height) + v.bundleList.setSize(width, height) v.topicViewport.SetWidth(width) v.contentHeight = height v.fitThreadViewport() @@ -1224,6 +1343,7 @@ func (v *mailView) switchBox(index int) tea.Cmd { v.inThread = false v.threadNotice = "" v.clearSearch() + v.clearBundle() v.requests.cancel() v.notice = "" v.postingList.setPostings(nil) @@ -1429,6 +1549,31 @@ func (v *mailView) loadMoreSearchResults() tea.Cmd { return v.fetchMoreSearchResults(v.vc.ctx, v.searchMoreID, v.searchQuery, v.searchNextPage) } +// requestBundle opens a bundle row: the unseen threads it groups, from their first page. +// They grow downwards from there, the same way a box does. +func (v *mailView) requestBundle(postingID int64) tea.Cmd { + v.bundleNextPage = "" + v.bundleLoadingMore = false + v.bundleMoreID++ + requestID, ctx := v.requests.begin(v.vc.ctx, mailRequestBundle) + return v.fetchBundle(ctx, requestID, v.currentBoxID(), postingID) +} + +// loadMoreBundlePostings reads the page of the bundle below the ones the reader has +// scrolled to, or below threads they can already see the end of. +func (v *mailView) loadMoreBundlePostings() tea.Cmd { + if !v.bundleActive || v.bundleLoadingMore || v.bundleNextPage == "" { + return nil + } + if v.bundleList.hasRowsBelow() && len(v.bundleList.postings)-v.bundleList.cursor > loadMoreThreshold { + return nil + } + + v.bundleLoadingMore = true + v.bundleMoreID++ + return v.fetchMoreBundlePostings(v.vc.ctx, v.bundleMoreID, v.bundlePostingID, v.bundleNextPage) +} + func (v *mailView) requestTopic(boxID, topicID, postingID int64, title string) tea.Cmd { requestID, ctx := v.requests.begin(v.vc.ctx, mailRequestTopic) return v.fetchTopic(ctx, requestID, boxID, topicID, postingID, title) @@ -1560,12 +1705,21 @@ func (v *mailView) openSelected() tea.Cmd { if v.searchActive { selected = v.searchList.selectedPosting() } + if v.bundleActive { + selected = v.bundleList.selectedPosting() + } if selected == nil { return nil } - topicID := selected.TopicID - if topicID == 0 { - topicID = selected.ID + // A bundle names a topic only when it holds one unseen thread — otherwise its row + // opens the bundle itself. The posting's own id is never a topic id, so anything + // else without one has nowhere to go. + if selected.TopicID == 0 { + if selected.Bundled { + return v.requestBundle(selected.ID) + } + err := fmt.Errorf("this item does not identify an email thread") + return func() tea.Msg { return errMsg{err} } } // Posting.Name is the thread's subject; Summary is only the last message's // excerpt, kept as the fallback for a posting with no name. @@ -1573,7 +1727,7 @@ func (v *mailView) openSelected() tea.Cmd { if title == "" { title = selected.Summary } - return v.requestTopic(v.currentBoxID(), topicID, selected.ID, title) + return v.requestTopic(v.currentBoxID(), selected.TopicID, selected.ID, title) } // markPostingSeen marks a thread as seen once it has been opened, the way the @@ -1599,6 +1753,9 @@ func (v *mailView) openedPosting(postingID int64) *mail.Posting { if v.searchActive { list = &v.searchList } + if v.bundleActive { + list = &v.bundleList + } for i := range list.postings { if list.postings[i].ID == postingID { return &list.postings[i] @@ -1842,17 +1999,15 @@ func (v *mailView) handlePostingAction(key string) tea.Cmd { return v.vc.sdk.Postings().Unmute(v.vc.ctx, p.ID) }) case "r", "R": - topicID := p.TopicID - if topicID == 0 { - topicID = p.ID + if p.TopicID == 0 { + return v.openSelected() } - return v.loadReplyContext(topicID, p.Summary) + return v.loadReplyContext(p.TopicID, p.Summary) case "f", "F": - topicID := p.TopicID - if topicID == 0 { - topicID = p.ID + if p.TopicID == 0 { + return v.openSelected() } - return v.loadForwardContext(topicID, p.Summary) + return v.loadForwardContext(p.TopicID, p.Summary) } return nil } @@ -2091,6 +2246,32 @@ func (v *mailView) readSearchPage(ctx context.Context, query string, page int) ( return postings, nextPage, nil } +func (v *mailView) fetchBundle(ctx context.Context, requestID uint64, boxID, postingID int64) tea.Cmd { + return func() tea.Msg { + postings, title, nextPage, err := v.readBundlePage(ctx, postingID, "") + return bundleLoadedMsg{requestID: requestID, boxID: boxID, postingID: postingID, title: title, postings: postings, nextPage: nextPage, err: err} + } +} + +// fetchMoreBundlePostings reads the page of the bundle below the ones on screen, in the +// growing lane and without the spinner. +func (v *mailView) fetchMoreBundlePostings(ctx context.Context, requestID uint64, postingID int64, cursor string) tea.Cmd { + return func() tea.Msg { + postings, _, nextPage, err := v.readBundlePage(ctx, postingID, cursor) + return bundleAppendedMsg{requestID: requestID, postingID: postingID, postings: postings, nextPage: nextPage, err: err} + } +} + +// readBundlePage reads one page of the unseen threads a bundle posting groups, and the +// bundled contact's name for the title. +func (v *mailView) readBundlePage(ctx context.Context, postingID int64, cursor string) ([]mail.Posting, string, string, error) { + page, err := v.vc.sdk.Postings().BundleUnseenPage(ctx, postingID, cursor) + if err != nil { + return nil, "", "", err + } + return mail.Postings(page.Postings), terminal.SanitizeLine(page.Contact.Name), page.NextPage, nil +} + // tuiThreadLimits is what the TUI reads a thread within: threadload's defaults, at the // TUI's own concurrency. var tuiThreadLimits = func() threadload.Limits { diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 8955eec2..86cd8bf6 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -114,6 +114,13 @@ func mailWithTestServer(t *testing.T, status int) (*mailView, *recordedMailReque switch r.URL.Path { case "/advanced_search.json": _, _ = w.Write([]byte(`{"matches":[{"topic":{"id":100,"name":"Hello world","app_url":"https://app.hey.com/topics/100","updated_at":"2026-08-19T09:00:00Z"},"posting_id":10,"entries":[{"id":501,"kind":"message","summary":"Matching message summary","created_at":"2026-08-19T09:00:00Z","creator":{"id":10,"name":"Alice"}}]}]}`)) + case "/postings/311/bundles/unseen.json": + if r.URL.Query().Get("page") == "" { + w.Header().Set("Link", `; rel="next"`) + _, _ = w.Write([]byte(`{"contact":{"id":88,"name":"GitHub","email_address":"notifications@example.com"},"postings":[{"id":511,"kind":"topic","name":"Deploy failed on main","app_url":"https://app.hey.com/topics/100","created_at":"2026-08-25T09:00:00Z","creator":{"id":88,"name":"GitHub"}}]}`)) + } else { + _, _ = w.Write([]byte(`{"contact":{"id":88,"name":"GitHub","email_address":"notifications@example.com"},"postings":[{"id":512,"kind":"topic","name":"Nightly build is green again","app_url":"https://app.hey.com/topics/101","created_at":"2026-08-24T21:00:00Z","creator":{"id":88,"name":"GitHub"}}]}`)) + } case "/topics/100/entries.json": _, _ = w.Write([]byte(`[{"id":501,"kind":"message","summary":"Hello world","created_at":"2026-08-19T09:00:00Z","creator":{"id":10,"name":"Alice"}}]`)) case "/messages/501.json": @@ -3236,3 +3243,108 @@ func TestThreadJKJumpsBetweenMessages(t *testing.T) { t.Errorf("k at the first message should go to the top: offset %d", got) } } + +// bundleRow is a bundle posting the way HEY lists one: several unseen threads from one +// contact, so no topic of its own — its app_url names the contact, not a thread. +func bundleRow() mail.Posting { + return mail.Posting{ + ID: 311, + Bundled: true, + Name: "Deploy failed on main • Nightly build is green again", + Creator: mail.Contact{Name: "GitHub"}, + } +} + +func TestMailViewOpensABundle(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.postingList.postings[0] = bundleRow() + + loaded, ok := runCmd(v.HandleContentKey(keyPress("enter"))).(bundleLoadedMsg) + if !ok || loaded.err != nil { + t.Fatalf("opening a bundle returned %#v", loaded) + } + more, _ := v.Update(loaded) + if !v.bundleActive || v.bundleTitle != "GitHub" { + t.Fatalf("bundle view: active %v title %q", v.bundleActive, v.bundleTitle) + } + if len(v.bundleList.postings) != 1 || v.bundleList.postings[0].TopicID != 100 { + t.Fatalf("bundle postings = %+v", v.bundleList.postings) + } + if _, _, label, _ := v.SubnavItems(); !strings.Contains(label, "New from GitHub") { + t.Errorf("subnav label = %q", label) + } + + // The list is shorter than the window with a page below, so it grows at once. + appended, ok := runCmd(more).(bundleAppendedMsg) + if !ok || appended.err != nil { + t.Fatalf("growing the bundle returned %#v", appended) + } + v.Update(appended) + if len(v.bundleList.postings) != 2 || v.bundleList.postings[1].TopicID != 101 { + t.Fatalf("grown bundle postings = %+v", v.bundleList.postings) + } + if v.bundleNextPage != "" { + t.Errorf("nextPage = %q, want none after the last page", v.bundleNextPage) + } + + // Enter on a member opens its thread the normal way, and esc steps back out + // through the bundle to the box. + topic, ok := runCmd(v.HandleContentKey(keyPress("enter"))).(topicLoadedMsg) + if !ok || topic.err != nil || topic.topicID != 100 { + t.Fatalf("opening a member returned %#v", topic) + } + v.Update(topic) + if !v.inThread { + t.Fatal("a bundle member should open as a thread") + } + v.ExitThread() + if v.inThread || !v.bundleActive { + t.Fatalf("leaving the thread should return to the bundle: inThread %v bundleActive %v", v.inThread, v.bundleActive) + } + v.ExitThread() + if v.bundleActive { + t.Fatal("leaving the bundle should return to the box list") + } +} + +func TestMailViewReplyOnABundleOpensIt(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.postingList.postings[0] = bundleRow() + + loaded, ok := runCmd(v.HandleContentKey(keyPress("r"))).(bundleLoadedMsg) + if !ok || loaded.err != nil { + t.Fatalf("reply on a bundle returned %#v", loaded) + } + v.Update(loaded) + if !v.bundleActive { + t.Fatal("reply on a bundle should open the bundle") + } +} + +func TestMailViewRefusesAPostingWithoutAThread(t *testing.T) { + v := mailWithPostings() + v.postingList.postings[0].TopicID = 0 + + failed, ok := runCmd(v.HandleContentKey(keyPress("enter"))).(errMsg) + if !ok || failed.err == nil { + t.Fatalf("opening a topicless posting returned %#v, want errMsg", failed) + } + if v.bundleActive || v.inThread { + t.Error("a topicless posting should open nothing") + } +} + +func TestMailViewBundleSurvivesAStaleAppend(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.postingList.postings[0] = bundleRow() + + loaded, _ := runCmd(v.HandleContentKey(keyPress("enter"))).(bundleLoadedMsg) + v.Update(loaded) + stale := bundleAppendedMsg{requestID: v.bundleMoreID - 1, postingID: 311, postings: []mail.Posting{{ID: 599}}} + v.Update(stale) + for _, posting := range v.bundleList.postings { + if posting.ID == 599 { + t.Fatal("a stale append should not grow the bundle") + } + } +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index cb7c87a3..35b8bd04 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -51,6 +51,7 @@ func testPostings() []mail.Posting { return []mail.Posting{ { ID: 100, + TopicID: 100, Summary: "Hello world", CreatedAt: time.Date(2025, 3, 1, 10, 0, 0, 0, time.UTC), Seen: false, @@ -58,6 +59,7 @@ func testPostings() []mail.Posting { }, { ID: 101, + TopicID: 101, Summary: "Meeting notes", CreatedAt: time.Date(2025, 3, 1, 9, 0, 0, 0, time.UTC), Seen: true, From 58c2586fa7c272579b60086231c8955660278194 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 26 Aug 2026 11:43:41 +0200 Subject: [PATCH 2/6] Refresh the nix vendorHash for hey-sdk v0.26.0 --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index f3db7a72..3dd86f40 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -18,7 +18,7 @@ buildGoModule.override { inherit go; } (finalAttrs: { # To update: run `make update-nix-hash` (Docker). It rewrites this quoted # value in place, so keep it a string literal rather than lib.fakeHash. - vendorHash = "sha256-jrvzWPmLQBHEakJvp5crisp1YHVubpTSOST9qQaFung="; + vendorHash = "sha256-Q8+ivgqy9erik6lBDilOe88rGSsLeEUX03mzUh3bP24="; subPackages = [ "cmd/hey" ]; From 09e3104cf2fc0ac4d6f2c63cbe5e536dfdc39023 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 26 Aug 2026 11:54:56 +0200 Subject: [PATCH 3/6] Identify a bundle row by its kind, not the bundled flag HEY's `bundled` means a posting is filed inside a bundle; the bundle row itself is `kind: "bundle"`. Keying on the flag left every real bundle refused as a row that names no thread. The TUI fixture now goes through mail.NewPosting so the tests exercise the actual mapping. --- internal/mail/posting.go | 20 +++++++++++--------- internal/mail/posting_test.go | 10 ++++++++-- internal/tui/mail.go | 2 +- internal/tui/mail_test.go | 15 +++++++++------ 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/internal/mail/posting.go b/internal/mail/posting.go index f4caf5d7..c62c813c 100644 --- a/internal/mail/posting.go +++ b/internal/mail/posting.go @@ -22,14 +22,16 @@ type Posting struct { Summary string AlternativeSenderName string Seen bool - Bundled bool - BubbledUp bool - Muted bool - VisibleEntryCount int32 - Creator Contact - Extenzions []Extenzion - Folders []Folder - Collections []Collection + // IsBundle marks a row that is a bundle of one contact's unseen threads. It comes + // from the posting's kind — HEY's `bundled` flag means filed *inside* a bundle. + IsBundle bool + BubbledUp bool + Muted bool + VisibleEntryCount int32 + Creator Contact + Extenzions []Extenzion + Folders []Folder + Collections []Collection } // Contact is who a posting came from. @@ -77,7 +79,7 @@ func NewPosting(posting generated.Posting) Posting { Summary: terminal.SanitizeLine(posting.Summary), AlternativeSenderName: terminal.SanitizeLine(posting.AlternativeSenderName), Seen: posting.Seen, - Bundled: posting.Bundled, + IsBundle: posting.Kind == "bundle", BubbledUp: posting.BubbledUp, Muted: posting.Muted, VisibleEntryCount: posting.VisibleEntryCount, diff --git a/internal/mail/posting_test.go b/internal/mail/posting_test.go index 5afd0a5c..4a013e5f 100644 --- a/internal/mail/posting_test.go +++ b/internal/mail/posting_test.go @@ -35,7 +35,8 @@ func TestNewPostingKeepsWhatARowShows(t *testing.T) { if posting.ID != 4471829 || posting.TopicID != 501 || posting.Name != "Kitchen remodel quote" { t.Errorf("posting = %+v", posting) } - if !posting.Seen || !posting.Bundled || !posting.BubbledUp || !posting.Muted || posting.VisibleEntryCount != 3 { + // HEY's `bundled` means filed inside a bundle; only kind "bundle" makes a row one. + if !posting.Seen || posting.IsBundle || !posting.BubbledUp || !posting.Muted || posting.VisibleEntryCount != 3 { t.Errorf("posting state = %+v", posting) } if posting.Summary != "Here is the revised quote for the cabinets" || posting.AlternativeSenderName != "Ryan at Fine Woodwork" { @@ -130,12 +131,17 @@ func TestTopicIDOfFallsBackToTheBundleURL(t *testing.T) { } for name, test := range tests { + test.posting.Kind = "bundle" if got := TopicIDOf(test.posting); got != test.want { t.Errorf("%s: topic ID = %d, want %d", name, got, test.want) } - if described := NewPosting(test.posting); described.TopicID != test.want { + described := NewPosting(test.posting) + if described.TopicID != test.want { t.Errorf("%s: NewPosting topic ID = %d, want %d", name, described.TopicID, test.want) } + if !described.IsBundle { + t.Errorf("%s: a kind %q posting should be a bundle", name, test.posting.Kind) + } } } diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 7697caa9..516567cd 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -1715,7 +1715,7 @@ func (v *mailView) openSelected() tea.Cmd { // opens the bundle itself. The posting's own id is never a topic id, so anything // else without one has nowhere to go. if selected.TopicID == 0 { - if selected.Bundled { + if selected.IsBundle { return v.requestBundle(selected.ID) } err := fmt.Errorf("this item does not identify an email thread") diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 86cd8bf6..f48a0c21 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -3245,14 +3245,17 @@ func TestThreadJKJumpsBetweenMessages(t *testing.T) { } // bundleRow is a bundle posting the way HEY lists one: several unseen threads from one -// contact, so no topic of its own — its app_url names the contact, not a thread. +// contact, so no topic of its own — its app_url names the contact, not a thread. It is +// built through mail.NewPosting so the tests exercise the real kind mapping: a bundle is +// kind "bundle", while HEY's `bundled` flag means filed inside one. func bundleRow() mail.Posting { - return mail.Posting{ - ID: 311, - Bundled: true, + return mail.NewPosting(generated.Posting{ + Id: 311, + Kind: "bundle", Name: "Deploy failed on main • Nightly build is green again", - Creator: mail.Contact{Name: "GitHub"}, - } + AppUrl: "https://app.hey.com/contacts/88", + Creator: generated.Contact{Id: 88, Name: "GitHub"}, + }) } func TestMailViewOpensABundle(t *testing.T) { From 5f88a71e549fef95c79083deeacd4de09039a539 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 26 Aug 2026 11:58:13 +0200 Subject: [PATCH 4/6] Say why a fully read bundle opens empty HEY serves a bundle's unseen threads, so a bundle that has been read through answers none. A bare (empty) list reads as a bug; say what it means instead. --- internal/tui/mail.go | 10 ++++++++-- internal/tui/mail_test.go | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 516567cd..8077aba0 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -746,10 +746,16 @@ func (v *mailView) View() string { return v.searchList.view() } if v.bundleActive { + view := v.bundleList.view() + // HEY serves a bundle's unseen threads, so a bundle that has been read + // through answers none — say so instead of showing a bare empty list. + if len(v.bundleList.postings) == 0 { + view = styleMuted.Render(" Nothing unseen from " + v.bundleTitle + " — everything in this bundle has been read.") + } if v.notice != "" { - return v.vc.styles.title.Render(v.notice) + "\n" + v.bundleList.view() + return v.vc.styles.title.Render(v.notice) + "\n" + view } - return v.bundleList.view() + return view } return v.listView() } diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index f48a0c21..bf769101 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -3310,6 +3310,17 @@ func TestMailViewOpensABundle(t *testing.T) { } } +func TestMailViewSaysWhyAReadBundleIsEmpty(t *testing.T) { + v := mailWithPostings() + v.bundleActive = true + v.bundleTitle = "GitHub" + v.bundleList.setPostings(nil) + + if view := v.View(); !strings.Contains(view, "Nothing unseen from GitHub") { + t.Errorf("empty bundle view = %q", view) + } +} + func TestMailViewReplyOnABundleOpensIt(t *testing.T) { v, _ := mailWithTestServer(t, http.StatusNoContent) v.postingList.postings[0] = bundleRow() From 75979ab0ca77ff4cd8e8d1e385232d48ecd937b8 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 26 Aug 2026 13:01:51 +0200 Subject: [PATCH 5/6] Open a read bundle as its contact's thread list An unread bundle opens its unseen threads; a bundle that has been read through now opens every thread with its contact -- where the web app sends a read bundle -- instead of dead-ending on an empty unseen list. The list is HEY's own, heading and all, read through Contacts().ThreadsPage (hey-sdk v0.27.0), and pages down its Link cursor like every other list. --- go.mod | 2 +- go.sum | 4 +- internal/tui/mail.go | 89 ++++++++++++++++++++++++++++++++------- internal/tui/mail_test.go | 42 +++++++++++++++--- 4 files changed, 114 insertions(+), 23 deletions(-) diff --git a/go.mod b/go.mod index bc5866a5..86649ba8 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655 - github.com/basecamp/hey-sdk/go v0.26.0 + github.com/basecamp/hey-sdk/go v0.27.0 github.com/charmbracelet/x/ansi v0.11.8 github.com/fsnotify/fsnotify v1.10.1 github.com/gofrs/flock v0.13.0 diff --git a/go.sum b/go.sum index bef710dd..b23c620b 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655 h1:zz0WUSEmjURj0T+soXuTtgX291nYouqa+UoyYY3Xxk8= github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/hey-sdk/go v0.26.0 h1:wOsb9rdcIG8cr6KPLZhhp3pCikUAawfw2GSLLiqlwrA= -github.com/basecamp/hey-sdk/go v0.26.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk= +github.com/basecamp/hey-sdk/go v0.27.0 h1:LAX80V/WqV3hz9c3xqXcm6/2f0Qd2n5YxBIJ/qK6fF0= +github.com/basecamp/hey-sdk/go v0.27.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 8077aba0..1ee98612 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -121,12 +121,15 @@ type searchResultsAppendedMsg struct { err error } -// bundleLoadedMsg is the first page of the unseen threads inside a bundle posting, -// opened from its row the way a thread is. +// bundleLoadedMsg is the first page of what a bundle row opens: the unseen threads +// inside an unread bundle, or — for a bundle that has been read through — every thread +// with its contact, which is where the web app sends a read bundle. contactID says +// which; zero is the unseen list for postingID's bundle. type bundleLoadedMsg struct { requestID uint64 boxID int64 postingID int64 + contactID int64 title string nextPage string postings []mail.Posting @@ -138,6 +141,7 @@ type bundleLoadedMsg struct { type bundleAppendedMsg struct { requestID uint64 postingID int64 + contactID int64 nextPage string postings []mail.Posting err error @@ -247,7 +251,8 @@ type mailView struct { bundleList contentList bundleActive bool bundlePostingID int64 // the bundle row the open list belongs to - bundleTitle string // the bundled contact's name, sanitized + bundleContactID int64 // set when the list is the contact's threads instead of the bundle's unseen + bundleTitle string // what the list is, sanitized: "New from X" or "All threads with X" bundleNextPage string // the cursor for the page below, empty at the last bundleLoadingMore bool // a page of the bundle is already on its way screenerCount int // senders waiting in The Screener @@ -416,6 +421,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { } v.bundleActive = true v.bundlePostingID = msg.postingID + v.bundleContactID = msg.contactID v.bundleTitle = msg.title v.bundleNextPage = msg.nextPage v.bundleLoadingMore = false @@ -423,7 +429,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { return v.loadMoreBundlePostings(), true case bundleAppendedMsg: - if msg.requestID != v.bundleMoreID || !v.bundleActive || msg.postingID != v.bundlePostingID { + if msg.requestID != v.bundleMoreID || !v.bundleActive || msg.postingID != v.bundlePostingID || msg.contactID != v.bundleContactID { return nil, true } v.bundleLoadingMore = false @@ -747,10 +753,14 @@ func (v *mailView) View() string { } if v.bundleActive { view := v.bundleList.view() - // HEY serves a bundle's unseen threads, so a bundle that has been read - // through answers none — say so instead of showing a bare empty list. if len(v.bundleList.postings) == 0 { - view = styleMuted.Render(" Nothing unseen from " + v.bundleTitle + " — everything in this bundle has been read.") + // A contact with no threads is HEY's own empty case; an unseen list + // answering none means the bundle was read since its row was drawn. + if v.bundleContactID != 0 { + view = styleMuted.Render(" No emails with this contact.") + } else { + view = styleMuted.Render(" Nothing unseen here any more — reload the box to catch it up.") + } } if v.notice != "" { return v.vc.styles.title.Render(v.notice) + "\n" + view @@ -915,7 +925,7 @@ func (v *mailView) SubnavItems() ([]navItem, int, string, bool) { return nil, 0, label, true } if v.bundleActive { - label := "New from " + v.bundleTitle + label := v.bundleTitle if v.bundleLoadingMore { label += " · loading more…" } @@ -1249,6 +1259,7 @@ func (v *mailView) clearSearch() { func (v *mailView) clearBundle() { v.bundleActive = false v.bundlePostingID = 0 + v.bundleContactID = 0 v.bundleTitle = "" v.bundleNextPage = "" v.bundleLoadingMore = false @@ -1555,8 +1566,8 @@ func (v *mailView) loadMoreSearchResults() tea.Cmd { return v.fetchMoreSearchResults(v.vc.ctx, v.searchMoreID, v.searchQuery, v.searchNextPage) } -// requestBundle opens a bundle row: the unseen threads it groups, from their first page. -// They grow downwards from there, the same way a box does. +// requestBundle opens an unread bundle row: the unseen threads it groups, from their +// first page. They grow downwards from there, the same way a box does. func (v *mailView) requestBundle(postingID int64) tea.Cmd { v.bundleNextPage = "" v.bundleLoadingMore = false @@ -1565,6 +1576,16 @@ func (v *mailView) requestBundle(postingID int64) tea.Cmd { return v.fetchBundle(ctx, requestID, v.currentBoxID(), postingID) } +// requestContactThreads opens a read bundle row: every thread with its contact, from +// the first page, in the same list the unseen bundle uses. +func (v *mailView) requestContactThreads(contactID int64) tea.Cmd { + v.bundleNextPage = "" + v.bundleLoadingMore = false + v.bundleMoreID++ + requestID, ctx := v.requests.begin(v.vc.ctx, mailRequestBundle) + return v.fetchContactThreads(ctx, requestID, v.currentBoxID(), contactID) +} + // loadMoreBundlePostings reads the page of the bundle below the ones the reader has // scrolled to, or below threads they can already see the end of. func (v *mailView) loadMoreBundlePostings() tea.Cmd { @@ -1577,6 +1598,9 @@ func (v *mailView) loadMoreBundlePostings() tea.Cmd { v.bundleLoadingMore = true v.bundleMoreID++ + if v.bundleContactID != 0 { + return v.fetchMoreContactThreads(v.vc.ctx, v.bundleMoreID, v.bundleContactID, v.bundleNextPage) + } return v.fetchMoreBundlePostings(v.vc.ctx, v.bundleMoreID, v.bundlePostingID, v.bundleNextPage) } @@ -1718,10 +1742,15 @@ func (v *mailView) openSelected() tea.Cmd { return nil } // A bundle names a topic only when it holds one unseen thread — otherwise its row - // opens the bundle itself. The posting's own id is never a topic id, so anything - // else without one has nowhere to go. + // opens the bundle itself: the unseen threads while there are any, or every thread + // with its contact once it has been read through, which is where the web app sends + // a read bundle. The posting's own id is never a topic id, so anything else without + // one has nowhere to go. if selected.TopicID == 0 { if selected.IsBundle { + if selected.Seen { + return v.requestContactThreads(selected.Creator.ID) + } return v.requestBundle(selected.ID) } err := fmt.Errorf("this item does not identify an email thread") @@ -2268,14 +2297,44 @@ func (v *mailView) fetchMoreBundlePostings(ctx context.Context, requestID uint64 } } -// readBundlePage reads one page of the unseen threads a bundle posting groups, and the -// bundled contact's name for the title. +// readBundlePage reads one page of the unseen threads a bundle posting groups, titled +// the way the web app titles its bundle view. func (v *mailView) readBundlePage(ctx context.Context, postingID int64, cursor string) ([]mail.Posting, string, string, error) { page, err := v.vc.sdk.Postings().BundleUnseenPage(ctx, postingID, cursor) if err != nil { return nil, "", "", err } - return mail.Postings(page.Postings), terminal.SanitizeLine(page.Contact.Name), page.NextPage, nil + return mail.Postings(page.Postings), "New from " + terminal.SanitizeLine(page.Contact.Name), page.NextPage, nil +} + +func (v *mailView) fetchContactThreads(ctx context.Context, requestID uint64, boxID, contactID int64) tea.Cmd { + return func() tea.Msg { + postings, title, nextPage, err := v.readContactThreadsPage(ctx, contactID, "") + return bundleLoadedMsg{requestID: requestID, boxID: boxID, contactID: contactID, title: title, postings: postings, nextPage: nextPage, err: err} + } +} + +// fetchMoreContactThreads reads the page of the contact's threads below the ones on +// screen, in the growing lane and without the spinner. +func (v *mailView) fetchMoreContactThreads(ctx context.Context, requestID uint64, contactID int64, cursor string) tea.Cmd { + return func() tea.Msg { + postings, _, nextPage, err := v.readContactThreadsPage(ctx, contactID, cursor) + return bundleAppendedMsg{requestID: requestID, contactID: contactID, postings: postings, nextPage: nextPage, err: err} + } +} + +// readContactThreadsPage reads one page of the threads a contact is on, titled with +// HEY's own heading for the list. +func (v *mailView) readContactThreadsPage(ctx context.Context, contactID int64, cursor string) ([]mail.Posting, string, string, error) { + page, err := v.vc.sdk.Contacts().ThreadsPage(ctx, contactID, cursor) + if err != nil { + return nil, "", "", err + } + title := terminal.SanitizeLine(page.Contact.EntriesTitle) + if title == "" { + title = "All threads with " + terminal.SanitizeLine(page.Contact.Name) + } + return mail.Postings(page.Contact.Postings), title, page.NextPage, nil } // tuiThreadLimits is what the TUI reads a thread within: threadload's defaults, at the diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index bf769101..0a60b1e1 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -121,6 +121,8 @@ func mailWithTestServer(t *testing.T, status int) (*mailView, *recordedMailReque } else { _, _ = w.Write([]byte(`{"contact":{"id":88,"name":"GitHub","email_address":"notifications@example.com"},"postings":[{"id":512,"kind":"topic","name":"Nightly build is green again","app_url":"https://app.hey.com/topics/101","created_at":"2026-08-24T21:00:00Z","creator":{"id":88,"name":"GitHub"}}]}`)) } + case "/contacts/88.json": + _, _ = w.Write([]byte(`{"id":88,"name":"GitHub","entries_title":"All threads with GitHub","postings":[{"id":513,"kind":"topic","name":"Deploy failed on main","seen":true,"app_url":"https://app.hey.com/topics/100","created_at":"2026-08-25T09:00:00Z","creator":{"id":88,"name":"GitHub"}},{"id":514,"kind":"topic","name":"Nightly build is green again","seen":true,"app_url":"https://app.hey.com/topics/101","created_at":"2026-08-24T21:00:00Z","creator":{"id":88,"name":"GitHub"}}]}`)) case "/topics/100/entries.json": _, _ = w.Write([]byte(`[{"id":501,"kind":"message","summary":"Hello world","created_at":"2026-08-19T09:00:00Z","creator":{"id":10,"name":"Alice"}}]`)) case "/messages/501.json": @@ -3267,7 +3269,7 @@ func TestMailViewOpensABundle(t *testing.T) { t.Fatalf("opening a bundle returned %#v", loaded) } more, _ := v.Update(loaded) - if !v.bundleActive || v.bundleTitle != "GitHub" { + if !v.bundleActive || v.bundleTitle != "New from GitHub" { t.Fatalf("bundle view: active %v title %q", v.bundleActive, v.bundleTitle) } if len(v.bundleList.postings) != 1 || v.bundleList.postings[0].TopicID != 100 { @@ -3310,14 +3312,44 @@ func TestMailViewOpensABundle(t *testing.T) { } } -func TestMailViewSaysWhyAReadBundleIsEmpty(t *testing.T) { +func TestMailViewOpensAReadBundleAsContactThreads(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + row := bundleRow() + row.Seen = true + v.postingList.postings[0] = row + + loaded, ok := runCmd(v.HandleContentKey(keyPress("enter"))).(bundleLoadedMsg) + if !ok || loaded.err != nil || loaded.contactID != 88 { + t.Fatalf("opening a read bundle returned %#v", loaded) + } + v.Update(loaded) + if !v.bundleActive || v.bundleTitle != "All threads with GitHub" { + t.Fatalf("contact threads view: active %v title %q", v.bundleActive, v.bundleTitle) + } + if len(v.bundleList.postings) != 2 || v.bundleList.postings[0].TopicID != 100 || v.bundleList.postings[1].TopicID != 101 { + t.Fatalf("contact threads = %+v", v.bundleList.postings) + } + + topic, ok := runCmd(v.HandleContentKey(keyPress("enter"))).(topicLoadedMsg) + if !ok || topic.err != nil || topic.topicID != 100 { + t.Fatalf("opening a thread returned %#v", topic) + } +} + +func TestMailViewSaysWhyABundleListIsEmpty(t *testing.T) { v := mailWithPostings() v.bundleActive = true - v.bundleTitle = "GitHub" + v.bundleTitle = "All threads with GitHub" + v.bundleContactID = 88 v.bundleList.setPostings(nil) - if view := v.View(); !strings.Contains(view, "Nothing unseen from GitHub") { - t.Errorf("empty bundle view = %q", view) + if view := v.View(); !strings.Contains(view, "No emails with this contact") { + t.Errorf("empty contact threads view = %q", view) + } + + v.bundleContactID = 0 + if view := v.View(); !strings.Contains(view, "Nothing unseen here any more") { + t.Errorf("empty unseen bundle view = %q", view) } } From 2d5565ee8614675f8a783483b5ecc594651ec287 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 26 Aug 2026 13:05:17 +0200 Subject: [PATCH 6/6] Refresh the nix vendorHash for hey-sdk v0.27.0 --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index 3dd86f40..e5a9eabd 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -18,7 +18,7 @@ buildGoModule.override { inherit go; } (finalAttrs: { # To update: run `make update-nix-hash` (Docker). It rewrites this quoted # value in place, so keep it a string literal rather than lib.fakeHash. - vendorHash = "sha256-Q8+ivgqy9erik6lBDilOe88rGSsLeEUX03mzUh3bP24="; + vendorHash = "sha256-oOabp2C4PLdPvOTxvMUj7/XcU+pWVJwB2JRt0Eu6CRs="; subPackages = [ "cmd/hey" ];