Skip to content

Commit 00b11c4

Browse files
Merge pull request #1422 from sendbird/fix/clnp-6892
[CLNP-6892][fix]: Fix search result click not triggering bounce animation
2 parents 45f99c6 + cc87b64 commit 00b11c4

3 files changed

Lines changed: 70 additions & 31 deletions

File tree

src/modules/GroupChannel/components/Message/MessageView.tsx

Lines changed: 53 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
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';
33
import { useTypingLifecycle } from '../../../../hooks/useTypingLifecycle';
44
import { type EmojiCategory, EmojiContainer, User } from '@sendbird/chat';
55
import { GroupChannel } from '@sendbird/chat/groupChannel';
@@ -190,7 +190,7 @@ const MessageView = (props: MessageViewProps) => {
190190
const [showEdit, setShowEdit] = useState(false);
191191
const [showRemove, setShowRemove] = useState(false);
192192
const [showFileViewer, setShowFileViewer] = useState(false);
193-
const [isAnimated, setIsAnimated] = useState(false);
193+
// isAnimated state removed — animation now driven by animatedMessageId + onAnimationEnd
194194
const [mentionNickname, setMentionNickname] = useState('');
195195
const [mentionedUsers, setMentionedUsers] = useState<User[]>([]);
196196
const [mentionedUserIds, setMentionedUserIds] = useState<string[]>([]);
@@ -248,29 +248,58 @@ const MessageView = (props: MessageViewProps) => {
248248
if (usedInLegacy) handleScroll?.(true);
249249
}, []);
250250

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);
269285
}
270286
return () => {
271-
timeouts.forEach((it) => clearTimeout(it));
287+
if (fallbackTimerRef.current !== null) {
288+
clearTimeout(fallbackTimerRef.current);
289+
fallbackTimerRef.current = null;
290+
}
272291
};
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+
}, []);
274303

275304
useLayoutEffect(() => {
276305
if (newMessageIds?.length > 0 && newMessageIds.includes(message.messageId)) {
@@ -438,8 +467,9 @@ const MessageView = (props: MessageViewProps) => {
438467
<div
439468
className={classnames(
440469
'sendbird-msg-hoc sendbird-msg--scroll-ref',
441-
isAnimated && 'sendbird-msg-hoc__animated',
470+
showBounce && 'sendbird-msg-hoc__animated',
442471
)}
472+
onAnimationEnd={showBounce ? handleAnimationEnd : undefined}
443473
data-testid="sendbird-message-view"
444474
style={children || renderMessage ? undefined : { marginBottom: '2px' }}
445475
data-sb-message-id={message.messageId}

src/modules/GroupChannel/context/GroupChannelProvider.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -300,19 +300,30 @@ const GroupChannelManager :React.FC<React.PropsWithChildren<GroupChannelProvider
300300
};
301301
}, [messageDataSource.initialized, state.currentChannel?.url]);
302302

303-
// Starting point handling
303+
// Starting point handling — skip when animated message handles scroll
304304
useEffect(() => {
305-
if (typeof startingPoint === 'number' && state.initialized) {
305+
if (typeof startingPoint === 'number' && state.initialized && !_animatedMessageId) {
306306
actions.scrollToMessage(startingPoint, 0, false, false);
307307
}
308308
}, [state.initialized, startingPoint]);
309309

310-
// Animated message handling
310+
// Animated message handling — scroll + animation
311+
// NOTE: Depend on state.initialized so that deep-link / direct Provider usage
312+
// (animatedMessageId + startingPoint set on initial mount) retries after the
313+
// channel is initialized. Without it, scrollToMessage runs while messages are
314+
// empty and the starting-point effect is suppressed by !_animatedMessageId,
315+
// leaving the message un-scrolled and un-animated.
311316
useEffect(() => {
312-
if (_animatedMessageId) {
313-
actions.setAnimatedMessageId(_animatedMessageId);
317+
if (_animatedMessageId && state.initialized) {
318+
if (typeof startingPoint === 'number') {
319+
// Search result click: scroll to message and animate
320+
actions.scrollToMessage(startingPoint, _animatedMessageId, true, false);
321+
} else {
322+
// Thread parent jump: scroll already handled by startingPoint effect, just animate
323+
actions.setAnimatedMessageId(_animatedMessageId);
324+
}
314325
}
315-
}, [_animatedMessageId]);
326+
}, [_animatedMessageId, state.initialized]);
316327

317328
// State update effect
318329
const eventHandlers = useMemo(() => ({

src/modules/GroupChannel/context/hooks/useGroupChannel.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,6 @@ export const useGroupChannel = () => {
125125

126126
clickHandler.deactivate();
127127

128-
setAnimatedMessageId(null);
129-
130128
const message = state.messages.find((it) => it.messageId === messageId || it.createdAt === createdAt);
131129

132130
if (message) {

0 commit comments

Comments
 (0)