diff --git a/app/app.go b/app/app.go index 5059821e..d0c4c394 100644 --- a/app/app.go +++ b/app/app.go @@ -1947,6 +1947,22 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.showErrorNotif = false return m, nil + case tui.InfoNotifyMsg: + dur := time.Duration(msg.Duration * float64(time.Second)) + if dur <= 0 { + dur = 2 * time.Second + } + col := max(0, m.width-44) + m.errorNotification = overlay.NewInfo( + overlay.WithMessage(msg.Message), + overlay.WithKey(config.Keybinds.Global.DismissNotification), + overlay.WithPosition(0, col), + overlay.WithDismissMode(overlay.DismissAfterTimer), + overlay.WithDuration(dur), + ) + m.showErrorNotif = true + return m, tea.Tick(dur, func(time.Time) tea.Msg { return clearErrorNotifMsg{} }) + case tui.NotifyMsg: return m, m.showErrorCmd(msg.Message) diff --git a/config/cache.go b/config/cache.go index f0c4d98e..a4f5d507 100644 --- a/config/cache.go +++ b/config/cache.go @@ -556,6 +556,10 @@ type Draft struct { InReplyTo string `json:"in_reply_to,omitempty"` References []string `json:"references,omitempty"` QuotedText string `json:"quoted_text,omitempty"` + SignSMIME bool `json:"sign_smime,omitempty"` + EncryptSMIME bool `json:"encrypt_smime,omitempty"` + SignPGP bool `json:"sign_pgp,omitempty"` + EncryptPGP bool `json:"encrypt_pgp,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 6163ce6a..29f221d0 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -32,6 +32,7 @@ "attachments_none": "None", "enter_to_add": "Enter to add", "encrypt_smime": "Encrypt Email (S/MIME)", + "sign_smime": "Sign Email (S/MIME)", "sign_pgp": "Sign Email (PGP)", "encrypt_pgp": "Encrypt Email (PGP)", "send": "Send", diff --git a/pgp/setup.go b/pgp/setup.go new file mode 100644 index 00000000..c91baca6 --- /dev/null +++ b/pgp/setup.go @@ -0,0 +1,65 @@ +package pgp + +import ( + "os" + "path/filepath" + "strings" + + "github.com/floatpane/matcha/config" +) + +// HasSMIMESetup reports whether the account has S/MIME signing configured. +func HasSMIMESetup(acc *config.Account) bool { + return acc != nil && acc.SMIMECert != "" && acc.SMIMEKey != "" +} + +// HasSMIMECertForRecipient reports whether a usable S/MIME certificate exists +// for the given recipient. For the account's own address it checks the +// configured S/MIME certificate; otherwise it looks in the certs directory. +func HasSMIMECertForRecipient(recipient string, acc *config.Account) bool { + if acc == nil { + return false + } + email := strings.ToLower(strings.TrimSpace(recipient)) + if email == "" { + return false + } + if strings.EqualFold(email, acc.Email) && acc.SMIMECert != "" { + return true + } + cfgDir, err := config.GetConfigDir() + if err != nil { + return false + } + certPath := filepath.Join(cfgDir, "certs", email+".pem") + if _, err := os.Stat(certPath); err == nil { + return true + } + return false +} + +// HasPGPSetup reports whether the account has PGP configured (file-based or +// YubiKey hardware key). +func HasPGPSetup(acc *config.Account) bool { + return acc != nil && (acc.PGPKeySource != "" || acc.PGPPublicKey != "") +} + +// HasLocalKeyForRecipient reports whether a public key file for the recipient +// exists in the configured PGP directory. +func HasLocalKeyForRecipient(recipient string) (bool, error) { + email := strings.ToLower(strings.TrimSpace(recipient)) + if email == "" { + return false, nil + } + cfgDir, err := config.GetConfigDir() + if err != nil { + return false, err + } + pgpDir := filepath.Join(cfgDir, "pgp") + for _, ext := range []string{".asc", ".gpg", ".pem"} { + if _, err := os.Stat(filepath.Join(pgpDir, email+ext)); err == nil { + return true, nil + } + } + return false, nil +} diff --git a/tui/composer.go b/tui/composer.go index 77b02050..9cbe6dad 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -54,6 +54,7 @@ const ( focusSignature focusAttachment focusEncryptSMIME + focusSignSMIME focusSignPGP focusEncryptPGP focusSend @@ -76,6 +77,7 @@ type Composer struct { attachmentNames map[string]string attachmentCursor int encryptSMIME bool + signSMIME bool signPGP bool encryptPGP bool width int @@ -139,14 +141,19 @@ type Composer struct { // WKD key download confirmation wkdConfirmRecipients []string // recipients missing local keys, awaiting confirmation + + // PGP keys discovered for recipients (local or WKD). Used to decide when to + // show the PGP encryption toggle. + pgpKeysAvailable map[string]bool } // NewComposer initializes a new composer model. func NewComposer(from, to, subject, body string, hideTips bool) *Composer { m := &Composer{ - draftID: uuid.New().String(), - hideTips: hideTips, - attachmentNames: make(map[string]string), + draftID: uuid.New().String(), + hideTips: hideTips, + attachmentNames: make(map[string]string), + pgpKeysAvailable: make(map[string]bool), } tiStyles := ThemedTextInputStyles() @@ -316,63 +323,195 @@ func (m *Composer) updatePGPDefaults() { } } -func (m *Composer) missingRecipientKeys() []string { - cfgDir, err := config.GetConfigDir() - if err != nil { - return nil +func (m *Composer) updateSMIMEDefaults() { + if acc := m.getSelectedAccount(); acc != nil { + m.signSMIME = acc.SMIMESignByDefault } - pgpDir := cfgDir + "/pgp" +} - var allRecipients []string +func (m *Composer) signSMIMEEnabled() bool { + acc := m.getSelectedAccount() + if !pgp.HasSMIMESetup(acc) { + return false + } + return m.signSMIME +} + +func (m *Composer) pgpRecipients() []string { + var recipients []string for _, s := range []string{m.toInput.Value(), m.ccInput.Value(), m.bccInput.Value()} { for _, r := range strings.Split(s, ",") { - if trimmed := strings.TrimSpace(r); trimmed != "" { - allRecipients = append(allRecipients, trimmed) + if email := strings.TrimSpace(r); email != "" { + recipients = append(recipients, email) } } } - return pgp.MissingLocalKeys(pgpDir, allRecipients) + return recipients +} + +func (m *Composer) hasPGPKeyAvailable() bool { + for _, email := range m.pgpRecipients() { + if m.pgpKeysAvailable[email] { + return true + } + } + return false } -func (m *Composer) downloadWKDKeysCmd(recipients []string) tea.Cmd { +func (m *Composer) missingRecipientKeys() []string { + var missing []string + for _, email := range m.pgpRecipients() { + if !m.pgpKeysAvailable[email] { + missing = append(missing, email) + } + } + return missing +} + +func (m *Composer) hasSMIMECertForAnyRecipient() bool { + acc := m.getSelectedAccount() + if !pgp.HasSMIMESetup(acc) { + return false + } + for _, email := range m.pgpRecipients() { + if email != "" && pgp.HasSMIMECertForRecipient(email, acc) { + return true + } + } + return false +} + +func (m *Composer) refreshPGPKeyAvailability() tea.Cmd { + var cmds []tea.Cmd + for _, email := range m.pgpRecipients() { + if !m.pgpKeysAvailable[email] { + cmds = append(cmds, m.lookupPGPKeyCmd(email)) + } + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Composer) lookupPGPKeyCmd(email string) tea.Cmd { + return func() tea.Msg { + found, err := pgp.HasLocalKeyForRecipient(email) + if err == nil && found { + return pgpKeyFoundMsg{email: email, source: "local"} + } + entity, err := pgp.LookupWKD(email) + if err != nil { + return nil + } + return pgpKeyFoundMsg{email: email, source: "wkd", entity: entity} + } +} + +type pgpKeyFoundMsg struct { + email string + source string + entity interface{} +} + +func (m *Composer) installPGPKeyCmd(email string) tea.Cmd { return func() tea.Msg { cfgDir, err := config.GetConfigDir() if err != nil { - return NotifyMsg{Message: "WKD: could not determine config directory"} + return NotifyMsg{Message: "PGP: could not determine config directory"} } pgpDir := cfgDir + "/pgp" - - var downloaded []string - var failed []string - for _, email := range recipients { - entity, err := pgp.LookupWKD(email) - if err != nil { - failed = append(failed, email) - continue - } - if err := pgp.CacheWKDKey(pgpDir, email, entity); err != nil { - failed = append(failed, email) - continue - } - downloaded = append(downloaded, email) + entity, err := pgp.LookupWKD(email) + if err != nil { + return NotifyMsg{Message: fmt.Sprintf("PGP: could not download key for %s", email)} + } + if err := pgp.CacheWKDKey(pgpDir, email, entity); err != nil { + return NotifyMsg{Message: fmt.Sprintf("PGP: could not save key for %s", email)} } + return InfoNotifyMsg{Message: fmt.Sprintf("PGP key downloaded for %s", email), Duration: 2} + } +} - if len(downloaded) > 0 { - m.encryptPGP = true +func (m *Composer) visibleCryptoToggles() []int { + selAcc := m.getSelectedAccount() + hasSMIME := pgp.HasSMIMESetup(selAcc) + hasPGP := pgp.HasPGPSetup(selAcc) + + var toggles []int + if hasSMIME && m.hasSMIMECertForAnyRecipient() { + toggles = append(toggles, focusEncryptSMIME) + } + if hasSMIME { + toggles = append(toggles, focusSignSMIME) + } + if hasPGP { + toggles = append(toggles, focusSignPGP) + } + if hasPGP && m.hasPGPKeyAvailable() { + toggles = append(toggles, focusEncryptPGP) + } + return toggles +} + +func (m *Composer) orderedFocusIndices() []int { + minFocus := focusFrom + // Skip From field if only one non-catch-all account (nothing to switch or edit) + if len(m.accounts) <= 1 && !m.isCatchAllAccount() { + minFocus = focusTo + } + + indices := make([]int, 0, 8+len(m.visibleCryptoToggles())+1) + indices = append(indices, + focusFrom, + focusTo, + focusCc, + focusBcc, + focusSubject, + focusBody, + focusSignature, + focusAttachment, + ) + indices = append(indices, m.visibleCryptoToggles()...) + indices = append(indices, focusSend) + + result := make([]int, 0, len(indices)) + for _, idx := range indices { + if idx >= minFocus { + result = append(result, idx) } + } + return result +} + +func (m *Composer) advanceFocus(focusList []int) { + m.cycleFocus(focusList, 1) +} - var msg string - switch { - case len(downloaded) > 0 && len(failed) > 0: - msg = fmt.Sprintf("WKD: downloaded keys for %s; failed for %s", - strings.Join(downloaded, ", "), strings.Join(failed, ", ")) - case len(downloaded) > 0: - msg = fmt.Sprintf("WKD: downloaded keys for %s", strings.Join(downloaded, ", ")) - default: - msg = fmt.Sprintf("WKD: no keys found for %s", strings.Join(failed, ", ")) +func (m *Composer) retreatFocus(focusList []int) { + m.cycleFocus(focusList, -1) +} + +func (m *Composer) cycleFocus(focusList []int, delta int) { + if len(focusList) == 0 { + return + } + pos := -1 + for i, f := range focusList { + if f == m.focusIndex { + pos = i + break } - return NotifyMsg{Message: msg} } + if pos == -1 { + if delta > 0 { + pos = -1 + } else { + pos = len(focusList) + } + } + pos += delta + pos = ((pos % len(focusList)) + len(focusList)) % len(focusList) + m.focusIndex = focusList[pos] } // NewComposerWithAccounts initializes a composer with multiple account support. @@ -389,6 +528,7 @@ func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string } m.updateSignature() m.updatePGPDefaults() + m.updateSMIMEDefaults() return m } @@ -894,6 +1034,17 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo } return m, nil + case pgpKeyFoundMsg: + if msg.email != "" { + m.pgpKeysAvailable[msg.email] = true + } + if msg.source == "wkd" { + return m, func() tea.Msg { + return InfoNotifyMsg{Message: fmt.Sprintf("PGP key found for %s", msg.email), Duration: 2} + } + } + return m, nil + case FileSelectedMsg: // Avoid duplicates and add all selected paths for _, newPath := range msg.Paths { @@ -1030,9 +1181,9 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo if len(m.wkdConfirmRecipients) > 0 { switch msg.String() { case "y", "Y": - recipients := m.wkdConfirmRecipients + recipient := m.wkdConfirmRecipients[0] m.wkdConfirmRecipients = nil - return m, m.downloadWKDKeysCmd(recipients) + return m, m.installPGPKeyCmd(recipient) case "n", "N", "esc": m.wkdConfirmRecipients = nil return m, nil @@ -1113,34 +1264,11 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo case kb.Composer.NextField, kb.Composer.PrevField: previousFocus := m.focusIndex + focusList := m.orderedFocusIndices() if msg.String() == kb.Composer.PrevField { - m.focusIndex-- + m.retreatFocus(focusList) } else { - m.focusIndex++ - } - - maxFocus := focusSend - minFocus := focusFrom - // Skip From field if only one non-catch-all account (nothing to switch or edit) - if len(m.accounts) <= 1 && !m.isCatchAllAccount() { - minFocus = focusTo - } - - if m.focusIndex > maxFocus { - m.focusIndex = minFocus - } else if m.focusIndex < minFocus { - m.focusIndex = maxFocus - } - - // Skip PGP focus states when PGP is not configured for the account. - if selAcc := m.getSelectedAccount(); selAcc == nil || (selAcc.PGPKeySource == "" && selAcc.PGPPublicKey == "") { - if m.focusIndex == focusSignPGP || m.focusIndex == focusEncryptPGP { - if msg.String() == kb.Composer.PrevField { - m.focusIndex = focusEncryptSMIME - } else { - m.focusIndex = focusSend - } - } + m.advanceFocus(focusList) } if previousFocus == focusFrom { @@ -1208,6 +1336,12 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo } return m, nil + case focusSignSMIME: + if msg.String() == keyEnter || msg.String() == " " { + m.signSMIME = !m.signSMIME + } + return m, nil + case focusSignPGP: if msg.String() == keyEnter || msg.String() == " " { m.signPGP = !m.signPGP @@ -1261,7 +1395,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo InReplyTo: m.inReplyTo, References: m.references, Signature: m.signatureInput.Value(), - SignSMIME: acc != nil && acc.SMIMESignByDefault, + SignSMIME: m.signSMIMEEnabled(), EncryptSMIME: m.encryptSMIME, SignPGP: m.signPGP, EncryptPGP: m.encryptPGP, @@ -1307,6 +1441,12 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.showSuggestions = false m.suggestions = nil } + + if pgp.HasPGPSetup(m.getSelectedAccount()) && currentValue != previousToValue { + if cmd := m.refreshPGPKeyAvailability(); cmd != nil { + cmds = append(cmds, cmd) + } + } } case focusCc: previousCcValue := m.ccInput.Value() @@ -1314,6 +1454,11 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo cmds = append(cmds, cmd) if m.ccInput.Value() != previousCcValue { m.ccError = "" + if pgp.HasPGPSetup(m.getSelectedAccount()) { + if cmd := m.refreshPGPKeyAvailability(); cmd != nil { + cmds = append(cmds, cmd) + } + } } case focusBcc: previousBccValue := m.bccInput.Value() @@ -1321,6 +1466,11 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo cmds = append(cmds, cmd) if m.bccInput.Value() != previousBccValue { m.bccError = "" + if pgp.HasPGPSetup(m.getSelectedAccount()) { + if cmd := m.refreshPGPKeyAvailability(); cmd != nil { + cmds = append(cmds, cmd) + } + } } case focusSubject: m.subjectInput, cmd = m.subjectInput.Update(msg) @@ -1417,17 +1567,27 @@ func (m *Composer) View() tea.View { //nolint:gocyclo attachmentField = b.String() } - encToggle := "[ ]" + acc := m.getSelectedAccount() + hasSMIME := pgp.HasSMIMESetup(acc) + hasPGP := pgp.HasPGPSetup(acc) + + encSMIMEToggle := "[ ]" if m.encryptSMIME { - encToggle = "[x]" + encSMIMEToggle = "[x]" } - encField := blurredStyle.Render(fmt.Sprintf(" %s %s", t("composer.encrypt_smime"), encToggle)) + encSMIMEField := blurredStyle.Render(fmt.Sprintf(" %s %s", t("composer.encrypt_smime"), encSMIMEToggle)) if m.focusIndex == focusEncryptSMIME { - encField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.encrypt_smime"), encToggle)) + encSMIMEField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.encrypt_smime"), encSMIMEToggle)) } - acc := m.getSelectedAccount() - hasPGP := acc != nil && (acc.PGPKeySource != "" || acc.PGPPublicKey != "") + signSMIMEToggle := "[ ]" + if m.signSMIME { + signSMIMEToggle = "[x]" + } + signSMIMEField := blurredStyle.Render(fmt.Sprintf(" %s %s", t("composer.sign_smime"), signSMIMEToggle)) + if m.focusIndex == focusSignSMIME { + signSMIMEField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.sign_smime"), signSMIMEToggle)) + } signPGPToggle := "[ ]" if m.signPGP { @@ -1510,6 +1670,8 @@ func (m *Composer) View() tea.View { //nolint:gocyclo tip = fmt.Sprintf("Enter: add file • up/down: select attachment • %s: remove selected", ck.Delete) case focusEncryptSMIME: tip = "Press Space or Enter to toggle S/MIME encryption on or off." + case focusSignSMIME: + tip = "Press Space or Enter to toggle S/MIME signing on or off." case focusSignPGP: tip = "Press Space or Enter to toggle PGP signing on or off." case focusEncryptPGP: @@ -1538,15 +1700,21 @@ func (m *Composer) View() tea.View { //nolint:gocyclo if len(m.attachmentPaths) > 0 { composerViewElements = append(composerViewElements, "") } - composerViewElements = append(composerViewElements, - smimeToggleStyle.Render(encField), - ) - if hasPGP { + showSMIMEEnc := hasSMIME && m.hasSMIMECertForAnyRecipient() + if showSMIMEEnc { composerViewElements = append(composerViewElements, - smimeToggleStyle.Render(signPGPField), - smimeToggleStyle.Render(encPGPField), + smimeToggleStyle.Render(encSMIMEField), ) } + if hasSMIME { + composerViewElements = append(composerViewElements, smimeToggleStyle.Render(signSMIMEField)) + } + if hasPGP { + composerViewElements = append(composerViewElements, smimeToggleStyle.Render(signPGPField)) + if m.hasPGPKeyAvailable() { + composerViewElements = append(composerViewElements, smimeToggleStyle.Render(encPGPField)) + } + } composerViewElements = append(composerViewElements, button, "", @@ -1979,6 +2147,10 @@ func (m *Composer) ToDraft() config.Draft { InReplyTo: m.inReplyTo, References: m.references, QuotedText: m.quotedText, + SignSMIME: m.signSMIME, + EncryptSMIME: m.encryptSMIME, + SignPGP: m.signPGP, + EncryptPGP: m.encryptPGP, } } @@ -2000,5 +2172,9 @@ func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTip m.inReplyTo = draft.InReplyTo m.references = draft.References m.quotedText = draft.QuotedText + m.signSMIME = draft.SignSMIME + m.encryptSMIME = draft.EncryptSMIME + m.signPGP = draft.SignPGP + m.encryptPGP = draft.EncryptPGP return m } diff --git a/tui/composer_focus.patch b/tui/composer_focus.patch new file mode 100644 index 00000000..e10c37ec --- /dev/null +++ b/tui/composer_focus.patch @@ -0,0 +1,69 @@ + +func (m *Composer) visibleCryptoToggles() []int { + selAcc := m.getSelectedAccount() + hasSMIME := pgp.HasSMIMESetup(selAcc) + hasPGP := pgp.HasPGPSetup(selAcc) + var visible []int + if hasSMIME && m.hasSMIMECertForAnyRecipient() { + visible = append(visible, focusEncryptSMIME) + } + if hasSMIME { + visible = append(visible, focusSignSMIME) + } + if hasPGP { + visible = append(visible, focusSignPGP) + } + if hasPGP && m.hasPGPKeyAvailable() { + visible = append(visible, focusEncryptPGP) + } + return visible +} + +func (m *Composer) minFocus() int { + if len(m.accounts) <= 1 && !m.isCatchAllAccount() { + return focusTo + } + return focusFrom +} + +func (m *Composer) advanceFocus(visible []int) { + switch { + case m.focusIndex < focusEncryptSMIME: + if len(visible) > 0 { + m.focusIndex = visible[0] + } else { + m.focusIndex = focusSend + } + case m.focusIndex >= focusEncryptSMIME && m.focusIndex <= focusEncryptPGP: + for _, f := range visible { + if f > m.focusIndex { + m.focusIndex = f + return + } + } + m.focusIndex = focusSend + default: + m.focusIndex = m.minFocus() + } +} + +func (m *Composer) retreatFocus(visible []int) { + switch { + case m.focusIndex > focusEncryptPGP: + if len(visible) > 0 { + m.focusIndex = visible[len(visible)-1] + } else { + m.focusIndex = focusAttachment + } + case m.focusIndex >= focusEncryptSMIME && m.focusIndex <= focusEncryptPGP: + for i := len(visible) - 1; i >= 0; i-- { + if visible[i] < m.focusIndex { + m.focusIndex = visible[i] + return + } + } + m.focusIndex = focusAttachment + default: + m.focusIndex = focusSend + } +} diff --git a/tui/composer_test.go b/tui/composer_test.go index 5dfe39df..99e2b12b 100644 --- a/tui/composer_test.go +++ b/tui/composer_test.go @@ -278,18 +278,12 @@ func TestComposerUpdate(t *testing.T) { t.Errorf("After six Tabs, focusIndex should be %d (focusAttachment), got %d", focusAttachment, composer.focusIndex) } - // Simulate pressing Tab again to move to the 'EncryptSMIME' toggle. - model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) - composer = model.(*Composer) - if composer.focusIndex != focusEncryptSMIME { - t.Errorf("After seven Tabs, focusIndex should be %d (focusEncryptSMIME), got %d", focusEncryptSMIME, composer.focusIndex) - } - // Simulate pressing Tab again to move to the 'Send' button. + // Crypto toggles are skipped when S/MIME and PGP are not configured. model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) composer = model.(*Composer) if composer.focusIndex != focusSend { - t.Errorf("After eight Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex) + t.Errorf("After seven Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex) } // Simulate one more Tab to wrap around. @@ -297,7 +291,7 @@ func TestComposerUpdate(t *testing.T) { model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) composer = model.(*Composer) if composer.focusIndex != focusTo { - t.Errorf("After nine Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex) + t.Errorf("After eight Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex) } }) @@ -410,7 +404,8 @@ func TestComposerUpdate(t *testing.T) { t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, multiComposer.focusIndex) } - // Tab through all fields: To -> Cc -> Bcc -> Subject -> Body -> Signature -> Attachment -> EncryptSMIME -> Send -> From (wrap) + // Tab through all fields: To -> Cc -> Bcc -> Subject -> Body -> Signature -> Attachment -> Send -> From (wrap) + // Crypto toggles are skipped when S/MIME and PGP are not configured. model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // To -> Cc multiComposer = model.(*Composer) model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Cc -> Bcc @@ -423,9 +418,7 @@ func TestComposerUpdate(t *testing.T) { multiComposer = model.(*Composer) model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Signature -> Attachment multiComposer = model.(*Composer) - model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Attachment -> EncryptSMIME - multiComposer = model.(*Composer) - model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptSMIME -> Send + model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Attachment -> Send multiComposer = model.(*Composer) model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Send -> From (wrap) multiComposer = model.(*Composer) @@ -434,7 +427,7 @@ func TestComposerUpdate(t *testing.T) { // With multiple accounts, From field should be included in tab order if multiComposer.focusIndex != focusTo { - t.Errorf("After ten Tabs with multi-account, focusIndex should wrap to %d (focusTo), got %d", focusTo, multiComposer.focusIndex) + t.Errorf("After nine Tabs with multi-account, focusIndex should wrap to %d (focusTo), got %d", focusTo, multiComposer.focusIndex) } }) } diff --git a/tui/messages.go b/tui/messages.go index 7004b9be..17011578 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -14,6 +14,11 @@ type NotifyMsg struct { Message string } +type InfoNotifyMsg struct { + Message string + Duration float64 +} + type MailboxKind string const (