diff --git a/internal/tui/mail.go b/internal/tui/mail.go index ac9482a8..a2d08ab3 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -194,13 +194,15 @@ const ( ) type postingActionDoneMsg struct { - action string - boxID int64 - sourceKind mail.Kind - postingID int64 - effect postingActionEffect - seen bool // the action was taken on the Previously Seen screen - err error + action string + boxID int64 + sourceKind mail.Kind + postingID int64 + 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 } // postingSeenMsg reports the mark-seen that opening a thread triggers on its @@ -254,6 +256,9 @@ type mailView struct { topicViewport viewport.Model topicContent string topicID int64 + threadPosting mail.Posting // snapshot of the posting the open thread was opened from, zero when it has none + threadBoxKind string // the box kind the open thread files out of, following it as filings move it + threadFilingSeq uint64 // dispatch order of open-thread filings, so only the latest records where the thread landed topicName string entries []mail.Entry attachments []messageAttachment @@ -515,6 +520,15 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { } v.inThread = true v.topicID = msg.topicID + // The posting the thread was opened from, snapshotted rather than looked up + // again later: the automatic mark-seen and the live refresh can resort the + // row, slide it under the cover, or drop it off the head page while the + // thread stays on screen. + v.threadPosting = mail.Posting{ID: msg.postingID, TopicID: msg.topicID} + if opened := v.openedPosting(msg.postingID); opened != nil { + v.threadPosting = *opened + } + v.threadBoxKind = v.actionBoxKind() v.topicName = msg.title v.entries = msg.entries v.attachments = msg.attachments @@ -687,6 +701,13 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { case postingActionDoneMsg: v.finishMutation() + // A move of the open thread leaves it on screen in its new box, so later + // filing keys measure against where it landed, not where it was opened — + // and only the latest dispatched filing gets to say where that is. + if msg.err == nil && v.inThread && msg.postingID == v.threadPosting.ID && + msg.destinationKind != "" && msg.filingSeq == v.threadFilingSeq { + v.threadBoxKind = msg.destinationKind + } if msg.seen { return v.applySeenPostingAction(msg), true } @@ -713,6 +734,12 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { v.postingList.postings[idx].Muted = false } } + // 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 + // list re-reads its head to hold what the server now does. + if msg.destinationKind != "" && msg.destinationKind == v.actionBoxKind() && v.postingIndex(msg.postingID) < 0 { + return tea.Batch(done, v.refreshBox(msg.boxID)), true + } if v.requests.kind == mailRequestPostings { if source := v.currentSource(); source != nil { return tea.Batch(done, v.requestPostings(*source)), true @@ -929,6 +956,9 @@ 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"}) + } if len(v.entries) > 1 { bindings = append(bindings, helpBinding{"j/k", "next/previous message"}) } @@ -1230,6 +1260,8 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { if v.topicID != 0 { return v.loadForwardContext(v.topicID, v.topicName) } + case "a", "A", "l": + return v.fileOpenThread(msg.String()) case "[": v.moveAttachmentCursor(-1) return nil @@ -1407,6 +1439,8 @@ func (v *mailView) ExitThread() { if v.inThread { v.inThread = false v.threadNotice = "" + v.threadPosting = mail.Posting{} + v.threadBoxKind = "" v.modal = nil v.requests.cancel() return @@ -1587,6 +1621,8 @@ func (v *mailView) switchBox(index int) tea.Cmd { } v.inThread = false v.threadNotice = "" + v.threadPosting = mail.Posting{} + v.threadBoxKind = "" v.clearSearch() v.clearBundle() v.clearSeen() @@ -1608,6 +1644,8 @@ func (v *mailView) openPreviouslySeen() tea.Cmd { } v.inThread = false v.threadNotice = "" + v.threadPosting = mail.Posting{} + v.threadBoxKind = "" v.clearSearch() v.clearBundle() v.notice = "" @@ -2267,23 +2305,80 @@ func (v *mailView) imboxSource() *mail.Source { return nil } +// 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 +// Seen — has a posting row to act on: over search results, bundles, and topics +// opened directly the key answers with a notice instead of silence. +func (v *mailView) fileOpenThread(key string) tea.Cmd { + posting := v.fileablePosting() + if posting == nil { + v.notice = "Can't file this thread from here" + return nil + } + move := v.postingAction(key, *posting, v.threadBoxKind) + if move == nil { + return nil + } + // 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. + v.threadFilingSeq++ + seq := v.threadFilingSeq + return func() tea.Msg { + msg := move() + if done, ok := msg.(postingActionDoneMsg); ok { + done.filingSeq = seq + return done + } + return msg + } +} + +// 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 +// live refresh can drop it off the head page — while the thread stays on screen. +func (v *mailView) fileablePosting() *mail.Posting { + if v.searchActive || v.bundleActive || v.threadPosting.ID == 0 { + return nil + } + return &v.threadPosting +} + func (v *mailView) handlePostingAction(key string) tea.Cmd { selected := v.actionList().selectedPosting() if selected == nil { return nil } - p := *selected + return v.postingAction(key, *selected, v.actionBoxKind()) +} + +// 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 { + if source := v.actionSource(); source != nil { + return source.BoxKind + } + return "" +} + +// postingAction runs key's action on p. fromBoxKind is the box kind the posting +// files out of — the list's own box for a row, the box the open thread lives in +// for a filing key pressed there — so a move to the box it is already in answers +// with a notice instead of a request. +func (v *mailView) postingAction(key string, p mail.Posting, fromBoxKind string) tea.Cmd { boxID := v.currentBoxID() switch key { // Only lowercase moves to Reply Later: Shift+L navigates to Labels, the // way Shift+K reaches Collections. case "l": - return v.moveSelectedToKnownBox("Reply Later", hey.BoxKindLater, boxID, p.ID, func() error { + return v.moveSelectedToKnownBox("Reply Later", hey.BoxKindLater, fromBoxKind, boxID, p.ID, func() error { return v.vc.sdk.Postings().MoveToReplyLater(v.vc.ctx, p.ID) }) case "a", "A": - return v.moveSelectedToKnownBox("Set Aside", hey.BoxKindSetAside, boxID, p.ID, func() error { + return v.moveSelectedToKnownBox("Set Aside", hey.BoxKindSetAside, fromBoxKind, boxID, p.ID, func() error { return v.vc.sdk.Postings().MoveToSetAside(v.vc.ctx, p.ID) }) case "e", "E": @@ -2303,13 +2398,13 @@ func (v *mailView) handlePostingAction(key string) tea.Cmd { return v.vc.sdk.Postings().MarkUnseen(v.vc.ctx, []int64{p.ID}) }) case "i", "I": - return v.moveSelectedToImbox(boxID, p.ID) + return v.moveSelectedToImbox(fromBoxKind, boxID, p.ID) case "d", "D": - return v.moveSelectedToKnownBox("The Feed", hey.BoxKindFeed, boxID, p.ID, func() error { + return v.moveSelectedToKnownBox("The Feed", hey.BoxKindFeed, fromBoxKind, boxID, p.ID, func() error { return v.vc.sdk.Postings().MoveToFeed(v.vc.ctx, p.ID) }) case "p", "P": - return v.moveSelectedToKnownBox("Paper Trail", hey.BoxKindTrail, boxID, p.ID, func() error { + return v.moveSelectedToKnownBox("Paper Trail", hey.BoxKindTrail, fromBoxKind, boxID, p.ID, func() error { return v.vc.sdk.Postings().MoveToPaperTrail(v.vc.ctx, p.ID) }) case "t", "T": @@ -2350,10 +2445,10 @@ func (v *mailView) handlePostingAction(key string) tea.Cmd { return nil } -func (v *mailView) moveSelectedToImbox(boxID, postingID int64) tea.Cmd { +func (v *mailView) moveSelectedToImbox(fromBoxKind string, boxID, postingID int64) tea.Cmd { if source := v.imboxSource(); source != nil { imboxID := source.ID - return v.moveSelectedToKnownBox("Imbox", hey.BoxKindImbox, boxID, postingID, func() error { + return v.moveSelectedToKnownBox("Imbox", hey.BoxKindImbox, fromBoxKind, boxID, postingID, func() error { return v.vc.sdk.Postings().Move(v.vc.ctx, imboxID, postingID) }) } @@ -2361,12 +2456,23 @@ func (v *mailView) moveSelectedToImbox(boxID, postingID int64) tea.Cmd { return nil } -func (v *mailView) moveSelectedToKnownBox(name, kind string, boxID, postingID int64, fn func() error) tea.Cmd { - if !v.movesOutOfCurrentBox(kind) { +func (v *mailView) moveSelectedToKnownBox(name, kind, fromBoxKind string, boxID, postingID int64, fn func() error) tea.Cmd { + // The destination is one of HEY's own box kinds, so the posting's kind answers + // whether the move would do anything — a label or a collection carries none and + // is never the destination. + if fromBoxKind == kind { v.notice = "Already in " + name return nil } - return v.doPostingAction("Thread moved to "+name, v.boxMoveEffect(), boxID, postingID, fn) + move := v.doPostingAction("Thread moved to "+name, v.boxMoveEffect(), boxID, postingID, fn) + return func() tea.Msg { + done, ok := move().(postingActionDoneMsg) + if !ok { + return nil + } + done.destinationKind = kind + return done + } } func (v *mailView) boxMoveEffect() postingActionEffect { @@ -2379,17 +2485,6 @@ func (v *mailView) boxMoveEffect() postingActionEffect { return postingActionRemove } -// movesOutOfCurrentBox reports whether a key that files a thread somewhere would move it -// at all. The destination is one of HEY's own box kinds, so it is the box's kind that -// answers — a label or a collection carries none and is never the destination. -func (v *mailView) movesOutOfCurrentBox(destinationBoxKind string) bool { - source := v.actionSource() - if source == nil { - return true - } - return source.BoxKind != destinationBoxKind -} - func (v *mailView) doPostingAction(label string, effect postingActionEffect, boxID, postingID int64, fn func() error) tea.Cmd { sourceKind := v.currentSourceKind() seen := v.seenActive diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 8d1ea5c4..27cb6956 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -606,6 +606,257 @@ func TestMailViewPostingKeysCallExpectedEndpoints(t *testing.T) { } } +func TestMailViewFilesOpenThread(t *testing.T) { + tests := []struct { + name string + key string + boxID int64 + notice string + }{ + {"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"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread { + t.Fatal("enter should open the selected thread") + } + + done, ok := runCmd(v.HandleContentKey(keyPress(tt.key))).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("filing command returned %#v", done) + } + if recorded.method != http.MethodPost || recorded.path != "/postings/moves.json" { + t.Errorf("request = %s %s, want POST /postings/moves.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 recorded.body.BoxID == nil || *recorded.body.BoxID != tt.boxID { + t.Errorf("box_id = %v, want %d", recorded.body.BoxID, tt.boxID) + } + + answer, _ := v.Update(done) + if toast := deliverToView(v, answer); toast != tt.notice { + t.Errorf("toast = %q, want %q", toast, tt.notice) + } + if !v.inThread { + t.Error("filing should keep the thread open, the way the web app stays on the topic") + } + if v.postingIndex(100) != -1 { + t.Error("the filed thread should leave the box list behind the reader") + } + }) + } +} + +func TestMailViewFilesOpenThreadAfterMarkSeenCoversItsRow(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.postingList.setCover(coverTopo) + + opened, _ := v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread { + t.Fatal("enter should open the selected thread") + } + // The mark-seen that opening triggers slides the row under the cover and + // clamps the cursor off it, so filing cannot go through the selection. + deliverToView(v, opened) + if p := v.actionList().selectedPosting(); p != nil && p.ID == 100 { + t.Fatal("mark-seen under a cover should move the cursor off the opened row") + } + + done, ok := runCmd(v.HandleContentKey(keyPress("a"))).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("filing command returned %#v", done) + } + if recorded.method != http.MethodPost || recorded.path != "/postings/moves.json" { + t.Errorf("request = %s %s, want POST /postings/moves.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) + } +} + +func TestMailViewFilesOpenThreadAfterLiveRefreshDropsItsRow(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread { + t.Fatal("enter should open the selected thread") + } + + // A live refresh whose head no longer returns the opened row takes it out of + // the list entirely, so filing cannot go looking for it there. + v.Update(postingsRefreshedMsg{ + requestID: v.liveRequestID, + boxID: v.currentBoxID(), + sourceKind: v.currentSourceKind(), + postings: testPostings()[1:], + }) + if v.postingIndex(100) != -1 { + t.Fatal("test needs the refresh to drop the opened row from the list") + } + if bindings := fmt.Sprint(v.HelpBindings()); !strings.Contains(bindings, "set aside") { + t.Errorf("thread help = %s, should keep advertising filing", bindings) + } + + done, ok := runCmd(v.HandleContentKey(keyPress("l"))).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("filing command returned %#v", done) + } + if recorded.method != http.MethodPost || recorded.path != "/postings/moves.json" { + t.Errorf("request = %s %s, want POST /postings/moves.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) + } +} + +func TestMailViewFilesOpenThreadWhereItLandedLastTime(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.boxes = append(v.boxes, mail.Source{Kind: mail.KindBox, ID: 3, Name: "Set Aside", BoxKind: hey.BoxKindSetAside}) + v.boxIndex = len(v.boxes) - 1 + v.Update(currentPostingsLoaded(v, testPostings())) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread { + t.Fatal("enter should open the selected thread") + } + + // The thread was opened from Set Aside, so a answers without a request. + if cmd := v.HandleContentKey(keyPress("a")); cmd != nil { + t.Errorf("filing to the box the thread is in should not move: %#v", runCmd(cmd)) + } + if v.notice != "Already in Set Aside" { + t.Errorf("notice = %q, want %q", v.notice, "Already in Set Aside") + } + + // Filing to Reply Later moves the thread there, and later keys measure + // against where it landed: l answers in place, a moves it back. + done, ok := runCmd(v.HandleContentKey(keyPress("l"))).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("filing command returned %#v", done) + } + v.Update(done) + + if cmd := v.HandleContentKey(keyPress("l")); cmd != nil { + t.Errorf("filing to the box the thread landed in should not move: %#v", runCmd(cmd)) + } + if v.notice != "Already in Reply Later" { + t.Errorf("notice = %q, want %q", v.notice, "Already in Reply Later") + } + + done, ok = runCmd(v.HandleContentKey(keyPress("a"))).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("filing command returned %#v", done) + } + if recorded.body.BoxID == nil || *recorded.body.BoxID != 3 { + t.Errorf("box_id = %v, want 3", recorded.body.BoxID) + } + + // Landing back in the box on screen, whose row was removed when the thread + // first filed away, re-reads the head so the list holds what the server does. + restore, _ := v.Update(done) + if restore == nil { + t.Fatal("filing back into the box on screen should re-read its head") + } + v.Update(postingsRefreshedMsg{ + requestID: v.liveRequestID, + boxID: v.currentBoxID(), + sourceKind: v.currentSourceKind(), + postings: testPostings(), + }) + if v.postingIndex(100) == -1 { + t.Error("the re-read head should put the returned thread back on the list") + } +} + +func TestMailViewFilesOpenThreadRecordsOnlyTheLatestFiling(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread { + t.Fatal("enter should open the selected thread") + } + + // Two filing keys faster than their requests answer, with the responses + // crossing: the first-dispatched move answers last. Only the latest + // dispatch records where the thread landed. + aside := v.HandleContentKey(keyPress("a")) + later := v.HandleContentKey(keyPress("l")) + laterDone, ok := runCmd(later).(postingActionDoneMsg) + if !ok || laterDone.err != nil { + t.Fatalf("filing command returned %#v", laterDone) + } + asideDone, ok := runCmd(aside).(postingActionDoneMsg) + if !ok || asideDone.err != nil { + t.Fatalf("filing command returned %#v", asideDone) + } + v.Update(laterDone) + v.Update(asideDone) + + if cmd := v.HandleContentKey(keyPress("l")); cmd != nil { + t.Errorf("the thread landed in Reply Later, so l should answer in place: %#v", runCmd(cmd)) + } + if v.notice != "Already in Reply Later" { + t.Errorf("notice = %q, want %q", v.notice, "Already in Reply Later") + } +} + +func TestMailViewFilesOpenThreadOnlyFromFilingLists(t *testing.T) { + t.Run("search result", 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} + + if cmd := v.HandleContentKey(keyPress("a")); cmd != nil { + t.Errorf("a search-opened thread should not file: %#v", runCmd(cmd)) + } + if v.notice != "Can't file this thread from here" { + t.Errorf("notice = %q, want the filing explanation", v.notice) + } + }) + + t.Run("directly opened topic", func(t *testing.T) { + v := mailWithPostings() + v.inThread = true + v.topicID = 555 // opened by URL, with no posting row behind it + + if cmd := v.HandleContentKey(keyPress("l")); cmd != nil { + t.Errorf("a directly opened thread should not file the selected row: %#v", runCmd(cmd)) + } + if v.notice != "Can't file this thread from here" { + t.Errorf("notice = %q, want the filing explanation", v.notice) + } + }) +} + +func TestMailViewThreadHelpAdvertisesFilingKeys(t *testing.T) { + v := mailWithPostings() + v.inThread = true + v.topicID = 100 + v.threadPosting = mail.Posting{ID: 100, TopicID: 100} + + bindings := fmt.Sprint(v.HelpBindings()) + for _, want := range []string{"reply later", "set aside"} { + if !strings.Contains(bindings, want) { + t.Errorf("thread help = %s, want %q", bindings, want) + } + } + + v.threadPosting = mail.Posting{} // opened by URL, with no posting row behind it + bindings = fmt.Sprint(v.HelpBindings()) + for _, missing := range []string{"reply later", "set aside"} { + if strings.Contains(bindings, missing) { + t.Errorf("unfilable thread help = %s, should drop %q", bindings, missing) + } + } +} + func TestMailViewUnseenKeysRestoreSeenAndBubbledUpThreads(t *testing.T) { for _, testCase := range []struct { name string