Skip to content

Commit c7895a7

Browse files
Merge pull request #1423 from sendbird/fix/clnp-7811
[CLNP-7811][fix]: send endTyping on empty input, channel change, and unmount
2 parents 48f6414 + f658585 commit c7895a7

9 files changed

Lines changed: 365 additions & 25 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { renderHook, act } from '@testing-library/react';
2+
import { useTypingLifecycle } from '../useTypingLifecycle';
3+
4+
const makeChannel = (url = 'channel-a') => ({
5+
url,
6+
startTyping: jest.fn(),
7+
endTyping: jest.fn(),
8+
});
9+
10+
describe('useTypingLifecycle', () => {
11+
it('startTyping calls channel.startTyping', () => {
12+
const channel = makeChannel();
13+
const { result } = renderHook(() => useTypingLifecycle(channel));
14+
15+
act(() => result.current.startTyping());
16+
17+
expect(channel.startTyping).toHaveBeenCalledTimes(1);
18+
expect(channel.endTyping).not.toHaveBeenCalled();
19+
});
20+
21+
it('stopTyping calls channel.endTyping', () => {
22+
const channel = makeChannel();
23+
const { result } = renderHook(() => useTypingLifecycle(channel));
24+
25+
act(() => result.current.startTyping());
26+
act(() => result.current.stopTyping());
27+
28+
expect(channel.endTyping).toHaveBeenCalledTimes(1);
29+
});
30+
31+
it('calls endTyping on unmount when typing was active', () => {
32+
const channel = makeChannel();
33+
const { result, unmount } = renderHook(() => useTypingLifecycle(channel));
34+
35+
act(() => result.current.startTyping());
36+
unmount();
37+
38+
expect(channel.endTyping).toHaveBeenCalledTimes(1);
39+
});
40+
41+
it('does not call endTyping on unmount when typing was never active', () => {
42+
const channel = makeChannel();
43+
const { unmount } = renderHook(() => useTypingLifecycle(channel));
44+
45+
unmount();
46+
47+
expect(channel.endTyping).not.toHaveBeenCalled();
48+
});
49+
50+
it('does not duplicate endTyping when stopTyping called before unmount', () => {
51+
const channel = makeChannel();
52+
const { result, unmount } = renderHook(() => useTypingLifecycle(channel));
53+
54+
act(() => result.current.startTyping());
55+
act(() => result.current.stopTyping());
56+
unmount();
57+
58+
expect(channel.endTyping).toHaveBeenCalledTimes(1);
59+
});
60+
61+
it('calls endTyping on previous channel when channel changes', () => {
62+
const channelA = makeChannel('a');
63+
const channelB = makeChannel('b');
64+
65+
const { result, rerender } = renderHook(
66+
({ channel }) => useTypingLifecycle(channel),
67+
{ initialProps: { channel: channelA } },
68+
);
69+
70+
act(() => result.current.startTyping());
71+
expect(channelA.startTyping).toHaveBeenCalledTimes(1);
72+
73+
rerender({ channel: channelB });
74+
75+
expect(channelA.endTyping).toHaveBeenCalledTimes(1);
76+
expect(channelB.endTyping).not.toHaveBeenCalled();
77+
});
78+
79+
it('calls endTyping when active toggles to false', () => {
80+
const channel = makeChannel();
81+
82+
const { result, rerender } = renderHook(
83+
({ active }) => useTypingLifecycle(channel, active),
84+
{ initialProps: { active: true } },
85+
);
86+
87+
act(() => result.current.startTyping());
88+
rerender({ active: false });
89+
90+
expect(channel.endTyping).toHaveBeenCalledTimes(1);
91+
});
92+
93+
it('does not call endTyping when active toggles to false without typing', () => {
94+
const channel = makeChannel();
95+
96+
const { rerender } = renderHook(
97+
({ active }) => useTypingLifecycle(channel, active),
98+
{ initialProps: { active: true } },
99+
);
100+
101+
rerender({ active: false });
102+
103+
expect(channel.endTyping).not.toHaveBeenCalled();
104+
});
105+
106+
it('handles null channel gracefully', () => {
107+
const { result, unmount } = renderHook(() => useTypingLifecycle(null));
108+
109+
expect(() => {
110+
act(() => result.current.startTyping());
111+
act(() => result.current.stopTyping());
112+
unmount();
113+
}).not.toThrow();
114+
});
115+
116+
describe('same-URL channel re-instantiation', () => {
117+
it('cleans up on the latest instance when typing starts after re-instantiation', () => {
118+
const original = makeChannel('a');
119+
const refreshed = makeChannel('a');
120+
121+
const { result, rerender, unmount } = renderHook(
122+
({ channel }) => useTypingLifecycle(channel),
123+
{ initialProps: { channel: original } },
124+
);
125+
126+
rerender({ channel: refreshed });
127+
act(() => result.current.startTyping());
128+
129+
expect(refreshed.startTyping).toHaveBeenCalledTimes(1);
130+
expect(original.startTyping).not.toHaveBeenCalled();
131+
132+
unmount();
133+
134+
expect(refreshed.endTyping).toHaveBeenCalledTimes(1);
135+
expect(original.endTyping).not.toHaveBeenCalled();
136+
});
137+
138+
it('cleans up on the original instance if typing started before re-instantiation', () => {
139+
const original = makeChannel('a');
140+
const refreshed = makeChannel('a');
141+
142+
const { result, rerender, unmount } = renderHook(
143+
({ channel }) => useTypingLifecycle(channel),
144+
{ initialProps: { channel: original } },
145+
);
146+
147+
act(() => result.current.startTyping());
148+
rerender({ channel: refreshed });
149+
unmount();
150+
151+
expect(original.endTyping).toHaveBeenCalledTimes(1);
152+
expect(refreshed.endTyping).not.toHaveBeenCalled();
153+
});
154+
155+
it('uses the latest instance for endTyping when typing is restarted after re-instantiation', () => {
156+
const original = makeChannel('a');
157+
const refreshed = makeChannel('a');
158+
159+
const { result, rerender, unmount } = renderHook(
160+
({ channel }) => useTypingLifecycle(channel),
161+
{ initialProps: { channel: original } },
162+
);
163+
164+
act(() => result.current.startTyping());
165+
rerender({ channel: refreshed });
166+
act(() => result.current.startTyping());
167+
unmount();
168+
169+
expect(original.startTyping).toHaveBeenCalledTimes(1);
170+
expect(refreshed.startTyping).toHaveBeenCalledTimes(1);
171+
// endTyping should hit only the refreshed instance (the most recent
172+
// startTyping target), not the original.
173+
expect(original.endTyping).not.toHaveBeenCalled();
174+
expect(refreshed.endTyping).toHaveBeenCalledTimes(1);
175+
});
176+
});
177+
});

