diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index 2dce6fb82..196a8e400 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -2330,6 +2330,9 @@
"origin": {
"message": "Origin"
},
+ "padlock": {
+ "message": "Padlock"
+ },
"parameters": {
"message": "Parameters"
},
diff --git a/app/images/lock-icon.svg b/app/images/lock-icon.svg
new file mode 100644
index 000000000..824974a09
--- /dev/null
+++ b/app/images/lock-icon.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/images/unlock-icon.svg b/app/images/unlock-icon.svg
new file mode 100644
index 000000000..2ad1eadeb
--- /dev/null
+++ b/app/images/unlock-icon.svg
@@ -0,0 +1,3 @@
+
diff --git a/ui/components/app/app-components.scss b/ui/components/app/app-components.scss
index aac5586b8..62be2129b 100644
--- a/ui/components/app/app-components.scss
+++ b/ui/components/app/app-components.scss
@@ -40,6 +40,7 @@
@import 'gas-details-item/index';
@import 'gas-details-item/gas-details-item-title/index';
@import 'gas-timing/index';
+@import 'hold-to-reveal-button/index';
@import 'home-notification/index';
@import 'info-box/index';
@import 'menu-bar/index';
diff --git a/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.js b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.js
new file mode 100644
index 000000000..076b02f30
--- /dev/null
+++ b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.js
@@ -0,0 +1,192 @@
+import React, { useCallback, useContext, useRef, useState } from 'react';
+import PropTypes from 'prop-types';
+import Button from '../../ui/button';
+import { I18nContext } from '../../../contexts/i18n';
+import Box from '../../ui/box/box';
+import {
+ ALIGN_ITEMS,
+ DISPLAY,
+ JUSTIFY_CONTENT,
+} from '../../../helpers/constants/design-system';
+
+const radius = 14;
+const strokeWidth = 2;
+const radiusWithStroke = radius - strokeWidth / 2;
+
+export default function HoldToRevealButton({ buttonText, onLongPressed }) {
+ const t = useContext(I18nContext);
+ const isLongPressing = useRef(false);
+ const [isUnlocking, setIsUnlocking] = useState(false);
+ const [hasTriggeredUnlock, setHasTriggeredUnlock] = useState(false);
+
+ /**
+ * Prevent animation events from propogating up
+ *
+ * @param e - Native animation event - React.AnimationEvent
+ */
+ const preventPropogation = (e) => {
+ e.stopPropagation();
+ };
+
+ /**
+ * Event for mouse click down
+ */
+ const onMouseDown = () => {
+ isLongPressing.current = true;
+ };
+
+ /**
+ * Event for mouse click up
+ */
+ const onMouseUp = () => {
+ isLongPressing.current = false;
+ };
+
+ /**
+ * 1. Progress cirle completed. Begin next animation phase (Shrink halo and show unlocked padlock)
+ */
+ const onProgressComplete = () => {
+ isLongPressing.current && setIsUnlocking(true);
+ };
+
+ /**
+ * 2. Trigger onLongPressed callback. Begin next animation phase (Shrink unlocked padlock and fade in original content)
+ *
+ * @param e - Native animation event - React.AnimationEvent
+ */
+ const triggerOnLongPressed = (e) => {
+ onLongPressed();
+ setHasTriggeredUnlock(true);
+ preventPropogation(e);
+ };
+
+ /**
+ * 3. Reset animation states
+ */
+ const resetAnimationStates = () => {
+ setIsUnlocking(false);
+ setHasTriggeredUnlock(false);
+ };
+
+ const renderPreCompleteContent = useCallback(() => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }, [isUnlocking, hasTriggeredUnlock, t]);
+
+ const renderPostCompleteContent = useCallback(() => {
+ return isUnlocking ? (
+
+
+
+
+
+
+
+
+
+
+
+ ) : null;
+ }, [isUnlocking, hasTriggeredUnlock, t]);
+
+ return (
+
+ );
+}
+
+HoldToRevealButton.propTypes = {
+ /**
+ * Text to be displayed on the button
+ */
+ buttonText: PropTypes.string.isRequired,
+ /**
+ * Function to be called after the animation is finished
+ */
+ onLongPressed: PropTypes.func.isRequired,
+};
diff --git a/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.stories.js b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.stories.js
new file mode 100644
index 000000000..3f46c1f0d
--- /dev/null
+++ b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.stories.js
@@ -0,0 +1,22 @@
+import React from 'react';
+import HoldToRevealButton from './hold-to-reveal-button';
+
+export default {
+ title: 'Components/App/HoldToRevealButton',
+ id: __filename,
+ argTypes: {
+ buttonText: { control: 'text' },
+ onLongPressed: { action: 'Revealing the SRP' },
+ },
+};
+
+export const DefaultStory = (args) => {
+ return ;
+};
+
+DefaultStory.storyName = 'Default';
+
+DefaultStory.args = {
+ buttonText: 'Hold to reveal SRP',
+ onLongPressed: () => console.log('Revealed'),
+};
diff --git a/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.test.js b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.test.js
new file mode 100644
index 000000000..f3f7c4d97
--- /dev/null
+++ b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.test.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import { render, fireEvent, waitFor } from '@testing-library/react';
+import HoldToRevealButton from './hold-to-reveal-button';
+
+describe('HoldToRevealButton', () => {
+ let props = {};
+
+ beforeEach(() => {
+ const mockOnLongPressed = jest.fn();
+
+ props = {
+ onLongPressed: mockOnLongPressed,
+ buttonText: 'Hold to reveal SRP',
+ };
+ });
+
+ it('should render a button with label', () => {
+ const { getByText } = render();
+
+ expect(getByText('Hold to reveal SRP')).toBeInTheDocument();
+ });
+
+ it('should render a button when mouse is down and up', () => {
+ const { getByText } = render();
+
+ const button = getByText('Hold to reveal SRP');
+
+ fireEvent.mouseDown(button);
+
+ expect(button).toBeDefined();
+
+ fireEvent.mouseUp(button);
+
+ expect(button).toBeDefined();
+ });
+
+ it('should not show the locked padlock when a button is long pressed and then should show it after it was lifted off before the animation concludes', () => {
+ const { getByText } = render();
+
+ const button = getByText('Hold to reveal SRP');
+
+ fireEvent.mouseDown(button);
+
+ waitFor(() => {
+ expect(button.firstChild).toHaveClass(
+ 'hold-to-reveal-button__lock-icon-container',
+ );
+ });
+
+ fireEvent.mouseUp(button);
+
+ waitFor(() => {
+ expect(button.firstChild).not.toHaveClass(
+ 'hold-to-reveal-button__lock-icon-container',
+ );
+ });
+ });
+
+ it('should show the unlocked padlock when a button is long pressed for the duration of the animation', () => {
+ const { getByText } = render();
+
+ const button = getByText('Hold to reveal SRP');
+
+ fireEvent.mouseDown(button);
+
+ waitFor(() => {
+ expect(button.firstChild).toHaveClass(
+ 'hold-to-reveal-button__unlock-icon-container',
+ );
+ });
+ });
+});
diff --git a/ui/components/app/hold-to-reveal-button/index.js b/ui/components/app/hold-to-reveal-button/index.js
new file mode 100644
index 000000000..d180f5e69
--- /dev/null
+++ b/ui/components/app/hold-to-reveal-button/index.js
@@ -0,0 +1 @@
+export { default } from './hold-to-reveal-button';
diff --git a/ui/components/app/hold-to-reveal-button/index.scss b/ui/components/app/hold-to-reveal-button/index.scss
new file mode 100644
index 000000000..f9765ec60
--- /dev/null
+++ b/ui/components/app/hold-to-reveal-button/index.scss
@@ -0,0 +1,164 @@
+// Variables
+$circle-radius: 14px;
+$circle-diameter: $circle-radius * 2;
+// Circumference ~ (2*PI*R). We reduced the number a little to create a snappier interaction
+$circle-circumference: 82;
+$circle-stroke-width: 2px;
+
+// Keyframes
+@keyframes collapse {
+ from {
+ transform: scale(1);
+ }
+
+ to {
+ transform: scale(0);
+ }
+}
+
+@keyframes expand {
+ from {
+ transform: scale(0);
+ }
+
+ to {
+ transform: scale(1);
+ }
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+.hold-to-reveal-button {
+ // Shared styles
+ &__absolute-fill {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ }
+
+ &__icon {
+ height: $circle-diameter;
+ width: $circle-diameter;
+ }
+
+ &__circle-shared {
+ fill: transparent;
+ stroke-width: $circle-stroke-width;
+ }
+
+ // Class styles
+ &__button-hold {
+ padding: 6px 13px 6px 9px !important;
+ transform: scale(1) !important;
+ transition: 0.5s transform !important;
+
+ &:active {
+ background-color: var(--primary-1) !important;
+ transform: scale(1.05) !important;
+
+ .hold-to-reveal-button__circle-foreground {
+ stroke-dashoffset: 0 !important;
+ }
+
+ .hold-to-reveal-button__lock-icon-container {
+ opacity: 0 !important;
+ }
+ }
+ }
+
+ &__absolute-fill {
+ @extend .hold-to-reveal-button__absolute-fill;
+ }
+
+ &__icon-container {
+ @extend .hold-to-reveal-button__icon;
+
+ position: relative;
+ }
+
+ &__main-icon-show {
+ animation: 0.4s fadeIn 1.2s forwards;
+ }
+
+ &__invisible {
+ opacity: 0;
+ }
+
+ &__circle-svg {
+ @extend .hold-to-reveal-button__icon;
+
+ transform: rotate(-90deg);
+ }
+
+ &__circle-background {
+ @extend .hold-to-reveal-button__circle-shared;
+
+ stroke: var(--primary-3);
+ }
+
+ &__circle-foreground {
+ @extend .hold-to-reveal-button__circle-shared;
+
+ stroke: var(--ui-white);
+ stroke-dasharray: $circle-circumference;
+ stroke-dashoffset: $circle-circumference;
+ transition: 1s stroke-dashoffset;
+ }
+
+ &__lock-icon-container {
+ @extend .hold-to-reveal-button__absolute-fill;
+
+ transition: 0.3s opacity;
+ opacity: 1;
+ }
+
+ &__lock-icon {
+ width: 7.88px;
+ height: 9px;
+ }
+
+ &__unlock-icon-hide {
+ animation: 0.3s collapse 1s forwards;
+ }
+
+ &__circle-static-outer-container {
+ animation: 0.25s collapse forwards;
+ }
+
+ &__circle-static-outer {
+ fill: var(--ui-white);
+ }
+
+ &__circle-static-inner-container {
+ animation: 0.125s collapse forwards;
+ }
+
+ &__circle-static-inner {
+ fill: var(--primary-1);
+ }
+
+ &__unlock-icon-container {
+ @extend .hold-to-reveal-button__absolute-fill;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transform: scale(0);
+ animation: 0.175s expand 0.2s forwards;
+ }
+
+ &__unlock-icon {
+ width: 14px;
+ height: 11px;
+ }
+}