Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`, 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 |
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ linked account before it opens. An integration that owns a dedicated TUI window
same `--instance <name>` 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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
25 changes: 25 additions & 0 deletions internal/mail/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Comment thread
robzolkos marked this conversation as resolved.
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 {
Expand Down
59 changes: 59 additions & 0 deletions internal/mail/page_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 9 additions & 3 deletions internal/tui/bulk_reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ type bulkReplyDraftLoadedMsg struct {
requestID uint64
boxID int64
postingIDs []int64
seen bool
draft *generated.BulkReplyDraft
err error
}

type bulkReplySentMsg struct {
delivery *generated.BulkReplyDelivery
skipped int
seen bool
err error
}

Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -298,6 +303,7 @@ func (v *mailView) startBulkReply() tea.Cmd {
requestID: requestID,
boxID: boxID,
postingIDs: postingIDs,
seen: seen,
draft: draft,
err: err,
}
Expand All @@ -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}
}
}

Expand Down
61 changes: 58 additions & 3 deletions internal/tui/bulk_reply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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") {
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 6 additions & 3 deletions internal/tui/collections_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -575,15 +578,15 @@ 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)
}
}

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)
}
Expand Down
13 changes: 13 additions & 0 deletions internal/tui/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading