1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +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."

View File

@@ -268,6 +268,9 @@ const devServer =
https://www.paypalobjects.com
https://q.stripe.com
https://haveibeenpwned.com
;media-src
'self'
https://assets.bitwarden.com
;child-src
'self'
https://js.stripe.com