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
4 changes: 2 additions & 2 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ uppercase belongs to Labels:
| `a` | move to Set Aside |
| `d` | move to The Feed |
| `p` | move to Paper Trail |
| `t` | trash |
| `t` | trash — every selected thread when any are selected, otherwise the one under the cursor |
| `!` | mark as spam |
| `-` / `+` | ignore / stop ignoring |
| Space | select the thread for a bulk action |
| Space | select the thread for a bulk action (`t` or Ctrl+B) |
| Ctrl+B | preview every bulk-reply recipient, then write one reply to every selected thread |
| Ctrl+U | recall a delayed bulk reply while HEY's undo window is open |
| Ctrl+S | open The Screener |
Expand Down
1 change: 1 addition & 0 deletions internal/tui/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ func (c *contentList) removeAt(index int) {
if c.cursor > index {
c.cursor--
}
c.keepSelected()
c.settleCover()
}

Expand Down
79 changes: 61 additions & 18 deletions internal/tui/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,23 @@ type postingActionDoneMsg struct {
boxID int64
sourceKind mail.Kind
postingID int64
postingIDs []int64 // every posting a bulk action took, empty for a single row's
effect postingActionEffect
destinationKind string // the box kind a move filed into, empty for every other action
filingSeq uint64 // which open-thread filing dispatched the move, zero for a list row's
seen bool // the action was taken on the Previously Seen screen
err error
}

// postings is every posting the action took: the bulk selection when there was one,
// otherwise the single row.
func (msg postingActionDoneMsg) postings() []int64 {
if len(msg.postingIDs) > 0 {
return msg.postingIDs
}
return []int64{msg.postingID}
}

// postingSeenMsg reports the mark-seen that opening a thread triggers on its
// own, as the web app does out of band once it has rendered the topic.
type postingSeenMsg struct {
Expand Down Expand Up @@ -718,24 +728,26 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) {
return func() tea.Msg { return errMsg{msg.err} }, true
}
done := notify(msg.action)
idx := v.postingIndex(msg.postingID)
if idx >= 0 {
switch msg.effect {
case postingActionNone:
case postingActionRemove:
v.removePostingAt(idx)
case postingActionSeen:
v.postingList.markSeen(idx)
case postingActionUnseen:
v.postingList.markUnseen(idx)
case postingActionIgnore:
v.postingList.postings[idx].Muted = true
case postingActionStopIgnoring:
v.postingList.postings[idx].Muted = false
for _, postingID := range msg.postings() {
idx := v.postingIndex(postingID)
if idx >= 0 {
switch msg.effect {
case postingActionNone:
case postingActionRemove:
v.removePostingAt(idx)
case postingActionSeen:
v.postingList.markSeen(idx)
case postingActionUnseen:
v.postingList.markUnseen(idx)
case postingActionIgnore:
v.postingList.postings[idx].Muted = true
case postingActionStopIgnoring:
v.postingList.postings[idx].Muted = false
}
}
if msg.effect == postingActionRemove {
v.removeFromOverlaidLists(postingID)
}
}
if msg.effect == postingActionRemove {
v.removeFromOverlaidLists(msg.postingID)
}
// The open thread can file back into the box on screen — out and back while
// it stays open — and its row was removed when it first filed away, so the
Expand Down Expand Up @@ -1532,7 +1544,11 @@ func (v *mailView) applySeenPostingAction(msg postingActionDoneMsg) tea.Cmd {
if msg.err != nil {
return func() tea.Msg { return errMsg{msg.err} }
}
if idx := postingIndexIn(v.seenList.postings, msg.postingID); idx >= 0 {
for _, postingID := range msg.postings() {
idx := postingIndexIn(v.seenList.postings, postingID)
if idx < 0 {
continue
}
switch msg.effect {
case postingActionNone:
case postingActionRemove, postingActionUnseen:
Expand Down Expand Up @@ -2428,13 +2444,40 @@ func (v *mailView) fileablePosting() *mail.Posting {
}

func (v *mailView) handlePostingAction(key string) tea.Cmd {
if key == "t" || key == "T" {
if ids := v.actionList().selectedIDs(); len(ids) > 0 {
return v.trashSelected(ids)
}
}
selected := v.actionList().selectedPosting()
if selected == nil {
return nil
}
return v.postingAction(key, *selected, v.postingBoxKind(*selected))
}

// trashSelected trashes every selected thread in one request, the way the web app's
// toolbar acts on a selection rather than on the row under the cursor. The rows leave
// the list when HEY answers and take their selection with them; a failure leaves the
// selection standing for another try.
func (v *mailView) trashSelected(ids []int64) tea.Cmd {
label := "Thread moved to Trash"
if len(ids) > 1 {
label = fmt.Sprintf("%d threads moved to Trash", len(ids))
}
trash := v.doPostingAction(label, postingActionRemove, v.currentBoxID(), ids[0], func() error {
return v.vc.sdk.Postings().MoveToTrash(v.vc.ctx, ids...)
})
return func() tea.Msg {
done, ok := trash().(postingActionDoneMsg)
if !ok {
return nil
}
done.postingIDs = ids
return done
}
}

// actionBoxKind is the box kind a list row files out of, empty over a source that
// is not one of HEY's own boxes.
func (v *mailView) actionBoxKind() string {
Expand Down
42 changes: 42 additions & 0 deletions internal/tui/mail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"slices"
"strconv"
"strings"
"sync/atomic"
Expand Down Expand Up @@ -4303,3 +4304,44 @@ func TestMailViewBundleSurvivesAStaleAppend(t *testing.T) {
}
}
}

func TestMailViewTrashesSelectedThreadsInOneRequest(t *testing.T) {
v, recorded := mailWithTestServer(t, http.StatusNoContent)
selectTwoThreads(v)

done, ok := runCmd(v.HandleContentKey(keyPress("t"))).(postingActionDoneMsg)
if !ok || done.err != nil {
t.Fatalf("bulk trash returned %#v", done)
}
if recorded.path != "/postings/trash.json" || !slices.Equal(recorded.body.PostingIDs, []int64{100, 101}) {
t.Fatalf("request = %s %v, want one POST /postings/trash.json with [100 101]", recorded.path, recorded.body.PostingIDs)
}

answer, _ := v.Update(done)
if toast := deliverToView(v, answer); toast != "2 threads moved to Trash" {
t.Errorf("toast = %q", toast)
}
if len(v.postingList.postings) != 0 {
t.Errorf("postings left = %d, want every selected row gone", len(v.postingList.postings))
}
if ids := v.postingList.selectedIDs(); len(ids) != 0 {
t.Errorf("selection left = %v, want the trashed rows out of it", ids)
}
if v.AccountSwitchBlocked() {
t.Error("completed bulk trash still blocks account switching")
}
}

func TestMailViewTrashFailureKeepsSelection(t *testing.T) {
v, _ := mailWithTestServer(t, http.StatusInternalServerError)
selectTwoThreads(v)

done := runCmd(v.HandleContentKey(keyPress("t"))).(postingActionDoneMsg)
if done.err == nil {
t.Fatal("a failed bulk trash should carry its error")
}
v.Update(done)
if len(v.postingList.postings) != 2 || !slices.Equal(v.postingList.selectedIDs(), []int64{100, 101}) {
t.Errorf("postings = %d selected = %v, want both rows still selected", len(v.postingList.postings), v.postingList.selectedIDs())
}
}