From 0a1baa7e42566868812959ee3bb836fd6421bc06 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:42:45 -0600 Subject: [PATCH] [PM-31060] Product Update Notification (#19027) * add feature flag * temp * add ping animation with filled info icon * add ping animation to stop after 4 goes around * add local state for autofill-icon * add logic to avoid new accounts * fix closing of popover * fix strict typings * remove `creationDate` logic from being considered for autofill notification * remove "now," from the autofill description * remove height and width in the svg --- apps/browser/src/_locales/en/messages.json | 9 ++ .../autofill-vault-list-items.component.html | 4 +- .../autofill-vault-list-items.component.ts | 5 +- .../simplified-autofill-info.component.html | 40 ++++++ ...simplified-autofill-info.component.spec.ts | 131 ++++++++++++++++++ .../simplified-autofill-info.component.ts | 110 +++++++++++++++ .../vault-list-items-container.component.html | 1 + libs/assets/src/svg/svgs/index.ts | 1 + libs/assets/src/svg/svgs/info-filled.icon.ts | 7 + libs/state/src/core/state-definitions.ts | 4 + 10 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 apps/browser/src/vault/popup/components/vault/simplified-autofill-info/simplified-autofill-info.component.html create mode 100644 apps/browser/src/vault/popup/components/vault/simplified-autofill-info/simplified-autofill-info.component.spec.ts create mode 100644 apps/browser/src/vault/popup/components/vault/simplified-autofill-info/simplified-autofill-info.component.ts create mode 100644 libs/assets/src/svg/svgs/info-filled.icon.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 51ca51960d7..8cbf884072e 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -6151,6 +6151,15 @@ "searchResults": { "message": "Search results" }, + "simplifiedAutofill": { + "message": "Simplified autofill" + }, + "simplifiedAutofillDescription": { + "message": "When you click a suggested autofill item, it fills rather than taking you to details. You can still view these items from the More menu." + }, + "openSimplifiedAutofillPopover": { + "message": "Open simplified autofill popover" + }, "resizeSideNavigation": { "message": "Resize side navigation" }, diff --git a/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html index 8ea65e77c5e..caf03f0e96b 100644 --- a/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html +++ b/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html @@ -10,4 +10,6 @@ [disableDescriptionMargin]="showEmptyAutofillTip$ | async" [groupByType]="groupByType()" [primaryActionAutofill]="clickItemsToAutofillVaultView$ | async" -> +> + + diff --git a/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.ts index 64f662ab840..e85244cabd0 100644 --- a/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.ts @@ -7,12 +7,13 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; -import { IconButtonModule, TypographyModule } from "@bitwarden/components"; +import { TypographyModule } from "@bitwarden/components"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; import { PopupCipherViewLike } from "../../../views/popup-cipher.view"; +import { SimplifiedAutofillInfoComponent } from "../simplified-autofill-info/simplified-autofill-info.component"; import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -23,7 +24,7 @@ import { VaultListItemsContainerComponent } from "../vault-list-items-container/ TypographyModule, VaultListItemsContainerComponent, JslibModule, - IconButtonModule, + SimplifiedAutofillInfoComponent, ], selector: "app-autofill-vault-list-items", templateUrl: "autofill-vault-list-items.component.html", diff --git a/apps/browser/src/vault/popup/components/vault/simplified-autofill-info/simplified-autofill-info.component.html b/apps/browser/src/vault/popup/components/vault/simplified-autofill-info/simplified-autofill-info.component.html new file mode 100644 index 00000000000..16fdafecb85 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault/simplified-autofill-info/simplified-autofill-info.component.html @@ -0,0 +1,40 @@ +@if (shouldShowIcon$ | async) { + + + +

+ {{ "simplifiedAutofillDescription" | i18n }} +

+ +
+} diff --git a/apps/browser/src/vault/popup/components/vault/simplified-autofill-info/simplified-autofill-info.component.spec.ts b/apps/browser/src/vault/popup/components/vault/simplified-autofill-info/simplified-autofill-info.component.spec.ts new file mode 100644 index 00000000000..3dfd9368109 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault/simplified-autofill-info/simplified-autofill-info.component.spec.ts @@ -0,0 +1,131 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { BehaviorSubject, of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { StateProvider } from "@bitwarden/state"; + +import { SimplifiedAutofillInfoComponent } from "./simplified-autofill-info.component"; + +describe("SimplifiedAutofillInfoComponent", () => { + let fixture: ComponentFixture; + + const getUserState$ = jest.fn().mockReturnValue(of(null)); + const getFeatureFlag$ = jest.fn().mockReturnValue(of(true)); + const activeAccount$ = new BehaviorSubject({ id: "test-user-id" }); + + beforeEach(async () => { + // Mock getAnimations for all span elements before any components are created + if (!HTMLSpanElement.prototype.getAnimations) { + HTMLSpanElement.prototype.getAnimations = jest.fn().mockReturnValue([]); + } + + await TestBed.configureTestingModule({ + imports: [SimplifiedAutofillInfoComponent], + providers: [ + { provide: I18nService, useValue: { t: (key: string) => key } }, + { + provide: ConfigService, + useValue: { getFeatureFlag$ }, + }, + { + provide: AccountService, + useValue: { activeAccount$: activeAccount$ }, + }, + { + provide: StateProvider, + useValue: { + getUserState$, + getUser: jest.fn().mockReturnValue({ + update: jest.fn().mockResolvedValue(undefined), + }), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SimplifiedAutofillInfoComponent); + fixture.detectChanges(); + }); + + it("sets pingElement to hidden when animation finishes", async () => { + const mockAnimation: Partial & { animationName: string } = { + animationName: "tw-ping", + onfinish: null, + }; + + // Override the mock to return our specific animation + (HTMLSpanElement.prototype.getAnimations as jest.Mock).mockReturnValue([ + mockAnimation as Animation, + ]); + + // Create a new fixture with fresh mocks that will show the ping animation + getUserState$.mockReturnValue(of({ hasSeen: false, hasDismissed: false })); + + const newFixture = TestBed.createComponent(SimplifiedAutofillInfoComponent); + + // Trigger change detection to render the template and run the effect + newFixture.detectChanges(); + await newFixture.whenStable(); + + expect(mockAnimation.onfinish).toBeDefined(); + expect(mockAnimation.onfinish).not.toBeNull(); + const onfinishHandler = mockAnimation.onfinish; + + await onfinishHandler.call(mockAnimation, null); + + const newPingElement = newFixture.nativeElement.querySelector("span"); + + expect(newPingElement.hidden).toBe(true); + }); + + describe("shouldShowIcon$", () => { + it("renders the icon button when feature flag is enabled and not dismissed", async () => { + getUserState$.mockReturnValue(of({ hasSeen: false, hasDismissed: false })); + + const newFixture = TestBed.createComponent(SimplifiedAutofillInfoComponent); + newFixture.detectChanges(); + await newFixture.whenStable(); + + const button = newFixture.nativeElement.querySelector("button[type='button']"); + expect(button).toBeTruthy(); + }); + + it("does not render icon button when dismissed", async () => { + getFeatureFlag$.mockReturnValue(of(true)); + getUserState$.mockReturnValue(of({ hasSeen: true, hasDismissed: true })); + + const newFixture = TestBed.createComponent(SimplifiedAutofillInfoComponent); + newFixture.detectChanges(); + await newFixture.whenStable(); + + const button = newFixture.nativeElement.querySelector("button[type='button']"); + expect(button).toBeFalsy(); + }); + }); + + describe("shouldShowPingAnimation$", () => { + it("renders ping animation when not seen", async () => { + getUserState$.mockReturnValue(of({ hasSeen: false, hasDismissed: false })); + + const newFixture = TestBed.createComponent(SimplifiedAutofillInfoComponent); + newFixture.detectChanges(); + await newFixture.whenStable(); + + const pingElement = newFixture.nativeElement.querySelector("span.tw-bg-primary-600"); + expect(pingElement).toBeTruthy(); + }); + + it("does not render ping animation when already seen", async () => { + getUserState$.mockReturnValue(of({ hasSeen: true, hasDismissed: false })); + + const newFixture = TestBed.createComponent(SimplifiedAutofillInfoComponent); + newFixture.detectChanges(); + await newFixture.whenStable(); + + const pingElement = newFixture.nativeElement.querySelector("span.tw-bg-primary-600"); + expect(pingElement).toBeFalsy(); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault/simplified-autofill-info/simplified-autofill-info.component.ts b/apps/browser/src/vault/popup/components/vault/simplified-autofill-info/simplified-autofill-info.component.ts new file mode 100644 index 00000000000..f156fb29ba2 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault/simplified-autofill-info/simplified-autofill-info.component.ts @@ -0,0 +1,110 @@ +import { AsyncPipe } from "@angular/common"; +import { + Component, + ChangeDetectionStrategy, + viewChild, + ElementRef, + inject, + effect, +} from "@angular/core"; +import { combineLatest, firstValueFrom } from "rxjs"; +import { map, switchMap } from "rxjs/operators"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { InfoFilledIcon } from "@bitwarden/assets/svg"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { PopoverModule, IconModule, ButtonModule, SvgModule } from "@bitwarden/components"; +import { StateProvider, UserKeyDefinition, VAULT_AUTOFILL_SIMPLIFIED_ICON } from "@bitwarden/state"; + +const VAULT_AUTOFILL_SIMPLIFIED_ICON_KEY = new UserKeyDefinition<{ + hasSeen: boolean; + hasDismissed: boolean; +}>(VAULT_AUTOFILL_SIMPLIFIED_ICON, "vaultAutofillSimplifiedIcon", { + deserializer: (value) => value, + clearOn: [], +}); + +@Component({ + selector: "app-simplified-autofill-info", + templateUrl: "./simplified-autofill-info.component.html", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [JslibModule, PopoverModule, IconModule, ButtonModule, SvgModule, AsyncPipe], +}) +export class SimplifiedAutofillInfoComponent { + private configService = inject(ConfigService); + private stateProvider = inject(StateProvider); + private accountService = inject(AccountService); + + readonly pingElement = viewChild>("pingElement"); + protected readonly InfoFilledIcon = InfoFilledIcon; + + private userId$ = this.accountService.activeAccount$.pipe(getUserId); + + private vaultAutofillSimplifiedIconState$ = this.userId$.pipe( + switchMap((userId) => + this.stateProvider.getUserState$(VAULT_AUTOFILL_SIMPLIFIED_ICON_KEY, userId), + ), + ); + + protected shouldShowPingAnimation$ = this.vaultAutofillSimplifiedIconState$.pipe( + map((state) => !state?.hasSeen), + ); + + /** Emits true when the icon should be shown to the user */ + protected shouldShowIcon$ = combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.PM31039ItemActionInExtension), + this.vaultAutofillSimplifiedIconState$, + ]).pipe( + map(([isFeatureEnabled, state]) => { + if (!isFeatureEnabled) { + return false; + } + + return !state?.hasDismissed; + }), + ); + + constructor() { + // Set up animation handler when ping element becomes available + effect(() => { + const pingElement = this.pingElement()?.nativeElement; + if (!pingElement) { + return; + } + + const animation = pingElement + .getAnimations() + .find((a) => "animationName" in a && a.animationName === "tw-ping"); + if (animation) { + animation.onfinish = () => { + // Set the ping element to hidden after the animation finishes to avoid any alignment issues with the icon. + pingElement.hidden = true; + void this.updateUserState({ hasSeen: true, hasDismissed: false }); + }; + } + }); + } + + /** Update the user state when the popover closes */ + protected async onPopoverClose(): Promise { + await this.updateUserState({ hasDismissed: true, hasSeen: true }); + } + + /** Updates the user's state for the simplified autofill icon */ + private async updateUserState(newState: { + hasSeen: boolean; + hasDismissed: boolean; + }): Promise { + const userId = await firstValueFrom(this.userId$); + + const state = this.stateProvider.getUser(userId, VAULT_AUTOFILL_SIMPLIFIED_ICON_KEY); + await state.update((oldState) => ({ + ...oldState, + ...newState, + })); + } +} diff --git a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html index 69c548540eb..ff6935df2a6 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html @@ -44,6 +44,7 @@ (click)="onRefresh.emit()" [label]="'refresh' | i18n" > + + + +`; diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index 33c9c780dec..f5d583c2f1d 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -219,6 +219,10 @@ export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition( "vaultBrowserIntroCarousel", "disk", ); +export const VAULT_AUTOFILL_SIMPLIFIED_ICON = new StateDefinition( + "vaultAutofillSimplifiedIcon", + "disk", +); export const VAULT_AT_RISK_PASSWORDS_MEMORY = new StateDefinition("vaultAtRiskPasswords", "memory"); export const WELCOME_EXTENSION_DIALOG_DISK = new StateDefinition( "vaultWelcomeExtensionDialogDismissed",