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 (
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 {
+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) {
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()