Animate raised hand button
Co-authored-by: Jamie Kyle <jamie@signal.org>
This commit is contained in:
parent
7fe17d2073
commit
13e44f087e
7 changed files with 217 additions and 30 deletions
|
@ -47,6 +47,17 @@ export const globalTypes = {
|
||||||
showName: true,
|
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(
|
const mockStore: Store<StateType> = createStore(
|
||||||
|
@ -118,10 +129,11 @@ window.ConversationController.isSignalConversationId = () => false;
|
||||||
window.ConversationController.onConvoMessageMount = noop;
|
window.ConversationController.onConvoMessageMount = noop;
|
||||||
window.reduxStore = mockStore;
|
window.reduxStore = mockStore;
|
||||||
|
|
||||||
const withModeAndThemeProvider = (Story, context) => {
|
const withGlobalTypesProvider = (Story, context) => {
|
||||||
const theme =
|
const theme =
|
||||||
context.globals.theme === 'light' ? ThemeType.light : ThemeType.dark;
|
context.globals.theme === 'light' ? ThemeType.light : ThemeType.dark;
|
||||||
const mode = context.globals.mode;
|
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
|
// Adding it to the body as well so that we can cover modals and other
|
||||||
// components that are rendered outside of this decorator container
|
// components that are rendered outside of this decorator container
|
||||||
|
@ -144,7 +156,7 @@ const withModeAndThemeProvider = (Story, context) => {
|
||||||
document.body.classList.add('page-is-visible');
|
document.body.classList.add('page-is-visible');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container} dir={direction}>
|
||||||
<StorybookThemeContext.Provider value={theme}>
|
<StorybookThemeContext.Provider value={theme}>
|
||||||
<Story {...context} />
|
<Story {...context} />
|
||||||
</StorybookThemeContext.Provider>
|
</StorybookThemeContext.Provider>
|
||||||
|
@ -171,7 +183,7 @@ function withScrollLockProvider(Story, context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const decorators = [
|
export const decorators = [
|
||||||
withModeAndThemeProvider,
|
withGlobalTypesProvider,
|
||||||
withMockStoreProvider,
|
withMockStoreProvider,
|
||||||
withScrollLockProvider,
|
withScrollLockProvider,
|
||||||
];
|
];
|
||||||
|
|
|
@ -7057,6 +7057,8 @@ button.module-image__border-overlay:focus {
|
||||||
|
|
||||||
.module-tooltip-arrow {
|
.module-tooltip-arrow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
/* stylelint-disable-next-line declaration-property-value-disallowed-list */
|
||||||
|
direction: ltr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-tooltip-arrow::after {
|
.module-tooltip-arrow::after {
|
||||||
|
|
|
@ -185,8 +185,9 @@
|
||||||
|
|
||||||
&__tooltip[data-placement='bottom'] .module-tooltip-arrow::before {
|
&__tooltip[data-placement='bottom'] .module-tooltip-arrow::before {
|
||||||
border-color: transparent transparent $color-gray-62 transparent;
|
border-color: transparent transparent $color-gray-62 transparent;
|
||||||
margin-block-start: -14px;
|
margin-top: -14px;
|
||||||
margin-inline-start: -7px;
|
/* stylelint-disable-next-line liberty/use-logical-spec */
|
||||||
|
margin-left: -7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tooltip[data-placement='bottom'] .module-tooltip-arrow::after {
|
&__tooltip[data-placement='bottom'] .module-tooltip-arrow::after {
|
||||||
|
@ -195,8 +196,9 @@
|
||||||
|
|
||||||
&__tooltip[data-placement='top'] .module-tooltip-arrow::before {
|
&__tooltip[data-placement='top'] .module-tooltip-arrow::before {
|
||||||
border-color: $color-gray-62 transparent transparent transparent;
|
border-color: $color-gray-62 transparent transparent transparent;
|
||||||
margin-block-start: 0;
|
margin-top: 0;
|
||||||
margin-inline-start: -7px;
|
/* stylelint-disable-next-line liberty/use-logical-spec */
|
||||||
|
margin-left: -7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tooltip[data-placement='top'] .module-tooltip-arrow::after {
|
&__tooltip[data-placement='top'] .module-tooltip-arrow::after {
|
||||||
|
|
|
@ -75,7 +75,10 @@ import { Spinner } from './Spinner';
|
||||||
import type { Props as ReactionPickerProps } from './conversation/ReactionPicker';
|
import type { Props as ReactionPickerProps } from './conversation/ReactionPicker';
|
||||||
import type { SmartReactionPicker } from '../state/smart/ReactionPicker';
|
import type { SmartReactionPicker } from '../state/smart/ReactionPicker';
|
||||||
import { Emoji } from './emoji/Emoji';
|
import { Emoji } from './emoji/Emoji';
|
||||||
import { CallingRaisedHandsList } from './CallingRaisedHandsList';
|
import {
|
||||||
|
CallingRaisedHandsList,
|
||||||
|
CallingRaisedHandsListButton,
|
||||||
|
} from './CallingRaisedHandsList';
|
||||||
import type { CallReactionBurstType } from './CallReactionBurst';
|
import type { CallReactionBurstType } from './CallReactionBurst';
|
||||||
import {
|
import {
|
||||||
CallReactionBurstProvider,
|
CallReactionBurstProvider,
|
||||||
|
@ -744,24 +747,15 @@ export function CallScreen({
|
||||||
)}
|
)}
|
||||||
{remoteParticipantsElement}
|
{remoteParticipantsElement}
|
||||||
{lonelyInCallNode}
|
{lonelyInCallNode}
|
||||||
{raisedHands && raisedHandsCount > 0 && (
|
{raisedHands && (
|
||||||
<>
|
<>
|
||||||
<button
|
<CallingRaisedHandsListButton
|
||||||
className="CallingRaisedHandsList__Button"
|
i18n={i18n}
|
||||||
|
syncedLocalHandRaised={syncedLocalHandRaised}
|
||||||
|
raisedHandsCount={raisedHandsCount}
|
||||||
onClick={toggleRaisedHandsList}
|
onClick={toggleRaisedHandsList}
|
||||||
type="button"
|
/>
|
||||||
>
|
{showRaisedHandsList && raisedHandsCount > 0 && (
|
||||||
<span className="CallingRaisedHandsList__ButtonIcon" />
|
|
||||||
{syncedLocalHandRaised ? (
|
|
||||||
<>
|
|
||||||
{i18n('icu:you')}
|
|
||||||
{raisedHandsCount > 1 && ` + ${String(raisedHandsCount - 1)}`}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
raisedHandsCount
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{showRaisedHandsList && (
|
|
||||||
<CallingRaisedHandsList
|
<CallingRaisedHandsList
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onClose={() => setShowRaisedHandsList(false)}
|
onClose={() => setShowRaisedHandsList(false)}
|
||||||
|
|
|
@ -6,8 +6,14 @@ import { times } from 'lodash';
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
import type { Meta } from '@storybook/react';
|
import type { Meta } from '@storybook/react';
|
||||||
import type { PropsType } from './CallingRaisedHandsList';
|
import type {
|
||||||
import { CallingRaisedHandsList } from './CallingRaisedHandsList';
|
CallingRaisedHandsListButtonPropsType,
|
||||||
|
PropsType,
|
||||||
|
} from './CallingRaisedHandsList';
|
||||||
|
import {
|
||||||
|
CallingRaisedHandsList,
|
||||||
|
CallingRaisedHandsListButton,
|
||||||
|
} from './CallingRaisedHandsList';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import { AvatarColors } from '../types/Colors';
|
import { AvatarColors } from '../types/Colors';
|
||||||
import { getDefaultConversationWithServiceId } from '../test-both/helpers/getDefaultConversation';
|
import { getDefaultConversationWithServiceId } from '../test-both/helpers/getDefaultConversation';
|
||||||
|
@ -57,6 +63,15 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||||
raisedHands: overrideProps.raisedHands || new Set<number>(),
|
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 {
|
export default {
|
||||||
title: 'Components/CallingRaisedHandsList',
|
title: 'Components/CallingRaisedHandsList',
|
||||||
} satisfies Meta<PropsType>;
|
} satisfies Meta<PropsType>;
|
||||||
|
@ -95,3 +110,27 @@ export function Many(): JSX.Element {
|
||||||
});
|
});
|
||||||
return <CallingRaisedHandsList {...props} />;
|
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} />;
|
||||||
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
|
||||||
import { Avatar, AvatarSize } from './Avatar';
|
import { Avatar, AvatarSize } from './Avatar';
|
||||||
import { ContactName } from './conversation/ContactName';
|
import { ContactName } from './conversation/ContactName';
|
||||||
|
@ -11,6 +12,7 @@ import type { LocalizerType } from '../types/Util';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import { ModalHost } from './ModalHost';
|
import { ModalHost } from './ModalHost';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
|
import { usePrevious } from '../hooks/usePrevious';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
readonly i18n: LocalizerType;
|
readonly i18n: LocalizerType;
|
||||||
|
@ -133,3 +135,124 @@ export function CallingRaisedHandsList({
|
||||||
</ModalHost>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -27,16 +27,31 @@ export default {
|
||||||
direction: TooltipPlacement.Top,
|
direction: TooltipPlacement.Top,
|
||||||
sticky: false,
|
sticky: false,
|
||||||
},
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
} satisfies Meta<PropsType>;
|
} satisfies Meta<PropsType>;
|
||||||
|
|
||||||
const Trigger = (
|
const Trigger = (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
marginTop: 200,
|
background: '#eee',
|
||||||
marginBottom: 200,
|
padding: 20,
|
||||||
marginInlineStart: 200,
|
borderRadius: 4,
|
||||||
marginInlineEnd: 200,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Trigger
|
Trigger
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue