Animate raised hand button

Co-authored-by: Jamie Kyle <jamie@signal.org>
This commit is contained in:
ayumi-signal 2024-01-19 14:00:13 -08:00 committed by GitHub
parent 7fe17d2073
commit 13e44f087e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 217 additions and 30 deletions

View file

@ -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<StateType> = 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 (
<div className={styles.container}>
<div className={styles.container} dir={direction}>
<StorybookThemeContext.Provider value={theme}>
<Story {...context} />
</StorybookThemeContext.Provider>
@ -171,7 +183,7 @@ function withScrollLockProvider(Story, context) {
}
export const decorators = [
withModeAndThemeProvider,
withGlobalTypesProvider,
withMockStoreProvider,
withScrollLockProvider,
];

View file

@ -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 {

View file

@ -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 {

View file

@ -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 && (
<>
<button
className="CallingRaisedHandsList__Button"
<CallingRaisedHandsListButton
i18n={i18n}
syncedLocalHandRaised={syncedLocalHandRaised}
raisedHandsCount={raisedHandsCount}
onClick={toggleRaisedHandsList}
type="button"
>
<span className="CallingRaisedHandsList__ButtonIcon" />
{syncedLocalHandRaised ? (
<>
{i18n('icu:you')}
{raisedHandsCount > 1 && ` + ${String(raisedHandsCount - 1)}`}
</>
) : (
raisedHandsCount
)}
</button>
{showRaisedHandsList && (
/>
{showRaisedHandsList && raisedHandsCount > 0 && (
<CallingRaisedHandsList
i18n={i18n}
onClose={() => setShowRaisedHandsList(false)}

View file

@ -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> = {}): PropsType => ({
raisedHands: overrideProps.raisedHands || new Set<number>(),
});
const createPropsForButton = (
overrideProps: Partial<CallingRaisedHandsListButtonPropsType> = {}
): CallingRaisedHandsListButtonPropsType => ({
i18n,
syncedLocalHandRaised: overrideProps.syncedLocalHandRaised || false,
raisedHandsCount: overrideProps.raisedHandsCount || 1,
onClick: action('on-click'),
});
export default {
title: 'Components/CallingRaisedHandsList',
} satisfies Meta<PropsType>;
@ -95,3 +110,27 @@ export function Many(): JSX.Element {
});
return <CallingRaisedHandsList {...props} />;
}
export function Button(): JSX.Element {
const props = createPropsForButton();
return <CallingRaisedHandsListButton {...props} />;
}
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 <CallingRaisedHandsListButton {...props} />;
}

View file

@ -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({
</ModalHost>
);
}
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 (
<animated.button
className="CallingRaisedHandsList__Button"
onClick={onClick}
style={{ ...opacitySpringProps, ...scaleSpringProps }}
type="button"
>
<span className="CallingRaisedHandsList__ButtonIcon" />
{shownSyncedLocalHandRaised ? (
<>
{i18n('icu:you')}
{shownRaisedHandsCount > 1 &&
` + ${String(shownRaisedHandsCount - 1)}`}
</>
) : (
shownRaisedHandsCount
)}
</animated.button>
);
}

View file

@ -27,16 +27,31 @@ export default {
direction: TooltipPlacement.Top,
sticky: false,
},
decorators: [
(Story): JSX.Element => {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}
>
<Story />
</div>
);
},
],
} satisfies Meta<PropsType>;
const Trigger = (
<span
style={{
display: 'inline-block',
marginTop: 200,
marginBottom: 200,
marginInlineStart: 200,
marginInlineEnd: 200,
background: '#eee',
padding: 20,
borderRadius: 4,
}}
>
Trigger