src/hooks/useTypingLifecycle.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useCallback, useEffect, useRef } from 'react';
2+
3+
type TypingChannel = {
4+
url?: string;
5+
startTyping?: () => void;
6+
endTyping?: () => void;
7+
} | null | undefined;
8+
9+
/**
10+
* Tracks typing lifecycle on a Sendbird channel and ensures `endTyping` is
11+
* sent when the channel changes, the `active` flag toggles to false, or the
12+
* component unmounts. Returns memoized `startTyping`/`stopTyping` helpers
13+
* that the caller wires to input events.
14+
*
15+
* @param channel - the channel currently being typed in
16+
* @param active - when set to false, the cleanup runs and clears typing
17+
* state. Useful for edit-mode wrappers that pass `showEdit`
18+
* so closing the edit panel emits `endTyping`.
19+
*/
20+
export function useTypingLifecycle(
21+
channel: TypingChannel,
22+
active: boolean = true,
23+
): { startTyping: () => void; stopTyping: () => void } {
24+
// Holds the channel that startTyping was last invoked on. Storing the
25+
// actual reference (rather than a boolean flag) ensures endTyping is sent
26+
// on the same channel instance that received startTyping, even if the
27+
// channel object is re-instantiated under the same URL.
28+
const typingChannelRef = useRef<TypingChannel>(null);
29+
30+
const startTyping = useCallback(() => {
31+
channel?.startTyping?.();
32+
typingChannelRef.current = channel ?? null;
33+
}, [channel]);
34+
35+
const stopTyping = useCallback(() => {
36+
typingChannelRef.current?.endTyping?.();
37+
typingChannelRef.current = null;
38+
}, []);
39+
40+
// Run cleanup on channel URL change, active toggle, or unmount.
41+
// Reads typingChannelRef so endTyping is sent on the channel where
42+
// startTyping was actually invoked.
43+
useEffect(() => {
44+
return () => {
45+
if (typingChannelRef.current) {
46+
typingChannelRef.current.endTyping?.();
47+
typingChannelRef.current = null;
48+
}
49+
};
50+
}, [channel?.url, active]);
51+
52+
return { startTyping, stopTyping };
53+
}

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { EveryMessage, RenderCustomSeparatorProps, RenderMessageParamsType, ReplyType } from '../../../../types';
22
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3+
import { useTypingLifecycle } from '../../../../hooks/useTypingLifecycle';
34
import { type EmojiCategory, EmojiContainer, User } from '@sendbird/chat';
45
import { GroupChannel } from '@sendbird/chat/groupChannel';
56
import type { FileMessage, UserMessage, UserMessageCreateParams, UserMessageUpdateParams } from '@sendbird/chat/message';
@@ -220,6 +221,8 @@ const MessageView = (props: MessageViewProps) => {
220221
);
221222
}, [mentionedUserIds]);
222223

