1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-26 17:43:22 +00:00

[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
This commit is contained in:
Nick Krantz
2026-02-24 13:42:45 -06:00
committed by GitHub
parent 4bcdd08ab8
commit 0a1baa7e42
10 changed files with 309 additions and 3 deletions

View File

@@ -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"
},

View File

@@ -10,4 +10,6 @@
[disableDescriptionMargin]="showEmptyAutofillTip$ | async"
[groupByType]="groupByType()"
[primaryActionAutofill]="clickItemsToAutofillVaultView$ | async"
></app-vault-list-items-container>
>
<app-simplified-autofill-info slot="title-end"></app-simplified-autofill-info>
</app-vault-list-items-container>

View File

@@ -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",

View File

@@ -0,0 +1,40 @@
@if (shouldShowIcon$ | async) {
<button
type="button"
class="tw-block tw-border-none tw-p-0 tw-ms-0.5"
[bitPopoverTriggerFor]="popover"
[position]="'below-center'"
#triggerRef="popoverTrigger"
[appA11yTitle]="'openSimplifiedAutofillPopover' | i18n"
bitLink
>
<div class="tw-size-[0.825rem] tw-items-center tw-relative">
@if (shouldShowPingAnimation$ | async) {
<span
#pingElement
class="tw-content-[''] tw-absolute tw-inset-0 tw-rounded-full tw-bg-primary-600 motion-safe:tw-animate-[ping_1s_ease-in-out_4]"
></span>
}
<!-- :before element is to create a background that matches the page background, otherwise the ping would be visible behind the icon -->
<bit-svg
class="tw-items-center tw-relative tw-z-0 before:tw-content-[''] before:tw-absolute before:tw-inset-[15%] before:tw-size-[70%] before:tw-rounded-full before:tw-bg-background-alt before:tw-z-[-1]"
[content]="InfoFilledIcon"
aria-hidden="true"
></bit-svg>
</div>
</button>
<bit-popover [title]="'simplifiedAutofill' | i18n" #popover (closed)="onPopoverClose()">
<p>
{{ "simplifiedAutofillDescription" | i18n }}
</p>
<button
type="button"
bitButton
buttonType="primary"
(click)="triggerRef?.closePopover(); onPopoverClose()"
>
{{ "gotIt" | i18n }}
</button>
</bit-popover>
}

View File

@@ -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<SimplifiedAutofillInfoComponent>;
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<Animation> & { 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();
});
});
});

View File

@@ -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<ElementRef<HTMLSpanElement>>("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<void> {
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<void> {
const userId = await firstValueFrom(this.userId$);
const state = this.stateProvider.getUser(userId, VAULT_AUTOFILL_SIMPLIFIED_ICON_KEY);
await state.update((oldState) => ({
...oldState,
...newState,
}));
}
}

View File

@@ -44,6 +44,7 @@
(click)="onRefresh.emit()"
[label]="'refresh' | i18n"
></button>
<ng-content select="[slot=title-end]"></ng-content>
<span bitTypography="body2" slot="end">
<span
[ngClass]="{

View File

@@ -18,6 +18,7 @@ export * from "./empty-trash";
export * from "./favorites.icon";
export * from "./gear";
export * from "./generator";
export * from "./info-filled.icon";
export * from "./item-types";
export * from "./lock.icon";
export * from "./login-cards";

View File

@@ -0,0 +1,7 @@
import { svg } from "../svg";
export const InfoFilledIcon = svg`
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path class="tw-fill-primary-600" d="M12 17C12.2833 17 12.5208 16.9042 12.7125 16.7125C12.9042 16.5208 13 16.2833 13 16V12C13 11.7167 12.9042 11.4792 12.7125 11.2875C12.5208 11.0958 12.2833 11 12 11C11.7167 11 11.4792 11.0958 11.2875 11.2875C11.0958 11.4792 11 11.7167 11 12V16C11 16.2833 11.0958 16.5208 11.2875 16.7125C11.4792 16.9042 11.7167 17 12 17ZM12 9C12.2833 9 12.5208 8.90417 12.7125 8.7125C12.9042 8.52083 13 8.28333 13 8C13 7.71667 12.9042 7.47917 12.7125 7.2875C12.5208 7.09583 12.2833 7 12 7C11.7167 7 11.4792 7.09583 11.2875 7.2875C11.0958 7.47917 11 7.71667 11 8C11 8.28333 11.0958 8.52083 11.2875 8.7125C11.4792 8.90417 11.7167 9 12 9ZM12 22C10.6167 22 9.31667 21.7375 8.1 21.2125C6.88333 20.6875 5.825 19.975 4.925 19.075C4.025 18.175 3.3125 17.1167 2.7875 15.9C2.2625 14.6833 2 13.3833 2 12C2 10.6167 2.2625 9.31667 2.7875 8.1C3.3125 6.88333 4.025 5.825 4.925 4.925C5.825 4.025 6.88333 3.3125 8.1 2.7875C9.31667 2.2625 10.6167 2 12 2C13.3833 2 14.6833 2.2625 15.9 2.7875C17.1167 3.3125 18.175 4.025 19.075 4.925C19.975 5.825 20.6875 6.88333 21.2125 8.1C21.7375 9.31667 22 10.6167 22 12C22 13.3833 21.7375 14.6833 21.2125 15.9C20.6875 17.1167 19.975 18.175 19.075 19.075C18.175 19.975 17.1167 20.6875 15.9 21.2125C14.6833 21.7375 13.3833 22 12 22Z" />
</svg>
`;

View File

@@ -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",