feat: grace period for any action (#1474)

Drew Smirnoff created

## What?

Adds a grace period for actions (move, delete)

## Why?

Follow-up on #1378

---------

Signed-off-by: drew <me@andrinoff.com>

Change summary

main.go         | 299 ++++++++++++++++++++++++++++++++++++++------------
tui/messages.go |   5 
2 files changed, 232 insertions(+), 72 deletions(-)

Detailed changes

main.go 🔗

@@ -76,8 +76,11 @@ var (
 )
 
 const (
-	goosDarwin  = "darwin"
-	folderInbox = "INBOX"
+	goosDarwin        = "darwin"
+	folderInbox       = "INBOX"
+	actionKindDelete  = "delete"
+	actionKindArchive = "archive"
+	actionKindMove    = "move"
 )
 
 // UpdateAvailableMsg is sent into the TUI when a newer release is detected.
@@ -87,6 +90,20 @@ type UpdateAvailableMsg struct {
 }
 
 // internal struct for parsing GitHub release JSON.
+type pendingEmailAction struct {
+	jobID      string
+	kind       string // "delete", "archive", "move"
+	uids       []uint32
+	accountID  string
+	folderName string
+	destFolder string // for "move"
+	mailbox    tui.MailboxKind
+	// Snapshots for undo restore
+	emailsSnap []fetcher.Email
+	acctSnap   []fetcher.Email
+	folderSnap []fetcher.Email
+}
+
 type githubRelease struct {
 	TagName string `json:"tag_name"`
 	Assets  []struct {
@@ -121,11 +138,13 @@ type mainModel struct {
 	// mailto: URL parsed from os.Args
 	mailtoURL *url.URL
 	// Optional in-app log panel.
-	showLogPanel bool
-	logCh        <-chan logging.Entry
-	logPanel     *tui.LogPanel
-	pendingJobID string
-	sendNotice   string
+	showLogPanel  bool
+	logCh         <-chan logging.Entry
+	logPanel      *tui.LogPanel
+	pendingJobID  string
+	sendNotice    string
+	pendingAction *pendingEmailAction
+	actionNotice  string
 }
 
 type logEntryMsg struct {
@@ -343,6 +362,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		if msg.String() == config.Keybinds.Composer.UndoSend {
+			if m.pendingAction != nil {
+				m.restorePendingAction()
+				return m, nil
+			}
 			if m.pendingJobID != "" {
 				jobID := m.pendingJobID
 				m.pendingJobID = ""
@@ -880,6 +903,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 			m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID)
 		}
 
+		emailsSnap := slices.Clone(m.emails)
+		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
+		folderSnap := slices.Clone(m.folderEmails[folderName])
+
 		m.removeEmailFromStores(msg.UID, msg.AccountID)
 
 		if emails, ok := m.folderEmails[folderName]; ok {
@@ -893,7 +920,20 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 			go saveFolderEmailsToCache(folderName, filtered)
 		}
 
-		return m, m.moveEmailToFolderCmd(msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder)
+		pa := &pendingEmailAction{
+			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
+			kind:       actionKindMove,
+			uids:       []uint32{msg.UID},
+			accountID:  msg.AccountID,
+			folderName: folderName,
+			destFolder: msg.DestFolder,
+			emailsSnap: emailsSnap,
+			acctSnap:   acctSnap,
+			folderSnap: folderSnap,
+		}
+		flushCmd := m.flushPendingAction()
+		notice := fmt.Sprintf("Email moved to %s (%s to undo)", msg.DestFolder, config.Keybinds.Composer.UndoSend)
+		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
 
 	case tui.UpdatePreviewMsg:
 		// Trigger preview body fetch
@@ -1803,6 +1843,15 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 		m.current, _ = m.current.Update(m.currentWindowSize())
 		return m, m.current.Init()
 
+	case tui.ActionGracePeriodExpiredMsg:
+		if m.pendingAction != nil && m.pendingAction.jobID == msg.JobID {
+			pa := m.pendingAction
+			m.pendingAction = nil
+			m.actionNotice = ""
+			return m, m.executePendingAction(pa)
+		}
+		return m, nil
+
 	case tui.SendRSVPMsg:
 		account := m.config.GetAccountByID(msg.AccountID)
 		if account == nil {
@@ -1870,6 +1919,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 			m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID)
 		}
 
+		emailsSnap := slices.Clone(m.emails)
+		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
+		folderSnap := slices.Clone(m.folderEmails[folderName])
+
 		m.removeEmailFromStores(msg.UID, msg.AccountID)
 
 		if emails, ok := m.folderEmails[folderName]; ok {
@@ -1883,7 +1936,20 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 			go saveFolderEmailsToCache(folderName, filtered)
 		}
 
-		return m, m.deleteFolderEmailCmd(msg.UID, msg.AccountID, folderName, msg.Mailbox)
+		pa := &pendingEmailAction{
+			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
+			kind:       actionKindDelete,
+			uids:       []uint32{msg.UID},
+			accountID:  msg.AccountID,
+			folderName: folderName,
+			mailbox:    msg.Mailbox,
+			emailsSnap: emailsSnap,
+			acctSnap:   acctSnap,
+			folderSnap: folderSnap,
+		}
+		flushCmd := m.flushPendingAction()
+		notice := fmt.Sprintf("Email deleted (%s to undo)", config.Keybinds.Composer.UndoSend)
+		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
 
 	case tui.ArchiveEmailMsg:
 		tui.ClearKittyGraphics()
@@ -1903,6 +1969,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 			m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID)
 		}
 
+		emailsSnap := slices.Clone(m.emails)
+		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
+		folderSnap := slices.Clone(m.folderEmails[folderName])
+
 		m.removeEmailFromStores(msg.UID, msg.AccountID)
 
 		if emails, ok := m.folderEmails[folderName]; ok {
@@ -1916,7 +1986,20 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 			go saveFolderEmailsToCache(folderName, filtered)
 		}
 
-		return m, m.archiveFolderEmailCmd(msg.UID, msg.AccountID, folderName, msg.Mailbox)
+		pa := &pendingEmailAction{
+			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
+			kind:       actionKindArchive,
+			uids:       []uint32{msg.UID},
+			accountID:  msg.AccountID,
+			folderName: folderName,
+			mailbox:    msg.Mailbox,
+			emailsSnap: emailsSnap,
+			acctSnap:   acctSnap,
+			folderSnap: folderSnap,
+		}
+		flushCmd := m.flushPendingAction()
+		notice := fmt.Sprintf("Email archived (%s to undo)", config.Keybinds.Composer.UndoSend)
+		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
 
 	case tui.EmailMarkedReadMsg:
 		if msg.Err != nil {
@@ -1963,6 +2046,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 			m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
 		}
 
+		emailsSnap := slices.Clone(m.emails)
+		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
+		folderSnap := slices.Clone(m.folderEmails[folderName])
+
 		for _, uid := range msg.UIDs {
 			m.removeEmailFromStores(uid, msg.AccountID)
 		}
@@ -1978,7 +2065,23 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 			go saveFolderEmailsToCache(folderName, filtered)
 		}
 
