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
153 changes: 124 additions & 29 deletions internal/tui/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Comment thread
jeremy marked this conversation as resolved.
v.threadBoxKind = msg.destinationKind
Comment thread
jeremy marked this conversation as resolved.
}
if msg.seen {
return v.applySeenPostingAction(msg), true
}
Expand All @@ -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
Expand Down Expand Up @@ -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"})
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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 = ""
Expand Down Expand Up @@ -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 {
Comment thread
jeremy marked this conversation as resolved.
return nil
}
return &v.threadPosting
Comment thread
jeremy marked this conversation as resolved.
}

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":
Expand All @@ -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":
Expand Down Expand Up @@ -2350,23 +2445,34 @@ 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)
})
}
v.notice = "Imbox is unavailable"
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)
Comment thread
jeremy marked this conversation as resolved.
return func() tea.Msg {
done, ok := move().(postingActionDoneMsg)
if !ok {
return nil
}
done.destinationKind = kind
return done
}
}

func (v *mailView) boxMoveEffect() postingActionEffect {
Expand All @@ -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
Expand Down
Loading
Loading