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
7 changes: 5 additions & 2 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,11 @@ Most of those keep working while you are reading a thread, the way the web app's
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.
trashed thread is not somewhere you file out of. Where you opened the thread from does
not come into it: a bundle, a contact's threads, a label and a search all work, because
every thread carries the box it is in and files out of that one rather than out of the
list you found it through. Filing takes the row out of the list you were reading. Only a
thread opened by its id has no row behind it, 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
Expand Down
7 changes: 6 additions & 1 deletion internal/mail/posting.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import (
// formatting a time into a string and parsing it back is how a reader east of UTC ends
// up looking at yesterday's date.
type Posting struct {
ID int64
ID int64
// BoxID is the box this row is in, which HEY serves on every posting. A list can
// draw rows from several boxes at once — a search, a label, a contact's threads —
// so the box a thread files out of is the row's own rather than the list's.
BoxID int64
TopicID int64
CreatedAt time.Time
Name string
Expand Down Expand Up @@ -73,6 +77,7 @@ func Postings(postings []generated.Posting) []Posting {
func NewPosting(posting generated.Posting) Posting {
return Posting{
ID: posting.Id,
BoxID: posting.BoxId,
TopicID: TopicIDOf(posting),
CreatedAt: posting.CreatedAt,
Name: terminal.SanitizeLine(posting.Name),
Expand Down
6 changes: 6 additions & 0 deletions internal/mail/posting_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ func TestNewPostingKeepsWhatARowShows(t *testing.T) {

posting := NewPosting(generated.Posting{
Id: 4471829,
BoxId: 91,
AppUrl: "https://app.hey.com/topics/501",
CreatedAt: created,
Kind: "topic",
Expand All @@ -35,6 +36,11 @@ func TestNewPostingKeepsWhatARowShows(t *testing.T) {
if posting.ID != 4471829 || posting.TopicID != 501 || posting.Name != "Kitchen remodel quote" {
t.Errorf("posting = %+v", posting)
}
// The box is the row's own, not the list's: it is what a thread found through a
// search, a label or a contact files out of.
if posting.BoxID != 91 {
t.Errorf("box = %d, want the box HEY served the row in", posting.BoxID)
}
// HEY's `bundled` means filed inside a bundle; only kind "bundle" makes a row one.
if !posting.Seen || posting.IsBundle || !posting.BubbledUp || !posting.Muted || posting.VisibleEntryCount != 3 {
t.Errorf("posting state = %+v", posting)
Expand Down
50 changes: 47 additions & 3 deletions internal/tui/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) {
if opened := v.openedPosting(msg.postingID); opened != nil {
v.threadPosting = *opened
}
v.threadBoxKind = v.actionBoxKind()
v.threadBoxKind = v.postingBoxKind(v.threadPosting)
v.topicName = msg.title
v.entries = msg.entries
v.attachments = msg.attachments
Expand Down Expand Up @@ -734,6 +734,9 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) {
v.postingList.postings[idx].Muted = false
}
}
if msg.effect == postingActionRemove {
v.removeFromOverlaidLists(msg.postingID)
}
// 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.
Expand Down Expand Up @@ -1960,6 +1963,18 @@ func (v *mailView) removePostingAt(index int) {
v.postingList.removeAt(index)
}

// removeFromOverlaidLists takes a row out of the lists drawn over the box list. Nothing
// re-reads those — a search's results and a bundle's threads are drawn once when they
// open — so a row left behind stays on screen offering to file a thread that has
// already moved.
func (v *mailView) removeFromOverlaidLists(postingID int64) {
for _, list := range []*contentList{&v.searchList, &v.bundleList} {
if index := postingIndexIn(list.postings, postingID); index >= 0 {
list.removeAt(index)
}
}
}

func (v *mailView) moveAttachmentCursor(delta int) {
if len(v.attachments) == 0 {
return
Expand Down Expand Up @@ -2400,8 +2415,13 @@ func (v *mailView) openThreadPicker(key string) tea.Cmd {
// 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.
//
// Where the thread was opened from does not come into it. Every posting HEY serves
// carries its own box, so a thread found through a search, a bundle, a contact or a
// label files out of the box it is actually in rather than out of whatever list is
// behind it. Only a topic opened by its id has no row, and nothing to file.
func (v *mailView) fileablePosting() *mail.Posting {
if v.searchActive || v.bundleActive || v.threadPosting.ID == 0 {
if v.threadPosting.ID == 0 {
return nil
}
return &v.threadPosting
Expand All @@ -2412,7 +2432,7 @@ func (v *mailView) handlePostingAction(key string) tea.Cmd {
if selected == nil {
return nil
}
return v.postingAction(key, *selected, v.actionBoxKind())
return v.postingAction(key, *selected, v.postingBoxKind(*selected))
}

// actionBoxKind is the box kind a list row files out of, empty over a source that
Expand All @@ -2424,6 +2444,30 @@ func (v *mailView) actionBoxKind() string {
return ""
}

// postingBoxKind is the box kind a posting files out of, taken from the posting's own
// box rather than the list showing it. A search, a label, a collection and a contact's
// threads all draw rows from several boxes at once, so the list's box says nothing
// about where any one row lives. Falls back to the list for a row HEY served without
// a box.
func (v *mailView) postingBoxKind(p mail.Posting) string {
if kind := v.boxKindOf(p.BoxID); kind != "" {
return kind
}
return v.actionBoxKind()
}

func (v *mailView) boxKindOf(boxID int64) string {
if boxID == 0 {
return ""
}
for i := range v.boxes {
if v.boxes[i].Kind == mail.KindBox && v.boxes[i].ID == boxID {
return v.boxes[i].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
Expand Down
Loading
Loading