1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-27 14:53:44 +00:00

autofill info dialog

This commit is contained in:
jaasen-livefront
2026-01-20 18:11:27 -08:00
parent b71ca1a2c0
commit eba5f6f1ce
8 changed files with 275 additions and 70 deletions

View File

@@ -3765,6 +3765,12 @@
"autofillSelectInfoWithoutCommand": {
"message": "Select an item from this screen, or explore other options in settings."
},
"simplifiedAutofill": {
"message": "Simplified autofill"
},
"simplifiedAutofillDesc": {
"message": "Now, 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."
},
"gotIt": {
"message": "Got it"
},

View File

@@ -0,0 +1,13 @@
<bit-dialog [title]="'simplifiedAutofill' | i18n">
<div bitDialogContent>
<p bitTypography="body2">
{{ "autofillSelectInfoWithoutCommand" | i18n }}
</p>
</div>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" (click)="close()">
{{ "gotIt" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,18 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ButtonModule, DialogModule, DialogRef, TypographyModule } from "@bitwarden/components";
@Component({
templateUrl: "./autofill-suggestions-info-dialog.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, DialogModule, TypographyModule, ButtonModule, JslibModule],
})
export class AutofillSuggestionsInfoDialogComponent {
private dialogRef = inject(DialogRef<void>);
close() {
this.dialogRef.close();
}
}

View File

@@ -5,6 +5,10 @@
[showRefresh]="showRefresh"
(onRefresh)="refreshCurrentTab()"
[description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : undefined"
[showInfoIcon]="showInfoIcon$ | async"
[pingInfoIcon]="pingInfoIcon()"
(onInfoIconClick)="openAutofillSuggestionsInfo()"
(onInfoIconDismissed)="dismissAutofillSuggestionsInfo()"
showAutofillButton
[disableDescriptionMargin]="showEmptyAutofillTip$ | async"
[primaryActionAutofill]="clickItemsToAutofillVaultView$ | async"

View File

@@ -1,15 +1,18 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { combineLatest, map, Observable, startWith } from "rxjs";
import { Component, DestroyRef, OnInit, inject, signal } from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { combineLatest, filter, from, map, Observable, startWith, switchMap, take } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
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 BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils";
import { VaultPopupAutofillSuggestionsInfoService } from "../../../services/vault-popup-autofill-suggestions-info.service";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
import { PopupCipherViewLike } from "../../../views/popup-cipher.view";
@@ -28,7 +31,10 @@ import { VaultListItemsContainerComponent } from "../vault-list-items-container/
selector: "app-autofill-vault-list-items",
templateUrl: "autofill-vault-list-items.component.html",
})
export class AutofillVaultListItemsComponent {
export class AutofillVaultListItemsComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly activeUserId$ = this.accountService.activeAccount$.pipe(getUserId);
/**
* The list of ciphers that can be used to autofill the current page.
* @protected
@@ -48,6 +54,35 @@ export class AutofillVaultListItemsComponent {
startWith(true), // Start with true to avoid flashing the fill button on first load
);
/** When true, the info icon should ping (4 iterations). */
protected readonly pingInfoIcon = signal(false);
/** Flag indicating that the current tab location is blocked */
protected readonly currentURIIsBlocked$: Observable<boolean> =
this.vaultPopupAutofillService.currentTabIsOnBlocklist$;
/** Computed state for the per-user info icon (dismissed/ping completed). */
private readonly infoState$ = this.activeUserId$.pipe(
switchMap((userId) => this.autofillSuggestionsInfoService.state$(userId)),
);
/**
* Show the info icon only when:
* - the current page is not blocked
* - the autofill suggestions list is populated
* - the user hasn't dismissed the info dialog
*/
protected readonly showInfoIcon$ = combineLatest([
this.autofillCiphers$,
this.currentURIIsBlocked$,
this.infoState$,
]).pipe(
map(([ciphers, isBlocked, infoState]) => {
const hasItems = (ciphers ?? []).length > 0;
return !isBlocked && hasItems && !(infoState.dismissed ?? false);
}),
);
protected readonly groupByType = toSignal(
this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => !hasFilter)),
);
@@ -71,18 +106,61 @@ export class AutofillVaultListItemsComponent {
),
);
/**
* Flag indicating that the current tab location is blocked
*/
currentURIIsBlocked$: Observable<boolean> =
this.vaultPopupAutofillService.currentTabIsOnBlocklist$;
constructor(
private vaultPopupItemsService: VaultPopupItemsService,
private vaultPopupAutofillService: VaultPopupAutofillService,
private vaultSettingsService: VaultSettingsService,
private accountService: AccountService,
private autofillSuggestionsInfoService: VaultPopupAutofillSuggestionsInfoService,
) {}
ngOnInit() {
// Start the ping animation once (4 iterations) the first time suggestions become populated.
combineLatest([
this.activeUserId$,
this.autofillCiphers$,
this.currentURIIsBlocked$,
this.infoState$,
])
.pipe(
takeUntilDestroyed(this.destroyRef),
filter(([_userId, ciphers, isBlocked, infoState]) => {
const hasItems = (ciphers ?? []).length > 0;
return (
!isBlocked &&
hasItems &&
!(infoState.dismissed ?? false) &&
!(infoState.pingCompleted ?? false)
);
}),
take(1),
)
.subscribe(([userId]) => {
// Mark completed immediately so it never replays across vault opens.
void this.autofillSuggestionsInfoService.markPingCompleted(userId);
this.pingInfoIcon.set(true);
window.setTimeout(() => this.pingInfoIcon.set(false), 4000);
});
}
protected openAutofillSuggestionsInfo(): void {
// Keep the icon visible until the user dismisses the popover, but stop any ping immediately.
this.pingInfoIcon.set(false);
}
protected dismissAutofillSuggestionsInfo(): void {
this.pingInfoIcon.set(false);
this.activeUserId$
.pipe(
take(1),
takeUntilDestroyed(this.destroyRef),
switchMap((userId) => from(this.autofillSuggestionsInfoService.markDismissed(userId))),
)
.subscribe();
}
/**
* Refreshes the current tab to re-populate the autofill ciphers.
* @protected

View File

@@ -33,9 +33,52 @@
<ng-template #sectionHeader>
<bit-section-header class="tw-p-0.5 -tw-mx-0.5">
<h2 bitTypography="h6">
{{ title() }}
</h2>
<div class="tw-flex tw-items-center tw-gap-2">
<h2 bitTypography="h6" class="!tw-mb-0">
{{ title() }}
</h2>
@if (showInfoIcon()) {
<ng-container>
<button
type="button"
class="tw-relative tw-inline-flex tw-items-center tw-justify-center tw-w-6 tw-h-6 tw-rounded-md hover:tw-bg-secondary-100 focus-visible:tw-outline-none focus-visible:tw-ring-inset focus-visible:tw-ring-2 focus-visible:tw-ring-primary-600"
[bitPopoverTriggerFor]="autofillSuggestionsInfoPopover"
[position]="'below-center'"
#autofillSuggestionsInfoTrigger="popoverTrigger"
(click)="$event.stopPropagation(); onInfoIconClick.emit()"
[attr.aria-label]="'learnMore' | i18n"
[title]="'learnMore' | i18n"
>
@if (pingInfoIcon()) {
<span
class="tw-absolute tw-inline-flex tw-h-6 tw-w-6 tw-rounded-full tw-bg-primary-600/20 tw-animate-ping tw-[animation-iteration-count:4]"
aria-hidden="true"
></span>
}
<i class="bwi bwi-info-circle tw-text-muted" aria-hidden="true"></i>
</button>
<bit-popover
[title]="'simplifiedAutofill' | i18n"
#autofillSuggestionsInfoPopover
(closed)="onInfoIconDismissed.emit()"
>
<p class="tw-mb-0">{{ "simplifiedAutofillDesc" | i18n }}</p>
<div class="tw-mt-4 tw-flex tw-justify-start">
<button
type="button"
bitButton
buttonType="primary"
(click)="autofillSuggestionsInfoTrigger.closePopover(); onInfoIconDismissed.emit()"
>
{{ "gotIt" | i18n }}
</button>
</div>
</bit-popover>
</ng-container>
}
</div>
<button
*ngIf="showRefresh()"
bitIconButton="bwi-refresh"

View File

@@ -4,15 +4,14 @@ import {
AfterViewInit,
booleanAttribute,
Component,
EventEmitter,
inject,
Output,
Signal,
signal,
ViewChild,
computed,
ChangeDetectionStrategy,
input,
output,
} from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router";
@@ -40,6 +39,7 @@ import {
DialogService,
IconButtonModule,
ItemModule,
PopoverModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
@@ -80,6 +80,7 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options
DisclosureComponent,
DisclosureTriggerForDirective,
ScrollLayoutDirective,
PopoverModule,
],
selector: "app-vault-list-items-container",
templateUrl: "vault-list-items-container.component.html",
@@ -100,9 +101,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Indicates whether the section should be open or closed if collapsibleKey is provided
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
protected sectionOpenState: Signal<boolean> = computed(() => {
protected readonly sectionOpenState: Signal<boolean> = computed(() => {
if (!this.collapsibleKey()) {
return true;
}
@@ -136,24 +135,18 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
*/
private viewCipherTimeout?: number;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
ciphers = input<PopupCipherViewLike[]>([]);
readonly ciphers = input<PopupCipherViewLike[]>([]);
/**
* If true, we will group ciphers by type (Login, Card, Identity)
* within subheadings in a single container, converted to a WritableSignal.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
groupByType = input<boolean | undefined>(false);
readonly groupByType = input<boolean | undefined>(false);
/**
* Computed signal for a grouped list of ciphers with an optional header
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
cipherGroups = computed<
readonly cipherGroups = computed<
{
subHeaderKey?: string;
ciphers: PopupCipherViewLike[];
@@ -195,9 +188,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Title for the vault list item section.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
title = input<string | undefined>(undefined);
readonly title = input<string | undefined>(undefined);
/**
* Optionally allow the items to be collapsed.
@@ -205,46 +196,53 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
* The key must be added to the state definition in `vault-popup-section.service.ts` since the
* collapsed state is stored locally.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined);
readonly collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined);
/**
* Optional description for the vault list item section. Will be shown below the title even when
* no ciphers are available.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
description = input<string | undefined>(undefined);
readonly description = input<string | undefined>(undefined);
/**
* Option to show a refresh button in the section header.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
showRefresh = input(false, { transform: booleanAttribute });
readonly showRefresh = input(false, { transform: booleanAttribute });
/**
* Option to show an info icon next to the section title.
*/
readonly showInfoIcon = input(false, { transform: booleanAttribute });
/**
* When true, applies a Tailwind ping animation behind the info icon.
*/
readonly pingInfoIcon = input(false, { transform: booleanAttribute });
/**
* Event emitted when the refresh button is clicked.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output()
onRefresh = new EventEmitter<void>();
readonly onRefresh = output();
/**
* Event emitted when the header info icon is clicked.
*/
readonly onInfoIconClick = output();
/**
* Event emitted when the header info popover is dismissed.
*/
readonly onInfoIconDismissed = output();
/**
* Flag indicating that the current tab location is blocked
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
currentURIIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
readonly currentURIIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
/**
* Resolved i18n key to use for suggested cipher items
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
cipherItemTitleKey = computed(() => {
readonly cipherItemTitleKey = computed(() => {
return (cipher: CipherViewLike) => {
const login = CipherViewLikeUtils.getLogin(cipher);
const hasUsername = login?.username != null;
@@ -259,47 +257,37 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Option to show the autofill button for each item.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
showAutofillButton = input(false, { transform: booleanAttribute });
readonly showAutofillButton = input(false, { transform: booleanAttribute });
/**
* Flag indicating whether the suggested cipher item autofill button should be shown or not
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
hideAutofillButton = computed(
readonly hideAutofillButton = computed(
() => !this.showAutofillButton() || this.currentURIIsBlocked() || this.primaryActionAutofill(),
);
/**
* Flag indicating whether the cipher item autofill menu options should be shown or not
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
hideAutofillMenuOptions = computed(() => this.currentURIIsBlocked() || this.showAutofillButton());
readonly hideAutofillMenuOptions = computed(
() => this.currentURIIsBlocked() || this.showAutofillButton(),
);
/**
* Option to perform autofill operation as the primary action for autofill suggestions.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
primaryActionAutofill = input(false, { transform: booleanAttribute });
readonly primaryActionAutofill = input(false, { transform: booleanAttribute });
/**
* Remove the bottom margin from the bit-section in this component
* (used for containers at the end of the page where bottom margin is not needed)
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
disableSectionMargin = input(false, { transform: booleanAttribute });
readonly disableSectionMargin = input(false, { transform: booleanAttribute });
/**
* Remove the description margin
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
disableDescriptionMargin = input(false, { transform: booleanAttribute });
readonly disableDescriptionMargin = input(false, { transform: booleanAttribute });
/**
* The tooltip text for the organization icon for ciphers that belong to an organization.
@@ -313,9 +301,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
return collections[0]?.name;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
protected autofillShortcutTooltip = signal<string | undefined>(undefined);
readonly autofillShortcutTooltip = signal<string | undefined>(undefined);
constructor(
private i18nService: I18nService,

View File

@@ -0,0 +1,57 @@
import { Injectable } from "@angular/core";
import { map, Observable } from "rxjs";
import {
StateProvider,
UserKeyDefinition,
VAULT_SETTINGS_DISK,
} from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
export type AutofillSuggestionsInfoState = {
dismissed?: boolean;
pingCompleted?: boolean;
};
const DEFAULT_STATE: AutofillSuggestionsInfoState = {
dismissed: false,
pingCompleted: false,
};
const AUTOFILL_SUGGESTIONS_INFO_KEY = new UserKeyDefinition<AutofillSuggestionsInfoState>(
VAULT_SETTINGS_DISK,
"autofillSuggestionsInfo",
{
deserializer: (obj) => obj,
clearOn: [],
},
);
@Injectable({
providedIn: "root",
})
export class VaultPopupAutofillSuggestionsInfoService {
constructor(private stateProvider: StateProvider) {}
private stateForUser(userId: UserId) {
return this.stateProvider.getUser(userId, AUTOFILL_SUGGESTIONS_INFO_KEY);
}
state$(userId: UserId): Observable<AutofillSuggestionsInfoState> {
return this.stateForUser(userId).state$.pipe(map((state) => state ?? DEFAULT_STATE));
}
async markPingCompleted(userId: UserId): Promise<void> {
await this.stateForUser(userId).update((current) => ({
...(current ?? DEFAULT_STATE),
pingCompleted: true,
}));
}
async markDismissed(userId: UserId): Promise<void> {
await this.stateForUser(userId).update((current) => ({
...(current ?? DEFAULT_STATE),
dismissed: true,
}));
}
}