Call Reaction Bursts
This commit is contained in:
parent
775c881688
commit
2394a25fc1
8 changed files with 547 additions and 9 deletions
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]
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue