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
5 changes: 5 additions & 0 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ screens them out, Tab moves to Screener History and back, `X` clears the whole S
after a confirmation, and Escape or `q` returns to mail. Both lists keep going as you
scroll, the same way the mail list does.

Space opens a bigger preview of the selected sender's most recent email: the whole message,
not just the first line the list has room for. Arrow keys and PgUp/PgDn scroll it, `y` and
`n` answer for that sender straight from it, and Space or Escape closes it again. Opening a
preview only reads the email; it doesn't screen the sender or tell them anything.

## Imbox cover art

The Imbox can wear cover art, the way the HEY web app does: everything you have already
Expand Down
42 changes: 39 additions & 3 deletions internal/tui/screener.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ var screenerTabItems = []navItem{
// someone already screened in or out.
type screenerRow struct {
id int64
entryID int64 // the most recent email they sent, which space previews; pending rows only
name string
email string
subject string // subject of what they sent, or the address they write from; pending rows only
Expand Down Expand Up @@ -244,6 +245,9 @@ type screenerView struct {
liveRequestID uint64 // identifies the only live re-read allowed to update the queue
moreRequestID uint64 // identifies the only page-below read allowed to grow a pane
mutations int

preview *screenerPreview // space's full look at a sender's email, nil when closed
previewRequestID uint64 // identifies the only preview read allowed to fill it
}

func newScreenerView(vc *viewContext) *screenerView {
Expand All @@ -253,15 +257,24 @@ func newScreenerView(vc *viewContext) *screenerView {
func (v *screenerView) Init() tea.Cmd {
v.notice = ""
v.confirmingClear = false
v.closePreview()
v.tab = screenerPendingTab
return v.requestPending()
}

// Restyle is a no-op: the screener keeps plain rows and styles them on every View.
func (v *screenerView) Restyle() {}
// Restyle renders an open preview again; the rows are plain and styled on every View.
func (v *screenerView) Restyle() {
if v.preview != nil {
v.preview.width = 0
Comment on lines +266 to +268
}
}

func (v *screenerView) Update(msg tea.Msg) (tea.Cmd, bool) {
switch msg := msg.(type) {
case screenerPreviewLoadedMsg:
v.previewLoaded(msg)
return nil, true

case screenerPendingLoadedMsg:
if msg.requestID != v.requestID {
return nil, true
Expand Down Expand Up @@ -351,6 +364,9 @@ func (v *screenerView) View() string {
if v.confirmingClear {
return v.clearConfirmationView()
}
if v.preview != nil {
return v.previewView()
}

var b strings.Builder
if v.notice != "" {
Expand Down Expand Up @@ -382,10 +398,18 @@ func (v *screenerView) HelpBindings() []helpBinding {
{"n/esc", "cancel"},
}
}
if v.preview != nil {
return []helpBinding{
{"↑↓", "scroll"},
{key: v.screenQuestion()},
{"space/esc", "close preview"},
}
}
bindings := []helpBinding{{"↑↓", "navigate"}}
if v.tab == screenerPendingTab {
bindings = append(bindings,
helpBinding{key: v.screenQuestion()},
helpBinding{"space", "preview"},
helpBinding{"tab", "screener history"},
)
} else {
Expand Down Expand Up @@ -425,6 +449,9 @@ func (v *screenerView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd {
if v.confirmingClear {
return v.handleClearConfirmationKey(msg)
}
if v.preview != nil {
return v.handlePreviewKey(msg)
}

key := msg.String()
if key == "F" {
Expand Down Expand Up @@ -462,6 +489,8 @@ func (v *screenerView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd {
return v.screen(hey.ClearanceApproved)
case "n":
return v.screen(hey.ClearanceDenied)
case "space", " ":
return v.openPreview()
case "X":
v.confirmingClear = true
v.notice = ""
Expand Down Expand Up @@ -567,7 +596,13 @@ func (v *screenerView) screen(status string) tea.Cmd {
if row == nil {
return nil
}
clearanceID, name := row.id, screenerRowName(*row)
return v.screenRow(*row, status)
}

// screenRow answers Yes or No for one sender: the one under the cursor, or the one open
// in the preview.
func (v *screenerView) screenRow(row screenerRow, status string) tea.Cmd {
clearanceID, name := row.id, screenerRowName(row)
v.mutations++
v.notice = ""
return func() tea.Msg {
Expand Down Expand Up @@ -717,6 +752,7 @@ func pendingScreenerRow(clearance generated.Clearance) screenerRow {
subject, summary := clearanceEntryParts(clearance)
return screenerRow{
id: clearance.Id,
entryID: clearance.MostRecentEntry.Id,
name: terminal.SanitizeLine(clearance.Petitioner.Name),
email: terminal.SanitizeLine(clearance.Petitioner.EmailAddress),
subject: subject,
Expand Down
199 changes: 199 additions & 0 deletions internal/tui/screener_preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package tui

import (
"fmt"
"strings"

"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"

hey "github.com/basecamp/hey-sdk/go/pkg/hey"

"github.com/basecamp/hey-cli/internal/htmlutil"
"github.com/basecamp/hey-cli/internal/markdown"
)

// screenerPreviewLoadedMsg is the email a sender in The Screener wrote, read so the
// reader can see all of it before deciding. requestID is the only preview read allowed
// to fill the preview, so a slow answer for a sender the reader already closed is dropped.
type screenerPreviewLoadedMsg struct {
requestID uint64
clearanceID int64
body htmlutil.Markdown
err error
}

// screenerPreview is space's bigger look at the sender under the cursor: their most
// recent email in full, over the queue, where a row only has room for its first line.
// It is read-only — the answer is still Yes or No, and either one closes it.
type screenerPreview struct {
row screenerRow
loading bool
failure string
body htmlutil.Markdown
width int // the width body was last rendered at, 0 when it needs rendering again
viewport viewport.Model
}

// openPreview reads the most recent email of the sender under the cursor. HEY serves its
// entry with each clearance, so this is one message read; nothing about it marks the
// email seen or answers the sender.
func (v *screenerView) openPreview() tea.Cmd {
if v.tab != screenerPendingTab {
return nil
}
row := v.pane().selected()
if row == nil {
return nil
}
if row.entryID == 0 {
v.notice = "HEY sent nothing to preview for " + screenerRowName(*row)
return nil
}
v.previewRequestID++
requestID := v.previewRequestID
v.notice = ""
v.preview = &screenerPreview{
row: *row,
loading: true,
viewport: viewport.New(viewport.WithWidth(0), viewport.WithHeight(0)),
}
sdk, ctx, entryID, clearanceID := v.vc.sdk, v.vc.ctx, row.entryID, row.id
return func() tea.Msg {
message, err := sdk.Messages().Get(ctx, entryID)
if err == nil && message == nil {
err = fmt.Errorf("message %d returned no data", entryID)
}
if err != nil {
return screenerPreviewLoadedMsg{requestID: requestID, clearanceID: clearanceID, err: err}
}
return screenerPreviewLoadedMsg{requestID: requestID, clearanceID: clearanceID, body: htmlToMarkdown(message.Content)}
}
}

func (v *screenerView) previewLoaded(msg screenerPreviewLoadedMsg) {
if v.preview == nil || msg.requestID != v.previewRequestID {
return
}
v.preview.loading = false
if msg.err != nil {
v.preview.failure = errorNotice("Could not load the email", msg.err)
return
}
v.preview.body = msg.body
v.preview.width = 0
v.layoutPreview()
v.preview.viewport.GotoTop()
}

func (v *screenerView) closePreview() {
v.preview = nil
v.previewRequestID++
}

// handlePreviewKey is every key while the preview is open. Yes and No answer for the
// sender being previewed — not whoever a live re-read has since put under the cursor —
// and put the preview away with the decision.
func (v *screenerView) handlePreviewKey(msg tea.KeyPressMsg) tea.Cmd {
key := msg.String()
switch {
case key == "space" || key == " " || key == "q" || msg.Key().Code == tea.KeyEscape:
v.closePreview()
return nil
case key == "F":
v.emphaticNo = !v.emphaticNo
return nil
case key == "y" || key == "i" || key == "n":
row := v.preview.row
v.closePreview()
status := hey.ClearanceApproved
if key == "n" {
status = hey.ClearanceDenied
}
return v.screenRow(row, status)
}
if v.preview.loading || v.preview.failure != "" {
return nil
}
v.layoutPreview()
var cmd tea.Cmd
v.preview.viewport, cmd = v.preview.viewport.Update(msg)
return cmd
}

// previewHeader is who wrote, what about and when, above the body. Each line is cut to
// the width before it is styled, the date's room held back first: a line that wrapped
// would make the header taller than layoutPreview counts, and push the preview off the
// bottom of the screen.
func (v *screenerView) previewHeader() string {
row := v.preview.row
width := max(v.vc.width, 20)
date := ""
if row.trailing != "" {
date = " " + row.trailing
}
room := max(width-2-displayWidth(date), 1)
name, address := row.name, ""
if name == "" {
name = row.email
} else if row.email != "" {
address = " <" + row.email + ">"
}
name = truncateStr(name, room)
address = truncateStr(address, room-displayWidth(name))
subject := row.subject
if subject == row.email {
subject = ""
}
subject = truncateStr(subject, width-2)

var b strings.Builder
b.WriteString(" " + lipgloss.NewStyle().Foreground(colorBright).Bold(true).Render(name))
b.WriteString(lipgloss.NewStyle().Foreground(colorBright).Render(address))
b.WriteString(styleMuted.Render(date) + "\n")
b.WriteString(" " + lipgloss.NewStyle().Foreground(colorLink).Bold(true).Render(subject) + "\n\n")
return b.String()
}

// layoutPreview fits the viewport under the header and renders the body at the width
// there is, again only when the width changed since the last time.
func (v *screenerView) layoutPreview() {
preview := v.preview
headerLines := strings.Count(v.previewHeader(), "\n")
if v.notice != "" {
headerLines++
}
preview.viewport.SetWidth(max(v.vc.width, 1))
preview.viewport.SetHeight(max(v.vc.height-headerLines, 1))
width := max(v.vc.width-4, 40)
if preview.loading || preview.failure != "" || preview.width == width {
return
}
preview.width = width
content := markdown.Render(preview.body, width)
if preview.body.IsEmpty() {
content = styleMuted.Render("(this email has no text)")
}
offset := preview.viewport.YOffset()
preview.viewport.SetContent(content)
preview.viewport.SetYOffset(offset)
}

func (v *screenerView) previewView() string {
var b strings.Builder
if v.notice != "" {
b.WriteString(v.vc.styles.title.Render(truncateStr(v.notice, max(v.vc.width, 1))) + "\n")
}
b.WriteString(v.previewHeader())
switch {
case v.preview.loading:
b.WriteString(styleMuted.Render(" Loading…"))
case v.preview.failure != "":
b.WriteString(styleMuted.Render(" " + v.preview.failure))
default:
v.layoutPreview()
b.WriteString(v.preview.viewport.View())
}
return b.String()
}
Loading
Loading