Skip to content
Open
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
31 changes: 31 additions & 0 deletions internal/tui/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ type mailView struct {
topicViewport viewport.Model
topicContent string
topicID int64
topicPostingID int64
topicName string
entries []mail.Entry
attachments []messageAttachment
Expand Down Expand Up @@ -515,6 +516,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) {
}
v.inThread = true
v.topicID = msg.topicID
v.topicPostingID = msg.postingID
v.topicName = msg.title
v.entries = msg.entries
v.attachments = msg.attachments
Expand Down Expand Up @@ -713,6 +715,9 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) {
v.postingList.postings[idx].Muted = false
}
}
if msg.effect == postingActionRemove {
v.removeFromOverlaidLists(msg.postingID)
}
if v.requests.kind == mailRequestPostings {
if source := v.currentSource(); source != nil {
return tea.Batch(done, v.requestPostings(*source)), true
Expand Down Expand Up @@ -929,6 +934,9 @@ func (v *mailView) HelpBindings() []helpBinding {
}
if v.inThread {
bindings := []helpBinding{{"r", "reply"}, {"f", "forward"}}
if v.topicPostingID != 0 {
bindings = append(bindings, helpBinding{"t", "trash"})
}
if len(v.entries) > 1 {
bindings = append(bindings, helpBinding{"j/k", "next/previous message"})
}
Expand Down Expand Up @@ -1240,6 +1248,8 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd {
return v.saveSelectedAttachment()
case "o":
return v.openSelectedAttachment()
case "t", "T":
return v.trashOpenThread()
case "j":
if len(v.entryOffsets) > 1 {
v.jumpEntry(1)
Expand Down Expand Up @@ -1896,6 +1906,14 @@ func (v *mailView) removePostingAt(index int) {
v.postingList.removeAt(index)
}

func (v *mailView) removeFromOverlaidLists(postingID int64) {
for _, list := range []*contentList{&v.searchList, &v.bundleList} {
if index := postingIndexIn(list.postings, postingID); index >= 0 {
list.removeAt(index)
}
}
}

func (v *mailView) moveAttachmentCursor(delta int) {
if len(v.attachments) == 0 {
return
Expand Down Expand Up @@ -2350,6 +2368,19 @@ func (v *mailView) handlePostingAction(key string) tea.Cmd {
return nil
}

func (v *mailView) trashOpenThread() tea.Cmd {
if v.topicPostingID == 0 {
v.notice = "Open this thread from a box to trash it"
return nil
}
postingID := v.topicPostingID
cmd := v.doPostingAction("Thread moved to Trash", postingActionRemove, v.currentBoxID(), postingID, func() error {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dabb874

return v.vc.sdk.Postings().MoveToTrash(v.vc.ctx, postingID)
})
v.ExitThread()
return cmd
}

func (v *mailView) moveSelectedToImbox(boxID, postingID int64) tea.Cmd {
if source := v.imboxSource(); source != nil {
imboxID := source.ID
Expand Down
163 changes: 163 additions & 0 deletions internal/tui/mail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2158,6 +2158,156 @@ 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.topicPostingID != 100 {
t.Fatalf("thread state = open:%v posting:%d", v.inThread, v.topicPostingID)
}

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)
}
})
}
}

func TestMailViewTrashesTheThreadOnScreenRatherThanTheListSelection(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 || v.topicPostingID != 10 {
t.Fatalf("thread state = open:%v posting:%d", v.inThread, v.topicPostingID)
}

done, ok := runCmd(v.HandleContentKey(keyPress("t"))).(postingActionDoneMsg)
if !ok || done.err != nil {
t.Fatalf("trash command returned %#v", done)
}
if done.postingID != 10 {
t.Errorf("trashed posting %d, want the searched thread's posting 10", done.postingID)
}
if len(recorded.body.PostingIDs) != 1 || recorded.body.PostingIDs[0] != 10 {
t.Errorf("posting_ids = %v, want [10]", recorded.body.PostingIDs)
}
if v.inThread || !v.searchActive {
t.Errorf("trashing a searched thread landed on open:%v search:%v, want the results", v.inThread, v.searchActive)
}

v.Update(done)
if len(v.searchList.postings) != 0 {
t.Errorf("search results after trashing = %+v, want the row gone", v.searchList.postings)
}
if len(v.postingList.postings) != 2 {
t.Errorf("a searched thread's trash landed on the box list: %+v", v.postingList.postings)
}
}

func TestMailViewTrashesAThreadOpenedFromABundle(t *testing.T) {
v, _ := mailWithTestServer(t, http.StatusNoContent)
v.postingList.postings[0] = bundleRow()
loaded, _ := runCmd(v.HandleContentKey(keyPress("enter"))).(bundleLoadedMsg)
more, _ := v.Update(loaded)
appended, _ := runCmd(more).(bundleAppendedMsg)
v.Update(appended)
if len(v.bundleList.postings) != 2 {
t.Fatalf("bundle postings = %+v", v.bundleList.postings)
}

v.Update(runCmd(v.HandleContentKey(keyPress("enter"))))
if !v.inThread || v.topicPostingID != 511 {
t.Fatalf("thread state = open:%v posting:%d", v.inThread, v.topicPostingID)
}

done, ok := runCmd(v.HandleContentKey(keyPress("t"))).(postingActionDoneMsg)
if !ok || done.err != nil || done.postingID != 511 {
t.Fatalf("trash command returned %#v", done)
}
if v.inThread || !v.bundleActive {
t.Errorf("trashing landed on open:%v bundle:%v, want the bundle", v.inThread, v.bundleActive)
}

v.Update(done)
if len(v.bundleList.postings) != 1 || v.bundleList.postings[0].ID != 512 {
t.Errorf("bundle postings after trashing = %+v, want the other thread alone", v.bundleList.postings)
}
}

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("<p>hello</p>")}},
})

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.topicPostingID != 611 {
t.Fatalf("thread state = open:%v seen:%v posting:%d", v.inThread, v.seenActive, v.topicPostingID)
}

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) {
Expand Down Expand Up @@ -2829,6 +2979,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.topicPostingID = 0
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) {
Expand Down