From 13e44f087e5a4af20d33820c6afe7bcb8facf8ba Mon Sep 17 00:00:00 2001 From: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Date: Fri, 19 Jan 2024 14:00:13 -0800 Subject: [PATCH] Animate raised hand button Co-authored-by: Jamie Kyle --- .storybook/preview.tsx | 18 ++- stylesheets/_modules.scss | 2 + stylesheets/components/CallingButton.scss | 10 +- ts/components/CallScreen.tsx | 28 ++-- .../CallingRaisedHandsList.stories.tsx | 43 +++++- ts/components/CallingRaisedHandsList.tsx | 123 ++++++++++++++++++ ts/components/Tooltip.stories.tsx | 23 +++- 7 files changed, 217 insertions(+), 30 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 39b7d1a345..ef1d95a300 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -47,6 +47,17 @@ export const globalTypes = { showName: true, }, }, + direction: { + name: 'Direction', + description: 'Direction of text', + defaultValue: 'auto', + toolbar: { + dynamicTitle: true, + icon: 'circlehollow', + items: ['auto', 'ltr', 'rtl'], + showName: true, + }, + }, }; const mockStore: Store = createStore( @@ -118,10 +129,11 @@ window.ConversationController.isSignalConversationId = () => false; window.ConversationController.onConvoMessageMount = noop; window.reduxStore = mockStore; -const withModeAndThemeProvider = (Story, context) => { +const withGlobalTypesProvider = (Story, context) => { const theme = context.globals.theme === 'light' ? ThemeType.light : ThemeType.dark; const mode = context.globals.mode; + const direction = context.globals.direction ?? 'auto'; // Adding it to the body as well so that we can cover modals and other // components that are rendered outside of this decorator container @@ -144,7 +156,7 @@ const withModeAndThemeProvider = (Story, context) => { document.body.classList.add('page-is-visible'); return ( -
+
@@ -171,7 +183,7 @@ function withScrollLockProvider(Story, context) { } export const decorators = [ - withModeAndThemeProvider, + withGlobalTypesProvider, withMockStoreProvider, withScrollLockProvider, ]; diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 83b25f239e..c70e46f136 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -7057,6 +7057,8 @@ button.module-image__border-overlay:focus { .module-tooltip-arrow { position: absolute; + /* stylelint-disable-next-line declaration-property-value-disallowed-list */ + direction: ltr; } .module-tooltip-arrow::after { diff --git a/stylesheets/components/CallingButton.scss b/stylesheets/components/CallingButton.scss index 8c3b5d19bb..bca19c2f2c 100644 --- a/stylesheets/components/CallingButton.scss +++ b/stylesheets/components/CallingButton.scss @@ -185,8 +185,9 @@ &__tooltip[data-placement='bottom'] .module-tooltip-arrow::before { border-color: transparent transparent $color-gray-62 transparent; - margin-block-start: -14px; - margin-inline-start: -7px; + margin-top: -14px; + /* stylelint-disable-next-line liberty/use-logical-spec */ + margin-left: -7px; } &__tooltip[data-placement='bottom'] .module-tooltip-arrow::after { @@ -195,8 +196,9 @@ &__tooltip[data-placement='top'] .module-tooltip-arrow::before { border-color: $color-gray-62 transparent transparent transparent; - margin-block-start: 0; - margin-inline-start: -7px; + margin-top: 0; + /* stylelint-disable-next-line liberty/use-logical-spec */ + margin-left: -7px; } &__tooltip[data-placement='top'] .module-tooltip-arrow::after { diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index 8158907e2b..21a848994f 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -75,7 +75,10 @@ import { Spinner } from './Spinner'; import type { Props as ReactionPickerProps } from './conversation/ReactionPicker'; import type { SmartReactionPicker } from '../state/smart/ReactionPicker'; import { Emoji } from './emoji/Emoji'; -import { CallingRaisedHandsList } from './CallingRaisedHandsList'; +import { + CallingRaisedHandsList, + CallingRaisedHandsListButton, +} from './CallingRaisedHandsList'; import type { CallReactionBurstType } from './CallReactionBurst'; import { CallReactionBurstProvider, @@ -744,24 +747,15 @@ export function CallScreen({ )} {remoteParticipantsElement} {lonelyInCallNode} - {raisedHands && raisedHandsCount > 0 && ( + {raisedHands && ( <> - - {showRaisedHandsList && ( + /> + {showRaisedHandsList && raisedHandsCount > 0 && ( setShowRaisedHandsList(false)} diff --git a/ts/components/CallingRaisedHandsList.stories.tsx b/ts/components/CallingRaisedHandsList.stories.tsx index 06807147a4..ef686843b4 100644 --- a/ts/components/CallingRaisedHandsList.stories.tsx +++ b/ts/components/CallingRaisedHandsList.stories.tsx @@ -6,8 +6,14 @@ import { times } from 'lodash'; import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; -import type { PropsType } from './CallingRaisedHandsList'; -import { CallingRaisedHandsList } from './CallingRaisedHandsList'; +import type { + CallingRaisedHandsListButtonPropsType, + PropsType, +} from './CallingRaisedHandsList'; +import { + CallingRaisedHandsList, + CallingRaisedHandsListButton, +} from './CallingRaisedHandsList'; import type { ConversationType } from '../state/ducks/conversations'; import { AvatarColors } from '../types/Colors'; import { getDefaultConversationWithServiceId } from '../test-both/helpers/getDefaultConversation'; @@ -57,6 +63,15 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ raisedHands: overrideProps.raisedHands || new Set(), }); +const createPropsForButton = ( + overrideProps: Partial = {} +): CallingRaisedHandsListButtonPropsType => ({ + i18n, + syncedLocalHandRaised: overrideProps.syncedLocalHandRaised || false, + raisedHandsCount: overrideProps.raisedHandsCount || 1, + onClick: action('on-click'), +}); + export default { title: 'Components/CallingRaisedHandsList', } satisfies Meta; @@ -95,3 +110,27 @@ export function Many(): JSX.Element { }); return ; } + +export function Button(): JSX.Element { + const props = createPropsForButton(); + return ; +} + +export function ButtonChanging(): JSX.Element { + const initialProps = createPropsForButton(); + + const [props, setProps] = React.useState(initialProps); + React.useEffect(() => { + const interval = setInterval(() => { + const raisedHandsCount = Math.floor(4 * Math.random()); + setProps(prevProps => ({ + ...prevProps, + raisedHandsCount, + syncedLocalHandRaised: Boolean(raisedHandsCount && Math.random() > 0.5), + })); + }, 2000); + return () => clearInterval(interval); + }, []); + + return ; +} diff --git a/ts/components/CallingRaisedHandsList.tsx b/ts/components/CallingRaisedHandsList.tsx index 11a0157ab8..77a90bde83 100644 --- a/ts/components/CallingRaisedHandsList.tsx +++ b/ts/components/CallingRaisedHandsList.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; +import { animated, useSpring } from '@react-spring/web'; import { Avatar, AvatarSize } from './Avatar'; import { ContactName } from './conversation/ContactName'; @@ -11,6 +12,7 @@ import type { LocalizerType } from '../types/Util'; import type { ConversationType } from '../state/ducks/conversations'; import { ModalHost } from './ModalHost'; import * as log from '../logging/log'; +import { usePrevious } from '../hooks/usePrevious'; export type PropsType = { readonly i18n: LocalizerType; @@ -133,3 +135,124 @@ export function CallingRaisedHandsList({ ); } + +const BUTTON_OPACITY_SPRING_CONFIG = { + mass: 1, + tension: 210, + friction: 20, + precision: 0.01, + clamp: true, +} as const; + +const BUTTON_SCALE_SPRING_CONFIG = { + mass: 1.5, + tension: 230, + friction: 8, + precision: 0.02, + velocity: 0.0025, +} as const; + +export type CallingRaisedHandsListButtonPropsType = { + i18n: LocalizerType; + raisedHandsCount: number; + syncedLocalHandRaised: boolean; + onClick: () => void; +}; + +export function CallingRaisedHandsListButton({ + i18n, + syncedLocalHandRaised, + raisedHandsCount, + onClick, +}: CallingRaisedHandsListButtonPropsType): JSX.Element | null { + const [isVisible, setIsVisible] = React.useState(raisedHandsCount > 0); + + const [opacitySpringProps, opacitySpringApi] = useSpring( + { + from: { opacity: 0 }, + to: { opacity: 1 }, + config: BUTTON_OPACITY_SPRING_CONFIG, + }, + [] + ); + const [scaleSpringProps, scaleSpringApi] = useSpring( + { + from: { scale: 0.9 }, + to: { scale: 1 }, + config: BUTTON_SCALE_SPRING_CONFIG, + }, + [] + ); + + const prevRaisedHandsCount = usePrevious(raisedHandsCount, raisedHandsCount); + const prevSyncedLocalHandRaised = usePrevious( + syncedLocalHandRaised, + syncedLocalHandRaised + ); + + const onRestAfterAnimateOut = React.useCallback(() => { + if (!raisedHandsCount) { + setIsVisible(false); + } + }, [raisedHandsCount]); + + React.useEffect(() => { + if (raisedHandsCount > prevRaisedHandsCount) { + setIsVisible(true); + opacitySpringApi.stop(); + opacitySpringApi.start({ opacity: 1 }); + scaleSpringApi.stop(); + scaleSpringApi.start({ + from: { scale: 0.99 }, + to: { scale: 1 }, + config: { velocity: 0.0025 }, + }); + } else if (raisedHandsCount === 0) { + opacitySpringApi.stop(); + opacitySpringApi.start({ + to: { opacity: 0 }, + onRest: () => onRestAfterAnimateOut, + }); + } + }, [ + raisedHandsCount, + prevRaisedHandsCount, + opacitySpringApi, + scaleSpringApi, + onRestAfterAnimateOut, + setIsVisible, + ]); + + if (!isVisible) { + return null; + } + + // When the last hands are lowered, maintain the last count while fading out to prevent + // abrupt label changes. + let shownSyncedLocalHandRaised: boolean = syncedLocalHandRaised; + let shownRaisedHandsCount: number = raisedHandsCount; + if (raisedHandsCount === 0 && prevRaisedHandsCount) { + shownRaisedHandsCount = prevRaisedHandsCount; + shownSyncedLocalHandRaised = prevSyncedLocalHandRaised; + } + + return ( + + + {shownSyncedLocalHandRaised ? ( + <> + {i18n('icu:you')} + {shownRaisedHandsCount > 1 && + ` + ${String(shownRaisedHandsCount - 1)}`} + + ) : ( + shownRaisedHandsCount + )} + + ); +} diff --git a/ts/components/Tooltip.stories.tsx b/ts/components/Tooltip.stories.tsx index 05165dc2fc..6a94156498 100644 --- a/ts/components/Tooltip.stories.tsx +++ b/ts/components/Tooltip.stories.tsx @@ -27,16 +27,31 @@ export default { direction: TooltipPlacement.Top, sticky: false, }, + decorators: [ + (Story): JSX.Element => { + return ( +
+ +
+ ); + }, + ], } satisfies Meta; const Trigger = ( Trigger