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