|
1 | 1 | import type { EveryMessage, RenderCustomSeparatorProps, RenderMessageParamsType, ReplyType } from '../../../../types'; |
2 | | -import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; |
| 2 | +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; |
3 | 3 | import { useTypingLifecycle } from '../../../../hooks/useTypingLifecycle'; |
4 | 4 | import { type EmojiCategory, EmojiContainer, User } from '@sendbird/chat'; |
5 | 5 | import { GroupChannel } from '@sendbird/chat/groupChannel'; |
@@ -190,7 +190,7 @@ const MessageView = (props: MessageViewProps) => { |
190 | 190 | const [showEdit, setShowEdit] = useState(false); |
191 | 191 | const [showRemove, setShowRemove] = useState(false); |
192 | 192 | const [showFileViewer, setShowFileViewer] = useState(false); |
193 | | - const [isAnimated, setIsAnimated] = useState(false); |
| 193 | + // isAnimated state removed — animation now driven by animatedMessageId + onAnimationEnd |
194 | 194 | const [mentionNickname, setMentionNickname] = useState(''); |
195 | 195 | const [mentionedUsers, setMentionedUsers] = useState<User[]>([]); |
196 | 196 | const [mentionedUserIds, setMentionedUserIds] = useState<string[]>([]); |
@@ -248,29 +248,58 @@ const MessageView = (props: MessageViewProps) => { |
248 | 248 | if (usedInLegacy) handleScroll?.(true); |
249 | 249 | }, []); |
250 | 250 |
|
251 | | - useLayoutEffect(() => { |
252 | | - const timeouts: ReturnType<typeof setTimeout>[] = []; |
253 | | - |
254 | | - if (animatedMessageId === message.messageId && messageScrollRef?.current) { |
255 | | - timeouts.push( |
256 | | - setTimeout(() => { |
257 | | - setIsAnimated(true); |
258 | | - }, 500), |
259 | | - ); |
260 | | - |
261 | | - timeouts.push( |
262 | | - setTimeout(() => { |
263 | | - setAnimatedMessageId(null); |
264 | | - onMessageAnimated?.(); |
265 | | - }, 1600), |
266 | | - ); |
267 | | - } else { |
268 | | - setIsAnimated(false); |
| 251 | + // Animation: once triggered, protect with local state until CSS animation completes |
| 252 | + const [showBounce, setShowBounce] = useState(false); |
| 253 | + const isAnimationTarget = animatedMessageId === message.messageId; |
| 254 | + // Fallback timer ref so handleAnimationEnd can cancel it once the real |
| 255 | + // animation completes (avoids double cleanup). |
| 256 | + const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 257 | + // Hold cleanup logic in a ref so we don't have to put unstable props |
| 258 | + // (animatedMessageId, onMessageAnimated) in effect deps — otherwise a |
| 259 | + // non-memoized onMessageAnimated would cancel the fallback timer on every |
| 260 | + // render before it can fire. |
| 261 | + const finalizeAnimationRef = useRef<() => void>(() => {}); |
| 262 | + finalizeAnimationRef.current = () => { |
| 263 | + setShowBounce(false); |
| 264 | + // Only clear if this message is still the animation target |
| 265 | + if (animatedMessageId === message.messageId) { |
| 266 | + setAnimatedMessageId(null); |
| 267 | + onMessageAnimated?.(); |
| 268 | + } |
| 269 | + }; |
| 270 | + |
| 271 | + useEffect(() => { |
| 272 | + if (isAnimationTarget) { |
| 273 | + setShowBounce(true); |
| 274 | + // The bounce keyframe is applied to a descendant `.sendbird-message-content`, |
| 275 | + // which is not rendered when consumers supply a custom `renderMessage`. |
| 276 | + // In that case `onAnimationEnd` never fires and the animation state would |
| 277 | + // be stuck, so schedule a fallback to force cleanup after the CSS |
| 278 | + // animation duration (1s) plus a small buffer (matches the prior |
| 279 | + // setTimeout-based timing). |
| 280 | + if (fallbackTimerRef.current !== null) clearTimeout(fallbackTimerRef.current); |
| 281 | + fallbackTimerRef.current = setTimeout(() => { |
| 282 | + fallbackTimerRef.current = null; |
| 283 | + finalizeAnimationRef.current(); |
| 284 | + }, 1600); |
269 | 285 | } |
270 | 286 | return () => { |
271 | | - timeouts.forEach((it) => clearTimeout(it)); |
| 287 | + if (fallbackTimerRef.current !== null) { |
| 288 | + clearTimeout(fallbackTimerRef.current); |
| 289 | + fallbackTimerRef.current = null; |
| 290 | + } |
272 | 291 | }; |
273 | | - }, [animatedMessageId, messageScrollRef.current, message.messageId]); |
| 292 | + }, [isAnimationTarget]); |
| 293 | + |
| 294 | + const handleAnimationEnd = useCallback((e: React.AnimationEvent) => { |
| 295 | + if (e.animationName === 'bounce') { |
| 296 | + if (fallbackTimerRef.current !== null) { |
| 297 | + clearTimeout(fallbackTimerRef.current); |
| 298 | + fallbackTimerRef.current = null; |
| 299 | + } |
| 300 | + finalizeAnimationRef.current(); |
| 301 | + } |
| 302 | + }, []); |
274 | 303 |
|
275 | 304 | useLayoutEffect(() => { |
276 | 305 | if (newMessageIds?.length > 0 && newMessageIds.includes(message.messageId)) { |
@@ -438,8 +467,9 @@ const MessageView = (props: MessageViewProps) => { |
438 | 467 | <div |
439 | 468 | className={classnames( |
440 | 469 | 'sendbird-msg-hoc sendbird-msg--scroll-ref', |
441 | | - isAnimated && 'sendbird-msg-hoc__animated', |
| 470 | + showBounce && 'sendbird-msg-hoc__animated', |
442 | 471 | )} |
| 472 | + onAnimationEnd={showBounce ? handleAnimationEnd : undefined} |
443 | 473 | data-testid="sendbird-message-view" |
444 | 474 | style={children || renderMessage ? undefined : { marginBottom: '2px' }} |
445 | 475 | data-sb-message-id={message.messageId} |
|
0 commit comments