-		return m, m.batchDeleteEmailsCmd(msg.UIDs, msg.AccountID, folderName, msg.Mailbox, len(msg.UIDs))
+		pa := &pendingEmailAction{
+			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
+			kind:       actionKindDelete,
+			uids:       msg.UIDs,
+			accountID:  msg.AccountID,
+			folderName: folderName,
+			mailbox:    msg.Mailbox,
+			emailsSnap: emailsSnap,
+			acctSnap:   acctSnap,
+			folderSnap: folderSnap,
+		}
+		flushCmd := m.flushPendingAction()
+		notice := fmt.Sprintf("%d emails deleted (%s to undo)", len(msg.UIDs), config.Keybinds.Composer.UndoSend)
+		if len(msg.UIDs) == 1 {
+			notice = fmt.Sprintf("Email deleted (%s to undo)", config.Keybinds.Composer.UndoSend)
+		}
+		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
 
 	case tui.BatchArchiveEmailsMsg:
 		tui.ClearKittyGraphics()
@@ -1997,6 +2100,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 			m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
 		}
 
+		emailsSnap := slices.Clone(m.emails)
+		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
+		folderSnap := slices.Clone(m.folderEmails[folderName])
+
 		for _, uid := range msg.UIDs {
 			m.removeEmailFromStores(uid, msg.AccountID)
 		}
@@ -2012,7 +2119,23 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 			go saveFolderEmailsToCache(folderName, filtered)
 		}
 
-		return m, m.batchArchiveEmailsCmd(msg.UIDs, msg.AccountID, folderName, msg.Mailbox, len(msg.UIDs))
+		pa := &pendingEmailAction{
+			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
+			kind:       actionKindArchive,
+			uids:       msg.UIDs,
+			accountID:  msg.AccountID,
+			folderName: folderName,
+			mailbox:    msg.Mailbox,
+			emailsSnap: emailsSnap,
+			acctSnap:   acctSnap,
+			folderSnap: folderSnap,
+		}
+		flushCmd := m.flushPendingAction()
+		notice := fmt.Sprintf("%d emails archived (%s to undo)", len(msg.UIDs), config.Keybinds.Composer.UndoSend)
+		if len(msg.UIDs) == 1 {
+			notice = fmt.Sprintf("Email archived (%s to undo)", config.Keybinds.Composer.UndoSend)
+		}
+		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
 
 	case tui.BatchMoveEmailsMsg:
 		if m.config == nil {
@@ -2029,6 +2152,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 			m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
 		}
 
