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
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,24 @@ router), `section_view.go` (the interface), plus `nav.go`, `content.go`, `help.g

To add a new section: implement the `sectionView` interface in a new file, add a field and constructor call in `newModel`, and add a case in `switchSection`.

### Recipients are suggested from HEY's composer list

The To, Cc and Bcc fields of every compose form suggest people from
`Contacts().Addressable` — `/autocompletable/contacts/addressable`, the list HEY's web
composer uses, asked for `include_self` as the web asks. It is not `Contacts().List`: that
is a paged search index with no groups, no "Everyone at" and no recently-addressed order.
HEY answers the whole list with no query, so `mailView.loadRecipients` reads it in the
background whenever a composer opens (the SDK's ETag cache makes a repeat a 304) and
`matchRecipients` filters it in memory on every keystroke, against lower-cased copies made
once when the list arrives. Keep it that way: a keystroke must never wait on the network.
The finished list is routed to Mail from any section (`tui.go`), because it can arrive
after the composer has sent. A group is matched by its name only, and picking it writes its
addresses, since HEY takes addresses rather than a group's name.

Esc on a composer holding more than it opened with asks before it closes, and `s` keeps
the message as a HEY draft — `Messages().CreateDraft`, or `Entries().CreateReplyDraft` for a
reply — which is the TUI's version of the web composer never losing a message.

### Each calendar span gives the arrows what it is made of

The three spans are not one screen with a different date on it, so the arrows mean something
Expand Down
1 change: 1 addition & 0 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ forward answers. The drafts index (`/entries/drafts.json`) pages the same way, w
| `/contacts/{id}/note.json` | GET | SDK `Contacts().Note` | `hey contact note show`, Contacts TUI | covered |
| `/contacts/{id}/note.json` | PATCH | SDK `Contacts().SetNote` | `hey contact note set`, Contacts TUI | covered |
| `/contacts/{id}/note.json` | DELETE | SDK `Contacts().DeleteNote` | `hey contact note delete`, Contacts TUI | covered |
| `/autocompletable/contacts/addressable.json` | GET | SDK `Contacts().Addressable` | TUI composer's To, Cc and Bcc suggestions (new message, reply, forward) | covered |
| `/calendars.json` | GET | SDK `Calendars().List` | `hey calendar list` | covered |
| `/calendars/{id}/recordings.json` | GET | SDK `Calendars().GetRecordings` | `hey event list`, `hey event edit <id>` (reading the event back; with `--occurrence`, the occurrence's day and then the series' first day for its `Calendar::Countdown`), `hey event delete <id>` (checking the id is not a day of a series HEY has written out; with `--occurrence --apply-to future`, the occurrence's day), `hey todo list`, `hey journal list` | covered |
| `/topics/{id}/entries.json` | GET | SDK `Topics().GetEntriesPage` | `hey thread read <id>`, `hey attachment list <topic-id>`, TUI thread view; `hey reply <topic-id>`, `hey compose --thread-id`, `hey forward <topic-id>` and TUI `r`/`f` when the topic's own page holds no emailed message (`mail.ReplyTarget`) | covered, but see the paging note above |
Expand Down
25 changes: 24 additions & 1 deletion docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ While any threads are selected, the help bar counts them and offers only what ac
all of them: `e`, `u`, `t` and Ctrl+B. The other thread actions — moving, filing, labels,
collections, spam, ignoring, reply and forward — work on one thread at a time, so they
are refused with a notice rather than quietly acting on the thread under the cursor;
press Escape to clear the selection first. Escape still closes an open form, cancels a
press Escape to clear the selection first. Escape still closes an open form (asking first
when it holds a message you have written), cancels a
thread that is loading and leaves an open thread before it clears a selection, and on
Previously Seen the first Escape clears the selection and the next one leaves. A bulk
action lets go of the threads it acted on once HEY has answered, and keeps them selected if
Expand Down Expand Up @@ -125,6 +126,28 @@ picker. HEY never chooses a default: Enter inserts the selected snippet at the b
cursor, Escape returns without changing the draft, and the picker can be reopened to
insert another.

Typing in To, Cc or Bcc — in a new message, a reply or a forward — opens a short list of
people under the field, from the same list HEY's web composer suggests: the people you
wrote to most recently first, then your contacts by name, then "Everyone at" your
company and your contact groups. It narrows as you type, matching a name or an address,
and leaves out anyone already on that line. ↑ and ↓ (or Ctrl+P and Ctrl+N) move through
it; Tab or Enter adds the highlighted person as `Name <address>, ` in place of what you
were typing, ready for the next one; a group adds every address in it. Backspace right
after a recipient written `Name <address>` takes the whole recipient and its comma in one
press, the way HEY's composer removes a recipient; a bare address you typed still deletes
a letter at a time. Escape closes the
list and nothing else, and moving the cursor closes it too. With the list closed, Tab and
Enter move between fields as they always have. The list is read in the background when
the form opens, so typing never waits on it, and an address typed in full works whether
it arrives or not.

Escape on a message you have written something in asks before it goes: `s` (or Enter)
keeps it in HEY's drafts, where the web app and your phone can pick it up, `d` discards
it, and Escape takes you back to it. A form holding only what it opened with — an empty
new message, or a reply with just HEY's prefilled recipients — closes without asking. A
draft with an address HEY would not deliver to is refused the way a send is, so the
draft never comes back without that recipient.

## Live updates

The mail list follows the server. HEY tells the TUI when a box changed over the same
Expand Down
73 changes: 62 additions & 11 deletions internal/mail/address.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,43 @@ var encodedWord = regexp.MustCompile(`=\?[^?]*\?[bBqQ]\?[^?]*\?=`)
// <annie@example.com> is one recipient.
func SplitAddresses(s string) []string {
var addresses []string
var b strings.Builder
quoted, depth, angled := false, 0, false
flush := func() {
if address := strings.TrimSpace(b.String()); address != "" {
start := 0
for _, comma := range separators(s) {
if address := strings.TrimSpace(s[start:comma]); address != "" {
addresses = append(addresses, address)
}
b.Reset()
start = comma + 1
}
if address := strings.TrimSpace(s[start:]); address != "" {
addresses = append(addresses, address)
}
return addresses
}

// AddressAt returns the byte range of the recipient a cursor at byte offset pos is
// in, commas excluded, split the way SplitAddresses splits: the part of a list that
// someone typing at pos is writing.
func AddressAt(s string, pos int) (start, end int) {
pos = min(max(pos, 0), len(s))
end = len(s)
for _, comma := range separators(s) {
if comma < pos {
start = comma + 1
continue
}
end = comma
break
}
return start, end
}

// separators returns the byte offsets of the commas that separate recipients, leaving
// out the ones inside a quoted name, a comment or angle brackets.
func separators(s string) []int {
var commas []int
quoted, depth, angled := false, 0, false
escaped := false
for _, r := range s {
for i, r := range s {
switch {
case escaped:
escaped = false
Expand All @@ -52,13 +79,25 @@ func SplitAddresses(s string) []string {
case r == '>':
angled = false
case r == ',' && !angled:
flush()
continue
commas = append(commas, i)
}
b.WriteRune(r)
}
flush()
return addresses
return commas
}

// FormatAddress writes a recipient the way a person would type it: the bare address
// when there is no name to add, and Name <address> otherwise, quoting a name that
// holds a character that would split or end it, as the mail gem HEY parses with does.
func FormatAddress(name, address string) string {
name = strings.TrimSpace(name)
address = strings.TrimSpace(address)
if name == "" || strings.EqualFold(name, address) {
return address
}
if strings.ContainsAny(name, `()<>[]:;@\,."`) {
name = `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(name) + `"`
}
return name + " <" + address + ">"
}

