Skip to content
76 changes: 53 additions & 23 deletions src/modules/GroupChannel/components/Message/MessageView.tsx
Original file line number Diff line number Diff line change
@@ -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 { type EmojiCategory, EmojiContainer, User } from '@sendbird/chat';
import { GroupChannel } from '@sendbird/chat/groupChannel';
import type { FileMessage, UserMessage, UserMessageCreateParams, UserMessageUpdateParams } from '@sendbird/chat/message';
Expand Down Expand Up @@ -189,7 +189,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<User[]>([]);
const [mentionedUserIds, setMentionedUserIds] = useState<string[]>([]);
Expand Down Expand Up @@ -245,29 +245,58 @@ const MessageView = (props: MessageViewProps) => {
if (usedInLegacy) handleScroll?.(true);
}, []);

useLayoutEffect(() => {
const timeouts: ReturnType<typeof setTimeout>[] = [];

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<ReturnType<typeof setTimeout> | 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) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fallback으로 cleanup이 되는 부분이 필요가 없을까요? Uikit이 custom render를 제공하고 있어서, 정확한 루트로 진행이 안 될 수도 있습니다. fallback clean up이 가능하다면 같이 있으면 좋을 것 같습니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀하신 대로 bounce keyframe 이 디센던트 .sendbird-message-content 에 걸려 있어서 custom renderMessage 결과물에 그 클래스가 없으면 onAnimationEnd 가 발화되지 않아 animation state 가 stuck 되는 회귀가 있었습니다.

최신 커밋에서 fallback setTimeout 을 추가해 CSS animation duration(1s) + 버퍼 후 강제 정리하도록 수정했습니다.

  • fallbackTimerRef 로 timer 추적 → handleAnimationEnd 가 정상 발화하면 clear 해서 이중 정리 방지
  • cleanup 로직은 ref 에 담아 effect deps 가 [isAnimationTarget] 만 의존하도록 안정화 → 부모가 onMessageAnimated 를 memoize 하지 않아도 fallback timer 가 조기 clear 되지 않게 처리했습니다

감사합니다!

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)) {
Expand Down Expand Up @@ -436,8 +465,9 @@ const MessageView = (props: MessageViewProps) => {
<div
className={classnames(
'sendbird-msg-hoc sendbird-msg--scroll-ref',
isAnimated && 'sendbird-msg-hoc__animated',
showBounce && 'sendbird-msg-hoc__animated',
)}
onAnimationEnd={showBounce ? handleAnimationEnd : undefined}
data-testid="sendbird-message-view"
style={children || renderMessage ? undefined : { marginBottom: '2px' }}
data-sb-message-id={message.messageId}
Expand Down
23 changes: 17 additions & 6 deletions src/modules/GroupChannel/context/GroupChannelProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,19 +300,30 @@ const GroupChannelManager :React.FC<React.PropsWithChildren<GroupChannelProvider
};
}, [messageDataSource.initialized, state.currentChannel?.url]);

// Starting point handling
// Starting point handling — skip when animated message handles scroll
useEffect(() => {
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(() => ({
Expand Down
2 changes: 0 additions & 2 deletions src/modules/GroupChannel/context/hooks/useGroupChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading