diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.html b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.html
new file mode 100644
index 00000000000..3764f7d828f
--- /dev/null
+++ b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.html
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.spec.ts b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.spec.ts
new file mode 100644
index 00000000000..9f39a3edcac
--- /dev/null
+++ b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.spec.ts
@@ -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;
+ 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);
+ }));
+ });
+});
diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts
new file mode 100644
index 00000000000..2420414fc88
--- /dev/null
+++ b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts
@@ -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
+ >;
+
+ 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 {
+ 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";
+ }
+}
diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html
index fc2b1bc60cb..3b9ec19fd34 100644
--- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html
+++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html
@@ -6,16 +6,13 @@
>
- {{ "setupExtensionPageTitle" | i18n }}
- {{ "setupExtensionPageDescription" | i18n }}
-
-
-

-
+
+ {{ "setupExtensionPageTitle" | i18n }}
+
+
+ {{ "setupExtensionPageDescription" | i18n }}
+
+
{
navigate.mockClear();
openExtension.mockClear();
getFeatureFlag.mockClear().mockResolvedValue(true);
+ window.matchMedia = jest.fn().mockReturnValue(false);
await TestBed.configureTestingModule({
imports: [SetupExtensionComponent, RouterModule.forRoot([])],
diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts
index 839572f3a30..9ee8e189627 100644
--- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts
+++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts
@@ -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;
@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(
diff --git a/apps/web/src/images/setup-extension/setup-extension-placeholder.png b/apps/web/src/images/setup-extension/setup-extension-placeholder.png
deleted file mode 100644
index 03a6d8951c0..00000000000
Binary files a/apps/web/src/images/setup-extension/setup-extension-placeholder.png and /dev/null differ
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index ce35f2abd33..c5a7a64ee5c 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -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."
diff --git a/apps/web/webpack.config.js b/apps/web/webpack.config.js
index 04a68b16c00..a28052b98e3 100644
--- a/apps/web/webpack.config.js
+++ b/apps/web/webpack.config.js
@@ -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