Call Reaction Bursts
This commit is contained in:
parent
775c881688
commit
2394a25fc1
8 changed files with 547 additions and 9 deletions
19
stylesheets/components/CallReactionBurst.scss
Normal file
19
stylesheets/components/CallReactionBurst.scss
Normal 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;
|
||||||
|
}
|
|
@ -10,7 +10,10 @@
|
||||||
|
|
||||||
// Reactions appear in the same space as the Raised Hands button. When they are both
|
// Reactions appear in the same space as the Raised Hands button. When they are both
|
||||||
// present then move Reactions up.
|
// present then move Reactions up.
|
||||||
.CallingRaisedHandsList__Button + .CallingReactionsToasts {
|
.CallingRaisedHandsList__Button + .CallingReactionsToasts,
|
||||||
|
.CallingRaisedHandsList__Button
|
||||||
|
+ .CallingReactionsToasts
|
||||||
|
+ .CallingReactionsBurstToasts {
|
||||||
inset-block-end: calc($CallControls__height + 100px);
|
inset-block-end: calc($CallControls__height + 100px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -49,6 +49,7 @@
|
||||||
@import './components/CallingRaisedHandsList.scss';
|
@import './components/CallingRaisedHandsList.scss';
|
||||||
@import './components/CallingRaisedHandsToasts.scss';
|
@import './components/CallingRaisedHandsToasts.scss';
|
||||||
@import './components/CallingReactionsToasts.scss';
|
@import './components/CallingReactionsToasts.scss';
|
||||||
|
@import './components/CallReactionBurst.scss';
|
||||||
@import './components/ChatColorPicker.scss';
|
@import './components/ChatColorPicker.scss';
|
||||||
@import './components/Checkbox.scss';
|
@import './components/Checkbox.scss';
|
||||||
@import './components/CircleCheckbox.scss';
|
@import './components/CircleCheckbox.scss';
|
||||||
|
|
177
ts/components/CallReactionBurst.tsx
Normal file
177
ts/components/CallReactionBurst.tsx
Normal 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]
|
||||||
|
);
|
||||||
|
}
|
152
ts/components/CallReactionBurstEmoji.tsx
Normal file
152
ts/components/CallReactionBurstEmoji.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -645,7 +645,7 @@ export function GroupCallReactions(): JSX.Element {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GroupCallReactionsSpam(): JSX.Element {
|
export function GroupCallReactionsSpam(): JSX.Element {
|
||||||
const remoteParticipants = allRemoteParticipants.slice(0, 5);
|
const remoteParticipants = allRemoteParticipants.slice(0, 3);
|
||||||
const [props] = React.useState(
|
const [props] = React.useState(
|
||||||
createProps({
|
createProps({
|
||||||
callMode: CallMode.Group,
|
callMode: CallMode.Group,
|
||||||
|
@ -662,7 +662,7 @@ export function GroupCallReactionsSpam(): JSX.Element {
|
||||||
return <CallScreen {...props} activeCall={activeCall} />;
|
return <CallScreen {...props} activeCall={activeCall} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GroupCallReactionsBurstInOrder(): JSX.Element {
|
export function GroupCallReactionsManyInOrder(): JSX.Element {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const remoteParticipants = allRemoteParticipants.slice(0, 5);
|
const remoteParticipants = allRemoteParticipants.slice(0, 5);
|
||||||
const reactions = remoteParticipants.map((participant, i) => {
|
const reactions = remoteParticipants.map((participant, i) => {
|
||||||
|
|
|
@ -77,6 +77,11 @@ 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 } from './CallingRaisedHandsList';
|
||||||
|
import type { CallReactionBurstType } from './CallReactionBurst';
|
||||||
|
import {
|
||||||
|
CallReactionBurstProvider,
|
||||||
|
useCallReactionBursts,
|
||||||
|
} from './CallReactionBurst';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
activeCall: ActiveCallType;
|
activeCall: ActiveCallType;
|
||||||
|
@ -126,6 +131,20 @@ const REACTIONS_TOASTS_TRANSITION_FROM = {
|
||||||
opacity: 0,
|
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({
|
function CallDuration({
|
||||||
joinedAt,
|
joinedAt,
|
||||||
}: {
|
}: {
|
||||||
|
@ -996,8 +1015,13 @@ type CallingReactionsToastsType = {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
};
|
};
|
||||||
|
|
||||||
function useReactionsToast(props: CallingReactionsToastsType): void {
|
type UseReactionsToastType = CallingReactionsToastsType & {
|
||||||
const { reactions, conversationsByDemuxId, localDemuxId, i18n } = props;
|
showBurst: (toast: CallReactionBurstType) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useReactionsToast(props: UseReactionsToastType): void {
|
||||||
|
const { reactions, conversationsByDemuxId, localDemuxId, i18n, showBurst } =
|
||||||
|
props;
|
||||||
const ourServiceId: ServiceIdString | undefined = localDemuxId
|
const ourServiceId: ServiceIdString | undefined = localDemuxId
|
||||||
? conversationsByDemuxId.get(localDemuxId)?.serviceId
|
? conversationsByDemuxId.get(localDemuxId)?.serviceId
|
||||||
: undefined;
|
: undefined;
|
||||||
|
@ -1005,6 +1029,13 @@ function useReactionsToast(props: CallingReactionsToastsType): void {
|
||||||
const [previousReactions, setPreviousReactions] = React.useState<
|
const [previousReactions, setPreviousReactions] = React.useState<
|
||||||
ActiveCallReactionsType | undefined
|
ActiveCallReactionsType | undefined
|
||||||
>(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();
|
const { showToast } = useCallingToasts();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -1016,10 +1047,13 @@ function useReactionsToast(props: CallingReactionsToastsType): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const time = Date.now();
|
||||||
|
let anyReactionWasShown = false;
|
||||||
reactions.forEach(({ timestamp, demuxId, value }) => {
|
reactions.forEach(({ timestamp, demuxId, value }) => {
|
||||||
const conversation = conversationsByDemuxId.get(demuxId);
|
const conversation = conversationsByDemuxId.get(demuxId);
|
||||||
|
const key = `reactions-${timestamp}-${demuxId}`;
|
||||||
showToast({
|
showToast({
|
||||||
key: `reactions-${timestamp}-${demuxId}`,
|
key,
|
||||||
onlyShowOnce: true,
|
onlyShowOnce: true,
|
||||||
autoClose: true,
|
autoClose: true,
|
||||||
content: (
|
content: (
|
||||||
|
@ -1032,10 +1066,100 @@ function useReactionsToast(props: CallingReactionsToastsType): void {
|
||||||
</span>
|
</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,
|
reactions,
|
||||||
previousReactions,
|
previousReactions,
|
||||||
|
showBurst,
|
||||||
showToast,
|
showToast,
|
||||||
conversationsByDemuxId,
|
conversationsByDemuxId,
|
||||||
localDemuxId,
|
localDemuxId,
|
||||||
|
@ -1049,6 +1173,8 @@ function CallingReactionsToastsContainer(
|
||||||
): JSX.Element {
|
): JSX.Element {
|
||||||
const { i18n } = props;
|
const { i18n } = props;
|
||||||
const toastRegionRef = useRef<HTMLDivElement>(null);
|
const toastRegionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const burstRegionRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CallingToastProvider
|
<CallingToastProvider
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
@ -1057,14 +1183,18 @@ function CallingReactionsToastsContainer(
|
||||||
lifetime={CALLING_REACTIONS_LIFETIME}
|
lifetime={CALLING_REACTIONS_LIFETIME}
|
||||||
transitionFrom={REACTIONS_TOASTS_TRANSITION_FROM}
|
transitionFrom={REACTIONS_TOASTS_TRANSITION_FROM}
|
||||||
>
|
>
|
||||||
<div className="CallingReactionsToasts" ref={toastRegionRef} />
|
<CallReactionBurstProvider region={burstRegionRef}>
|
||||||
<CallingReactionsToasts {...props} />
|
<div className="CallingReactionsToasts" ref={toastRegionRef} />
|
||||||
|
<div className="CallingReactionsBurstToasts" ref={burstRegionRef} />
|
||||||
|
<CallingReactionsToasts {...props} />
|
||||||
|
</CallReactionBurstProvider>
|
||||||
</CallingToastProvider>
|
</CallingToastProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CallingReactionsToasts(props: CallingReactionsToastsType) {
|
function CallingReactionsToasts(props: CallingReactionsToastsType) {
|
||||||
useReactionsToast(props);
|
const { showBurst } = useCallReactionBursts();
|
||||||
|
useReactionsToast({ ...props, showBurst });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2994,6 +2994,14 @@
|
||||||
"updated": "2023-11-14T23:29:51.425Z",
|
"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."
|
"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",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CallingLobby.tsx",
|
"path": "ts/components/CallingLobby.tsx",
|
||||||
|
@ -3957,5 +3965,53 @@
|
||||||
"line": " message.innerHTML = window.i18n('icu:optimizingApplication');",
|
"line": " message.innerHTML = window.i18n('icu:optimizingApplication');",
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-09-17T21:02:59.414Z"
|
"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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue