diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 6f553bbb..be989410 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -957,7 +957,7 @@ func (v *mailView) HelpBindings() []helpBinding { if v.inThread { bindings := []helpBinding{{"r", "reply"}, {"f", "forward"}} if v.fileablePosting() != nil { - bindings = append(bindings, helpBinding{"l", "reply later"}, helpBinding{"a", "set aside"}) + bindings = append(bindings, helpBinding{"l", "reply later"}, helpBinding{"a", "set aside"}, helpBinding{"t", "trash"}) } if len(v.entries) > 1 { bindings = append(bindings, helpBinding{"j/k", "next/previous message"}) @@ -1266,7 +1266,7 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { if v.topicID != 0 { return v.loadForwardContext(v.topicID, v.topicName) } - case "a", "A", "l": + case "a", "A", "l", "t", "T": return v.fileOpenThread(msg.String()) case "[": v.moveAttachmentCursor(-1) @@ -2326,6 +2326,12 @@ func (v *mailView) fileOpenThread(key string) tea.Cmd { if move == nil { return nil } + // Set Aside and Reply Later leave the thread on screen, in the box it landed + // in, so the next filing key can act on it there. Trash closes it: the Trash + // is not a box you file out of, and the web app returns to the list too. + if key == "t" || key == "T" { + v.ExitThread() + } // Filing keys pressed faster than their requests answer can complete out of // order, so each dispatch takes a sequence number and only the latest one // records where the thread landed. diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 27cb6956..b500a5c8 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -2409,6 +2409,120 @@ func TestMailViewContentKeyInThread(t *testing.T) { v.HandleContentKey(keyPress("up")) } +func TestMailViewTrashesTheOpenThreadAndReturnsToTheList(t *testing.T) { + for _, key := range []string{"t", "T"} { + t.Run(key, func(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread || v.threadPosting.ID != 100 { + t.Fatalf("thread state = open:%v posting:%d", v.inThread, v.threadPosting.ID) + } + + cmd := v.HandleContentKey(keyPress(key)) + if v.inThread { + t.Error("trashing the open thread should return to the list") + } + done, ok := runCmd(cmd).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("trash command returned %#v", done) + } + if done.postingID != 100 || done.effect != postingActionRemove { + t.Errorf("action = posting %d effect %v, want posting 100 effect %v", done.postingID, done.effect, postingActionRemove) + } + if recorded.method != http.MethodPost || recorded.path != "/postings/trash.json" { + t.Errorf("request = %s %s, want POST /postings/trash.json", recorded.method, recorded.path) + } + if len(recorded.body.PostingIDs) != 1 || recorded.body.PostingIDs[0] != 100 { + t.Errorf("posting_ids = %v, want [100]", recorded.body.PostingIDs) + } + + answer, _ := v.Update(done) + if toast := deliverToView(v, answer); toast != "Thread moved to Trash" { + t.Errorf("toast = %q, want %q", toast, "Thread moved to Trash") + } + if len(v.postingList.postings) != 1 || v.postingList.postings[0].ID != 101 { + t.Errorf("postings after trashing = %v, want the other thread alone", v.postingList.postings) + } + }) + } +} + +// Trash keeps to the same rule as the other filing keys: a thread opened over +// search results or a bundle has no row to file, so the key says so rather than +// trashing whatever the box list's cursor happens to be sitting on. +func TestMailViewRefusesToTrashAThreadOpenedFromSearchResults(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.searchActive = true + v.searchQuery = "quarterly planning" + v.searchList.setPostings([]mail.Posting{{ID: 10, TopicID: 100, Name: "Hello world"}}) + + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread { + t.Fatal("the searched thread should have opened") + } + + if cmd := v.HandleContentKey(keyPress("t")); cmd != nil { + t.Errorf("trash over search results returned %#v, want nothing", runCmd(cmd)) + } + if !v.inThread { + t.Error("a refused trash should leave the thread open") + } + if v.notice == "" { + t.Error("a refused trash should say why") + } + if recorded.path == "/postings/trash.json" { + t.Error("a refused trash still asked the server to trash something") + } +} + +func TestMailViewCannotTrashAThreadOpenedWithoutItsPosting(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.Update(topicLoadedMsg{ + topicID: 100, + title: "Hello world", + entries: []mail.Entry{{Creator: mail.Contact{Name: "Alice"}, Body: htmlutil.ToMarkdown("
hello
")}}, + }) + + if cmd := v.HandleContentKey(keyPress("t")); cmd != nil { + t.Errorf("trash without a posting returned %#v, want nothing", runCmd(cmd)) + } + if !v.inThread { + t.Error("a refused trash should leave the thread open") + } + if v.notice == "" { + t.Error("a refused trash should say why") + } + if recorded.method != "" { + t.Errorf("a refused trash sent %s %s", recorded.method, recorded.path) + } +} + +func TestMailViewTrashesAThreadOpenedFromPreviouslySeen(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + loaded, _ := runCmd(v.handleBoxShortcut("9")).(seenLoadedMsg) + v.Update(loaded) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread || !v.seenActive || v.threadPosting.ID != 611 { + t.Fatalf("thread state = open:%v seen:%v posting:%d", v.inThread, v.seenActive, v.threadPosting.ID) + } + + done, ok := runCmd(v.HandleContentKey(keyPress("t"))).(postingActionDoneMsg) + if !ok || done.err != nil || !done.seen { + t.Fatalf("trash command returned %#v", done) + } + if v.inThread || !v.seenActive { + t.Errorf("trashing landed on open:%v seen:%v, want the Previously Seen list", v.inThread, v.seenActive) + } + v.Update(done) + if len(v.seenList.postings) != 0 { + t.Errorf("seen postings after trashing = %+v, want the row gone", v.seenList.postings) + } + if len(v.postingList.postings) != 2 { + t.Errorf("a seen-screen trash landed on the box list: %+v", v.postingList.postings) + } +} + // --- Subnav --- func TestMailViewSubnavItems(t *testing.T) { @@ -3080,6 +3194,19 @@ func TestMailViewHelpBindings(t *testing.T) { } } +func TestMailViewOpenThreadHelpOffersTrashOnlyWithAPosting(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !hasHelpBinding(v.HelpBindings(), "t") { + t.Errorf("help bindings = %v, want trash among them", v.HelpBindings()) + } + + v.threadPosting = mail.Posting{} + if hasHelpBinding(v.HelpBindings(), "t") { + t.Errorf("help bindings = %v, want no trash without a posting", v.HelpBindings()) + } +} + // A label scrolls rather than paging, so it advertises no page keys and p keeps meaning // paper trail. func TestMailViewLabelHelpOffersNoPageKeys(t *testing.T) {