From 8dedd3c4df8b7b83c2cde53c518a6d5a3f15dfdb Mon Sep 17 00:00:00 2001 From: Derek Brans Date: Mon, 7 Oct 2024 12:03:16 -0400 Subject: [PATCH] build: add lottie-web dependency to extension (#27632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds a new dependency, [Lottie](https://airbnb.io/lottie), to the extension. Lottie is an animation library. The pros: * Enhanced User Experience: Lottie enables us to add high-quality, lightweight animations that will make our extension more visually appealing. * Community Support: It is well-maintained by Airbnb and has a strong community behind it. * Consistency with Mobile App: We’re already using Lottie in mobile, so integrating it into the extension will provide a consistent user experience across platforms. With any additional dependency, we need a consider: * supply chain attack surface managed by lavamoat. * increase in bundle size – in this case < 100kB gzip-ed. [More context on slack](https://consensys.slack.com/archives/CTQAGKY5V/p1717772021376029). [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27632?quickstart=1) ## **Related issues** * **Related:** https://github.com/MetaMask/metamask-extension/pull/27650 – use lottie animation in the extension. ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: MetaMask Bot Co-authored-by: legobeat <109787230+legobeat@users.noreply.github.com> --- jest.config.js | 6 +- package.json | 1 + test/jest/setup.js | 8 ++ .../lottie-animation/index.ts | 2 + .../lottie-animation.test.tsx | 133 ++++++++++++++++++ .../lottie-animation/lottie-animation.tsx | 75 ++++++++++ yarn.lock | 8 ++ 7 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 ui/components/component-library/lottie-animation/index.ts create mode 100644 ui/components/component-library/lottie-animation/lottie-animation.test.tsx create mode 100644 ui/components/component-library/lottie-animation/lottie-animation.tsx diff --git a/jest.config.js b/jest.config.js index dbfb0522cff7..f1d38ab4aea3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -22,7 +22,11 @@ module.exports = { // TODO: enable resetMocks // resetMocks: true, restoreMocks: true, - setupFiles: ['/test/setup.js', '/test/env.js'], + setupFiles: [ + 'jest-canvas-mock', + '/test/setup.js', + '/test/env.js', + ], setupFilesAfterEnv: ['/test/jest/setup.js'], testMatch: [ '/app/scripts/**/*.test.(js|ts|tsx)', diff --git a/package.json b/package.json index e35d2a5f6c36..cc317effc2ae 100644 --- a/package.json +++ b/package.json @@ -408,6 +408,7 @@ "localforage": "^1.9.0", "lodash": "^4.17.21", "loglevel": "^1.8.1", + "lottie-web": "^5.12.2", "luxon": "^3.2.1", "nanoid": "^2.1.6", "pify": "^5.0.0", diff --git a/test/jest/setup.js b/test/jest/setup.js index 0ee19a4d61b8..77fbb92783bc 100644 --- a/test/jest/setup.js +++ b/test/jest/setup.js @@ -1,6 +1,14 @@ // This file is for Jest-specific setup only and runs before our Jest tests. import '../helpers/setup-after-helper'; +jest.mock('webextension-polyfill', () => { + return { + runtime: { + getManifest: () => ({ manifest_version: 2 }), + }, + }; +}); + jest.mock('../../ui/hooks/usePetnamesEnabled', () => ({ usePetnamesEnabled: () => false, })); diff --git a/ui/components/component-library/lottie-animation/index.ts b/ui/components/component-library/lottie-animation/index.ts new file mode 100644 index 000000000000..dd89a159f751 --- /dev/null +++ b/ui/components/component-library/lottie-animation/index.ts @@ -0,0 +1,2 @@ +export { LottieAnimation } from './lottie-animation'; +export type { LottieAnimationProps } from './lottie-animation'; diff --git a/ui/components/component-library/lottie-animation/lottie-animation.test.tsx b/ui/components/component-library/lottie-animation/lottie-animation.test.tsx new file mode 100644 index 000000000000..21168d788f2c --- /dev/null +++ b/ui/components/component-library/lottie-animation/lottie-animation.test.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { render, act } from '@testing-library/react'; +import lottie from 'lottie-web/build/player/lottie_light'; +import { LottieAnimation } from './lottie-animation'; + +// Mock lottie-web +jest.mock('lottie-web/build/player/lottie_light', () => { + const eventListeners: { [key: string]: (() => void) | undefined } = {}; + return { + loadAnimation: jest.fn(() => ({ + destroy: jest.fn(), + addEventListener: jest.fn((event: string, callback: () => void) => { + eventListeners[event] = callback; + }), + removeEventListener: jest.fn((event: string) => { + delete eventListeners[event]; + }), + // Method to trigger the 'complete' event in tests + triggerComplete: () => eventListeners.complete?.(), + })), + }; +}); + +describe('LottieAnimation', () => { + const mockData = { + /* Your mock animation data here */ + }; + const mockPath = 'https://example.com/animation.json'; + + it('renders without crashing', () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const customClass = 'custom-class'; + const { container } = render( + , + ); + expect(container.firstChild).toHaveClass(customClass); + }); + + it('applies custom style', () => { + const customStyle = { width: '100px', height: '100px' }; + const { container } = render( + , + ); + const element = container.firstChild as HTMLElement; + expect(element).toHaveStyle('width: 100px'); + expect(element).toHaveStyle('height: 100px'); + }); + + it('calls lottie.loadAnimation with correct config when using data', () => { + render(); + + expect(lottie.loadAnimation).toHaveBeenCalledWith( + expect.objectContaining({ + animationData: mockData, + loop: false, + autoplay: false, + renderer: 'svg', + container: expect.any(HTMLElement), + }), + ); + }); + + it('calls lottie.loadAnimation with correct config when using path', () => { + render(); + + expect(lottie.loadAnimation).toHaveBeenCalledWith( + expect.objectContaining({ + path: mockPath, + loop: true, + autoplay: true, + renderer: 'svg', + container: expect.any(HTMLElement), + }), + ); + }); + + it('logs an error when neither data nor path is provided', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + render(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'LottieAnimation: Exactly one of data or path must be provided', + ); + consoleSpy.mockRestore(); + }); + + it('logs an error when both data and path are provided', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + render(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'LottieAnimation: Exactly one of data or path must be provided', + ); + consoleSpy.mockRestore(); + }); + + it('calls onComplete when animation completes', () => { + const onCompleteMock = jest.fn(); + + render(); + + const animationInstance = (lottie.loadAnimation as jest.Mock).mock + .results[0].value; + + act(() => { + animationInstance.triggerComplete(); + }); + + expect(onCompleteMock).toHaveBeenCalledTimes(1); + }); + + it('removes event listener on unmount', () => { + const onCompleteMock = jest.fn(); + + const { unmount } = render( + , + ); + + const animationInstance = (lottie.loadAnimation as jest.Mock).mock + .results[0].value; + + unmount(); + + expect(animationInstance.removeEventListener).toHaveBeenCalledWith( + 'complete', + expect.any(Function), + ); + }); +}); diff --git a/ui/components/component-library/lottie-animation/lottie-animation.tsx b/ui/components/component-library/lottie-animation/lottie-animation.tsx new file mode 100644 index 000000000000..08f9c783c7f3 --- /dev/null +++ b/ui/components/component-library/lottie-animation/lottie-animation.tsx @@ -0,0 +1,75 @@ +import React, { useEffect, useRef } from 'react'; +import { + AnimationConfigWithData, + AnimationConfigWithPath, + AnimationItem, +} from 'lottie-web'; +// Use lottie_light to avoid unsafe-eval which breaks the CSP +// https://github.com/airbnb/lottie-web/issues/289#issuecomment-1454909624 +import lottie from 'lottie-web/build/player/lottie_light'; + +export type LottieAnimationProps = { + data?: object; + path?: string; + loop?: boolean; + autoplay?: boolean; + style?: React.CSSProperties; + className?: string; + onComplete?: () => void; +}; + +export const LottieAnimation: React.FC = ({ + data, + path, + loop = true, + autoplay = true, + style = {}, + className = '', + onComplete = () => null, +}) => { + const containerRef = useRef(null); + const animationInstance = useRef(null); + + useEffect(() => { + if (!containerRef.current) { + console.error('LottieAnimation: containerRef is null'); + return () => null; + } + + if (Boolean(data) === Boolean(path)) { + console.error( + 'LottieAnimation: Exactly one of data or path must be provided', + ); + return () => null; + } + + const animationConfig: AnimationConfigWithData | AnimationConfigWithPath = { + container: containerRef.current, + renderer: 'svg', + loop, + autoplay, + ...(data ? { animationData: data } : { path }), + }; + + try { + animationInstance.current = lottie.loadAnimation(animationConfig); + animationInstance.current.addEventListener('complete', onComplete); + + animationInstance.current.addEventListener('error', (error) => { + console.error('LottieAnimation error:', error); + }); + } catch (error) { + console.error('Failed to load animation:', error); + } + + return () => { + if (animationInstance.current) { + animationInstance.current.removeEventListener('complete', onComplete); + animationInstance.current.destroy(); + animationInstance.current = null; + } + }; + }, [data, path, loop, autoplay, onComplete]); + + return
; +}; diff --git a/yarn.lock b/yarn.lock index 018b0e30269c..4cae5223a04c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25302,6 +25302,13 @@ __metadata: languageName: node linkType: hard +"lottie-web@npm:^5.12.2": + version: 5.12.2 + resolution: "lottie-web@npm:5.12.2" + checksum: 10/cd377d54a675b37ac9359306b84097ea402dff3d74a2f45e6e0dbcff1df94b3a978e92e48fd34765754bdbb94bd2d8d4da31954d95f156e77489596b235cac91 + languageName: node + linkType: hard + "lower-case@npm:^2.0.2": version: 2.0.2 resolution: "lower-case@npm:2.0.2" @@ -26295,6 +26302,7 @@ __metadata: lodash: "npm:^4.17.21" loglevel: "npm:^1.8.1" loose-envify: "npm:^1.4.0" + lottie-web: "npm:^5.12.2" luxon: "npm:^3.2.1" mocha: "npm:^10.2.0" mocha-junit-reporter: "npm:^2.2.1"