feat: disable images (#171) (#172)

Drew Smirnoff created

* feat: new settings design and disable images (#171)

* feat: turn off images in email (#171)

Change summary

config/config.go       |   3 
main                   |   0 
main.go                |  10 -
tui/email_view.go      |  44 +++++++-
tui/email_view_test.go |  10 +-
tui/settings.go        | 219 ++++++++++++++++++++++++++++++-------------
view/html.go           |  17 ++-
view/html_test.go      |   6 
8 files changed, 212 insertions(+), 97 deletions(-)

Detailed changes

config/config.go 🔗

@@ -28,7 +28,8 @@ type Account struct {
 
 // Config stores the user's email configuration with multiple accounts.
 type Config struct {
-	Accounts []Account `json:"accounts"`
+	Accounts      []Account `json:"accounts"`
+	DisableImages bool      `json:"disable_images,omitempty"`
 }
 
 // GetIMAPServer returns the IMAP server address for the account.

main.go 🔗

@@ -490,11 +490,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, cmd
 
 	case tui.GoToSettingsMsg:
-		if m.config != nil {
-			m.current = tui.NewSettings(m.config.Accounts)
-		} else {
-			m.current = tui.NewSettings(nil)
-		}
+		m.current = tui.NewSettings(m.config)
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()
 
@@ -529,7 +525,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.emails = allEmails
 
 			// Go back to settings
-			m.current = tui.NewSettings(m.config.Accounts)
+			m.current = tui.NewSettings(m.config)
 			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		}
 		return m, m.current.Init()
@@ -568,7 +564,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		// Find the index for the email view (used for display purposes)
 		emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
-		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox)
+		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
 		m.current = emailView
 		return m, m.current.Init()
 

tui/email_view.go 🔗

@@ -33,12 +33,18 @@ type EmailView struct {
 	focusOnAttachments bool
 	accountID          string
 	mailbox            MailboxKind
+	disableImages      bool
+	showImages         bool
 }
 
-func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind) *EmailView {
+func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView {
 	// Pass the styles from the tui package to the view package
 	inlineImages := inlineImagesFromAttachments(email.Attachments)
-	body, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle)
+
+	// Initial state for showImages matches config unless overridden later
+	showImages := !disableImages
+
+	body, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle, !showImages)
 	if err != nil {
 		body = fmt.Sprintf("Error rendering body: %v", err)
 	}
@@ -58,11 +64,13 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma
 	vp.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
 
 	return &EmailView{
-		viewport:   vp,
-		email:      email,
-		emailIndex: emailIndex,
-		accountID:  email.AccountID,
-		mailbox:    mailbox,
+		viewport:      vp,
+		email:         email,
+		emailIndex:    emailIndex,
+		accountID:     email.AccountID,
+		mailbox:       mailbox,
+		disableImages: disableImages,
+		showImages:    showImages,
 	}
 }
 