224+
const { startTyping, stopTyping } = useTypingLifecycle(channel, showEdit);
225+
223226
// Side effect: scroll position update when showEdit is toggled or reactions updated
224227
useDidMountEffect(() => {
225228
handleScroll?.();
@@ -381,25 +384,24 @@ const MessageView = (props: MessageViewProps) => {
381384
mentionSelectedUser={selectedUser}
382385
isMentionEnabled={groupChannel.enableMention}
383386
message={message}
384-
onStartTyping={() => {
385-
channel?.startTyping?.();
386-
}}
387+
onStartTyping={startTyping}
388+
onStopTyping={stopTyping}
387389
onUpdateMessage={({ messageId, message, mentionTemplate }) => {
388390
updateUserMessage(messageId, {
389391
message,
390392
mentionedUsers,
391393
mentionedMessageTemplate: mentionTemplate,
392394
});
393395
setShowEdit(false);
394-
channel?.endTyping?.();
396+
stopTyping();
395397
}}
396398
onCancelEdit={() => {
397399
setMentionNickname('');
398400
setMentionedUsers([]);
399401
setMentionedUserIds([]);
400402
setMentionSuggestedUsers([]);
401403
setShowEdit(false);
402-
channel?.endTyping?.();
404+
stopTyping();
403405
}}
404406
onUserMentioned={(user) => {
405407
if (selectedUser?.userId === user?.userId) {

src/modules/GroupChannel/components/MessageInputWrapper/MessageInputWrapperView.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import './index.scss';
22
import React, { useState, useEffect } from 'react';
3+
import { useTypingLifecycle } from '../../../../hooks/useTypingLifecycle';
34
import type { User } from '@sendbird/chat';
45
import type { GroupChannel } from '@sendbird/chat/groupChannel';
56
import type {
@@ -128,6 +129,8 @@ export const MessageInputWrapperView = React.forwardRef((
128129
setMessageInputEvent(null);
129130
setShowVoiceMessageInput(false);
130131
}, [currentChannel?.url]);
132+
133+
const { startTyping, stopTyping } = useTypingLifecycle(currentChannel);
131134
useEffect(() => {
132135
setMentionedUsers(
133136
mentionedUsers.filter(({ userId }) => {
@@ -234,9 +237,8 @@ export const MessageInputWrapperView = React.forwardRef((
234237
renderFileUploadIcon={renderFileUploadIcon}
235238
renderSendMessageIcon={renderSendMessageIcon}
236239
renderVoiceMessageIcon={renderVoiceMessageIcon}
237-
onStartTyping={() => {
238-
currentChannel?.startTyping();
239-
}}
240+
onStartTyping={startTyping}
241+
onStopTyping={stopTyping}
240242
onSendMessage={({ message, mentionTemplate }) => {
241243
sendUserMessage({
242244
message,
@@ -247,7 +249,7 @@ export const MessageInputWrapperView = React.forwardRef((
247249
setMentionNickname('');
248250
setMentionedUsers([]);
249251
setQuoteMessage(null);
250-
currentChannel?.endTyping?.();
252+
stopTyping();
251253
}}
252254
onFileUpload={(fileList) => {
253255
handleUploadFiles(fileList);

src/modules/Thread/components/ParentMessageInfo/index.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { ReactNode, useEffect, useRef, useState } from 'react';
2+
import { useTypingLifecycle } from '../../../../hooks/useTypingLifecycle';
23
import format from 'date-fns/format';
34
import { FileMessage } from '@sendbird/chat/message';
45

@@ -132,6 +133,8 @@ export default function ParentMessageInfo({
132133
}));
133134
}, [mentionedUserIds]);
134135

136+
const { startTyping, stopTyping } = useTypingLifecycle(currentChannel, showEditInput);
137+
135138
const handleOnDownloadClick = async (e: React.MouseEvent) => {
136139
if (!onBeforeDownloadFileMessage) return;
137140

@@ -188,9 +191,8 @@ export default function ParentMessageInfo({
188191
mentionSelectedUser={selectedUser}
189192
isMentionEnabled={isMentionEnabled}
190193
message={parentMessage}
191-
onStartTyping={() => {
192-
currentChannel?.startTyping?.();
193-
}}
194+
onStartTyping={startTyping}
195+
onStopTyping={stopTyping}
194196
onUpdateMessage={({ messageId, message, mentionTemplate }) => {
195197
updateMessage({
196198
messageId,
@@ -199,15 +201,15 @@ export default function ParentMessageInfo({
199201
mentionTemplate,
200202
});
201203
setShowEditInput(false);
202-
currentChannel?.endTyping?.();
204+
stopTyping();
203205
}}
204206
onCancelEdit={() => {
205207
setMentionNickname('');
206208
setMentionedUsers([]);
207209
setMentionedUserIds([]);
208210
setMentionSuggestedUsers([]);
209211
setShowEditInput(false);
210-
currentChannel?.endTyping?.();
212+
stopTyping();
211213
}}
212214
onUserMentioned={(user) => {
213215
if (selectedUser?.userId === user?.userId) {

0 commit comments

Comments
 (0)