Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ uppercase belongs to Labels:

A thread opens on its latest message; `k` steps back through the ones before it and `j` forward again.

Sending a reply or a forward from a thread closes the thread and returns you to the list you opened it from — the Imbox, a search, a bundle. A send that fails keeps the form open.

While reading a thread, the From header shows the actual send-as address when HEY records one separately from the account user.

While reading a thread, links can be selected without a mouse. Tab selects the next link in document order and Shift+Tab selects the previous one; both wrap at the ends. A fixed row above the shortcut bar shows the complete selected destination without moving the thread. Press Enter to open it; opening stays unavailable until the terminal is wide enough to show the complete destination. Press Escape once to clear the selection, and Escape again to leave the thread. A thread with no selectable links keeps the normal global Tab focus behavior. Existing OSC 8 mouse links remain available.
Expand Down
3 changes: 3 additions & 0 deletions internal/tui/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ const (
type composeForm struct {
mode composeMode
topicName string
topicID int64 // the thread a reply or forward was written from; 0 for a new message
entryID int64 // reply target (composeReply only)
replySubject string // the "Re: …" subject a reply goes out under (composeReply only)
replyActingSenderID int64 // the sender a reply goes out as; 0 = account default (composeReply only)
Expand Down Expand Up @@ -140,6 +141,7 @@ func placeholderFor(label string) string {
func newReplyForm(ctxMsg replyContextLoadedMsg, s styles) *composeForm {
f := newComposeForm(composeReply, s)
f.topicName = ctxMsg.topicName
f.topicID = ctxMsg.topicID
f.entryID = ctxMsg.entryID
f.replySubject = ctxMsg.subject
f.replyActingSenderID = ctxMsg.actingSenderID
Expand All @@ -154,6 +156,7 @@ func newReplyForm(ctxMsg replyContextLoadedMsg, s styles) *composeForm {
func newForwardForm(ctxMsg forwardContextLoadedMsg, s styles) *composeForm {
f := newComposeForm(composeForward, s)
f.topicName = ctxMsg.topicName
f.topicID = ctxMsg.topicID
f.sendSDK = ctxMsg.sdk
f.forwardedContent = ctxMsg.content
f.inputs[fieldSubject].SetValue(ctxMsg.subject)
Expand Down
52 changes: 48 additions & 4 deletions internal/tui/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,13 +496,16 @@ func TestForwardFormLoadsLatestEntryAndSends(t *testing.T) {

// Sending from inside a thread used to leave its confirmation in the posting list's
// header, which a thread covers — so it was said to nobody. A toast belongs to the
// model and is drawn over whatever the section is showing.
func TestForwardCompletionIsSaidFromInsideAThread(t *testing.T) {
// model and is drawn over whatever the section is showing. The send also finishes with
// the thread, so the reader is back on the list they opened it from.
func TestForwardFromAThreadReturnsToTheList(t *testing.T) {
v := mailWithPostings()
v.Resize(80, 30)
v.inThread = true
v.topicID = 123
v.topicViewport.SetContent("Original thread")
v.modal = newForwardForm(forwardContextLoadedMsg{
topicID: 123,
topicName: "Quarterly planning",
subject: "Fwd: Quarterly planning",
content: "<div>Quoted message</div>",
Expand All @@ -516,8 +519,49 @@ func TestForwardCompletionIsSaidFromInsideAThread(t *testing.T) {
if toast := deliverToView(v, answer); toast != "Message forwarded" {
t.Errorf("toast = %q", toast)
}
if view := v.View(); !strings.Contains(view, "Original thread") {
t.Errorf("the thread should still be on screen, got %q", view)
if v.inThread {
t.Error("the thread should close once the forward is sent")
}
if view := v.View(); strings.Contains(view, "Original thread") {
t.Errorf("the list should be back on screen, got %q", view)
}
}

func TestReplyFromAThreadReturnsToTheList(t *testing.T) {
v := mailWithPostings()
v.Resize(80, 30)
v.inThread = true
v.topicID = 7
v.modal = newReplyForm(replyContextLoadedMsg{
boxID: 1, topicID: 7, topicName: "Kitchen renovation", entryID: 99, subject: "Re: Kitchen renovation",
to: []string{"jane.cooper@example.com"},
}, v.vc.styles)

v.Update(composeSentMsg{label: "Reply sent"})

if v.inThread {
t.Error("the thread should close once the reply is sent")
}
if composeModal(v) != nil {
t.Error("reply form should close after sending")
}
}

// A failed send keeps the reader in the thread with the form open, so nothing is lost.
func TestFailedReplyStaysInTheThread(t *testing.T) {
v := mailWithPostings()
v.Resize(80, 30)
v.inThread = true
v.topicID = 7
v.modal = newReplyForm(replyContextLoadedMsg{
boxID: 1, topicID: 7, topicName: "Kitchen renovation", entryID: 99, subject: "Re: Kitchen renovation",
to: []string{"jane.cooper@example.com"},
}, v.vc.styles)

v.Update(composeSentMsg{label: "Reply sent", err: io.ErrUnexpectedEOF})

if !v.inThread || composeModal(v) == nil {
t.Errorf("a failed send should keep the thread and the form: inThread=%v form=%v", v.inThread, composeModal(v))
}
}

Expand Down
6 changes: 6 additions & 0 deletions internal/tui/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,12 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) {
return nil, true
}
v.modal = nil
// A reply or a forward finishes with the thread it was written from, the way
// the web app sends you back to the box: the reader lands on the list they
// opened the thread from — the Imbox, a search, a bundle — ready for the next.
if v.inThread && form.topicID != 0 && form.topicID == v.topicID {
v.ExitThread()
}
return notify(msg.label), true

case attachmentSavedMsg:
Expand Down
Loading