From b813c8f221a2b5dfee2c8623cd6c9028171ac4fe Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 1 Apr 2026 17:38:24 -0700 Subject: [PATCH] Add notification feedback settings --- main.go | 195 +++++++++++++++++++++++++++++++++++++++++++-------- main_test.go | 81 +++++++++++++++++++++ 2 files changed, 248 insertions(+), 28 deletions(-) diff --git a/main.go b/main.go index 40dcc8f..70fc897 100644 --- a/main.go +++ b/main.go @@ -50,9 +50,18 @@ const ( const ( maxAttachmentBytes = 10 << 20 statusBannerDuration = 2600 * time.Millisecond + statusBannerLong = 6 * time.Second autofillStatusTTL = 12 * time.Second ) +type autofillNoticeMode string + +const ( + autofillNoticeAll autofillNoticeMode = "all" + autofillNoticeApprovals autofillNoticeMode = "approvals" + autofillNoticeSuppressed autofillNoticeMode = "suppressed" +) + type bannerKind string const ( @@ -138,10 +147,12 @@ type recentRemoteRecord struct { } type uiPreferences struct { - GroupControlsHidden bool `json:"groupControlsHidden"` - LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"` - HistoryHidden bool `json:"historyHidden"` - DenseLayout bool `json:"denseLayout"` + GroupControlsHidden bool `json:"groupControlsHidden"` + LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"` + HistoryHidden bool `json:"historyHidden"` + DenseLayout bool `json:"denseLayout"` + StatusBannerMillis int `json:"statusBannerMillis,omitempty"` + AutofillNoticeMode string `json:"autofillNoticeMode,omitempty"` } type entriesSectionState struct { @@ -265,6 +276,12 @@ type ui struct { toggleHistory widget.Clickable togglePasswordInline widget.Clickable toggleSyncPassword widget.Clickable + setStatusBannerShort widget.Clickable + setStatusBannerStandard widget.Clickable + setStatusBannerLong widget.Clickable + showAllAutofillNotices widget.Clickable + showApprovalAutofillOnly widget.Clickable + hideAutofillNotices widget.Clickable showEntries widget.Clickable showTemplates widget.Clickable showRecycle widget.Clickable @@ -359,6 +376,8 @@ type ui struct { lifecycleAdvancedHidden bool historyHidden bool denseLayout bool + statusBannerTTL time.Duration + autofillNoticePreference autofillNoticeMode recentVaults []string recentRemotes []recentRemoteRecord recentVaultGroups map[string][]string @@ -482,25 +501,27 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) lifecycleList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, - state: appstate.State{}, - selectedHistoryIndex: -1, - selectedAuditIndex: -1, - lifecycleMode: "local", - defaultSaveAsPath: paths.DefaultSaveAsPath, - recentVaultsPath: paths.RecentVaultsPath, - uiPreferencesPath: paths.UIPreferencesPath, - recentRemotesPath: paths.RecentRemotesPath, - autofillCachePath: paths.AutofillCachePath, - recentVaultGroups: map[string][]string{}, - recentVaultUsedAt: map[string]time.Time{}, - lifecycleAdvancedHidden: true, - historyHidden: true, - now: time.Now, - syncSourceMode: syncSourceLocal, - syncDirection: syncDirectionPull, - apiPolicyGroupScope: true, - backgroundResults: make(chan backgroundActionResult, 8), - requestMasterPassFocus: true, + state: appstate.State{}, + selectedHistoryIndex: -1, + selectedAuditIndex: -1, + lifecycleMode: "local", + defaultSaveAsPath: paths.DefaultSaveAsPath, + recentVaultsPath: paths.RecentVaultsPath, + uiPreferencesPath: paths.UIPreferencesPath, + recentRemotesPath: paths.RecentRemotesPath, + autofillCachePath: paths.AutofillCachePath, + recentVaultGroups: map[string][]string{}, + recentVaultUsedAt: map[string]time.Time{}, + lifecycleAdvancedHidden: true, + historyHidden: true, + statusBannerTTL: statusBannerDuration, + now: time.Now, + syncSourceMode: syncSourceLocal, + syncDirection: syncDirectionPull, + apiPolicyGroupScope: true, + autofillNoticePreference: autofillNoticeAll, + backgroundResults: make(chan backgroundActionResult, 8), + requestMasterPassFocus: true, } u.apiPolicyAllow.Value = true u.apiPolicyGroupScopeW.Value = true @@ -1299,6 +1320,8 @@ func (u *ui) loadUIPreferences() { u.lifecycleAdvancedHidden = prefs.LifecycleAdvancedHidden u.historyHidden = prefs.HistoryHidden u.denseLayout = prefs.DenseLayout + u.statusBannerTTL = normalizedStatusBannerTTL(prefs.StatusBannerMillis) + u.autofillNoticePreference = normalizedAutofillNoticeMode(prefs.AutofillNoticeMode) } func (u *ui) saveUIPreferences() { @@ -1313,6 +1336,8 @@ func (u *ui) saveUIPreferences() { LifecycleAdvancedHidden: u.lifecycleAdvancedHidden, HistoryHidden: u.historyHidden, DenseLayout: u.denseLayout, + StatusBannerMillis: int(u.statusBannerTTL / time.Millisecond), + AutofillNoticeMode: string(u.autofillNoticePreference), }, "", " ") if err != nil { return @@ -1334,6 +1359,38 @@ func (u *ui) applySettingsFormToPreferences() { u.denseLayout = u.settingsDenseLayout.Value } +func normalizedStatusBannerTTL(valueMillis int) time.Duration { + switch { + case valueMillis <= 0: + return statusBannerDuration + case time.Duration(valueMillis)*time.Millisecond > statusBannerLong: + return statusBannerLong + default: + return time.Duration(valueMillis) * time.Millisecond + } +} + +func normalizedAutofillNoticeMode(value string) autofillNoticeMode { + switch autofillNoticeMode(strings.TrimSpace(value)) { + case autofillNoticeApprovals: + return autofillNoticeApprovals + case autofillNoticeSuppressed: + return autofillNoticeSuppressed + default: + return autofillNoticeAll + } +} + +func (u *ui) setStatusBannerTTL(value time.Duration) { + u.statusBannerTTL = normalizedStatusBannerTTL(int(value / time.Millisecond)) + u.saveUIPreferences() +} + +func (u *ui) setAutofillNoticePreference(value autofillNoticeMode) { + u.autofillNoticePreference = normalizedAutofillNoticeMode(string(value)) + u.saveUIPreferences() +} + func (u *ui) noteRecentRemote(baseURL, path, username, password string, rememberAuth bool) { baseURL = strings.TrimSpace(baseURL) path = strings.TrimSpace(path) @@ -1705,7 +1762,7 @@ func (u *ui) armDeleteCurrentGroupAction() { u.deleteGroupPath = append([]string(nil), u.currentPath...) u.state.ErrorMessage = "" u.state.StatusMessage = fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / ")) - u.statusExpiresAt = u.now().Add(statusBannerDuration) + u.statusExpiresAt = u.now().Add(u.statusBannerTTL) } func (u *ui) runAction(label string, action func() error) { @@ -1732,7 +1789,7 @@ func (u *ui) runAction(label string, action func() error) { return } u.state.StatusMessage = label + " complete" - u.statusExpiresAt = u.now().Add(statusBannerDuration) + u.statusExpiresAt = u.now().Add(u.statusBannerTTL) } func (u *ui) runBackgroundAction(label string, prepare func() (func() error, error)) { @@ -1791,7 +1848,7 @@ func (u *ui) applyBackgroundResult(result backgroundActionResult) { return } u.state.StatusMessage = result.label + " complete" - u.statusExpiresAt = u.now().Add(statusBannerDuration) + u.statusExpiresAt = u.now().Add(u.statusBannerTTL) } func (u *ui) cancelLifecycleBusyState() { @@ -1933,6 +1990,9 @@ func (u *ui) statusToastSurface() uiBanner { } func (u *ui) autofillStatusSurface() uiAutofillStatus { + if u.autofillNoticePreference == autofillNoticeSuppressed { + return uiAutofillStatus{} + } if request, ok := u.pendingAutofillApproval(); ok { detail := approvalResourceText(request) if strings.TrimSpace(detail) == "" { @@ -1948,6 +2008,9 @@ func (u *ui) autofillStatusSurface() uiAutofillStatus { if u.auditLog == nil { return uiAutofillStatus{} } + if u.autofillNoticePreference == autofillNoticeApprovals { + return uiAutofillStatus{} + } for _, event := range u.auditLog.Events() { if status, ok := autofillStatusFromAuditEvent(event, u.now()); ok { return status @@ -2467,6 +2530,24 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.loadSettingsFormFromPreferences() u.securityDialogOpen = true } + for u.setStatusBannerShort.Clicked(gtx) { + u.setStatusBannerTTL(2 * time.Second) + } + for u.setStatusBannerStandard.Clicked(gtx) { + u.setStatusBannerTTL(statusBannerDuration) + } + for u.setStatusBannerLong.Clicked(gtx) { + u.setStatusBannerTTL(statusBannerLong) + } + for u.showAllAutofillNotices.Clicked(gtx) { + u.setAutofillNoticePreference(autofillNoticeAll) + } + for u.showApprovalAutofillOnly.Clicked(gtx) { + u.setAutofillNoticePreference(autofillNoticeApprovals) + } + for u.hideAutofillNotices.Clicked(gtx) { + u.setAutofillNoticePreference(autofillNoticeSuppressed) + } for u.closeAdvancedSync.Clicked(gtx) { u.syncDialogOpen = false u.showSyncPassword = false @@ -2970,7 +3051,7 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(14), "Choose how KeePassGO remembers UI layout behavior and which KDBX security settings it should use for new or future saves.") + lbl := material.Label(u.theme, unit.Sp(14), "Choose how KeePassGO remembers UI layout behavior, tune noncritical feedback, and set the KDBX security defaults used for new or future saves.") lbl.Color = mutedColor return lbl.Layout(gtx) }), @@ -3014,10 +3095,68 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(labeledEditorHelp(u.theme, "KDF", "Supported values: "+strings.Join([]string{vault.KDFAES, vault.KDFArgon2}, ", "), &u.securityKDF, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(syncDialogSectionLabel(u.theme, "Feedback")), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(14), "Success and reminder banners") + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "Choose how long noncritical status banners stay visible.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.closeSecuritySettings, "Cancel") + return syncChoiceButton(gtx, u.theme, &u.setStatusBannerShort, "Short", u.statusBannerTTL == 2*time.Second) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.setStatusBannerStandard, "Standard", u.statusBannerTTL == statusBannerDuration) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.setStatusBannerLong, "Long", u.statusBannerTTL == statusBannerLong) + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(14), "Autofill notices") + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "Keep recent autofill results visible, reduce them to approval-only, or hide them.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showAllAutofillNotices, "All", u.autofillNoticePreference == autofillNoticeAll) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showApprovalAutofillOnly, "Approval Only", u.autofillNoticePreference == autofillNoticeApprovals) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.hideAutofillNotices, "Hidden", u.autofillNoticePreference == autofillNoticeSuppressed) + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.closeSecuritySettings, "Close") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { diff --git a/main_test.go b/main_test.go index c2f8474..2e2c277 100644 --- a/main_test.go +++ b/main_test.go @@ -2914,6 +2914,27 @@ func TestUIStatusToastExpiresAfterTimeout(t *testing.T) { } } +func TestUIStatusToastExpiresAfterConfiguredTimeout(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC) + u := newUIWithModel("desktop", vault.Model{}) + u.now = func() time.Time { return now } + u.statusBannerTTL = statusBannerLong + u.state.StatusMessage = "save complete" + u.statusExpiresAt = now.Add(u.statusBannerTTL) + + now = now.Add(statusBannerDuration + time.Second) + if got := u.statusToastSurface(); got.Kind != bannerStatus { + t.Fatalf("statusToastSurface() before configured expiry = %#v, want visible status toast", got) + } + + now = now.Add(statusBannerLong) + if got := u.statusToastSurface(); got.Kind != bannerNone { + t.Fatalf("statusToastSurface() after configured expiry = %#v, want no toast", got) + } +} + func TestUIAutofillStatusSurfaceUsesPendingApproval(t *testing.T) { t.Parallel() @@ -2945,6 +2966,41 @@ func TestUIAutofillStatusSurfaceUsesPendingApproval(t *testing.T) { } } +func TestUIAutofillStatusSurfaceRespectsNoticePreference(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC) + u := newUIWithModel("desktop", vault.Model{}) + u.now = func() time.Time { return now } + u.auditLog = &apiaudit.Log{} + u.auditLog.Record(apiaudit.Event{ + Type: apiaudit.EventAutofillFound, + TokenName: "Browser Extension", + ClientName: "Firefox", + Operation: apitokens.OperationCopyPassword, + At: now, + }) + + u.autofillNoticePreference = autofillNoticeApprovals + if got := u.autofillStatusSurface(); got.Kind != autofillStatusNone { + t.Fatalf("autofillStatusSurface() with approvals-only preference = %#v, want no recent notice", got) + } + + u.autofillNoticePreference = autofillNoticeSuppressed + u.state.Approvals = &mainStubApprovalManager{ + pending: []apiapproval.Request{{ + ID: "approval-1", + TokenName: "Browser Extension", + ClientName: "Firefox", + Operation: apitokens.OperationCopyPassword, + Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "entry-1"}, + }}, + } + if got := u.autofillStatusSurface(); got.Kind != autofillStatusNone { + t.Fatalf("autofillStatusSurface() with suppressed preference = %#v, want no notice", got) + } +} + func TestUIAutofillStatusSurfaceUsesAuditEventsForFoundAmbiguousAndBlocked(t *testing.T) { t.Parallel() @@ -3502,6 +3558,31 @@ func TestUIEntryRowMetricsUseDenseLayout(t *testing.T) { } } +func TestUINotificationPreferencesPersist(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "ui-prefs.json") + + first := newUIWithSession("desktop", &session.Manager{}) + first.uiPreferencesPath = configPath + first.statusBannerTTL = statusBannerLong + first.autofillNoticePreference = autofillNoticeApprovals + first.saveUIPreferences() + + second := newUIWithSession("desktop", &session.Manager{}) + second.uiPreferencesPath = configPath + second.statusBannerTTL = statusBannerDuration + second.autofillNoticePreference = autofillNoticeAll + second.loadUIPreferences() + + if got := second.statusBannerTTL; got != statusBannerLong { + t.Fatalf("statusBannerTTL after reload = %v, want %v", got, statusBannerLong) + } + if got := second.autofillNoticePreference; got != autofillNoticeApprovals { + t.Fatalf("autofillNoticePreference after reload = %q, want %q", got, autofillNoticeApprovals) + } +} + func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) { t.Parallel()