diff --git a/src/modules/GroupChannel/components/Message/MessageView.tsx b/src/modules/GroupChannel/components/Message/MessageView.tsx index 9649e3b66..fc8e7b205 100644 --- a/src/modules/GroupChannel/components/Message/MessageView.tsx +++ b/src/modules/GroupChannel/components/Message/MessageView.tsx @@ -1,5 +1,5 @@ import type { EveryMessage, RenderCustomSeparatorProps, RenderMessageParamsType, ReplyType } from '../../../../types'; -import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTypingLifecycle } from '../../../../hooks/useTypingLifecycle'; import { type EmojiCategory, EmojiContainer, User } from '@sendbird/chat'; import { GroupChannel } from '@sendbird/chat/groupChannel'; @@ -190,7 +190,7 @@ const MessageView = (props: MessageViewProps) => { const [showEdit, setShowEdit] = useState(false); const [showRemove, setShowRemove] = useState(false); const [showFileViewer, setShowFileViewer] = useState(false); - const [isAnimated, setIsAnimated] = useState(false); + // isAnimated state removed — animation now driven by animatedMessageId + onAnimationEnd const [mentionNickname, setMentionNickname] = useState(''); const [mentionedUsers, setMentionedUsers] = useState([]); const [mentionedUserIds, setMentionedUserIds] = useState([]); @@ -248,29 +248,58 @@ const MessageView = (props: MessageViewProps) => { if (usedInLegacy) handleScroll?.(true); }, []); - useLayoutEffect(() => { - const timeouts: ReturnType[] = []; - - if (animatedMessageId === message.messageId && messageScrollRef?.current) { - timeouts.push( - setTimeout(() => { - setIsAnimated(true); - }, 500), - ); - - timeouts.push( - setTimeout(() => { - setAnimatedMessageId(null); - onMessageAnimated?.(); - }, 1600), - ); - } else { - setIsAnimated(false); + // Animation: once triggered, protect with local state until CSS animation completes + const [showBounce, setShowBounce] = useState(false); + const isAnimationTarget = animatedMessageId === message.messageId; + // Fallback timer ref so handleAnimationEnd can cancel it once the real + // animation completes (avoids double cleanup). + const fallbackTimerRef = useRef | null>(null); + // Hold cleanup logic in a ref so we don't have to put unstable props + // (animatedMessageId, onMessageAnimated) in effect deps — otherwise a + // non-memoized onMessageAnimated would cancel the fallback timer on every + // render before it can fire. + const finalizeAnimationRef = useRef<() => void>(() => {}); + finalizeAnimationRef.current = () => { + setShowBounce(false); + // Only clear if this message is still the animation target + if (animatedMessageId === message.messageId) { + setAnimatedMessageId(null); + onMessageAnimated?.(); + } + }; + + useEffect(() => { + if (isAnimationTarget) { + setShowBounce(true); + // The bounce keyframe is applied to a descendant `.sendbird-message-content`, + // which is not rendered when consumers supply a custom `renderMessage`. + // In that case `onAnimationEnd` never fires and the animation state would + // be stuck, so schedule a fallback to force cleanup after the CSS + // animation duration (1s) plus a small buffer (matches the prior + // setTimeout-based timing). + if (fallbackTimerRef.current !== null) clearTimeout(fallbackTimerRef.current); + fallbackTimerRef.current = setTimeout(() => { + fallbackTimerRef.current = null; + finalizeAnimationRef.current(); + }, 1600); } return () => { - timeouts.forEach((it) => clearTimeout(it)); + if (fallbackTimerRef.current !== null) { + clearTimeout(fallbackTimerRef.current); + fallbackTimerRef.current = null; + } }; - }, [animatedMessageId, messageScrollRef.current, message.messageId]); + }, [isAnimationTarget]); + + const handleAnimationEnd = useCallback((e: React.AnimationEvent) => { + if (e.animationName === 'bounce') { + if (fallbackTimerRef.current !== null) { + clearTimeout(fallbackTimerRef.current); + fallbackTimerRef.current = null; + } + finalizeAnimationRef.current(); + } + }, []); useLayoutEffect(() => { if (newMessageIds?.length > 0 && newMessageIds.includes(message.messageId)) { @@ -438,8 +467,9 @@ const MessageView = (props: MessageViewProps) => {
{ - if (typeof startingPoint === 'number' && state.initialized) { + if (typeof startingPoint === 'number' && state.initialized && !_animatedMessageId) { actions.scrollToMessage(startingPoint, 0, false, false); } }, [state.initialized, startingPoint]); - // Animated message handling + // Animated message handling — scroll + animation + // NOTE: Depend on state.initialized so that deep-link / direct Provider usage + // (animatedMessageId + startingPoint set on initial mount) retries after the + // channel is initialized. Without it, scrollToMessage runs while messages are + // empty and the starting-point effect is suppressed by !_animatedMessageId, + // leaving the message un-scrolled and un-animated. useEffect(() => { - if (_animatedMessageId) { - actions.setAnimatedMessageId(_animatedMessageId); + if (_animatedMessageId && state.initialized) { + if (typeof startingPoint === 'number') { + // Search result click: scroll to message and animate + actions.scrollToMessage(startingPoint, _animatedMessageId, true, false); + } else { + // Thread parent jump: scroll already handled by startingPoint effect, just animate + actions.setAnimatedMessageId(_animatedMessageId); + } } - }, [_animatedMessageId]); + }, [_animatedMessageId, state.initialized]); // State update effect const eventHandlers = useMemo(() => ({ diff --git a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts index 80f26208b..96b96bceb 100644 --- a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts +++ b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts @@ -125,8 +125,6 @@ export const useGroupChannel = () => { clickHandler.deactivate(); - setAnimatedMessageId(null); - const message = state.messages.find((it) => it.messageId === messageId || it.createdAt === createdAt); if (message) {