Call Reaction Bursts

This commit is contained in:
ayumi-signal 2024-01-10 14:35:26 -08:00 committed by GitHub
parent 775c881688
commit 2394a25fc1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 547 additions and 9 deletions

View File

@ -0,0 +1,19 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.CallingReactionsBurstToasts {
position: absolute;
width: 100%;
inset-block-end: calc($CallControls__height + 32px);
inset-inline-start: 15px;
}
.CallReactionBursts {
position: absolute;
z-index: $z-index-toast;
display: flex;
}
.CallReactionBurstEmoji {
position: absolute;
}

View File

@ -10,7 +10,10 @@
// Reactions appear in the same space as the Raised Hands button. When they are both
// present then move Reactions up.
.CallingRaisedHandsList__Button + .CallingReactionsToasts {
.CallingRaisedHandsList__Button + .CallingReactionsToasts,
.CallingRaisedHandsList__Button
+ .CallingReactionsToasts
+ .CallingReactionsBurstToasts {
inset-block-end: calc($CallControls__height + 100px);
}

View File

@ -49,6 +49,7 @@
@import './components/CallingRaisedHandsList.scss';
@import './components/CallingRaisedHandsToasts.scss';
@import './components/CallingReactionsToasts.scss';
@import './components/CallReactionBurst.scss';
@import './components/ChatColorPicker.scss';
@import './components/Checkbox.scss';
@import './components/CircleCheckbox.scss';

View File

@ -0,0 +1,177 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { v4 as uuid } from 'uuid';
import { useIsMounted } from '../hooks/useIsMounted';
import { CallReactionBurstEmoji } from './CallReactionBurstEmoji';
const LIFETIME = 3000;
export type CallReactionBurstType = {
value: string;
};
type CallReactionBurstStateType = CallReactionBurstType & {
key: string;
};
type CallReactionBurstContextType = {
showBurst: (burst: CallReactionBurstType) => string;
hideBurst: (key: string) => void;
};
const CallReactionBurstContext =
createContext<CallReactionBurstContextType | null>(null);
export function CallReactionBurstProvider({
children,
region,
}: {
children: React.ReactNode;
region?: React.RefObject<HTMLElement>;
}): JSX.Element {
const [bursts, setBursts] = useState<Array<CallReactionBurstStateType>>([]);
const timeouts = useRef<Map<string, NodeJS.Timeout>>(new Map());
const shownBursts = useRef<Set<string>>(new Set());
const isMounted = useIsMounted();
const clearBurstTimeout = useCallback((key: string) => {
const timeout = timeouts.current.get(key);
if (timeout) {
clearTimeout(timeout);
}
timeouts.current.delete(key);
}, []);
const hideBurst = useCallback(
(key: string) => {
if (!isMounted()) {
return;
}
clearBurstTimeout(key);
setBursts(state => {
const existingIndex = state.findIndex(burst => burst.key === key);
if (existingIndex === -1) {
// Important to return the same state object here to avoid infinite recursion if
// hideBurst is in a useEffect dependency array
return state;
}
return [
...state.slice(0, existingIndex),
...state.slice(existingIndex + 1),
];
});
},
[isMounted, clearBurstTimeout]
);
const startTimer = useCallback(
(key: string, duration: number) => {
timeouts.current.set(
key,
setTimeout(() => hideBurst(key), duration)
);
},
[hideBurst]
);
const showBurst = useCallback(
(burst: CallReactionBurstType): string => {
const key = uuid();
setBursts(state => {
startTimer(key, LIFETIME);
state.unshift({ ...burst, key });
shownBursts.current.add(key);
return state;
});
return key;
},
[startTimer]
);
const contextValue = useMemo(() => {
return {
showBurst,
hideBurst,
};
}, [showBurst, hideBurst]);
// Immediately trigger a state update before the portal gets shown to prevent
// DOM jumping on initial render
const [container, setContainer] = useState(document.body);
React.useLayoutEffect(() => {
if (region?.current) {
setContainer(region.current);
}
}, [region]);
return (
<CallReactionBurstContext.Provider value={contextValue}>
{createPortal(
<div className="CallReactionBursts">
{bursts.map(({ value, key }) => (
<CallReactionBurstEmoji
key={key}
value={value}
onAnimationEnd={() => hideBurst(key)}
/>
))}
</div>,
container
)}
{children}
</CallReactionBurstContext.Provider>
);
}
// Use this to access showBurst and hideBurst and ensure bursts are hidden on unmount
export function useCallReactionBursts(): CallReactionBurstContextType {
const context = useContext(CallReactionBurstContext);
if (!context) {
throw new Error(
'Call Reaction Bursts must be wrapped in CallReactionBurstProvider'
);
}
const burstsShown = useRef<Set<string>>(new Set());
const wrappedShowBurst = useCallback(
(burst: CallReactionBurstType) => {
const key = context.showBurst(burst);
burstsShown.current.add(key);
return key;
},
[context]
);
const hideAllShownBursts = useCallback(() => {
[...burstsShown.current].forEach(context.hideBurst);
}, [context]);
useEffect(() => {
return hideAllShownBursts;
}, [hideAllShownBursts]);
return useMemo(
() => ({
...context,
showBurst: wrappedShowBurst,
}),
[wrappedShowBurst, context]
);
}

View File

@ -0,0 +1,152 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { animated, useSpring } from '@react-spring/web';
import { random } from 'lodash';
import { v4 as uuid } from 'uuid';
import { Emojify } from './conversation/Emojify';
export type PropsType = {
value: string;
onAnimationEnd?: () => unknown;
};
const NUM_EMOJIS = 6;
const DELAY_BETWEEN_EMOJIS = 120;
const EMOJI_HEIGHT = 36;
type AnimationConfig = {
mass: number;
tension: number;
friction: number;
clamp: boolean;
precision: number;
velocity: number;
};
export function CallReactionBurstEmoji({ value }: PropsType): JSX.Element {
const [toY, setToY] = React.useState<number>(0);
const fromY = -50;
const generateEmojiProps = React.useCallback(() => {
return {
key: uuid(),
value,
springConfig: {
mass: random(10, 20),
tension: random(60, 90),
friction: random(20, 60),
clamp: true,
precision: 0,
velocity: -0.01,
},
fromY,
toY,
toScale: random(1, 2.5, true),
fromRotate: random(-45, 45),
toRotate: random(-45, 45),
};
}, [fromY, toY, value]);
// Calculate target Y position before first render. Emojis need to animate Y upwards
// by the value of the container's top, plus the emoji's maximum height.
const containerRef = React.useRef<HTMLDivElement | null>(null);
React.useLayoutEffect(() => {
if (containerRef.current) {
const { top } = containerRef.current.getBoundingClientRect();
const calculatedToY = -top;
setToY(calculatedToY);
setEmojis([{ ...generateEmojiProps(), toY: calculatedToY }]);
}
}, [generateEmojiProps]);
const [emojis, setEmojis] = React.useState<Array<AnimatedEmojiProps>>([
generateEmojiProps(),
]);
React.useEffect(() => {
const timer = setInterval(() => {
setEmojis(curEmojis => {
const emojiCount = curEmojis.length;
if (emojiCount + 1 >= NUM_EMOJIS) {
clearInterval(timer);
}
return [...curEmojis, generateEmojiProps()];
});
}, DELAY_BETWEEN_EMOJIS);
return () => {
clearInterval(timer);
};
}, [fromY, toY, value, generateEmojiProps]);
return (
<div className="CallReactionBurstEmoji" ref={containerRef}>
{emojis.map(props => (
<AnimatedEmoji {...props} />
))}
</div>
);
}
type AnimatedEmojiProps = {
value: string;
fromRotate: number;
fromY: number;
toRotate: number;
toScale: number;
toY: number;
springConfig: AnimationConfig;
onAnimationEnd?: () => unknown;
};
export function AnimatedEmoji({
value,
fromRotate,
fromY,
toRotate,
toScale,
toY,
springConfig,
onAnimationEnd,
}: AnimatedEmojiProps): JSX.Element {
const height = EMOJI_HEIGHT * toScale;
const { rotate, y } = useSpring({
from: {
rotate: fromRotate,
y: fromY,
},
to: {
rotate: toRotate,
y: toY - height - 10,
},
config: springConfig,
onRest: onAnimationEnd,
});
// These styles animate faster than Y.
// Reactions toasts animate with opacity so harmonize with that.
const { scale } = useSpring({
from: {
scale: 0.5,
},
to: {
scale: toScale,
},
});
return (
<animated.div
className="CallReactionBurstEmoji"
style={{
rotate,
scale,
y,
}}
>
<Emojify sizeClass="medium" text={value} />
</animated.div>
);
}

View File

@ -645,7 +645,7 @@ export function GroupCallReactions(): JSX.Element {
}
export function GroupCallReactionsSpam(): JSX.Element {
const remoteParticipants = allRemoteParticipants.slice(0, 5);
const remoteParticipants = allRemoteParticipants.slice(0, 3);
const [props] = React.useState(
createProps({
callMode: CallMode.Group,
@ -662,7 +662,7 @@ export function GroupCallReactionsSpam(): JSX.Element {
return <CallScreen {...props} activeCall={activeCall} />;
}
export function GroupCallReactionsBurstInOrder(): JSX.Element {
export function GroupCallReactionsManyInOrder(): JSX.Element {
const timestamp = Date.now();
const remoteParticipants = allRemoteParticipants.slice(0, 5);
const reactions = remoteParticipants.map((participant, i) => {

View File

@ -77,6 +77,11 @@ 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 type { CallReactionBurstType } from './CallReactionBurst';
import {
CallReactionBurstProvider,
useCallReactionBursts,
} from './CallReactionBurst';
export type PropsType = {
activeCall: ActiveCallType;
@ -126,6 +131,20 @@ const REACTIONS_TOASTS_TRANSITION_FROM = {
opacity: 0,
};
// How many reactions of the same emoji must occur before a burst.
const REACTIONS_BURST_THRESHOLD = 3;
// Timeframe in which multiple of the same emoji must occur before a burst.
const REACTIONS_BURST_WINDOW = 4000;
// Timeframe after a burst where new reactions of the same emoji are ignored for
// bursting. They are considered part of the recent burst.
const REACTIONS_BURST_TRAILING_WINDOW = 2000;
// Max number of bursts in a short timeframe to avoid overwhelming the user.
const REACTIONS_BURST_MAX_IN_SHORT_WINDOW = 3;
const REACTIONS_BURST_SHORT_WINDOW = 4000;
function CallDuration({
joinedAt,
}: {
@ -996,8 +1015,13 @@ type CallingReactionsToastsType = {
i18n: LocalizerType;
};
function useReactionsToast(props: CallingReactionsToastsType): void {
const { reactions, conversationsByDemuxId, localDemuxId, i18n } = props;
type UseReactionsToastType = CallingReactionsToastsType & {
showBurst: (toast: CallReactionBurstType) => string;
};
function useReactionsToast(props: UseReactionsToastType): void {
const { reactions, conversationsByDemuxId, localDemuxId, i18n, showBurst } =
props;
const ourServiceId: ServiceIdString | undefined = localDemuxId
? conversationsByDemuxId.get(localDemuxId)?.serviceId
: undefined;
@ -1005,6 +1029,13 @@ function useReactionsToast(props: CallingReactionsToastsType): void {
const [previousReactions, setPreviousReactions] = React.useState<
ActiveCallReactionsType | undefined
>(undefined);
const reactionsShown = useRef<
Map<
string,
{ value: string; isBursted: boolean; expireAt: number; demuxId: number }
>
>(new Map());
const burstsShown = useRef<Map<string, number>>(new Map());
const { showToast } = useCallingToasts();
useEffect(() => {
@ -1016,10 +1047,13 @@ function useReactionsToast(props: CallingReactionsToastsType): void {
return;
}
const time = Date.now();
let anyReactionWasShown = false;
reactions.forEach(({ timestamp, demuxId, value }) => {
const conversation = conversationsByDemuxId.get(demuxId);
const key = `reactions-${timestamp}-${demuxId}`;
showToast({
key: `reactions-${timestamp}-${demuxId}`,
key,
onlyShowOnce: true,
autoClose: true,
content: (
@ -1032,10 +1066,100 @@ function useReactionsToast(props: CallingReactionsToastsType): void {
</span>
),
});
// Track shown reactions for burst purposes. Skip if it's already tracked.
if (reactionsShown.current.has(key)) {
return;
}
// If there's a recent burst for this emoji, treat it as part of that burst.
const recentBurstTime = burstsShown.current.get(value);
const isBursted = !!(
recentBurstTime &&
recentBurstTime + REACTIONS_BURST_TRAILING_WINDOW > time
);
reactionsShown.current.set(key, {
value,
isBursted,
expireAt: timestamp + REACTIONS_BURST_WINDOW,
demuxId,
});
anyReactionWasShown = true;
});
if (!anyReactionWasShown) {
return;
}
const unburstedEmojis = new Map<string, Set<string>>();
const unburstedEmojisReactorIds = new Map<
string,
Set<ServiceIdString | number>
>();
reactionsShown.current.forEach(
({ value, isBursted, expireAt, demuxId }, key) => {
if (expireAt < time) {
reactionsShown.current.delete(key);
return;
}
if (isBursted) {
return;
}
const reactionKeys = unburstedEmojis.get(value) ?? new Set();
reactionKeys.add(key);
unburstedEmojis.set(value, reactionKeys);
// Only burst when enough unique people react.
const conversation = conversationsByDemuxId.get(demuxId);
const reactorId = conversation?.serviceId || demuxId;
const reactorIdSet = unburstedEmojisReactorIds.get(value) ?? new Set();
reactorIdSet.add(reactorId);
unburstedEmojisReactorIds.set(value, reactorIdSet);
}
);
burstsShown.current.forEach((timestamp, value) => {
if (timestamp < time - REACTIONS_BURST_SHORT_WINDOW) {
burstsShown.current.delete(value);
}
});
if (burstsShown.current.size >= REACTIONS_BURST_MAX_IN_SHORT_WINDOW) {
return;
}
for (const [value, reactorIds] of unburstedEmojisReactorIds.entries()) {
if (reactorIds.size < REACTIONS_BURST_THRESHOLD) {
continue;
}
const reactionKeys = unburstedEmojis.get(value);
if (!reactionKeys) {
unburstedEmojisReactorIds.delete(value);
continue;
}
burstsShown.current.set(value, time);
reactionKeys.forEach(key => {
const reactionShown = reactionsShown.current.get(key);
if (!reactionShown) {
return;
}
reactionShown.isBursted = true;
});
showBurst({ value });
if (burstsShown.current.size >= REACTIONS_BURST_MAX_IN_SHORT_WINDOW) {
break;
}
}
}, [
reactions,
previousReactions,
showBurst,
showToast,
conversationsByDemuxId,
localDemuxId,
@ -1049,6 +1173,8 @@ function CallingReactionsToastsContainer(
): JSX.Element {
const { i18n } = props;
const toastRegionRef = useRef<HTMLDivElement>(null);
const burstRegionRef = useRef<HTMLDivElement>(null);
return (
<CallingToastProvider
i18n={i18n}
@ -1057,14 +1183,18 @@ function CallingReactionsToastsContainer(
lifetime={CALLING_REACTIONS_LIFETIME}
transitionFrom={REACTIONS_TOASTS_TRANSITION_FROM}
>
<CallReactionBurstProvider region={burstRegionRef}>
<div className="CallingReactionsToasts" ref={toastRegionRef} />
<div className="CallingReactionsBurstToasts" ref={burstRegionRef} />
<CallingReactionsToasts {...props} />
</CallReactionBurstProvider>
</CallingToastProvider>
);
}
function CallingReactionsToasts(props: CallingReactionsToastsType) {
useReactionsToast(props);
const { showBurst } = useCallReactionBursts();
useReactionsToast({ ...props, showBurst });
return null;
}

View File

@ -2994,6 +2994,14 @@
"updated": "2023-11-14T23:29:51.425Z",
"reasonDetail": "Used to detect clicks outside of the Calling More Options button menu and ensures clicking the button does not re-open the menu."
},
{
"rule": "React-useRef",
"path": "ts/components/CallScreen.tsx",
"line": " const burstRegionRef = useRef<HTMLDivElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2023-12-21T11:13:56.623Z",
"reasonDetail": "Calling reactions bursts"
},
{
"rule": "React-useRef",
"path": "ts/components/CallingLobby.tsx",
@ -3957,5 +3965,53 @@
"line": " message.innerHTML = window.i18n('icu:optimizingApplication');",
"reasonCategory": "usageTrusted",
"updated": "2021-09-17T21:02:59.414Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CallReactionBurst.tsx",
"line": " const timeouts = useRef<Map<string, NodeJS.Timeout>>(new Map());",
"reasonCategory": "usageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "For hiding call reaction bursts after timeouts."
},
{
"rule": "React-useRef",
"path": "ts/components/CallReactionBurst.tsx",
"line": " const shownBursts = useRef<Set<string>>(new Set());",
"reasonCategory": "usageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "Keep track of shown reaction bursts."
},
{
"rule": "React-useRef",
"path": "ts/components/CallReactionBurst.tsx",
"line": " const burstsShown = useRef<Set<string>>(new Set());",
"reasonCategory": "usageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "In wrapping function, track bursts so we can hide on unmount."
},
{
"rule": "React-useRef",
"path": "ts/components/CallReactionBurstEmoji.tsx",
"line": " const containerRef = React.useRef<HTMLDivElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "For determining position of container for animations."
},
{
"rule": "React-useRef",
"path": "ts/components/CallScreen.tsx",
"line": " const reactionsShown = useRef<",
"reasonCategory": "usageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "Recent reactions shown for reactions burst"
},
{
"rule": "React-useRef",
"path": "ts/components/CallScreen.tsx",
"line": " const burstsShown = useRef<Map<string, number>>(new Map());",
"reasonCategory": "sageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "Recent bursts shown for burst behavior like throttling."
}
]