Add notification feedback settings

This commit is contained in:
Joe Julian
2026-04-01 17:38:24 -07:00
parent 6f2b4d49b3
commit b813c8f221
2 changed files with 248 additions and 28 deletions
+167 -28
View File
@@ -50,9 +50,18 @@ const (
const ( const (
maxAttachmentBytes = 10 << 20 maxAttachmentBytes = 10 << 20
statusBannerDuration = 2600 * time.Millisecond statusBannerDuration = 2600 * time.Millisecond
statusBannerLong = 6 * time.Second
autofillStatusTTL = 12 * time.Second autofillStatusTTL = 12 * time.Second
) )
type autofillNoticeMode string
const (
autofillNoticeAll autofillNoticeMode = "all"
autofillNoticeApprovals autofillNoticeMode = "approvals"
autofillNoticeSuppressed autofillNoticeMode = "suppressed"
)
type bannerKind string type bannerKind string
const ( const (
@@ -138,10 +147,12 @@ type recentRemoteRecord struct {
} }
type uiPreferences struct { type uiPreferences struct {
GroupControlsHidden bool `json:"groupControlsHidden"` GroupControlsHidden bool `json:"groupControlsHidden"`
LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"` LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"`
HistoryHidden bool `json:"historyHidden"` HistoryHidden bool `json:"historyHidden"`
DenseLayout bool `json:"denseLayout"` DenseLayout bool `json:"denseLayout"`
StatusBannerMillis int `json:"statusBannerMillis,omitempty"`
AutofillNoticeMode string `json:"autofillNoticeMode,omitempty"`
} }
type entriesSectionState struct { type entriesSectionState struct {
@@ -265,6 +276,12 @@ type ui struct {
toggleHistory widget.Clickable toggleHistory widget.Clickable
togglePasswordInline widget.Clickable togglePasswordInline widget.Clickable
toggleSyncPassword 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 showEntries widget.Clickable
showTemplates widget.Clickable showTemplates widget.Clickable
showRecycle widget.Clickable showRecycle widget.Clickable
@@ -359,6 +376,8 @@ type ui struct {
lifecycleAdvancedHidden bool lifecycleAdvancedHidden bool
historyHidden bool historyHidden bool
denseLayout bool denseLayout bool
statusBannerTTL time.Duration
autofillNoticePreference autofillNoticeMode
recentVaults []string recentVaults []string
recentRemotes []recentRemoteRecord recentRemotes []recentRemoteRecord
recentVaultGroups map[string][]string recentVaultGroups map[string][]string
@@ -482,25 +501,27 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
lifecycleList: widget.List{ lifecycleList: widget.List{
List: layout.List{Axis: layout.Vertical}, List: layout.List{Axis: layout.Vertical},
}, },
state: appstate.State{}, state: appstate.State{},
selectedHistoryIndex: -1, selectedHistoryIndex: -1,
selectedAuditIndex: -1, selectedAuditIndex: -1,
lifecycleMode: "local", lifecycleMode: "local",
defaultSaveAsPath: paths.DefaultSaveAsPath, defaultSaveAsPath: paths.DefaultSaveAsPath,
recentVaultsPath: paths.RecentVaultsPath, recentVaultsPath: paths.RecentVaultsPath,
uiPreferencesPath: paths.UIPreferencesPath, uiPreferencesPath: paths.UIPreferencesPath,
recentRemotesPath: paths.RecentRemotesPath, recentRemotesPath: paths.RecentRemotesPath,
autofillCachePath: paths.AutofillCachePath, autofillCachePath: paths.AutofillCachePath,
recentVaultGroups: map[string][]string{}, recentVaultGroups: map[string][]string{},
recentVaultUsedAt: map[string]time.Time{}, recentVaultUsedAt: map[string]time.Time{},
lifecycleAdvancedHidden: true, lifecycleAdvancedHidden: true,
historyHidden: true, historyHidden: true,
now: time.Now, statusBannerTTL: statusBannerDuration,
syncSourceMode: syncSourceLocal, now: time.Now,
syncDirection: syncDirectionPull, syncSourceMode: syncSourceLocal,
apiPolicyGroupScope: true, syncDirection: syncDirectionPull,
backgroundResults: make(chan backgroundActionResult, 8), apiPolicyGroupScope: true,
requestMasterPassFocus: true, autofillNoticePreference: autofillNoticeAll,
backgroundResults: make(chan backgroundActionResult, 8),
requestMasterPassFocus: true,
} }
u.apiPolicyAllow.Value = true u.apiPolicyAllow.Value = true
u.apiPolicyGroupScopeW.Value = true u.apiPolicyGroupScopeW.Value = true
@@ -1299,6 +1320,8 @@ func (u *ui) loadUIPreferences() {
u.lifecycleAdvancedHidden = prefs.LifecycleAdvancedHidden u.lifecycleAdvancedHidden = prefs.LifecycleAdvancedHidden
u.historyHidden = prefs.HistoryHidden u.historyHidden = prefs.HistoryHidden
u.denseLayout = prefs.DenseLayout u.denseLayout = prefs.DenseLayout
u.statusBannerTTL = normalizedStatusBannerTTL(prefs.StatusBannerMillis)
u.autofillNoticePreference = normalizedAutofillNoticeMode(prefs.AutofillNoticeMode)
} }
func (u *ui) saveUIPreferences() { func (u *ui) saveUIPreferences() {
@@ -1313,6 +1336,8 @@ func (u *ui) saveUIPreferences() {
LifecycleAdvancedHidden: u.lifecycleAdvancedHidden, LifecycleAdvancedHidden: u.lifecycleAdvancedHidden,
HistoryHidden: u.historyHidden, HistoryHidden: u.historyHidden,
DenseLayout: u.denseLayout, DenseLayout: u.denseLayout,
StatusBannerMillis: int(u.statusBannerTTL / time.Millisecond),
AutofillNoticeMode: string(u.autofillNoticePreference),
}, "", " ") }, "", " ")
if err != nil { if err != nil {
return return
@@ -1334,6 +1359,38 @@ func (u *ui) applySettingsFormToPreferences() {
u.denseLayout = u.settingsDenseLayout.Value 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) { func (u *ui) noteRecentRemote(baseURL, path, username, password string, rememberAuth bool) {
baseURL = strings.TrimSpace(baseURL) baseURL = strings.TrimSpace(baseURL)
path = strings.TrimSpace(path) path = strings.TrimSpace(path)
@@ -1705,7 +1762,7 @@ func (u *ui) armDeleteCurrentGroupAction() {
u.deleteGroupPath = append([]string(nil), u.currentPath...) u.deleteGroupPath = append([]string(nil), u.currentPath...)
u.state.ErrorMessage = "" u.state.ErrorMessage = ""
u.state.StatusMessage = fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / ")) 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) { func (u *ui) runAction(label string, action func() error) {
@@ -1732,7 +1789,7 @@ func (u *ui) runAction(label string, action func() error) {
return return
} }
u.state.StatusMessage = label + " complete" 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)) { func (u *ui) runBackgroundAction(label string, prepare func() (func() error, error)) {
@@ -1791,7 +1848,7 @@ func (u *ui) applyBackgroundResult(result backgroundActionResult) {
return return
} }
u.state.StatusMessage = result.label + " complete" u.state.StatusMessage = result.label + " complete"
u.statusExpiresAt = u.now().Add(statusBannerDuration) u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
} }
func (u *ui) cancelLifecycleBusyState() { func (u *ui) cancelLifecycleBusyState() {
@@ -1933,6 +1990,9 @@ func (u *ui) statusToastSurface() uiBanner {
} }
func (u *ui) autofillStatusSurface() uiAutofillStatus { func (u *ui) autofillStatusSurface() uiAutofillStatus {
if u.autofillNoticePreference == autofillNoticeSuppressed {
return uiAutofillStatus{}
}
if request, ok := u.pendingAutofillApproval(); ok { if request, ok := u.pendingAutofillApproval(); ok {
detail := approvalResourceText(request) detail := approvalResourceText(request)
if strings.TrimSpace(detail) == "" { if strings.TrimSpace(detail) == "" {
@@ -1948,6 +2008,9 @@ func (u *ui) autofillStatusSurface() uiAutofillStatus {
if u.auditLog == nil { if u.auditLog == nil {
return uiAutofillStatus{} return uiAutofillStatus{}
} }
if u.autofillNoticePreference == autofillNoticeApprovals {
return uiAutofillStatus{}
}
for _, event := range u.auditLog.Events() { for _, event := range u.auditLog.Events() {
if status, ok := autofillStatusFromAuditEvent(event, u.now()); ok { if status, ok := autofillStatusFromAuditEvent(event, u.now()); ok {
return status return status
@@ -2467,6 +2530,24 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
u.loadSettingsFormFromPreferences() u.loadSettingsFormFromPreferences()
u.securityDialogOpen = true 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) { for u.closeAdvancedSync.Clicked(gtx) {
u.syncDialogOpen = false u.syncDialogOpen = false
u.showSyncPassword = 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(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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 lbl.Color = mutedColor
return lbl.Layout(gtx) 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(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(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(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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+81
View File
@@ -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) { func TestUIAutofillStatusSurfaceUsesPendingApproval(t *testing.T) {
t.Parallel() 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) { func TestUIAutofillStatusSurfaceUsesAuditEventsForFoundAmbiguousAndBlocked(t *testing.T) {
t.Parallel() 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) { func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) {
t.Parallel() t.Parallel()