From 42135b02cdbbf319baae506df18001c5f6c6477f Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 26 Aug 2026 18:43:42 +0200 Subject: [PATCH 1/3] Open Previously Seen on its own screen with 9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web app's 9 jumps to Previously Seen; the TUI now gives those threads a screen of their own, on HEY's own route for them, ordered by when they were seen. The tab sits in the box row just before Labels, the arrows walk through it, and the box's own number or esc lands back on the list it was opened over. A covered Imbox hides its seen threads; this screen is the way to look at them. Every thread action works there — reply, forward, move, labels, collections, and the triage keys — reading its selection from the screen's list and filing out of the Imbox, which is whose threads these are whatever source the screen was opened over. A thread moved out, trashed, marked spam or marked unseen is not previously seen any more, so it leaves the screen and the list refills from below. The screen opens before its first page answers, the way a box switch does: the tab is selected there and then, so the ribbon reads on past it to Labels rather than asking for the screen again. --- README.md | 4 +- internal/mail/page.go | 25 ++ internal/mail/page_test.go | 59 +++++ internal/tui/collections_test.go | 9 +- internal/tui/content.go | 13 + internal/tui/mail.go | 411 +++++++++++++++++++++++++++---- internal/tui/mail_test.go | 279 ++++++++++++++++++++- internal/tui/tui_test.go | 6 +- 8 files changed, 742 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 191dc0a8..d179e19a 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ linked account before it opens. An integration that owns a dedicated TUI window same `--instance ` on both commands, keeping its remote requests separate from manually launched TUIs. -Navigate between Mail, Contacts, Calendar, and Journal. The context-sensitive shortcut bar is visible by default; press `?` to hide or restore it, and the choice is remembered across restarts. Mail navigation includes HEY boxes plus separate Labels and Collections tabs; Shift+L opens Labels directly and Shift+K opens Collections. Every list keeps going: scroll towards the bottom of a box, label, or collection and the next threads are read in behind you, so there are no pages to step through. The mail actions use HEY's web shortcuts in either letter case (except `l`, whose uppercase belongs to Labels): `/` or `s` searches, `r` replies, `f` forwards, `v` moves, `b` manages labels, `n` adds or removes the selected thread from collections, `e` marks seen, `u` marks unseen, `i` moves to the Imbox, `l` moves to Reply Later, `a` moves to Set Aside, `d` moves to The Feed, `p` moves to Paper Trail, and `t` trashes. Press `!` to mark as spam, `-` to ignore, and `+` to stop ignoring. Select threads with Space and press Ctrl+B to preview every bulk-reply recipient before writing and sending one reply to all selected threads. A delayed bulk reply can be recalled with Ctrl+U while HEY's undo window remains open. Search results retain the matching-message summary and keep going as you scroll, like every other list. While writing a new message, reply, or forward, press Ctrl+T to open the searchable Snippets picker. HEY never chooses a default: Enter inserts the selected snippet at the body cursor, Escape returns without changing the draft, and the picker can be reopened to insert another snippet. +Navigate between Mail, Contacts, Calendar, and Journal. The context-sensitive shortcut bar is visible by default; press `?` to hide or restore it, and the choice is remembered across restarts. Mail navigation includes HEY boxes plus separate Labels and Collections tabs; Shift+L opens Labels directly and Shift+K opens Collections. Previously Seen has its own tab after the boxes — `9`, the web app's shortcut — showing the Imbox's already-read threads newest-seen first, with the usual thread actions available; Escape returns to the box you were in. Every list keeps going: scroll towards the bottom of a box, label, or collection and the next threads are read in behind you, so there are no pages to step through. The mail actions use HEY's web shortcuts in either letter case (except `l`, whose uppercase belongs to Labels): `/` or `s` searches, `r` replies, `f` forwards, `v` moves, `b` manages labels, `n` adds or removes the selected thread from collections, `e` marks seen, `u` marks unseen, `i` moves to the Imbox, `l` moves to Reply Later, `a` moves to Set Aside, `d` moves to The Feed, `p` moves to Paper Trail, and `t` trashes. Press `!` to mark as spam, `-` to ignore, and `+` to stop ignoring. Select threads with Space and press Ctrl+B to preview every bulk-reply recipient before writing and sending one reply to all selected threads. A delayed bulk reply can be recalled with Ctrl+U while HEY's undo window remains open. Search results retain the matching-message summary and keep going as you scroll, like every other list. While writing a new message, reply, or forward, press Ctrl+T to open the searchable Snippets picker. HEY never chooses a default: Enter inserts the selected snippet at the body cursor, Escape returns without changing the draft, and the picker can be reopened to insert another snippet. The mail list follows the server. HEY tells the TUI when a box changed over the same Action Cable connection `hey watch` uses, and the box on screen is read again a moment @@ -281,7 +281,7 @@ scroll, the same way the mail list does. The Imbox can wear cover art, the way the HEY web app does: everything you have already read goes under it, so the box ends at what still wants your attention instead of trailing off into a month of receipts. The divider stays and says how much is under there — press -`x` to peek, `x` again to close it. +`x` to peek, `x` again to close it, or `9` to open Previously Seen on its own screen. Press Ctrl+V to choose one: `blobs`, `grid`, `peace`, `terrazzo`, `topo` or `waves`, the same six covers redrawn as characters, so they work in any terminal rather than only the diff --git a/internal/mail/page.go b/internal/mail/page.go index d119d98e..6be9dd60 100644 --- a/internal/mail/page.go +++ b/internal/mail/page.go @@ -83,6 +83,31 @@ func readBox(ctx context.Context, client *hey.Client, source Source, page string } } +// ReadSeenPage reads a page of the Imbox's Previously Seen postings, which HEY serves on +// their own route ordered by when they were seen — the Imbox's own pages order seen +// postings last, which is why the box cannot stand in for this. There is no Source +// parameter: the route is account-scoped and names the Imbox itself. An empty cursor +// reads the first page. +func ReadSeenPage(ctx context.Context, client *hey.Client, cursor string) (Page, error) { + var page *string + if cursor != "" { + historyPage, err := historyPageCursor(cursor) + if err != nil { + return Page{}, err + } + page = &historyPage + } + + box, err := client.Boxes().GetImboxSeen(ctx, &generated.GetImboxSeenParams{Page: page}) + if err != nil { + return Page{}, err + } + if box == nil { + return Page{}, fmt.Errorf("mail: the Imbox's seen postings answered no page") + } + return Page{Postings: box.Postings, Cursor: box.NextHistoryUrl}, nil +} + func historyPageCursor(nextHistoryURL string) (string, error) { parsed, err := url.Parse(nextHistoryURL) if err != nil { diff --git a/internal/mail/page_test.go b/internal/mail/page_test.go index 78bf6ff6..1b43fe8a 100644 --- a/internal/mail/page_test.go +++ b/internal/mail/page_test.go @@ -138,6 +138,65 @@ func TestReadPageReadsACollectionsGearedCursor(t *testing.T) { } } +// The seen route hands out a next_history_url naming /imbox, but its cursor belongs to +// the seen ordering: the next page is read from the seen route again, never from the box. +func TestReadSeenPageStaysOnTheSeenRoute(t *testing.T) { + var path, cursor string + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + path, cursor = r.URL.Path, r.URL.Query().Get("page") + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":1,"postings":[{"id":611}],"next_history_url":"/imbox?page=seen-cursor-3"}`) + }) + + page, err := ReadSeenPage(context.Background(), client, "https://app.hey.com/imbox?page=seen-cursor-2") + if err != nil { + t.Fatalf("read seen page: %v", err) + } + if path != "/imbox/seen.json" || cursor != "seen-cursor-2" { + t.Errorf("request = %s?page=%s, want /imbox/seen.json?page=seen-cursor-2", path, cursor) + } + if len(page.Postings) != 1 || page.Cursor != "/imbox?page=seen-cursor-3" { + t.Errorf("page = %+v", page) + } +} + +func TestReadSeenPageReadsTheFirstPageWithoutACursor(t *testing.T) { + var query string + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + query = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":1,"postings":[]}`) + }) + + page, err := ReadSeenPage(context.Background(), client, "") + if err != nil { + t.Fatalf("read seen page: %v", err) + } + if query != "" { + t.Errorf("query = %q, want no page param", query) + } + if page.Cursor != "" || len(page.Postings) != 0 { + t.Errorf("page = %+v", page) + } +} + +func TestReadSeenPageRefusesACursorlessURL(t *testing.T) { + requests := 0 + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":1,"postings":[]}`) + }) + + _, err := ReadSeenPage(context.Background(), client, "https://attacker.example/page-2") + if err == nil || !strings.Contains(err.Error(), "carries no page cursor") { + t.Fatalf("error = %v, want an unusable cursor", err) + } + if requests != 0 { + t.Errorf("requests = %d, want none", requests) + } +} + // A kind nobody taught this package about is a bug, not a box. func TestReadPageRefusesAnUnknownKind(t *testing.T) { client := testClient(t, func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/tui/collections_test.go b/internal/tui/collections_test.go index e3e9f64c..418c079e 100644 --- a/internal/tui/collections_test.go +++ b/internal/tui/collections_test.go @@ -87,8 +87,11 @@ func TestMailViewLoadsAndPagesCollections(t *testing.T) { if len(v.boxes) != 2 || v.boxes[1].Kind != mail.KindCollection { t.Fatalf("sources = %+v", v.boxes) } + if cmd := v.SubnavRight(); cmd == nil || !v.seenActive { + t.Fatal("right from the last box should land on Previously Seen") + } if cmd := v.SubnavRight(); cmd != nil || collectionsModal(v) == nil { - t.Fatal("right from the last box should open Collections") + t.Fatal("right from Previously Seen should open Collections") } first := runCmd(v.HandleContentKey(keyPress("enter"))).(postingsLoadedMsg) more, _ := v.Update(first) @@ -575,7 +578,7 @@ func TestMailViewCollectionMembershipDoesNotDuplicate(t *testing.T) { v := mailWithPostings() collection := mail.Collection{ID: 12, Name: "Kitchen remodel"} v.postingList.postings[0].Collections = []mail.Collection{collection} - v.updatePostingCollection(0, collection, true) + updatePostingCollection(&v.postingList, 0, collection, true) if len(v.postingList.postings[0].Collections) != 1 { t.Errorf("memberships = %+v", v.postingList.postings[0].Collections) } @@ -583,7 +586,7 @@ func TestMailViewCollectionMembershipDoesNotDuplicate(t *testing.T) { func TestMailViewCollectionMembershipRemoveUnknownIsStable(t *testing.T) { v := mailWithPostings() - v.updatePostingCollection(0, mail.Collection{ID: 12, Name: "Kitchen remodel"}, false) + updatePostingCollection(&v.postingList, 0, mail.Collection{ID: 12, Name: "Kitchen remodel"}, false) if len(v.postingList.postings[0].Collections) != 0 { t.Errorf("memberships = %+v", v.postingList.postings[0].Collections) } diff --git a/internal/tui/content.go b/internal/tui/content.go index de06438d..84d2c38d 100644 --- a/internal/tui/content.go +++ b/internal/tui/content.go @@ -129,6 +129,19 @@ func (c *contentList) settleCover() { c.clampCursor() } +// removeAt takes a posting the list is finished with out of it, keeping the cursor on +// the posting it was on. +func (c *contentList) removeAt(index int) { + if index < 0 || index >= len(c.postings) { + return + } + c.postings = append(c.postings[:index], c.postings[index+1:]...) + if c.cursor > index { + c.cursor-- + } + c.settleCover() +} + // coveredFrom is the index of the first Previously Seen posting while the cover // is down, or -1 when nothing is hidden. Everything from there on is under the // art: out of reach, and not rendered. diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 1ee98612..5abe7c7b 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -37,6 +37,7 @@ const ( mailRequestForward mailRequestSearch mailRequestBundle + mailRequestSeen mailRequestBulkReply ) @@ -147,6 +148,26 @@ type bundleAppendedMsg struct { err error } +// seenLoadedMsg is the first page of the Imbox's Previously Seen threads, read on +// their own route when the reader jumps to the seen screen. The screen is never +// re-read live — the box underneath still refreshes through its own lane, and +// reopening reads the list fresh. +type seenLoadedMsg struct { + requestID uint64 + nextPage string + postings []mail.Posting + err error +} + +// seenAppendedMsg is the page of seen threads below the ones on screen, read because +// the reader scrolled towards the bottom. Its own lane, like a box's. +type seenAppendedMsg struct { + requestID uint64 + nextPage string + postings []mail.Posting + err error +} + type attachmentSavedMsg struct { topicID int64 attachmentID string @@ -178,6 +199,7 @@ type postingActionDoneMsg struct { sourceKind mail.Kind postingID int64 effect postingActionEffect + seen bool // the action was taken on the Previously Seen screen err error } @@ -215,6 +237,7 @@ type collectionActionDoneMsg struct { postingID int64 collection mail.Collection added bool + seen bool // the action was taken on the Previously Seen screen err error } @@ -255,6 +278,10 @@ type mailView struct { 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 + seenList contentList + seenActive bool + seenNextPage string // the cursor for the page below, empty at the last + seenLoadingMore bool // a page of seen threads 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 @@ -269,6 +296,7 @@ type mailView struct { 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 + seenMoreID uint64 // the same, for the Previously Seen screen } func newMailView(vc *viewContext) *mailView { @@ -277,6 +305,7 @@ func newMailView(vc *viewContext) *mailView { topicViewport: viewport.New(viewport.WithWidth(0), viewport.WithHeight(0)), searchList: contentList{hideSeenState: true}, bundleList: contentList{hideSeenState: true}, + seenList: contentList{hideSeenState: true}, } if vc.loadCover != nil { view.cover = parseCoverPreset(vc.loadCover()) @@ -445,6 +474,35 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { } return v.loadMoreBundlePostings(), true + case seenLoadedMsg: + if cmd, ok := v.requests.settle(newRequestResult(msg.requestID, msg.err)); !ok { + return cmd, true + } + if !v.seenActive { + return nil, true + } + v.seenNextPage = msg.nextPage + v.seenLoadingMore = false + v.seenList.setPostings(msg.postings) + return v.loadMoreSeenPostings(), true + + case seenAppendedMsg: + if msg.requestID != v.seenMoreID || !v.seenActive { + return nil, true + } + v.seenLoadingMore = false + if msg.err != nil { + v.noteFailure("Could not load more mail", msg.err) + return nil, true + } + v.seenList.growPostings(msg.postings) + if len(msg.postings) == 0 { + v.seenNextPage = "" + } else { + v.seenNextPage = msg.nextPage + } + return v.loadMoreSeenPostings(), 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 @@ -625,6 +683,9 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { case postingActionDoneMsg: v.finishMutation() + if msg.seen { + return v.applySeenPostingAction(msg), true + } if msg.boxID != v.currentBoxID() || (msg.sourceKind != "" && msg.sourceKind != v.currentSourceKind()) { return nil, true } @@ -692,6 +753,19 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { case collectionActionDoneMsg: v.finishMutation() + if msg.seen { + if !v.seenActive { + return nil, true + } + if msg.err != nil { + v.notice = terminal.SanitizeLine(errorNotice("Could not update collections", msg.err)) + return nil, true + } + if index := postingIndexIn(v.seenList.postings, msg.postingID); index >= 0 { + updatePostingCollection(&v.seenList, index, msg.collection, msg.added) + } + return notify(msg.action), true + } if msg.sourceID != v.currentBoxID() || msg.sourceKind != v.currentSourceKind() { return nil, true } @@ -701,7 +775,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { } done := notify(msg.action) if index := v.postingIndex(msg.postingID); index >= 0 { - v.updatePostingCollection(index, msg.collection, msg.added) + updatePostingCollection(&v.postingList, index, msg.collection, msg.added) if !msg.added && msg.sourceKind == mail.KindCollection && msg.collection.ID == msg.sourceID { v.removePostingAt(index) } @@ -767,6 +841,16 @@ func (v *mailView) View() string { } return view } + if v.seenActive { + view := v.seenList.view() + if len(v.seenList.postings) == 0 && !v.requests.loading { + view = styleMuted.Render(" Nothing has been seen yet.") + } + if v.notice != "" { + return v.vc.styles.title.Render(v.notice) + "\n" + view + } + return view + } return v.listView() } @@ -860,6 +944,28 @@ func (v *mailView) HelpBindings() []helpBinding { if v.bundleActive { return []helpBinding{{"enter", "open"}, {"esc", "back"}} } + if v.seenActive { + ignoreBinding := helpBinding{"-", "ignore"} + if selected := v.seenList.selectedPosting(); selected != nil && selected.Muted { + ignoreBinding = helpBinding{"+", "stop ignoring"} + } + return []helpBinding{ + {"enter", "open"}, + {"r", "reply"}, + {"f", "forward"}, + {"v", "move"}, + {"b", "labels"}, + {"n", "collections"}, + {"u", "unseen"}, + {"l", "reply later"}, + {"a", "set aside"}, + {"d", "feed"}, + {"p", "paper trail"}, + {"t", "trash"}, + {"!", "spam"}, + ignoreBinding, + } + } ignoreBinding := helpBinding{"-", "ignore"} if selected := v.postingList.selectedPosting(); selected != nil && selected.Muted { ignoreBinding = helpBinding{"+", "stop ignoring"} @@ -895,6 +1001,7 @@ func (v *mailView) HelpBindings() []helpBinding { helpBinding{"t", "trash"}, helpBinding{"!", "spam"}, ignoreBinding, + helpBinding{"9", "previously seen"}, helpBinding{"ctrl+r", "reload"}, ) if v.postingList.cover != coverNone { @@ -931,8 +1038,15 @@ func (v *mailView) SubnavItems() ([]navItem, int, string, bool) { } return nil, 0, label, true } + // The seen screen keeps the box row — its threads are the Imbox's, the number keys + // still work, and esc lands on the tab that stays highlighted — under its own label. label := "Mail" - if v.boxIndex >= 0 && v.boxIndex < len(v.boxes) { + if v.seenActive { + label = "Previously Seen" + if v.seenLoadingMore { + label += " · loading more…" + } + } else if v.boxIndex >= 0 && v.boxIndex < len(v.boxes) { label = terminal.SanitizeLine(v.boxes[v.boxIndex].Name) if v.postingPaging.loading { label += " · loading more…" @@ -950,6 +1064,14 @@ func (v *mailView) SubnavItems() ([]navItem, int, string, bool) { } } items := boxNavItems(boxes) + // The screen is the Imbox's, so its tab arrives with the boxes rather than + // standing alone while they load. + if v.imboxSource() != nil { + items = append(items, navItem{shortcut: "9", label: "Previously Seen"}) + if v.seenActive { + selected = len(items) - 1 + } + } if v.hasLabels() { items = append(items, navItem{shortcut: "L", label: "Labels"}) if v.currentSourceKind() == mail.KindFolder { @@ -1012,21 +1134,21 @@ func (v *mailView) SubnavLeft() tea.Cmd { return nil } tabIndexes := v.tabBoxIndexes() + if v.seenActive { + if len(tabIndexes) > 0 { + return v.switchBox(tabIndexes[len(tabIndexes)-1]) + } + return nil + } switch v.currentSourceKind() { case mail.KindCollection: if v.hasLabels() { v.openLabels() return nil } - if len(tabIndexes) > 0 { - return v.switchBox(tabIndexes[len(tabIndexes)-1]) - } - return nil + return v.openPreviouslySeen() case mail.KindFolder: - if len(tabIndexes) > 0 { - return v.switchBox(tabIndexes[len(tabIndexes)-1]) - } - return nil + return v.openPreviouslySeen() case mail.KindBox: for i, boxIndex := range tabIndexes { if boxIndex == v.boxIndex && i > 0 { @@ -1041,6 +1163,14 @@ func (v *mailView) SubnavRight() tea.Cmd { if v.searchActive || v.searchOpen() || v.bundleActive { return nil } + if v.seenActive { + if v.hasLabels() { + v.openLabels() + } else if v.hasCollections() { + v.openCollections() + } + return nil + } switch v.currentSourceKind() { case mail.KindFolder: if v.hasCollections() { @@ -1061,12 +1191,7 @@ func (v *mailView) SubnavRight() tea.Cmd { if i+1 < len(tabIndexes) { return v.switchBox(tabIndexes[i+1]) } - if v.hasLabels() { - v.openLabels() - } else if v.hasCollections() { - v.openCollections() - } - return nil + return v.openPreviouslySeen() } } return nil @@ -1165,6 +1290,36 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { return nil } + if v.seenActive { + switch msg.Key().Code { + case tea.KeyUp: + v.seenList.moveUp() + case tea.KeyDown: + v.seenList.moveDown() + return v.loadMoreSeenPostings() + case tea.KeyEnter: + return v.openSelected() + default: + switch msg.String() { + case "k": + v.seenList.moveUp() + case "j": + v.seenList.moveDown() + return v.loadMoreSeenPostings() + case "v", "V": + v.startMove() + return nil + case "b", "B": + return v.startFolderPicker() + case "n", "N": + return v.startCollectionPicker() + default: + return v.handlePostingAction(msg.String()) + } + } + return nil + } + switch msg.Key().Code { case tea.KeyUp: v.postingList.moveUp() @@ -1212,20 +1367,23 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { return nil } -func (v *mailView) InThread() bool { return v.inThread || v.searchActive || v.bundleActive } +func (v *mailView) InThread() bool { + return v.inThread || v.searchActive || v.bundleActive || v.seenActive +} func (v *mailView) ExitDetail(key string) { - if key == "q" && (v.searchActive || v.bundleActive) && !v.inThread && (v.requests.kind == mailRequestTopic || v.requests.kind == mailRequestSearch) { + if key == "q" && (v.searchActive || v.bundleActive || v.seenActive) && !v.inThread && (v.requests.kind == mailRequestTopic || v.requests.kind == mailRequestSearch) { v.requests.cancel() v.clearSearch() v.clearBundle() + v.clearSeen() return } v.ExitThread() } func (v *mailView) ExitThread() { - if (v.searchActive || v.bundleActive) && !v.inThread && (v.requests.kind == mailRequestTopic || v.requests.kind == mailRequestSearch) { + if (v.searchActive || v.bundleActive || v.seenActive) && !v.inThread && (v.requests.kind == mailRequestTopic || v.requests.kind == mailRequestSearch) { v.requests.cancel() return } @@ -1241,6 +1399,11 @@ func (v *mailView) ExitThread() { v.requests.cancel() return } + if v.seenActive { + v.clearSeen() + v.requests.cancel() + return + } v.clearSearch() v.requests.cancel() } @@ -1269,8 +1432,44 @@ func (v *mailView) clearBundle() { v.modal = nil } +func (v *mailView) clearSeen() { + v.seenActive = false + v.seenNextPage = "" + v.seenLoadingMore = false + v.seenMoreID++ + v.seenList.setPostings(nil) + v.notice = "" + v.modal = nil +} + +// applySeenPostingAction lands a thread action taken on the Previously Seen screen. A +// thread moved out of the Imbox, trashed, marked spam or marked unseen is not previously +// seen any more, so it leaves the screen, and what is below comes up to fill the gap. +func (v *mailView) applySeenPostingAction(msg postingActionDoneMsg) tea.Cmd { + if !v.seenActive { + return nil + } + if msg.err != nil { + return func() tea.Msg { return errMsg{msg.err} } + } + if idx := postingIndexIn(v.seenList.postings, msg.postingID); idx >= 0 { + switch msg.effect { + case postingActionNone: + case postingActionRemove, postingActionUnseen: + v.seenList.removeAt(idx) + case postingActionSeen: + v.seenList.markSeen(idx) + case postingActionIgnore: + v.seenList.postings[idx].Muted = true + case postingActionStopIgnoring: + v.seenList.postings[idx].Muted = false + } + } + return tea.Batch(notify(msg.action), v.loadMoreSeenPostings()) +} + 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 != mailRequestBundle && 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 != mailRequestSeen && v.requests.kind != mailRequestBulkReply { return false } v.requests.cancel() @@ -1300,6 +1499,7 @@ func (v *mailView) Resize(width, height int) { v.postingList.setSize(width, height) v.searchList.setSize(width, height) v.bundleList.setSize(width, height) + v.seenList.setSize(width, height) v.topicViewport.SetWidth(width) v.contentHeight = height v.fitThreadViewport() @@ -1349,18 +1549,30 @@ func (v *mailView) handleBoxShortcut(key string) tea.Cmd { v.openCollections() return func() tea.Msg { return nil } } + case "9": + return v.openPreviouslySeen() } return v.switchBox(boxForShortcut(key, v.boxes)) } func (v *mailView) switchBox(index int) tea.Cmd { - if index < 0 || index >= len(v.boxes) || index == v.boxIndex { + if index < 0 || index >= len(v.boxes) { + return nil + } + if index == v.boxIndex { + // The box under the seen screen: its number or tab closes the screen the + // way esc does, landing on the list that is already there. + if v.seenActive { + v.clearSeen() + v.requests.cancel() + } return nil } v.inThread = false v.threadNotice = "" v.clearSearch() v.clearBundle() + v.clearSeen() v.requests.cancel() v.notice = "" v.postingList.setPostings(nil) @@ -1368,6 +1580,32 @@ func (v *mailView) switchBox(index int) tea.Cmd { return v.requestPostings(v.boxes[index]) } +// openPreviouslySeen jumps to the Imbox's Previously Seen threads on their own screen, +// the web app's 9 shortcut. It opens over whichever source is on screen — the route is +// account-scoped, so nothing is asked of the current box — and esc returns there. The +// screen shows every seen thread flat, which is also the way to see what a covered +// Imbox hides. +func (v *mailView) openPreviouslySeen() tea.Cmd { + if v.seenActive { + return nil + } + v.inThread = false + v.threadNotice = "" + v.clearSearch() + v.clearBundle() + v.notice = "" + // The screen opens before its first page answers, the way a box switch does: the + // tab is selected there and then, so the ribbon reads on past it to Labels rather + // than asking for the screen again. + v.seenActive = true + v.seenList.setPostings(nil) + v.seenNextPage = "" + v.seenLoadingMore = false + v.seenMoreID++ + requestID, ctx := v.requests.begin(v.vc.ctx, mailRequestSeen) + return v.fetchSeenPostings(ctx, requestID) +} + func (v *mailView) currentSource() *mail.Source { if v.boxIndex < 0 || v.boxIndex >= len(v.boxes) { return nil @@ -1604,14 +1842,33 @@ func (v *mailView) loadMoreBundlePostings() tea.Cmd { return v.fetchMoreBundlePostings(v.vc.ctx, v.bundleMoreID, v.bundlePostingID, v.bundleNextPage) } +// loadMoreSeenPostings reads the page of seen threads below the ones the reader has +// scrolled to, or below threads they can already see the end of. +func (v *mailView) loadMoreSeenPostings() tea.Cmd { + if !v.seenActive || v.seenLoadingMore || v.seenNextPage == "" { + return nil + } + if v.seenList.hasRowsBelow() && len(v.seenList.postings)-v.seenList.cursor > loadMoreThreshold { + return nil + } + + v.seenLoadingMore = true + v.seenMoreID++ + return v.fetchMoreSeenPostings(v.vc.ctx, v.seenMoreID, v.seenNextPage) +} + 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) } func (v *mailView) postingIndex(postingID int64) int { - for i := range v.postingList.postings { - if v.postingList.postings[i].ID == postingID { + return postingIndexIn(v.postingList.postings, postingID) +} + +func postingIndexIn(postings []mail.Posting, postingID int64) int { + for i := range postings { + if postings[i].ID == postingID { return i } } @@ -1619,14 +1876,7 @@ func (v *mailView) postingIndex(postingID int64) int { } func (v *mailView) removePostingAt(index int) { - if index < 0 || index >= len(v.postingList.postings) { - return - } - v.postingList.postings = append(v.postingList.postings[:index], v.postingList.postings[index+1:]...) - if v.postingList.cursor > index { - v.postingList.cursor-- - } - v.postingList.settleCover() + v.postingList.removeAt(index) } func (v *mailView) moveAttachmentCursor(delta int) { @@ -1735,6 +1985,9 @@ func (v *mailView) openSelected() tea.Cmd { if v.searchActive { selected = v.searchList.selectedPosting() } + if v.seenActive { + selected = v.seenList.selectedPosting() + } if v.bundleActive { selected = v.bundleList.selectedPosting() } @@ -1788,6 +2041,9 @@ func (v *mailView) openedPosting(postingID int64) *mail.Posting { if v.searchActive { list = &v.searchList } + if v.seenActive { + list = &v.seenList + } if v.bundleActive { list = &v.bundleList } @@ -1802,8 +2058,8 @@ func (v *mailView) openedPosting(postingID int64) *mail.Posting { // --- Posting actions --- func (v *mailView) startMove() { - selected := v.postingList.selectedPosting() - currentSource := v.currentSource() + selected := v.actionList().selectedPosting() + currentSource := v.actionSource() if selected == nil || currentSource == nil { return } @@ -1844,7 +2100,7 @@ func (v *mailView) startFolderPicker() tea.Cmd { v.notice = "Retrying labels…" return v.requestSources() } - selected := v.postingList.selectedPosting() + selected := v.actionList().selectedPosting() if selected == nil { return nil } @@ -1893,7 +2149,7 @@ func (v *mailView) startCollectionPicker() tea.Cmd { v.notice = "Retrying collections…" return v.requestSources() } - selected := v.postingList.selectedPosting() + selected := v.actionList().selectedPosting() if selected == nil { return nil } @@ -1922,6 +2178,7 @@ func (v *mailView) removePostingFromCollection(postingID, topicID int64, collect func (v *mailView) doCollectionAction(label string, postingID, topicID int64, collection mail.Collection, added bool) tea.Cmd { sourceID, sourceKind := v.currentSourceIdentity() + seen := v.seenActive v.pendingMutations++ return func() tea.Msg { var err error @@ -1937,24 +2194,25 @@ func (v *mailView) doCollectionAction(label string, postingID, topicID int64, co postingID: postingID, collection: collection, added: added, + seen: seen, err: err, } } } -func (v *mailView) updatePostingCollection(index int, collection mail.Collection, added bool) { - memberships := v.postingList.postings[index].Collections +func updatePostingCollection(list *contentList, index int, collection mail.Collection, added bool) { + memberships := list.postings[index].Collections for i, membership := range memberships { if membership.ID != collection.ID { continue } if !added { - v.postingList.postings[index].Collections = append(memberships[:i], memberships[i+1:]...) + list.postings[index].Collections = append(memberships[:i], memberships[i+1:]...) } return } if added { - v.postingList.postings[index].Collections = append(memberships, collection) + list.postings[index].Collections = append(memberships, collection) } } @@ -1964,8 +2222,36 @@ func (v *mailView) movePostingToBox(postingID int64, destination mail.Source) te }) } +// actionList is the list a thread action works on: the Previously Seen screen's list +// while it is open, the box list otherwise. Search results and bundles navigate only. +func (v *mailView) actionList() *contentList { + if v.seenActive { + return &v.seenList + } + return &v.postingList +} + +// actionSource is the box a thread action files out of: the Imbox while the Previously +// Seen screen is open — its threads are the Imbox's whatever source the screen was +// opened over — and the source on screen otherwise. +func (v *mailView) actionSource() *mail.Source { + if v.seenActive { + return v.imboxSource() + } + return v.currentSource() +} + +func (v *mailView) imboxSource() *mail.Source { + for i := range v.boxes { + if v.boxes[i].Kind == mail.KindBox && v.boxes[i].BoxKind == hey.BoxKindImbox { + return &v.boxes[i] + } + } + return nil +} + func (v *mailView) handlePostingAction(key string) tea.Cmd { - selected := v.postingList.selectedPosting() + selected := v.actionList().selectedPosting() if selected == nil { return nil } @@ -2048,13 +2334,11 @@ func (v *mailView) handlePostingAction(key string) tea.Cmd { } func (v *mailView) moveSelectedToImbox(boxID, postingID int64) tea.Cmd { - for _, source := range v.boxes { - if source.Kind == mail.KindBox && source.BoxKind == hey.BoxKindImbox { - imboxID := source.ID - return v.moveSelectedToKnownBox("Imbox", hey.BoxKindImbox, boxID, postingID, func() error { - return v.vc.sdk.Postings().Move(v.vc.ctx, imboxID, postingID) - }) - } + if source := v.imboxSource(); source != nil { + imboxID := source.ID + return v.moveSelectedToKnownBox("Imbox", hey.BoxKindImbox, boxID, postingID, func() error { + return v.vc.sdk.Postings().Move(v.vc.ctx, imboxID, postingID) + }) } v.notice = "Imbox is unavailable" return nil @@ -2069,6 +2353,9 @@ func (v *mailView) moveSelectedToKnownBox(name, kind string, boxID, postingID in } func (v *mailView) boxMoveEffect() postingActionEffect { + if v.seenActive { + return postingActionRemove + } if isOrganizedMailSource(v.currentSourceKind()) { return postingActionNone } @@ -2079,7 +2366,7 @@ func (v *mailView) boxMoveEffect() postingActionEffect { // at all. The destination is one of HEY's own box kinds, so it is the box's kind that // answers — a label or a collection carries none and is never the destination. func (v *mailView) movesOutOfCurrentBox(destinationBoxKind string) bool { - source := v.currentSource() + source := v.actionSource() if source == nil { return true } @@ -2088,6 +2375,7 @@ func (v *mailView) movesOutOfCurrentBox(destinationBoxKind string) bool { func (v *mailView) doPostingAction(label string, effect postingActionEffect, boxID, postingID int64, fn func() error) tea.Cmd { sourceKind := v.currentSourceKind() + seen := v.seenActive v.pendingMutations++ return func() tea.Msg { err := fn() @@ -2097,6 +2385,7 @@ func (v *mailView) doPostingAction(label string, effect postingActionEffect, box sourceKind: sourceKind, postingID: postingID, effect: effect, + seen: seen, err: err, } } @@ -2307,6 +2596,32 @@ func (v *mailView) readBundlePage(ctx context.Context, postingID int64, cursor s return mail.Postings(page.Postings), "New from " + terminal.SanitizeLine(page.Contact.Name), page.NextPage, nil } +func (v *mailView) fetchSeenPostings(ctx context.Context, requestID uint64) tea.Cmd { + return func() tea.Msg { + postings, nextPage, err := v.readSeenPage(ctx, "") + return seenLoadedMsg{requestID: requestID, postings: postings, nextPage: nextPage, err: err} + } +} + +// fetchMoreSeenPostings reads the page of seen threads below the ones on screen, in the +// growing lane and without the spinner. +func (v *mailView) fetchMoreSeenPostings(ctx context.Context, requestID uint64, cursor string) tea.Cmd { + return func() tea.Msg { + postings, nextPage, err := v.readSeenPage(ctx, cursor) + return seenAppendedMsg{requestID: requestID, postings: postings, nextPage: nextPage, err: err} + } +} + +// readSeenPage reads one page of the Imbox's Previously Seen threads on their own +// route, where HEY orders them by when they were seen. +func (v *mailView) readSeenPage(ctx context.Context, cursor string) ([]mail.Posting, string, error) { + page, err := mail.ReadSeenPage(ctx, v.vc.sdk, cursor) + if err != nil { + return nil, "", err + } + return mail.Postings(page.Postings), page.Cursor, 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, "") diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 0a60b1e1..7f9bc306 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -121,6 +121,12 @@ 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 "/imbox/seen.json": + if r.URL.Query().Get("page") == "" { + _, _ = w.Write([]byte(`{"id":1,"postings":[{"id":611,"kind":"topic","name":"Weekly team sync notes","seen":true,"app_url":"https://app.hey.com/topics/100","created_at":"2026-08-20T09:00:00Z","creator":{"id":21,"name":"Claire Lee"}}],"next_history_url":"/imbox?page=seen-page-2"}`)) + } else { + _, _ = w.Write([]byte(`{"id":1,"postings":[{"id":612,"kind":"topic","name":"Invoice #2041 from Fastmail","seen":true,"app_url":"https://app.hey.com/topics/101","created_at":"2026-08-18T15:00:00Z","creator":{"id":22,"name":"Fastmail Billing"}}]}`)) + } 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": @@ -919,8 +925,11 @@ func TestMailViewLoadsFolderSourcesAndPostings(t *testing.T) { t.Fatalf("mail sources = %+v", v.boxes) } + if cmd := v.SubnavRight(); cmd == nil || !v.seenActive { + t.Fatal("moving right past the last box should land on Previously Seen") + } if cmd := v.SubnavRight(); cmd != nil || labelsModal(v) == nil { - t.Fatal("moving right past the last box should open the Labels picker") + t.Fatal("moving right past Previously Seen should open the Labels picker") } folderCmd := v.HandleContentKey(keyPress("enter")) folderLoaded, ok := runCmd(folderCmd).(postingsLoadedMsg) @@ -970,7 +979,8 @@ func TestMailViewFolderGrowsAsTheReaderScrolls(t *testing.T) { vc.sdk = client v := newMailView(vc) v.Update(runCmd(v.Init())) - v.SubnavRight() + v.SubnavRight() // Previously Seen + v.SubnavRight() // Labels first := runCmd(v.HandleContentKey(keyPress("enter"))).(postingsLoadedMsg) more, _ := v.Update(first) if v.postingPaging.nextPage != "next-cursor" { @@ -2154,8 +2164,8 @@ func TestMailViewSubnavItems(t *testing.T) { v := mailWithPostings() items, selected, label, centered := v.SubnavItems() - if len(items) != 3 { - t.Errorf("expected 3 subnav items, got %d", len(items)) + if len(items) != 4 || items[3].label != "Previously Seen" || items[3].shortcut != "9" { + t.Errorf("expected the boxes and Previously Seen, got %+v", items) } if selected != 0 { t.Errorf("selected = %d, want 0", selected) @@ -2957,17 +2967,21 @@ func TestMailViewLabelsTabAndPicker(t *testing.T) { if last := items[len(items)-1]; last.label != "Labels" || last.shortcut != "L" { t.Fatalf("the last tab should be Labels with the L shortcut: %+v", items) } - if len(items) != len(testBoxes())+1 { + if len(items) != len(testBoxes())+2 { t.Errorf("labels should not appear as their own tabs: %+v", items) } if selected != 0 { t.Errorf("selected tab = %d, want 0", selected) } - // Moving right from the last box opens the picker instead of switching. + // Moving right from the last box lands on Previously Seen; right again opens + // the picker instead of switching. v.boxIndex = len(v.tabBoxIndexes()) - 1 + if cmd := v.SubnavRight(); cmd == nil || !v.seenActive { + t.Fatal("moving right past the last box should land on Previously Seen") + } if cmd := v.SubnavRight(); cmd != nil || labelsModal(v) == nil { - t.Fatal("moving right past the last box should open the picker") + t.Fatal("moving right past Previously Seen should open the picker") } if !v.CapturingInput() { t.Error("the open picker should capture input") @@ -3003,9 +3017,13 @@ func TestMailViewLabelsTabAndPicker(t *testing.T) { t.Error("escape should close the picker and keep the current label") } - // Left from the Labels tab returns to the last box tab. - if cmd := v.SubnavLeft(); cmd == nil || v.currentSourceKind() == mail.KindFolder { - t.Error("left from Labels should return to the last box tab") + // Left from the Labels tab lands on Previously Seen, and left again on the + // last box tab. + if cmd := v.SubnavLeft(); cmd == nil || !v.seenActive { + t.Error("left from Labels should land on Previously Seen") + } + if cmd := v.SubnavLeft(); cmd == nil || v.seenActive || v.currentSourceKind() != mail.KindBox { + t.Error("left from Previously Seen should return to the last box tab") } // Shift+L opens the Labels picker, the way Shift+K opens Collections. @@ -3367,6 +3385,247 @@ func TestMailViewReplyOnABundleOpensIt(t *testing.T) { } } +func TestMailViewJumpsToPreviouslySeen(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + + loaded, ok := runCmd(v.handleBoxShortcut("9")).(seenLoadedMsg) + if !ok || loaded.err != nil { + t.Fatalf("jumping to Previously Seen returned %#v", loaded) + } + if recorded.path != "/imbox/seen.json" { + t.Errorf("read %s, want /imbox/seen.json", recorded.path) + } + more, _ := v.Update(loaded) + if !v.seenActive { + t.Fatal("9 should open the Previously Seen screen") + } + if len(v.seenList.postings) != 1 || v.seenList.postings[0].TopicID != 100 { + t.Fatalf("seen postings = %+v", v.seenList.postings) + } + // The list is shorter than the window with a page below, so it grows at once, + // following the cursor out of next_history_url back into the seen route. + appended, ok := runCmd(more).(seenAppendedMsg) + if !ok || appended.err != nil { + t.Fatalf("growing the seen list returned %#v", appended) + } + if recorded.path != "/imbox/seen.json" || recorded.rawQueries[len(recorded.rawQueries)-1] != "page=seen-page-2" { + t.Errorf("read %s?%s, want /imbox/seen.json?page=seen-page-2", recorded.path, recorded.rawQueries[len(recorded.rawQueries)-1]) + } + v.Update(appended) + if len(v.seenList.postings) != 2 || v.seenList.postings[1].TopicID != 101 { + t.Fatalf("grown seen postings = %+v", v.seenList.postings) + } + if v.seenNextPage != "" { + t.Errorf("nextPage = %q, want none after the last page", v.seenNextPage) + } + // The box row stays under the screen's own label, with the screen's own tab — + // after the boxes, before Labels — selected. + if items, selected, label, _ := v.SubnavItems(); label != "Previously Seen" || selected != len(items)-1 || items[selected].label != "Previously Seen" { + t.Errorf("subnav = %+v, selected %d, label %q", items, selected, label) + } + + // Enter on a thread opens it the normal way, and esc steps back out through + // the seen screen to the box. + topic, ok := runCmd(v.HandleContentKey(keyPress("enter"))).(topicLoadedMsg) + if !ok || topic.err != nil || topic.topicID != 100 { + t.Fatalf("opening a seen thread returned %#v", topic) + } + v.Update(topic) + if !v.inThread { + t.Fatal("a seen thread should open as a thread") + } + v.ExitThread() + if v.inThread || !v.seenActive { + t.Fatalf("leaving the thread should return to the seen screen: inThread %v seenActive %v", v.inThread, v.seenActive) + } + v.ExitThread() + if v.seenActive { + t.Fatal("leaving the seen screen should return to the box list") + } +} + +func TestMailViewPreviouslySeenIsANoOpWhileOpen(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + + loaded, _ := runCmd(v.handleBoxShortcut("9")).(seenLoadedMsg) + v.Update(loaded) + if cmd := v.handleBoxShortcut("9"); cmd != nil { + t.Error("9 on the seen screen should do nothing") + } +} + +func TestMailViewBoxShortcutClosesPreviouslySeen(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + + loaded, _ := runCmd(v.handleBoxShortcut("9")).(seenLoadedMsg) + v.Update(loaded) + if cmd := v.handleBoxShortcut("2"); cmd == nil { + t.Fatal("a box shortcut should leave the seen screen for the box") + } + if v.seenActive { + t.Error("switching boxes should close the seen screen") + } +} + +func TestMailViewOwnBoxShortcutClosesPreviouslySeen(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + + loaded, _ := runCmd(v.handleBoxShortcut("9")).(seenLoadedMsg) + v.Update(loaded) + v.handleBoxShortcut("1") // the box the screen was opened over + if v.seenActive { + t.Error("the box's own shortcut should close the seen screen") + } + if len(v.postingList.postings) != 2 { + t.Errorf("box postings = %+v", v.postingList.postings) + } +} + +func TestMailViewSubnavStepsOutOfPreviouslySeen(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + + loaded, _ := runCmd(v.handleBoxShortcut("9")).(seenLoadedMsg) + v.Update(loaded) + if cmd := v.SubnavLeft(); cmd == nil { + t.Fatal("subnav left should leave the seen screen for the last box") + } + if v.seenActive { + t.Error("subnav navigation should close the seen screen") + } +} + +func TestMailViewSaysWhyTheSeenScreenIsEmpty(t *testing.T) { + v := mailWithPostings() + v.seenActive = true + v.seenList.setPostings(nil) + + if view := v.View(); !strings.Contains(view, "Nothing has been seen yet") { + t.Errorf("empty seen view = %q", view) + } +} + +func TestMailViewSeenScreenSurvivesAStaleAppend(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + + loaded, _ := runCmd(v.handleBoxShortcut("9")).(seenLoadedMsg) + v.Update(loaded) + stale := seenAppendedMsg{requestID: v.seenMoreID - 1, postings: []mail.Posting{{ID: 699}}} + v.Update(stale) + for _, posting := range v.seenList.postings { + if posting.ID == 699 { + t.Fatal("a stale append should not grow the seen list") + } + } +} + +func TestMailViewTriagesFromTheSeenScreen(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + loaded, _ := runCmd(v.handleBoxShortcut("9")).(seenLoadedMsg) + more, _ := v.Update(loaded) + appended, _ := runCmd(more).(seenAppendedMsg) + v.Update(appended) + if len(v.seenList.postings) != 2 { + t.Fatalf("seen postings = %+v", v.seenList.postings) + } + + // A seen thread is already in the Imbox, so i has nowhere to move it. + if cmd := v.HandleContentKey(keyPress("i")); cmd != nil || v.notice != "Already in Imbox" { + t.Fatalf("i on the seen screen: cmd %v notice %q", cmd, v.notice) + } + + // Ignoring marks the row and keeps it; stop ignoring clears it. + muted, ok := runCmd(v.HandleContentKey(keyPress("-"))).(postingActionDoneMsg) + if !ok || muted.err != nil || !muted.seen { + t.Fatalf("ignoring returned %#v", muted) + } + v.Update(muted) + if len(v.seenList.postings) != 2 || !v.seenList.postings[0].Muted { + t.Fatalf("ignored seen postings = %+v", v.seenList.postings) + } + unmuted, _ := runCmd(v.HandleContentKey(keyPress("+"))).(postingActionDoneMsg) + v.Update(unmuted) + if v.seenList.postings[0].Muted { + t.Fatal("stop ignoring should clear the mark") + } + + // Setting aside moves the thread out of the Imbox, so it leaves the screen. + aside, ok := runCmd(v.HandleContentKey(keyPress("a"))).(postingActionDoneMsg) + if !ok || aside.err != nil || !aside.seen || aside.effect != postingActionRemove { + t.Fatalf("set aside returned %#v", aside) + } + v.Update(aside) + if len(v.seenList.postings) != 1 || v.seenList.postings[0].ID != 612 { + t.Fatalf("seen postings after set aside = %+v", v.seenList.postings) + } + + // Marked unseen, a thread is not previously seen any more. + unseen, ok := runCmd(v.HandleContentKey(keyPress("u"))).(postingActionDoneMsg) + if !ok || unseen.err != nil || unseen.effect != postingActionUnseen { + t.Fatalf("mark unseen returned %#v", unseen) + } + v.Update(unseen) + if len(v.seenList.postings) != 0 { + t.Fatalf("seen postings after unseen = %+v", v.seenList.postings) + } + + // The box list underneath was never touched. + if len(v.postingList.postings) != 2 { + t.Errorf("box postings = %+v", v.postingList.postings) + } +} + +func TestMailViewSeenScreenActionAfterClosingLandsNowhere(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + loaded, _ := runCmd(v.handleBoxShortcut("9")).(seenLoadedMsg) + v.Update(loaded) + + done, ok := runCmd(v.HandleContentKey(keyPress("a"))).(postingActionDoneMsg) + if !ok || !done.seen { + t.Fatalf("set aside returned %#v", done) + } + v.ExitThread() + v.Update(done) + if len(v.postingList.postings) != 2 { + t.Errorf("a seen-screen action landed on the box list: %+v", v.postingList.postings) + } +} + +func TestMailViewMovePickerOnTheSeenScreenLeavesTheImboxOut(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.SubnavRight() // The Feed under the screen, so the Imbox would otherwise be offered + loaded, _ := runCmd(v.handleBoxShortcut("9")).(seenLoadedMsg) + v.Update(loaded) + + v.HandleContentKey(keyPress("v")) + picker := modalOf[*movePicker](v) + if picker == nil { + t.Fatal("v on the seen screen should open the move picker") + } + for _, destination := range picker.destinations { + if destination.BoxKind == hey.BoxKindImbox { + t.Error("the move picker should not offer the Imbox to a seen thread") + } + } +} + +func TestMailViewHelpBindingsNamePreviouslySeen(t *testing.T) { + v := mailWithPostings() + if !hasHelpBinding(v.HelpBindings(), "9") { + t.Error("the list help should name 9") + } + + v.seenActive = true + bindings := v.HelpBindings() + for _, key := range []string{"enter", "a", "l", "u", "t", "v", "b", "n"} { + if !hasHelpBinding(bindings, key) { + t.Errorf("seen screen help misses %q: %+v", key, bindings) + } + } + if hasHelpBinding(bindings, "9") { + t.Error("the seen screen help should not name 9") + } +} + func TestMailViewRefusesAPostingWithoutAThread(t *testing.T) { v := mailWithPostings() v.postingList.postings[0].TopicID = 0 diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 35b8bd04..c4a0dd43 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -634,7 +634,11 @@ func TestSubnavNavigationUpdatesHelpWhenItOpensLabels(t *testing.T) { m.focus = rowSubnav m.updateHelpBindings() - updated, _ := m.Update(keyPress("right")) + updated, _ := m.Update(keyPress("right")) // Previously Seen + m = updated.(model) + updated, _ = m.Update(seenLoadedMsg{requestID: m.mailView.requests.id}) + m = updated.(model) + updated, _ = m.Update(keyPress("right")) // Labels m = updated.(model) if labelsModal(m.mailView) == nil { From 31140a33e8134387f9aade861852f571005fbc44 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 26 Aug 2026 18:43:56 +0200 Subject: [PATCH 2/3] Use hey-sdk v0.28.0 for Previously Seen --- go.mod | 2 +- go.sum | 4 ++-- nix/package.nix | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 86649ba8..a51269c6 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.27.0 + github.com/basecamp/hey-sdk/go v0.28.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 b23c620b..74b1ab42 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.27.0 h1:LAX80V/WqV3hz9c3xqXcm6/2f0Qd2n5YxBIJ/qK6fF0= -github.com/basecamp/hey-sdk/go v0.27.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk= +github.com/basecamp/hey-sdk/go v0.28.0 h1:N3sNaELGngFuEW9cAWner+mwhWH734Fjru/PcTdLIUg= +github.com/basecamp/hey-sdk/go v0.28.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/nix/package.nix b/nix/package.nix index dd4a2e82..674ec470 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-oOabp2C4PLdPvOTxvMUj7/XcU+pWVJwB2JRt0Eu6CRs="; + vendorHash = "sha256-NcBIegczL+UYiHTTVa/fnhiur2lWvUH/+lOzSmmx1Io="; subPackages = [ "cmd/hey" ]; From 3a3eae725abecdf1b4d012401730ca8893e27e15 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 26 Aug 2026 15:32:55 -0400 Subject: [PATCH 3/3] Support bulk replies from Previously Seen --- API-COVERAGE.md | 1 + internal/tui/bulk_reply.go | 12 +++++-- internal/tui/bulk_reply_test.go | 61 +++++++++++++++++++++++++++++++-- internal/tui/mail.go | 25 +++++++++++--- internal/tui/mail_test.go | 2 +- 5 files changed, 90 insertions(+), 11 deletions(-) diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 1c537bbc..1eb3ce54 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -16,6 +16,7 @@ which is what `Entries().ListDraftsPage` and `hey draft list --page` exist for. | `/boxes.json` | GET | SDK `Boxes().List` | `hey box list` | covered | | `/boxes/{id}.json` | GET | SDK `Boxes().Get`, `Boxes().GetPage` | `hey box view `, TUI mail list growth | covered | | `/imbox.json` | GET | SDK `Boxes().GetImbox` | `hey box view imbox` | covered | +| `/imbox/seen.json` | GET | SDK `Boxes().GetImboxSeen` | TUI Previously Seen screen | covered | | `/feedbox.json` | GET | SDK `Boxes().GetFeedbox` | `hey box view feedbox` | covered | | `/trailbox.json` | GET | SDK `Boxes().GetTrailbox` | `hey box view trailbox` | covered | | `/asidebox.json` | GET | SDK `Boxes().GetAsidebox` | `hey box view asidebox` | covered | diff --git a/internal/tui/bulk_reply.go b/internal/tui/bulk_reply.go index 84cac09c..3c5f7f9c 100644 --- a/internal/tui/bulk_reply.go +++ b/internal/tui/bulk_reply.go @@ -21,6 +21,7 @@ type bulkReplyDraftLoadedMsg struct { requestID uint64 boxID int64 postingIDs []int64 + seen bool draft *generated.BulkReplyDraft err error } @@ -28,6 +29,7 @@ type bulkReplyDraftLoadedMsg struct { type bulkReplySentMsg struct { delivery *generated.BulkReplyDelivery skipped int + seen bool err error } @@ -39,6 +41,7 @@ type bulkReplyUndoneMsg struct { type bulkReplyForm struct { postingIDs []int64 draft generated.BulkReplyDraft + seen bool composing bool preview viewport.Model body textarea.Model @@ -50,9 +53,10 @@ type bulkReplyForm struct { height int } -func newBulkReplyForm(postingIDs []int64, draft *generated.BulkReplyDraft, s styles) *bulkReplyForm { +func newBulkReplyForm(postingIDs []int64, draft *generated.BulkReplyDraft, seen bool, s styles) *bulkReplyForm { form := &bulkReplyForm{ postingIDs: append([]int64(nil), postingIDs...), + seen: seen, styles: s, } if draft != nil { @@ -281,13 +285,14 @@ func replyNoun(count int) string { } func (v *mailView) startBulkReply() tea.Cmd { - postingIDs := v.postingList.selectedIDs() + postingIDs := v.actionList().selectedIDs() if len(postingIDs) == 0 { v.notice = "Select threads with space before starting a bulk reply" return nil } requestID, ctx := v.requests.begin(v.vc.ctx, mailRequestBulkReply) boxID := v.currentBoxID() + seen := v.seenActive return func() tea.Msg { draft, err := v.vc.sdk.BulkReplies().Draft(ctx, postingIDs) if err != nil && hey.AsError(err).Code == hey.CodeNotFound { @@ -298,6 +303,7 @@ func (v *mailView) startBulkReply() tea.Cmd { requestID: requestID, boxID: boxID, postingIDs: postingIDs, + seen: seen, draft: draft, err: err, } @@ -313,7 +319,7 @@ func (v *mailView) sendBulkReply(form *bulkReplyForm) tea.Cmd { skipped := len(form.postingIDs) - len(entryIDs) return func() tea.Msg { delivery, err := v.vc.sdk.BulkReplies().Send(v.vc.ctx, entryIDs, content) - return bulkReplySentMsg{delivery: delivery, skipped: max(skipped, 0), err: err} + return bulkReplySentMsg{delivery: delivery, skipped: max(skipped, 0), seen: form.seen, err: err} } } diff --git a/internal/tui/bulk_reply_test.go b/internal/tui/bulk_reply_test.go index 5ff6ec77..5c39e62c 100644 --- a/internal/tui/bulk_reply_test.go +++ b/internal/tui/bulk_reply_test.go @@ -162,6 +162,61 @@ func TestTUIBulkReplySelectionAndPreviewShowExactRecipients(t *testing.T) { } } +func TestTUIBulkReplyUsesThePreviouslySeenSelection(t *testing.T) { + view, _ := tuiBulkReplyServer(t) + view.postingList.toggleSelected() + view.seenActive = true + view.seenList.setPostings(testPostings()) + selectTwoThreads(view) + + if selected := view.seenList.selectedIDs(); !slices.Equal(selected, []int64{100, 101}) { + t.Fatalf("Previously Seen selected IDs = %v", selected) + } + if selected := view.postingList.selectedIDs(); !slices.Equal(selected, []int64{100}) { + t.Fatalf("box selected IDs = %v", selected) + } + + loaded, ok := runCmd(view.HandleContentKey(keyPress("ctrl+b"))).(bulkReplyDraftLoadedMsg) + if !ok || loaded.err != nil || !loaded.seen { + t.Fatalf("Previously Seen draft returned %#v", loaded) + } + view.Update(loaded) + if form := bulkReplyModal(view); form == nil || !form.seen { + t.Fatalf("Previously Seen bulk reply form = %#v", form) + } + + view.HandleContentKey(keyPress("enter")) + typeText(view, "Thanks everyone") + sent, ok := runCmd(view.HandleContentKey(ctrlS())).(bulkReplySentMsg) + if !ok || sent.err != nil || !sent.seen { + t.Fatalf("Previously Seen send returned %#v", sent) + } + view.Update(sent) + if selected := view.seenList.selectedIDs(); len(selected) != 0 { + t.Errorf("Previously Seen selection after send = %v", selected) + } + if selected := view.postingList.selectedIDs(); !slices.Equal(selected, []int64{100}) { + t.Errorf("bulk reply cleared the box selection: %v", selected) + } + if !hasHelpBinding(view.HelpBindings(), "ctrl+u") { + t.Errorf("Previously Seen help does not offer undo: %v", view.HelpBindings()) + } +} + +func TestTUIBulkReplyDraftStaysWithItsOriginatingScreen(t *testing.T) { + view, _ := tuiBulkReplyServer(t) + view.seenActive = true + view.seenList.setPostings(testPostings()) + view.HandleContentKey(keyPress("space")) + loaded := runCmd(view.HandleContentKey(keyPress("ctrl+b"))).(bulkReplyDraftLoadedMsg) + + view.seenActive = false + view.Update(loaded) + if bulkReplyModal(view) != nil { + t.Fatal("a Previously Seen draft should not open over the box list") + } +} + func TestTUIBulkReplyPreviewScrollsThroughEveryRecipient(t *testing.T) { entries := make([]generated.BulkReplyEntry, 12) for i := range entries { @@ -175,7 +230,7 @@ func TestTUIBulkReplyPreviewScrollsThroughEveryRecipient(t *testing.T) { }}}, } } - form := newBulkReplyForm([]int64{100, 101}, &generated.BulkReplyDraft{Entries: entries}, newStyles()) + form := newBulkReplyForm([]int64{100, 101}, &generated.BulkReplyDraft{Entries: entries}, false, newStyles()) form.resize(40, 8) if strings.Contains(form.view(), "recipient-12@example.com") { @@ -200,7 +255,7 @@ func TestTUIBulkReplyPreviewWrapsLongRecipientsWithoutDroppingText(t *testing.T) TopicId: 701, TopicName: "A complete safety preview", Addressed: generated.Addressed{Directly: []generated.Contact{{Id: 901, EmailAddress: email}}}, - }}}, newStyles()) + }}}, false, newStyles()) content := form.previewContent(24) compacted := strings.NewReplacer("\n", "", " ", "").Replace(content) @@ -369,7 +424,7 @@ func TestTUIBulkReplyCanCancelPreviewAndEditor(t *testing.T) { } func TestBulkReplyFormRequiresBodyBeforeSend(t *testing.T) { - form := newBulkReplyForm([]int64{100}, nil, newStyles()) + form := newBulkReplyForm([]int64{100}, nil, false, newStyles()) form.draft.Entries = append(form.draft.Entries, generated.BulkReplyEntry{Id: 501}) form.composing = true if cmd, _ := form.handleKey(newMailView(testVC()), tea.KeyPressMsg(tea.Key{Code: 's', Mod: tea.ModCtrl})); cmd != nil { diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 5abe7c7b..ac9482a8 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -567,7 +567,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { return form.init(), true case bulkReplyDraftLoadedMsg: - if !v.requests.accepts(newRequestResult(msg.requestID, msg.err)) || msg.boxID != v.currentBoxID() { + if !v.requests.accepts(newRequestResult(msg.requestID, msg.err)) || msg.boxID != v.currentBoxID() || msg.seen != v.seenActive { return nil, true } v.requests.finish(msg.requestID) @@ -578,7 +578,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { if msg.draft == nil || len(msg.draft.Entries) == 0 { return notify("No replyable threads found; nothing was sent"), true } - form := newBulkReplyForm(msg.postingIDs, msg.draft, v.vc.styles) + form := newBulkReplyForm(msg.postingIDs, msg.draft, msg.seen, v.vc.styles) v.openModal(form) return form.init(), true @@ -600,7 +600,11 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { return nil, true } v.modal = nil - v.postingList.clearSelected() + if msg.seen { + v.seenList.clearSelected() + } else { + v.postingList.clearSelected() + } count := int(msg.delivery.EntriesCount) sent := fmt.Sprintf("%d bulk %s sent", count, replyNoun(count)) v.lastBulkReplyID = 0 @@ -949,8 +953,10 @@ func (v *mailView) HelpBindings() []helpBinding { if selected := v.seenList.selectedPosting(); selected != nil && selected.Muted { ignoreBinding = helpBinding{"+", "stop ignoring"} } - return []helpBinding{ + bindings := []helpBinding{ {"enter", "open"}, + {"space", "select"}, + {"ctrl+b", "bulk reply"}, {"r", "reply"}, {"f", "forward"}, {"v", "move"}, @@ -965,6 +971,10 @@ func (v *mailView) HelpBindings() []helpBinding { {"!", "spam"}, ignoreBinding, } + if v.lastBulkReplyID != 0 { + bindings = append(bindings, helpBinding{"ctrl+u", "undo bulk reply"}) + } + return modifiersLast(bindings) } ignoreBinding := helpBinding{"-", "ignore"} if selected := v.postingList.selectedPosting(); selected != nil && selected.Muted { @@ -1306,6 +1316,13 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { case "j": v.seenList.moveDown() return v.loadMoreSeenPostings() + case " ", "space": + v.seenList.toggleSelected() + return nil + case "ctrl+b": + return v.startBulkReply() + case "ctrl+u": + return v.undoBulkReply() case "v", "V": v.startMove() return nil diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 7f9bc306..8d1ea5c4 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -3616,7 +3616,7 @@ func TestMailViewHelpBindingsNamePreviouslySeen(t *testing.T) { v.seenActive = true bindings := v.HelpBindings() - for _, key := range []string{"enter", "a", "l", "u", "t", "v", "b", "n"} { + for _, key := range []string{"enter", "space", "ctrl+b", "a", "l", "u", "t", "v", "b", "n"} { if !hasHelpBinding(bindings, key) { t.Errorf("seen screen help misses %q: %+v", key, bindings) }