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 @@ -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.
Expand Down
14 changes: 13 additions & 1 deletion internal/tui/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions internal/tui/mail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("<p>Here are the numbers for the quarter.</p>", 8))},
{ID: 2, Creator: mail.Contact{Name: "Sam Rivera"}, Body: htmlutil.ToMarkdown(strings.Repeat("<p>Thanks, a few questions on the forecast.</p>", 8))},
{ID: 3, Creator: mail.Contact{Name: "Ana Ortiz"}, Body: htmlutil.ToMarkdown(strings.Repeat("<p>Thursday at ten works for me.</p>", 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) {
Expand Down
Loading