+		emailsSnap := slices.Clone(m.emails)
+		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
+		folderSnap := slices.Clone(m.folderEmails[folderName])
+
 		for _, uid := range msg.UIDs {
 			m.removeEmailFromStores(uid, msg.AccountID)
 		}
@@ -2044,7 +2171,23 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 			go saveFolderEmailsToCache(folderName, filtered)
 		}
 
-		return m, m.batchMoveEmailsCmd(msg.UIDs, msg.AccountID, msg.SourceFolder, msg.DestFolder, len(msg.UIDs))
+		pa := &pendingEmailAction{
+			jobID:      fmt.Sprintf("action-%d", time.Now().UnixNano()),
+			kind:       actionKindMove,
+			uids:       msg.UIDs,
+			accountID:  msg.AccountID,
+			folderName: folderName,
+			destFolder: msg.DestFolder,
+			emailsSnap: emailsSnap,
+			acctSnap:   acctSnap,
+			folderSnap: folderSnap,
+		}
+		flushCmd := m.flushPendingAction()
+		notice := fmt.Sprintf("%d emails moved to %s (%s to undo)", len(msg.UIDs), msg.DestFolder, config.Keybinds.Composer.UndoSend)
+		if len(msg.UIDs) == 1 {
+			notice = fmt.Sprintf("Email moved to %s (%s to undo)", msg.DestFolder, config.Keybinds.Composer.UndoSend)
+		}
+		return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
 
 	case tui.BatchEmailActionDoneMsg:
 		if msg.Err != nil {
@@ -2127,6 +2270,9 @@ func (m *mainModel) View() tea.View {
 	if m.sendNotice != "" {
 		v.Content = m.renderSendNoticeOverlay(v.Content)
 	}
+	if m.actionNotice != "" {
+		v.Content = m.renderActionNoticeOverlay(v.Content)
+	}
 	v.AltScreen = true
 	return v
 }
@@ -2143,6 +2289,68 @@ func (m *mainModel) renderSendNoticeOverlay(content string) string {
 	return overlay.Block(content, lines, 0, col)
 }
 
+func (m *mainModel) renderActionNoticeOverlay(content string) string {
+	box := lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(theme.ActiveTheme.Accent).
+		Padding(0, 1).
+		Render(m.actionNotice)
+	lines := strings.Split(box, "\n")
+	boxWidth := lipgloss.Width(lines[0])
+	col := max(0, m.width-boxWidth)
+	return overlay.Block(content, lines, 0, col)
+}
+
+func (m *mainModel) startActionGracePeriod(pa *pendingEmailAction, notice string) tea.Cmd {
+	m.pendingAction = pa
+	m.actionNotice = notice
+	delay := time.Duration(m.config.GetUndoDelaySeconds()) * time.Second
+	jobID := pa.jobID
+	return tea.Tick(delay, func(t time.Time) tea.Msg {
+		return tui.ActionGracePeriodExpiredMsg{JobID: jobID}
+	})
+}
+
+func (m *mainModel) flushPendingAction() tea.Cmd {
+	if m.pendingAction == nil {
+		return nil
+	}
+	pa := m.pendingAction
+	m.pendingAction = nil
+	m.actionNotice = ""
+	return m.executePendingAction(pa)
+}
+
+func (m *mainModel) executePendingAction(pa *pendingEmailAction) tea.Cmd {
+	switch pa.kind {
+	case actionKindDelete:
+		return m.batchDeleteEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.mailbox, len(pa.uids))
+	case actionKindArchive:
+		return m.batchArchiveEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.mailbox, len(pa.uids))
+	case actionKindMove:
+		return m.batchMoveEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.destFolder, len(pa.uids))
+	}
+	return nil
+}
+
+func (m *mainModel) restorePendingAction() {
+	if m.pendingAction == nil {
+		return
+	}
+	pa := m.pendingAction
+	m.pendingAction = nil
+	m.actionNotice = ""
+
+	m.emails = pa.emailsSnap
+	m.emailsByAcct[pa.accountID] = pa.acctSnap
+	m.folderEmails[pa.folderName] = pa.folderSnap
+
+	if m.folderInbox != nil {
+		m.folderInbox.SetEmails(pa.folderSnap, m.config.Accounts)
+	}
+	go saveFolderEmailsToCache(pa.folderName, pa.folderSnap)
+}
+
 func (m *mainModel) currentWindowSize() tea.WindowSizeMsg {
 	return tea.WindowSizeMsg{
 		Width:  m.width,
@@ -3093,36 +3301,6 @@ func markEmailAsUnreadCmd(account *config.Account, uid uint32, accountID string,
 	}
 }
 
-func (m *mainModel) deleteFolderEmailCmd(uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
-	return func() tea.Msg {
-		if m.service == nil {
-			return tui.EmailActionDoneMsg{
-				UID:       uid,
-				AccountID: accountID,
-				Mailbox:   mailbox,
-				Err:       fmt.Errorf("service not initialized"),
-			}
-		}
-		err := m.service.DeleteEmails(accountID, folderName, []uint32{uid})
-		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
-	}
-}
-
-func (m *mainModel) archiveFolderEmailCmd(uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
-	return func() tea.Msg {
-		if m.service == nil {
-			return tui.EmailActionDoneMsg{
-				UID:       uid,
-				AccountID: accountID,
-				Mailbox:   mailbox,
-				Err:       fmt.Errorf("service not initialized"),
-			}
-		}
-		err := m.service.ArchiveEmails(accountID, folderName, []uint32{uid})
-		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
-	}
-}
-
 func (m *mainModel) batchDeleteEmailsCmd(uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
 	return func() tea.Msg {
 		if m.service == nil {
@@ -3130,7 +3308,7 @@ func (m *mainModel) batchDeleteEmailsCmd(uids []uint32, accountID, folderName st
 				Count:        count,
 				SuccessCount: 0,
 				FailureCount: count,
-				Action:       "delete",
+				Action:       actionKindDelete,
 				Mailbox:      mailbox,
 				Err:          fmt.Errorf("service not initialized"),
 			}
@@ -3147,7 +3325,7 @@ func (m *mainModel) batchDeleteEmailsCmd(uids []uint32, accountID, folderName st
 			Count:        count,
 			SuccessCount: successCount,
 			FailureCount: failureCount,
-			Action:       "delete",
+			Action:       actionKindDelete,
 			Mailbox:      mailbox,
 			Err:          err,
 		}
@@ -3161,7 +3339,7 @@ func (m *mainModel) batchArchiveEmailsCmd(uids []uint32, accountID, folderName s
 				Count:        count,
 				SuccessCount: 0,
 				FailureCount: count,
-				Action:       "archive",
+				Action:       actionKindArchive,
 				Mailbox:      mailbox,
 				Err:          fmt.Errorf("service not initialized"),
 			}
@@ -3178,7 +3356,7 @@ func (m *mainModel) batchArchiveEmailsCmd(uids []uint32, accountID, folderName s
 			Count:        count,
 			SuccessCount: successCount,
 			FailureCount: failureCount,
-			Action:       "archive",
+			Action:       actionKindArchive,
 			Mailbox:      mailbox,
 			Err:          err,
 		}
@@ -3192,7 +3370,7 @@ func (m *mainModel) batchMoveEmailsCmd(uids []uint32, accountID, sourceFolder, d
 				Count:        count,
 				SuccessCount: 0,
 				FailureCount: count,
-				Action:       "move",
+				Action:       actionKindMove,
 				Err:          fmt.Errorf("service not initialized"),
 			}
 		}
@@ -3208,30 +3386,7 @@ func (m *mainModel) batchMoveEmailsCmd(uids []uint32, accountID, sourceFolder, d
 			Count:        count,
 			SuccessCount: successCount,
 			FailureCount: failureCount,
-			Action:       "move",
-			Err:          err,
-		}
-	}
-}
-
-func (m *mainModel) moveEmailToFolderCmd(uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
-	return func() tea.Msg {
-		if m.service == nil {
-			return tui.EmailMovedMsg{
-				UID:          uid,
-				AccountID:    accountID,
-				SourceFolder: sourceFolder,
-				DestFolder:   destFolder,
-				Err:          fmt.Errorf("service not initialized"),
-			}
-		}
-
-		err := m.service.MoveEmails(accountID, []uint32{uid}, sourceFolder, destFolder)
-		return tui.EmailMovedMsg{
-			UID:          uid,
-			AccountID:    accountID,
-			SourceFolder: sourceFolder,
-			DestFolder:   destFolder,
+			Action:       actionKindMove,
 			Err:          err,
 		}
 	}

tui/messages.go 🔗

@@ -56,6 +56,11 @@ type UndoSendMsg struct {
 	JobID string
 }
 
+// ActionGracePeriodExpiredMsg is fired when the undo grace period for a delete/archive/move expires.
+type ActionGracePeriodExpiredMsg struct {
+	JobID string
+}
+
 type Credentials struct {
 	Provider     string
 	Name         string