// InvalidAddress returns the first recipient HEY would certainly not deliver to, or ""
Expand Down Expand Up @@ -98,6 +137,18 @@ func deliverable(address string) bool {
return deliverableDomain(spec[at+1:])
}

// BareAddress returns the address a recipient names, without its display name,
// angle brackets or comments: "Jane Doe <jane@example.com> (work)" and
// "jane@example.com (Jane Doe)" are both jane@example.com. A recipient HEY's
// parser would refuse, or text with no address in it, is returned trimmed, as
// written.
func BareAddress(recipient string) string {
if spec, _, ok := addrSpec(recipient); ok && strings.Contains(spec, "@") {
return spec
}
return strings.TrimSpace(recipient)
}

// addrSpec returns the bare address and the fewest characters HEY could write the
// whole address out in, so that a size over the limit is over it for HEY too. ok is
// false for what HEY's parser refuses outright: an unclosed angle bracket, or a second
Expand Down
82 changes: 82 additions & 0 deletions internal/mail/address_at_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package mail

import (
"strings"
"testing"
)

func TestAddressAtFindsTheRecipientUnderTheCursor(t *testing.T) {
list := `Jane Doe <jane@example.com>, "Bryan, Annie" <annie@example.com>, ric`
for _, tc := range []struct {
pos int
want string
}{
{0, "Jane Doe <jane@example.com>"},
{10, "Jane Doe <jane@example.com>"},
// On the comma itself the cursor still belongs to the recipient before it.
{strings.Index(list, ">,") + 1, "Jane Doe <jane@example.com>"},
{strings.Index(list, "Annie"), ` "Bryan, Annie" <annie@example.com>`},
{len(list), " ric"},
{len(list) + 10, " ric"},
{-3, "Jane Doe <jane@example.com>"},
} {
start, end := AddressAt(list, tc.pos)
if got := list[start:end]; got != tc.want {
t.Errorf("AddressAt(%d) = %q, want %q", tc.pos, got, tc.want)
}
}

if start, end := AddressAt("", 0); start != 0 || end != 0 {
t.Errorf("AddressAt on an empty list = %d, %d", start, end)
}
if start, end := AddressAt("jane@example.com, ", 18); start != 17 || end != 18 {
t.Errorf("AddressAt after a trailing comma = %d, %d, want the empty recipient at the end", start, end)
}
}