@@ -118,6 +126,20 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		} else {
 			switch msg.String() {
+			case "i":
+				if view.ImageProtocolSupported() {
+					m.showImages = !m.showImages
+					clearKittyGraphics()
+
+					inlineImages := inlineImagesFromAttachments(m.email.Attachments)
+					body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
+					if err != nil {
+						body = fmt.Sprintf("Error rendering body: %v", err)
+					}
+					wrapped := wrapBodyToWidth(body, m.viewport.Width)
+					m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
+					return m, nil
+				}
 			case "r":
 				// Clear Kitty graphics before opening composer
 				clearKittyGraphics()
@@ -161,7 +183,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		// When the window size changes, wrap and clear kitty images to keep placement stable
 		inlineImages := inlineImagesFromAttachments(m.email.Attachments)
-		body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle)
+		body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
 		if err != nil {
 			body = fmt.Sprintf("Error rendering body: %v", err)
 		}
@@ -188,7 +210,11 @@ func (m *EmailView) View() string {
 	if m.focusOnAttachments {
 		help = helpStyle.Render("↑/↓: navigate • enter: download • esc/tab: back to email body")
 	} else {
-		help = helpStyle.Render("\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox")
+		shortcuts := "\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox"
+		if view.ImageProtocolSupported() {
+			shortcuts = shortcuts + "• \uf03e i: toggle images"
+		}
+		help = helpStyle.Render(shortcuts)
 	}
 
 	var attachmentView string

tui/email_view_test.go 🔗

@@ -28,7 +28,7 @@ func TestEmailViewUpdate(t *testing.T) {
 	}
 
 	t.Run("Focus on attachments", func(t *testing.T) {
-		emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox)
+		emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox, false)
 		if emailView.focusOnAttachments {
 			t.Error("focusOnAttachments should be initially false")
 		}
@@ -50,7 +50,7 @@ func TestEmailViewUpdate(t *testing.T) {
 	})
 
 	t.Run("No focus on attachments when there are none", func(t *testing.T) {
-		emailView := NewEmailView(emailWithoutAttachments, 0, 80, 24, MailboxInbox)
+		emailView := NewEmailView(emailWithoutAttachments, 0, 80, 24, MailboxInbox, false)
 		if emailView.focusOnAttachments {
 			t.Error("focusOnAttachments should be initially false")
 		}
@@ -63,7 +63,7 @@ func TestEmailViewUpdate(t *testing.T) {
 	})
 
 	t.Run("Navigate attachments", func(t *testing.T) {
-		emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox)
+		emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox, false)
 		// Focus on attachments
 		model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
 		emailView = model.(*EmailView)
@@ -95,7 +95,7 @@ func TestEmailViewUpdate(t *testing.T) {
 	})
 
 	t.Run("Download attachment", func(t *testing.T) {
-		emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox)
+		emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox, false)
 		// Focus on attachments
 		model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
 		emailView = model.(*EmailView)
@@ -124,7 +124,7 @@ func TestEmailViewUpdate(t *testing.T) {
 	})
 
 	t.Run("Reply to email", func(t *testing.T) {
-		emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox)
+		emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox, false)
 
 		_, cmd := emailView.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")})
 		if cmd == nil {

tui/settings.go 🔗

@@ -16,9 +16,17 @@ var (
 	dangerStyle              = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
 )
 
-// Settings displays the account management screen.
+type SettingsState int
+
+const (
+	SettingsMain SettingsState = iota
+	SettingsAccounts
+)
+
+// Settings displays the settings screen.
 type Settings struct {
-	accounts         []config.Account
+	cfg              *config.Config
+	state            SettingsState
 	cursor           int
 	confirmingDelete bool
 	width            int
@@ -26,10 +34,14 @@ type Settings struct {
 }
 
 // NewSettings creates a new settings model.
-func NewSettings(accounts []config.Account) *Settings {
+func NewSettings(cfg *config.Config) *Settings {
+	if cfg == nil {
+		cfg = &config.Config{}
+	}
 	return &Settings{
-		accounts: accounts,
-		cursor:   0,
+		cfg:    cfg,
+		state:  SettingsMain,
+		cursor: 0,
 	}
 }
 
@@ -47,68 +59,155 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, nil
 
 	case tea.KeyMsg:
-		if m.confirmingDelete {
-			switch msg.String() {
-			case "y", "Y":
-				if m.cursor < len(m.accounts) {
-					accountID := m.accounts[m.cursor].ID
-					m.confirmingDelete = false
-					return m, func() tea.Msg {
-						return DeleteAccountMsg{AccountID: accountID}
-					}
-				}
-			case "n", "N", "esc":
-				m.confirmingDelete = false
-				return m, nil
-			}
+		if m.state == SettingsMain {
+			return m.updateMain(msg)
+		} else {
+			return m.updateAccounts(msg)
+		}
+	}
+	return m, nil
+}
+
+func (m *Settings) updateMain(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+	switch msg.String() {
+	case "up", "k":
+		if m.cursor > 0 {
+			m.cursor--
+		}
+	case "down", "j":
+		// Options: 0: Email Accounts, 1: Image Display, 2: Edit Signature
+		if m.cursor < 2 {
+			m.cursor++
+		}
+	case "enter":
+		switch m.cursor {
+		case 0: // Email Accounts
+			m.state = SettingsAccounts
+			m.cursor = 0
 			return m, nil
+		case 1: // Image Display
+			m.cfg.DisableImages = !m.cfg.DisableImages
+			// Save config immediately
+			_ = config.SaveConfig(m.cfg)
+			return m, nil
+		case 2: // Edit Signature
+			return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
 		}
+	case "esc":
+		return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
+	}
+	return m, nil
+}
 
+func (m *Settings) updateAccounts(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+	if m.confirmingDelete {
 		switch msg.String() {
-		case "up", "k":
-			if m.cursor > 0 {
-				m.cursor--
-			}
-		case "down", "j":
-			// +2 for "Add Account" and "Signature" options
-			if m.cursor < len(m.accounts)+1 {
-				m.cursor++
-			}
-		case "d":
-			// Delete selected account (not the "Add Account" option)
-			if m.cursor < len(m.accounts) && len(m.accounts) > 0 {
-				m.confirmingDelete = true
-			}
-		case "enter":
-			// If cursor is on "Add Account"
-			if m.cursor == len(m.accounts) {
-				return m, func() tea.Msg { return GoToAddAccountMsg{} }
-			}
-			// If cursor is on "Signature"
-			if m.cursor == len(m.accounts)+1 {
-				return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
+		case "y", "Y":
+			if m.cursor < len(m.cfg.Accounts) {
+				accountID := m.cfg.Accounts[m.cursor].ID
+				m.confirmingDelete = false
+				return m, func() tea.Msg {
+					return DeleteAccountMsg{AccountID: accountID}
+				}
 			}
-		case "esc":
-			return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
+		case "n", "N", "esc":
+			m.confirmingDelete = false
+			return m, nil
+		}
+		return m, nil
+	}
+
+	switch msg.String() {
+	case "up", "k":
+		if m.cursor > 0 {
+			m.cursor--
+		}
+	case "down", "j":
+		// +1 for "Add Account" option
+		if m.cursor < len(m.cfg.Accounts) {
+			m.cursor++
+		}
+	case "d":
+		// Delete selected account (not the "Add Account" option)
+		if m.cursor < len(m.cfg.Accounts) && len(m.cfg.Accounts) > 0 {
+			m.confirmingDelete = true
 		}
+	case "enter":
+		// If cursor is on "Add Account"
+		if m.cursor == len(m.cfg.Accounts) {
+			return m, func() tea.Msg { return GoToAddAccountMsg{} }
+		}
+	case "esc":
+		m.state = SettingsMain
+		m.cursor = 0
+		return m, nil
 	}
 	return m, nil
 }
 
 // View renders the settings screen.
 func (m *Settings) View() string {
+	if m.state == SettingsMain {
+		return m.viewMain()
+	}
+	return m.viewAccounts()
+}
+
+func (m *Settings) viewMain() string {
 	var b strings.Builder
 
-	b.WriteString(titleStyle.Render("Account Settings") + "\n\n")
-	b.WriteString(listHeader.Render("Your email accounts:"))
+	b.WriteString(titleStyle.Render("Settings") + "\n\n")
+
+	// Option 0: Email Accounts
+	if m.cursor == 0 {
+		b.WriteString(selectedAccountItemStyle.Render("> Email Accounts"))
+	} else {
+		b.WriteString(accountItemStyle.Render("  Email Accounts"))
+	}
+	b.WriteString("\n")
+
+	// Option 1: Image Display
+	status := "ON"
+	if m.cfg.DisableImages {
+		status = "OFF"
+	}
+	text := fmt.Sprintf("Image Display: %s", status)
+	if m.cursor == 1 {
+		b.WriteString(selectedAccountItemStyle.Render("> " + text))
+	} else {
+		b.WriteString(accountItemStyle.Render("  " + text))
+	}
+	b.WriteString("\n")
+
+	// Option 2: Edit Signature
+	sigText := "Edit Signature"
+	if config.HasSignature() {
+		sigText = "Edit Signature (configured)"
+	}
+	if m.cursor == 2 {
+		b.WriteString(selectedAccountItemStyle.Render("> " + sigText))
+	} else {
+		b.WriteString(accountItemStyle.Render("  " + sigText))
+	}
+	b.WriteString("\n\n")
+
+	b.WriteString(helpStyle.Render("↑/↓: navigate • enter: select/toggle • esc: back"))
+
+	return docStyle.Render(b.String())
+}
+
+func (m *Settings) viewAccounts() string {
+	var b strings.Builder
+
+	b.WriteString(titleStyle.Render("Account Settings"))
 	b.WriteString("\n\n")
 
-	if len(m.accounts) == 0 {
+	if len(m.cfg.Accounts) == 0 {
 		b.WriteString(accountEmailStyle.Render("  No accounts configured.\n"))
 		b.WriteString("\n")
 	}
 
-	for i, account := range m.accounts {
+	for i, account := range m.cfg.Accounts {
 		displayName := account.Email
 		if account.Name != "" {
 			displayName = fmt.Sprintf("%s (%s)", account.Name, account.FetchEmail)
@@ -131,29 +230,17 @@ func (m *Settings) View() string {
 
 	// Add Account option
 	addAccountText := "Add New Account"
-	if m.cursor == len(m.accounts) {
+	if m.cursor == len(m.cfg.Accounts) {
 		b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addAccountText)))
 	} else {
 		b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", addAccountText)))
 	}
-	b.WriteString("\n")
-
-	// Signature option
-	signatureText := "Edit Signature"
-	if config.HasSignature() {
-		signatureText = "Edit Signature (configured)"
-	}
-	if m.cursor == len(m.accounts)+1 {
-		b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", signatureText)))
-	} else {
-		b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", signatureText)))
-	}
 	b.WriteString("\n\n")
 
 	b.WriteString(helpStyle.Render("↑/↓: navigate • enter: select • d: delete account • esc: back"))
 
 	if m.confirmingDelete {
-		accountName := m.accounts[m.cursor].Email
+		accountName := m.cfg.Accounts[m.cursor].Email
 		dialog := DialogBoxStyle.Render(
 			lipgloss.JoinVertical(lipgloss.Center,
 				dangerStyle.Render("Delete account?"),
@@ -167,10 +254,10 @@ func (m *Settings) View() string {
 	return docStyle.Render(b.String())
 }
 
-// UpdateAccounts updates the list of accounts.
-func (m *Settings) UpdateAccounts(accounts []config.Account) {
-	m.accounts = accounts
-	if m.cursor >= len(accounts) {
-		m.cursor = len(accounts)
+// UpdateConfig updates the configuration (used when accounts are deleted).
+func (m *Settings) UpdateConfig(cfg *config.Config) {
+	m.cfg = cfg
+	if m.state == SettingsAccounts && m.cursor >= len(cfg.Accounts) {
+		m.cursor = len(cfg.Accounts)
 	}
 }

view/html.go 🔗

@@ -273,6 +273,11 @@ func konsoleSupported() bool {
 	return false
 }
 
+// ImageProtocolSupported checks if any supported image protocol terminal is detected.
+func ImageProtocolSupported() bool {
+	return imageProtocolSupported()
+}
+
 // imageProtocolSupported checks if any supported image protocol terminal is detected.
 func imageProtocolSupported() bool {
 	return kittySupported() || ghosttySupported() || iterm2Supported() ||
@@ -470,7 +475,7 @@ type InlineImage struct {
 }
 
 // ProcessBodyWithInline renders the body and resolves CID inline images when provided.
-func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
+func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, error) {
 	inlineMap := make(map[string]string, len(inline))
 	for _, img := range inline {
 		cid := strings.TrimSpace(img.CID)
@@ -482,16 +487,16 @@ func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Styl
 		}
 		inlineMap[cid] = img.Base64
 	}
-	return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle)
+	return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle, disableImages)
 }
 
 // ProcessBody takes a raw email body, decodes it, and formats it as plain
 // text with terminal hyperlinks.
-func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
-	return processBody(rawBody, nil, h1Style, h2Style, bodyStyle)
+func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, error) {
+	return processBody(rawBody, nil, h1Style, h2Style, bodyStyle, disableImages)
 }
 
-func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
+func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, error) {
 	decodedBody, err := decodeQuotedPrintable(rawBody)
 	if err != nil {
 		decodedBody = rawBody
@@ -583,7 +588,7 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
 			alt = "Does not contain alt text"
 		}
 
-		if imageProtocolSupported() {
+		if !disableImages && imageProtocolSupported() {
 			var payload string
 			if strings.HasPrefix(src, "data:image/") {
 				payload = dataURIBase64(src)

view/html_test.go 🔗

@@ -513,7 +513,7 @@ func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			tc.setupHyperlinks()
 
-			processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle)
+			processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
 			if err != nil {
 				t.Fatalf("ProcessBody() failed: %v", err)
 			}
@@ -635,7 +635,7 @@ func TestProcessBodyWithImageProtocol(t *testing.T) {
 			tc.clearAllImageEnv()
 			tc.setupImageProtocol()
 
-			processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle)
+			processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
 			if err != nil {
 				t.Fatalf("ProcessBody() failed: %v", err)
 			}
@@ -685,7 +685,7 @@ func TestProcessBody(t *testing.T) {
 
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
-			processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle)
+			processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
 			if err != nil {
 				t.Fatalf("ProcessBody() failed: %v", err)
 			}