Skip to content
48 changes: 23 additions & 25 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,26 @@ 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;

useEffect(() => {
if (isAnimationTarget && !showBounce) {
setShowBounce(true);
}
}, [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') {
setShowBounce(false);
// Only clear if this message is still the animation target
if (animatedMessageId === message.messageId) {
setAnimatedMessageId(null);
onMessageAnimated?.();
}
}
return () => {
timeouts.forEach((it) => clearTimeout(it));
};
}, [animatedMessageId, messageScrollRef.current, message.messageId]);
}, [animatedMessageId, message.messageId, setAnimatedMessageId, onMessageAnimated]);

useLayoutEffect(() => {
if (newMessageIds?.length > 0 && newMessageIds.includes(message.messageId)) {
Expand Down Expand Up @@ -436,8 +433,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
14 changes: 10 additions & 4 deletions src/modules/GroupChannel/context/GroupChannelProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,17 +300,23 @@ 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
useEffect(() => {
if (_animatedMessageId) {
actions.setAnimatedMessageId(_animatedMessageId);
if (typeof startingPoint === 'number') {
// Search result click: scroll to message and animate
actions.scrollToMessage(startingPoint, _animatedMessageId, true, false);
} else {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Defer animated scroll until channel state is initialized

When animatedMessageId is provided on initial mount (e.g., deep-link/open-with-highlight), this effect can run before message data is initialized and call actions.scrollToMessage(...) too early. In useGroupChannel.scrollToMessage, the fallback path is gated by state.initialized, so the call becomes a no-op while uninitialized; because this effect only depends on _animatedMessageId, it does not retry after initialization, and the new _animatedMessageId guard in the starting-point effect also suppresses the old scroll path. The result is that initial highlight navigation can fail to scroll/animate.

Useful? React with 👍 / 👎.

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.

Good catch — fixed in the latest commit.

The animated-message effect now also depends on state.initialized so it retries after the channel finishes initializing. The starting-point effect's !_animatedMessageId guard still keeps the two effects mutually exclusive, so there's no double-scroll path.

useEffect(() => {
  if (_animatedMessageId && state.initialized) {
    if (typeof startingPoint === 'number') {
      actions.scrollToMessage(startingPoint, _animatedMessageId, true, false);
    } else {
      actions.setAnimatedMessageId(_animatedMessageId);
    }
  }
}, [_animatedMessageId, state.initialized]);

Thanks!

// Thread parent jump: scroll already handled by startingPoint effect, just animate
actions.setAnimatedMessageId(_animatedMessageId);
}
}
}, [_animatedMessageId]);

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