diff --git a/docs/tui.md b/docs/tui.md index 69de03af..7893358b 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -61,6 +61,13 @@ uppercase belongs to Labels: | Ctrl+A | switch linked account | | Ctrl+V | choose an Imbox cover | +Most of those keep working while you are reading a thread, the way the web app's topic +toolbar stays live: `r`, `f`, `v`, `b`, `u`, `i`, `l`, `a`, `d`, `p` and `t` all act on +the thread on screen rather than on the list behind it. Filing a thread leaves it open in +the box it landed in, so the next key files it on from there; `t` closes it, because a +trashed thread is not somewhere you file out of. A thread opened from search results, from +a bundle, or by its id has no row to file and says so instead. + While writing a new message, reply or forward, Ctrl+T opens 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 diff --git a/internal/tui/mail.go b/internal/tui/mail.go index be989410..7e2c3937 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -755,6 +755,11 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { v.noteFailure("Could not mark thread as seen", msg.err) return nil, true } + // The snapshot the open thread files on was taken before this landed, so it + // still reports the thread unseen — which is what u measures against. + if msg.postingID == v.threadPosting.ID { + v.threadPosting.Seen = true + } if msg.boxID == v.currentBoxID() && msg.sourceKind == v.currentSourceKind() { if idx := v.postingIndex(msg.postingID); idx >= 0 { v.postingList.markSeen(idx) @@ -957,7 +962,21 @@ 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"}, helpBinding{"t", "trash"}) + folderBinding := helpBinding{"b", "labels"} + if v.folderDiscoveryErr != "" { + folderBinding = helpBinding{"b", "retry labels"} + } + bindings = append(bindings, + helpBinding{"v", "move"}, + folderBinding, + helpBinding{"u", "unseen"}, + helpBinding{"i", "imbox"}, + helpBinding{"l", "reply later"}, + helpBinding{"a", "set aside"}, + helpBinding{"d", "feed"}, + helpBinding{"p", "paper trail"}, + helpBinding{"t", "trash"}, + ) } if len(v.entries) > 1 { bindings = append(bindings, helpBinding{"j/k", "next/previous message"}) @@ -1266,8 +1285,10 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { if v.topicID != 0 { return v.loadForwardContext(v.topicID, v.topicName) } - case "a", "A", "l", "t", "T": + case "a", "A", "l", "t", "T", "u", "U", "i", "I", "d", "D", "p", "P": return v.fileOpenThread(msg.String()) + case "b", "B", "v", "V": + return v.openThreadPicker(msg.String()) case "[": v.moveAttachmentCursor(-1) return nil @@ -2119,7 +2140,7 @@ func (v *mailView) openedPosting(postingID int64) *mail.Posting { // --- Posting actions --- func (v *mailView) startMove() { - selected := v.actionList().selectedPosting() + selected := v.actionPosting() currentSource := v.actionSource() if selected == nil || currentSource == nil { return @@ -2161,7 +2182,7 @@ func (v *mailView) startFolderPicker() tea.Cmd { v.notice = "Retrying labels…" return v.requestSources() } - selected := v.actionList().selectedPosting() + selected := v.actionPosting() if selected == nil { return nil } @@ -2292,6 +2313,18 @@ func (v *mailView) actionList() *contentList { return &v.postingList } +// actionPosting is the posting a key acts on: the open thread's own while one is on +// screen, and the list's selection otherwise. Reading a thread moves the cursor off +// the row it was opened from — the automatic mark-seen resorts it under the cover — +// so a picker opened from a thread has to be told which posting it is for rather +// than reading the list underneath. +func (v *mailView) actionPosting() *mail.Posting { + if v.inThread { + return v.fileablePosting() + } + return v.actionList().selectedPosting() +} + // 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. @@ -2311,6 +2344,8 @@ func (v *mailView) imboxSource() *mail.Source { return nil } +const unfileableThreadNotice = "Can't file this thread from here" + // fileOpenThread files the thread on screen the way the same key files it on the // list, matching the web app's topic toolbar keeping its hotkeys live while a // thread is open. Only a thread opened from a filing list — a box or Previously @@ -2319,7 +2354,7 @@ func (v *mailView) imboxSource() *mail.Source { func (v *mailView) fileOpenThread(key string) tea.Cmd { posting := v.fileablePosting() if posting == nil { - v.notice = "Can't file this thread from here" + v.notice = unfileableThreadNotice return nil } move := v.postingAction(key, *posting, v.threadBoxKind) @@ -2347,6 +2382,21 @@ func (v *mailView) fileOpenThread(key string) tea.Cmd { } } +// openThreadPicker opens the label or move picker over the thread on screen. Both +// pickers file the posting they are given, so they answer to the same rule the +// filing keys do rather than opening over a thread there is nothing to file. +func (v *mailView) openThreadPicker(key string) tea.Cmd { + if v.fileablePosting() == nil { + v.notice = unfileableThreadNotice + return nil + } + if key == "b" || key == "B" { + return v.startFolderPicker() + } + v.startMove() + return nil +} + // fileablePosting is the posting the open thread files on: the snapshot taken when // the thread opened, standing in for a row the list may no longer hold — the // automatic mark-seen resorts it under the cover and clamps the cursor away, and a diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index b500a5c8..349246d8 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -616,6 +616,10 @@ func TestMailViewFilesOpenThread(t *testing.T) { {"reply later", "l", 4, "Thread moved to Reply Later"}, {"set aside", "a", 3, "Thread moved to Set Aside"}, {"set aside uppercase", "A", 3, "Thread moved to Set Aside"}, + {"feed", "d", 2, "Thread moved to The Feed"}, + {"feed uppercase", "D", 2, "Thread moved to The Feed"}, + {"paper trail", "p", 5, "Thread moved to Paper Trail"}, + {"paper trail uppercase", "P", 5, "Thread moved to Paper Trail"}, } for _, tt := range tests { @@ -654,6 +658,117 @@ func TestMailViewFilesOpenThread(t *testing.T) { } } +// Opening an unseen thread marks it seen, so u has something to undo. The snapshot +// the thread files on is taken before that lands and has to follow it, or the key +// answers that the thread it just marked seen is already unseen. +func TestMailViewMarksTheOpenThreadUnseen(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + marking, _ := v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread { + t.Fatal("enter should open the selected thread") + } + v.Update(runCmd(marking)) + + done, ok := runCmd(v.HandleContentKey(keyPress("u"))).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("unseen command returned %#v", done) + } + if recorded.path != "/postings/unseen.json" { + t.Errorf("request = %s %s, want POST /postings/unseen.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) + } + if !v.inThread { + t.Error("marking unseen should leave the thread open: it has not gone anywhere") + } +} + +// The same-box guard reaches the open thread, so a key naming the box the thread is +// already in says so rather than sending a move that would do nothing. +func TestMailViewRefusesToFileTheOpenThreadIntoItsOwnBox(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + + if cmd := v.HandleContentKey(keyPress("i")); cmd != nil { + t.Errorf("moving to the box it is in returned %#v, want nothing", runCmd(cmd)) + } + if v.notice != "Already in Imbox" { + t.Errorf("notice = %q, want the already-there explanation", v.notice) + } + if recorded.path == "/postings/moves.json" { + t.Error("a refused move still asked the server to move something") + } +} + +func TestMailViewOpensThePickersOverTheOpenThread(t *testing.T) { + t.Run("labels", func(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + + v.HandleContentKey(keyPress("b")) + picker, ok := v.modal.(*folderPicker) + if !ok { + t.Fatalf("modal = %#v, want the label picker", v.modal) + } + if picker.posting.ID != 100 { + t.Errorf("picker posting = %d, want the thread's own 100", picker.posting.ID) + } + }) + + t.Run("move", func(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + + v.HandleContentKey(keyPress("v")) + picker, ok := v.modal.(*movePicker) + if !ok { + t.Fatalf("modal = %#v, want the move picker", v.modal) + } + if picker.postingID != 100 { + t.Errorf("picker posting = %d, want the thread's own 100", picker.postingID) + } + }) +} + +// A picker aims at the thread on screen, not at the row the list's cursor has moved +// on to: opening a thread marks it seen, which resorts it under the cover. +func TestMailViewPickersAimAtTheThreadNotTheListCursor(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + v.postingList.moveDown() + + v.HandleContentKey(keyPress("b")) + picker, ok := v.modal.(*folderPicker) + if !ok { + t.Fatalf("modal = %#v, want the label picker", v.modal) + } + if picker.posting.ID != 100 { + t.Errorf("picker posting = %d, want the open thread's 100 rather than the moved cursor", picker.posting.ID) + } +} + +func TestMailViewRefusesThePickersWithoutAFileableThread(t *testing.T) { + for _, key := range []string{"b", "v"} { + t.Run(key, func(t *testing.T) { + v := mailWithPostings() + v.searchActive = true + v.searchList.setPostings([]mail.Posting{{ID: 10, TopicID: 100, Name: "Hello world"}}) + v.inThread = true + v.topicID = 100 + v.threadPosting = mail.Posting{ID: 10, TopicID: 100} + + v.HandleContentKey(keyPress(key)) + if v.modal != nil { + t.Errorf("modal = %#v, want no picker over a thread with no row to file", v.modal) + } + if v.notice != "Can't file this thread from here" { + t.Errorf("notice = %q, want the filing explanation", v.notice) + } + }) + } +} + func TestMailViewFilesOpenThreadAfterMarkSeenCoversItsRow(t *testing.T) { v, recorded := mailWithTestServer(t, http.StatusNoContent) v.postingList.setCover(coverTopo)