func TestBareAddressDropsTheNameAndComments(t *testing.T) {
for recipient, want := range map[string]string{
"jane@example.com": "jane@example.com",
" jane@example.com ": "jane@example.com",
"Jane Doe <jane@example.com>": "jane@example.com",
"Jane Doe <jane@example.com> (work)": "jane@example.com",
"jane@example.com (Jane Doe)": "jane@example.com",
`"Bryan, Annie" <annie@example.com>`: "annie@example.com",
"(personal) rick@example.org": "rick@example.org",
"not an address": "not an address",
"Jane Doe <jane@example.com": "Jane Doe <jane@example.com",
} {
if got := BareAddress(recipient); got != want {
t.Errorf("BareAddress(%q) = %q, want %q", recipient, got, want)
}
}
}

func TestFormatAddressWritesWhatHEYParsesBack(t *testing.T) {
for _, tc := range []struct {
name, address, want string
}{
{"", "jane@example.com", "jane@example.com"},
{"jane@example.com", "jane@example.com", "jane@example.com"},
{"JANE@example.com", "jane@example.com", "jane@example.com"},
{"Jane Doe", "jane@example.com", "Jane Doe <jane@example.com>"},
{"Bryan, Annie", "annie@example.com", `"Bryan, Annie" <annie@example.com>`},
{`Rick "The Man" Sanchez`, "rick@example.org", `"Rick \"The Man\" Sanchez" <rick@example.org>`},
{"J. Smith", "j.smith@example.org", `"J. Smith" <j.smith@example.org>`},
{"Zoë Martín", "zoe@example.com", "Zoë Martín <zoe@example.com>"},
} {
got := FormatAddress(tc.name, tc.address)
if got != tc.want {
t.Errorf("FormatAddress(%q, %q) = %q, want %q", tc.name, tc.address, got, tc.want)
continue
}
// Whatever is written has to survive the trip back through the list parsing
// and HEY's delivery rule as one recipient.
if split := SplitAddresses(got + ", someone@example.com"); len(split) != 2 || split[0] != got {
t.Errorf("FormatAddress(%q, %q) split into %q", tc.name, tc.address, split)
}
if bad := InvalidAddress([]string{got}); bad != "" {
t.Errorf("FormatAddress(%q, %q) = %q, which HEY would drop", tc.name, tc.address, got)
}
}
}
2 changes: 1 addition & 1 deletion internal/tui/calendar_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func (f *calendarSettingsForm) handleKey(msg tea.KeyPressMsg) bool {
}

switch {
case msg.Key().Code == tea.KeyTab && msg.Key().Mod == tea.ModShift:
case msg.String() == "shift+tab":
f.step(-1)
return false
case msg.Key().Code == tea.KeyTab, msg.Key().Code == tea.KeyEnter:
Expand Down
Loading
Loading