diff --git a/docs/tui.md b/docs/tui.md index ed7d965b..b95d8102 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -62,6 +62,8 @@ uppercase belongs to Labels: | Ctrl+A | switch linked account | | Ctrl+V | choose an Imbox cover | +A thread opens on its latest message; `k` steps back through the ones before it and `j` forward again. + 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. diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 70468d72..5a0dd95c 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -591,7 +591,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { } v.imageContent = imageContent.String() v.rebuildTopicContent() - v.topicViewport.GotoTop() + v.showLatestEntry() // A thread read only in part is not marked seen by being opened: the reader has // not had all of it, and seen would slide it under the Imbox's cover. The seen // key is there for a thread they are done with anyway. @@ -2202,6 +2202,18 @@ func (v *mailView) currentAttachmentAction(topicID int64, attachmentID string) b return false } +// showLatestEntry opens a thread on its newest message, which is where whatever +// brought the reader here arrived; k walks back through what came before it. The +// viewport stops at the end of the content, so a short last message shows the tail +// of the one above it too. +func (v *mailView) showLatestEntry() { + if len(v.entryOffsets) == 0 { + v.topicViewport.GotoTop() + return + } + v.topicViewport.SetYOffset(v.entryOffsets[len(v.entryOffsets)-1]) +} + // jumpEntry scrolls the thread to the next or previous message header. func (v *mailView) jumpEntry(delta int) { if len(v.entryOffsets) == 0 { diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 6e8cdcd1..0cf72ef1 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -294,6 +294,40 @@ func TestMailViewHandlesTopicLoaded(t *testing.T) { } } +// A thread opens on its newest message, where whatever brought the reader to it arrived, +// rather than on the first message of a conversation they have mostly read already. +func TestOpeningAThreadShowsItsLatestMessage(t *testing.T) { + v := mailWithPostings() + v.vc.width = 80 + v.vc.height = 12 + v.topicViewport.SetWidth(80) + v.Update(topicLoadedMsg{ + boxID: 1, + topicID: 100, + title: "Quarterly planning", + entries: []mail.Entry{ + {ID: 1, Creator: mail.Contact{Name: "Maria Gonzalez"}, Body: htmlutil.ToMarkdown(strings.Repeat("
Here are the numbers for the quarter.
", 8))}, + {ID: 2, Creator: mail.Contact{Name: "Sam Rivera"}, Body: htmlutil.ToMarkdown(strings.Repeat("Thanks, a few questions on the forecast.
", 8))}, + {ID: 3, Creator: mail.Contact{Name: "Ana Ortiz"}, Body: htmlutil.ToMarkdown(strings.Repeat("Thursday at ten works for me.
", 8))}, + }, + }) + + if len(v.entryOffsets) != 3 { + t.Fatalf("entry offsets = %v", v.entryOffsets) + } + if got, want := v.topicViewport.YOffset(), v.entryOffsets[2]; got != want { + t.Errorf("thread opened at line %d, want the latest message at %d", got, want) + } + if !strings.Contains(ansi.Strip(v.topicViewport.View()), "Ana Ortiz") { + t.Errorf("the latest message is not on screen: %q", ansi.Strip(v.topicViewport.View())) + } + + v.HandleContentKey(keyPress("k")) + if got, want := v.topicViewport.YOffset(), v.entryOffsets[1]; got != want { + t.Errorf("k should walk back to the message before it: offset %d, want %d", got, want) + } +} + // A thread heads its messages with the centered subject, and a blank line // separates each message header from the content under it. func TestThreadViewShowsSubjectAndSpacesTheHeader(t *testing.T) {