1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 16:23:44 +00:00

[PM-22180] Setup Extension Videos (#15419)

* remove placeholder image

* add videos for setup extension

* add support for mobile viewports

* add mobile/responsiveness for setup extension page

* add videos from `assets.bitwarden.com`

* align with figma for borders and shadow

* make text responsive for setup headings

* remove period

* add tests

* add tests for video sequence

* force font weight on `h2`

* add 8px to bottom margin of video container
This commit is contained in:
Nick Krantz
2025-07-07 08:56:31 -05:00
committed by GitHub
parent 03a7530f8b
commit 2e03b8cbac
9 changed files with 450 additions and 17 deletions

View File

@@ -0,0 +1,82 @@
<ng-template #newLoginItem>
<video
#video
muted
[ngClass]="{ 'tw-opacity-0': !allVideosLoaded }"
[attr.aria-hidden]="!allVideosLoaded"
class="tw-block tw-max-w-full tw-shadow-md tw-rounded-lg"
(loadeddata)="onVideoLoad()"
src="https://assets.bitwarden.com/extension-animations/new-login-item.mp4"
appDarkImgSrc="https://assets.bitwarden.com/extension-animations/new-login-item-dark.mp4"
aria-hidden
></video>
</ng-template>
<ng-template #browserExtensionEasyAccess>
<video
#video
muted
[ngClass]="{ 'tw-opacity-0': !allVideosLoaded }"
[attr.aria-hidden]="!allVideosLoaded"
class="tw-block tw-max-w-full tw-shadow-md tw-rounded-lg"
(loadeddata)="onVideoLoad()"
src="https://assets.bitwarden.com/extension-animations/browser-extension-easy-access.mp4"
appDarkImgSrc="https://assets.bitwarden.com/extension-animations/browser-extension-easy-access-dark.mp4"
aria-hidden
></video>
</ng-template>
<ng-template #onboardingAutofill>
<video
#video
muted
[ngClass]="{ 'tw-opacity-0': !allVideosLoaded }"
[attr.aria-hidden]="!allVideosLoaded"
class="tw-block tw-max-w-full tw-shadow-md tw-rounded-lg"
(loadeddata)="onVideoLoad()"
src="https://assets.bitwarden.com/extension-animations/onboarding-autofill.mp4"
appDarkImgSrc="https://assets.bitwarden.com/extension-animations/onboarding-autofill-dark.mp4"
aria-hidden
></video>
</ng-template>
<!--
On desktop, videos are shown in a 1 row, 3 column grid.
Below tablet viewports, videos are shown one at a time. The videos are absolute positioned, except the first.
The first video is relatively positioned to force the layout and spacing of the videos.
-->
<div
class="tw-mx-auto tw-w-[15rem] tw-mb-8 tw-relative md:tw-grid md:tw-gap-10 md:tw-w-auto md:tw-grid-rows-1 md:tw-grid-cols-3 md:tw-justify-center md:tw-justify-items-center"
[attr.aria-label]="'setupExtensionContentAlt' | i18n"
>
<div class="tw-relative tw-w-[15rem] tw-max-w-full tw-aspect-[0.807]">
<div
*ngIf="!allVideosLoaded"
class="tw-animate-pulse tw-bg-secondary-300 tw-size-full tw-absolute tw-left-0 tw-top-0 tw-rounded-lg"
data-testid="video-pulse"
></div>
<ng-container *ngTemplateOutlet="newLoginItem"></ng-container>
</div>
<div
class="tw-absolute tw-left-0 tw-top-0 tw-opacity-0 md:tw-opacity-100 md:tw-relative tw-w-[15rem] tw-max-w-full tw-aspect-[0.807]"
>
<div
*ngIf="!allVideosLoaded"
class="tw-animate-pulse tw-bg-secondary-300 tw-size-full tw-absolute tw-left-0 tw-top-0 tw-rounded-lg"
data-testid="video-pulse"
></div>
<ng-container *ngTemplateOutlet="browserExtensionEasyAccess"></ng-container>
</div>
<div
class="tw-absolute tw-left-0 tw-top-0 tw-opacity-0 md:tw-opacity-100 md:tw-relative tw-w-[15rem] tw-max-w-full tw-aspect-[0.807]"
>
<div
*ngIf="!allVideosLoaded"
class="tw-animate-pulse tw-bg-secondary-300 tw-size-full tw-absolute tw-left-0 tw-top-0 tw-rounded-lg"
data-testid="video-pulse"
></div>
<ng-container *ngTemplateOutlet="onboardingAutofill"></ng-container>
</div>
</div>

View File

@@ -0,0 +1,170 @@
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { provideNoopAnimations } from "@angular/platform-browser/animations";
import { RouterModule } from "@angular/router";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { AddExtensionVideosComponent } from "./add-extension-videos.component";
describe("AddExtensionVideosComponent", () => {
let fixture: ComponentFixture<AddExtensionVideosComponent>;
let component: AddExtensionVideosComponent;
// Mock HTMLMediaElement load to stop the video file from being loaded
Object.defineProperty(HTMLMediaElement.prototype, "load", {
value: jest.fn(),
writable: true,
});
const play = jest.fn(() => Promise.resolve());
HTMLMediaElement.prototype.play = play;
beforeEach(async () => {
window.matchMedia = jest.fn().mockReturnValue(false);
play.mockClear();
await TestBed.configureTestingModule({
imports: [AddExtensionVideosComponent, RouterModule.forRoot([])],
providers: [
provideNoopAnimations(),
{ provide: I18nService, useValue: { t: (key: string) => key } },
],
}).compileComponents();
fixture = TestBed.createComponent(AddExtensionVideosComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
describe("loading pulse", () => {
it("shows loading spinner when all videos are not loaded", () => {
const loadingSpinners = fixture.debugElement.queryAll(By.css("[data-testid='video-pulse']"));
expect(loadingSpinners.length).toBe(3);
});
it("shows all pulses until all videos are loaded", () => {
let loadingSpinners = fixture.debugElement.queryAll(By.css("[data-testid='video-pulse']"));
expect(loadingSpinners.length).toBe(3);
// Simulate two video loaded
component["videoElements"].get(0)?.nativeElement.dispatchEvent(new Event("loadeddata"));
component["videoElements"].get(1)?.nativeElement.dispatchEvent(new Event("loadeddata"));
loadingSpinners = fixture.debugElement.queryAll(By.css("[data-testid='video-pulse']"));
expect(component["numberOfLoadedVideos"]).toBe(2);
expect(loadingSpinners.length).toBe(3);
});
});
describe("window resizing", () => {
beforeEach(() => {
component["numberOfLoadedVideos"] = 3;
fixture.detectChanges();
});
it("shows all videos when window is resized to desktop viewport", fakeAsync(() => {
component["variant"] = "mobile";
Object.defineProperty(component["document"].documentElement, "clientWidth", {
configurable: true,
value: 1000,
});
window.dispatchEvent(new Event("resize"));
fixture.detectChanges();
tick(50);
expect(
Array.from(component["videoElements"]).every(
(video) => video.nativeElement.parentElement?.style.opacity === "1",
),
).toBe(true);
}));
it("shows only the playing video when window is resized to mobile viewport", fakeAsync(() => {
component["variant"] = "desktop";
// readonly property needs redefining
Object.defineProperty(component["document"].documentElement, "clientWidth", {
value: 500,
});
const video1 = component["videoElements"].get(1);
Object.defineProperty(video1!.nativeElement, "paused", {
value: false,
});
window.dispatchEvent(new Event("resize"));
fixture.detectChanges();
tick(50);
expect(component["videoElements"].get(0)?.nativeElement.parentElement?.style.opacity).toBe(
"0",
);
expect(component["videoElements"].get(1)?.nativeElement.parentElement?.style.opacity).toBe(
"1",
);
expect(component["videoElements"].get(2)?.nativeElement.parentElement?.style.opacity).toBe(
"0",
);
}));
});
describe("video sequence", () => {
let firstVideo: HTMLVideoElement;
let secondVideo: HTMLVideoElement;
let thirdVideo: HTMLVideoElement;
beforeEach(() => {
component["numberOfLoadedVideos"] = 2;
component["onVideoLoad"]();
firstVideo = component["videoElements"].get(0)!.nativeElement;
secondVideo = component["videoElements"].get(1)!.nativeElement;
thirdVideo = component["videoElements"].get(2)!.nativeElement;
});
it("starts the video sequence when all videos are loaded", fakeAsync(() => {
tick();
expect(firstVideo.play).toHaveBeenCalled();
}));
it("plays videos in sequence", fakeAsync(() => {
tick(); // let first video play
play.mockClear();
firstVideo.onended!(new Event("ended")); // trigger next video
tick();
expect(secondVideo.play).toHaveBeenCalledTimes(1);
play.mockClear();
secondVideo.onended!(new Event("ended")); // trigger next video
tick();
expect(thirdVideo.play).toHaveBeenCalledTimes(1);
}));
it("doesn't play videos again when the user prefers no motion", fakeAsync(() => {
component["prefersReducedMotion"] = true;
tick();
firstVideo.onended!(new Event("ended"));
tick();
secondVideo.onended!(new Event("ended"));
tick();
play.mockClear();
thirdVideo.onended!(new Event("ended")); // trigger first video again
tick();
expect(play).toHaveBeenCalledTimes(0);
}));
});
});

View File

@@ -0,0 +1,146 @@
import { CommonModule, DOCUMENT } from "@angular/common";
import { Component, ViewChildren, QueryList, ElementRef, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { debounceTime, fromEvent } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@Component({
selector: "vault-add-extension-videos",
templateUrl: "./add-extension-videos.component.html",
imports: [CommonModule, JslibModule],
})
export class AddExtensionVideosComponent {
@ViewChildren("video", { read: ElementRef }) protected videoElements!: QueryList<
ElementRef<HTMLVideoElement>
>;
private document = inject(DOCUMENT);
/** Current viewport size */
protected variant: "mobile" | "desktop" = "desktop";
/** Number of videos that have loaded and are ready to play */
protected numberOfLoadedVideos = 0;
/** True when the user prefers reduced motion */
protected prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/** Returns true when all videos are loaded */
get allVideosLoaded(): boolean {
return this.numberOfLoadedVideos >= 3;
}
constructor() {
fromEvent(window, "resize")
.pipe(takeUntilDestroyed(), debounceTime(25))
.subscribe(() => this.onResize());
}
/** Resets the video states based on the viewport width changes */
onResize(): void {
const oldVariant = this.variant;
this.variant = this.document.documentElement.clientWidth < 768 ? "mobile" : "desktop";
// When the viewport changes from desktop to mobile, hide all videos except the one that is playing.
if (this.variant !== oldVariant && this.variant === "mobile") {
this.videoElements.forEach((video) => {
if (video.nativeElement.paused) {
this.hideElement(video.nativeElement.parentElement!);
} else {
this.showElement(video.nativeElement.parentElement!);
}
});
}
// When the viewport changes from mobile to desktop, show all videos.
if (this.variant !== oldVariant && this.variant === "desktop") {
this.videoElements.forEach((video) => {
this.showElement(video.nativeElement.parentElement!);
});
}
}
/**
* Increment the number of loaded videos.
* When all videos are loaded, start the first one.
*/
protected onVideoLoad() {
this.numberOfLoadedVideos = this.numberOfLoadedVideos + 1;
if (this.allVideosLoaded) {
void this.startVideoSequence(0);
}
}
/** Recursive method to start the video sequence. */
private async startVideoSequence(i: number): Promise<void> {
let index = i;
const endOfVideos = index >= this.videoElements.length;
// When the user prefers reduced motion, don't play the videos more than once
if (endOfVideos && this.prefersReducedMotion) {
return;
}
// When the last of the videos has played, loop back to the start
if (endOfVideos) {
this.videoElements.forEach((video) => {
// Reset all videos to the start
video.nativeElement.currentTime = 0;
});
// Loop back to the first video
index = 0;
}
const video = this.videoElements.toArray()[index].nativeElement;
video.onended = () => {
void this.startVideoSequence(index + 1);
};
this.mobileTransitionIn(index);
// Set muted via JavaScript, browsers are respecting autoplay consistently over just the HTML attribute
video.muted = true;
await video.play();
}
/** For mobile viewports, fades the current video out and the next video in. */
private mobileTransitionIn(index: number): void {
// When the viewport is above the tablet breakpoint, all videos are shown at once.
// No transition is needed.
if (this.isAboveTabletBreakpoint()) {
return;
}
const currentParent = this.videoElements.toArray()[index].nativeElement.parentElement!;
const previousIndex = index === 0 ? this.videoElements.length - 1 : index - 1;
const previousParent = this.videoElements.toArray()[previousIndex].nativeElement.parentElement!;
// Fade out the previous video
this.hideElement(previousParent, true);
// Fade in the current video
this.showElement(currentParent, true);
}
/** Returns true when the viewport width is 768px or above. */
private isAboveTabletBreakpoint(): boolean {
const width = this.document.documentElement.clientWidth;
return width >= 768;
}
/** Visually hides the given element. */
private hideElement(element: HTMLElement, transition = false): void {
element.style.transition = transition ? "opacity 0.5s linear" : "";
element.style.opacity = "0";
}
/** Visually shows the given element. */
private showElement(element: HTMLElement, transition = false): void {
element.style.transition = transition ? "opacity 0.5s linear" : "";
element.style.opacity = "1";
}
}

View File

@@ -6,16 +6,13 @@
></i>
<section *ngIf="state === SetupExtensionState.NeedsExtension" class="tw-text-center tw-mt-4">
<h1 bitTypography="h2">{{ "setupExtensionPageTitle" | i18n }}</h1>
<h2 bitTypography="body1">{{ "setupExtensionPageDescription" | i18n }}</h2>
<div class="tw-mb-6">
<!-- Placeholder - will be removed in following tickets -->
<img
class="tw-max-w-3xl"
[alt]="'setupExtensionContentAlt' | i18n"
src="/images/setup-extension/setup-extension-placeholder.png"
/>
</div>
<h1 class="tw-text-xl tw-font-semibold tw-text-main sm:tw-text-2xl">
{{ "setupExtensionPageTitle" | i18n }}
</h1>
<h2 class="tw-text-sm tw-text-main tw-mb-6 tw-font-normal sm:tw-text-base">
{{ "setupExtensionPageDescription" | i18n }}
</h2>
<vault-add-extension-videos></vault-add-extension-videos>
<div class="tw-flex tw-flex-col tw-gap-4 tw-items-center">
<a
bitButton

View File

@@ -27,6 +27,7 @@ describe("SetupExtensionComponent", () => {
navigate.mockClear();
openExtension.mockClear();
getFeatureFlag.mockClear().mockResolvedValue(true);
window.matchMedia = jest.fn().mockReturnValue(false);
await TestBed.configureTestingModule({
imports: [SetupExtensionComponent, RouterModule.forRoot([])],

View File

@@ -1,5 +1,5 @@
import { NgIf } from "@angular/common";
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
import { DOCUMENT, NgIf } from "@angular/common";
import { Component, DestroyRef, inject, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router, RouterModule } from "@angular/router";
import { pairwise, startWith } from "rxjs";
@@ -23,6 +23,7 @@ import { VaultIcons } from "@bitwarden/vault";
import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service";
import { AddExtensionLaterDialogComponent } from "./add-extension-later-dialog.component";
import { AddExtensionVideosComponent } from "./add-extension-videos.component";
const SetupExtensionState = {
Loading: "loading",
@@ -35,15 +36,24 @@ type SetupExtensionState = UnionOfValues<typeof SetupExtensionState>;
@Component({
selector: "vault-setup-extension",
templateUrl: "./setup-extension.component.html",
imports: [NgIf, JslibModule, ButtonComponent, LinkModule, IconModule, RouterModule],
imports: [
NgIf,
JslibModule,
ButtonComponent,
LinkModule,
IconModule,
RouterModule,
AddExtensionVideosComponent,
],
})
export class SetupExtensionComponent implements OnInit {
export class SetupExtensionComponent implements OnInit, OnDestroy {
private webBrowserExtensionInteractionService = inject(WebBrowserInteractionService);
private configService = inject(ConfigService);
private router = inject(Router);
private destroyRef = inject(DestroyRef);
private platformUtilsService = inject(PlatformUtilsService);
private dialogService = inject(DialogService);
private document = inject(DOCUMENT);
protected SetupExtensionState = SetupExtensionState;
protected PartyIcon = VaultIcons.Party;
@@ -56,8 +66,21 @@ export class SetupExtensionComponent implements OnInit {
/** Reference to the add it later dialog */
protected dialogRef: DialogRef | null = null;
private viewportContent: string | null = null;
async ngOnInit() {
// It is not be uncommon for users to hit this page from smaller viewports.
// There are global styles that set a min-width for the page which cause it to render poorly.
// Remove them here.
// https://github.com/bitwarden/clients/blob/main/apps/web/src/scss/base.scss#L6
this.document.body.style.minWidth = "auto";
const viewportMeta = this.document.querySelector('meta[name="viewport"]');
// Save the current viewport content to reset it when the component is destroyed
this.viewportContent = viewportMeta?.getAttribute("content") ?? null;
viewportMeta?.setAttribute("content", "width=device-width, initial-scale=1.0");
await this.conditionallyRedirectUser();
this.webStoreUrl = getWebStoreUrl(this.platformUtilsService.getDevice());
@@ -83,6 +106,17 @@ export class SetupExtensionComponent implements OnInit {
});
}
ngOnDestroy(): void {
// Reset the body min-width when the component is destroyed
this.document.body.style.minWidth = "";
if (this.viewportContent !== null) {
this.document
.querySelector('meta[name="viewport"]')
?.setAttribute("content", this.viewportContent);
}
}
/** Conditionally redirects the user to the vault upon landing on the page. */
async conditionallyRedirectUser() {
const isFeatureEnabled = await this.configService.getFeatureFlag(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 KiB

View File

@@ -10746,15 +10746,15 @@
},
"gettingStartedWithBitwardenPart1": {
"message": "For tips on getting started with Bitwarden visit the",
"description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'."
"description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'"
},
"gettingStartedWithBitwardenPart2": {
"message": "Learning Center",
"description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'."
"description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'"
},
"gettingStartedWithBitwardenPart3": {
"message": "Help Center",
"description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'."
"description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'"
},
"setupExtensionContentAlt": {
"message": "With the Bitwarden browser extension you can easily create new logins, access your saved logins directly from your browser toolbar, and sign in to accounts quickly using Bitwarden autofill."