Skip to content

Commit cc87b64

Browse files
Merge branch 'main' into fix/clnp-6892
2 parents 17109ad + 45f99c6 commit cc87b64

25 files changed

Lines changed: 817 additions & 111 deletions

File tree

src/hooks/VoicePlayer/index.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface VoicePlayerContext {
3636
play: (props: VoicePlayerPlayProps) => void;
3737
pause: (groupKey?: string) => void;
3838
stop: (text?: string) => void;
39+
reset: (groupKey: string) => void;
3940
voicePlayerStore: VoicePlayerInitialState;
4041
}
4142

@@ -52,6 +53,7 @@ const Context = createContext<VoicePlayerContext>({
5253
play: noop,
5354
pause: noop,
5455
stop: noop,
56+
reset: noop,
5557
voicePlayerStore: VoicePlayerStoreDefaultValue,
5658
});
5759

@@ -75,6 +77,16 @@ export const VoicePlayerProvider = ({
7577
}
7678
};
7779

80+
const reset = (groupKey: string) => {
81+
if (groupKey === currentGroupKey && currentPlayer) {
82+
currentPlayer.pause();
83+
}
84+
voicePlayerDispatcher({
85+
type: RESET_AUDIO_UNIT,
86+
payload: { groupKey },
87+
});
88+
};
89+
7890
const pause = (groupKey?: string) => {
7991
if (currentPlayer) {
8092
if (groupKey === currentGroupKey) {
@@ -210,6 +222,7 @@ export const VoicePlayerProvider = ({
210222
play,
211223
pause,
212224
stop,
225+
reset,
213226
voicePlayerStore,
214227
}}>
215228
{/**

src/hooks/VoicePlayer/useVoicePlayer.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { useEffect } from 'react';
1+
import { useEffect, useRef } from 'react';
22
import { useVoicePlayerContext } from '.';
33
import { VOICE_PLAYER_AUDIO_ID, VOICE_MESSAGE_MIME_TYPE } from '../../utils/consts';
44
import { useVoiceRecorderContext } from '../VoiceRecorder';
55

6-
import { AudioUnitDefaultValue, VoicePlayerStatusType } from './dux/initialState';
6+
import { AudioUnitDefaultValue, VOICE_PLAYER_STATUS, VoicePlayerStatusType } from './dux/initialState';
77
import { generateGroupKey } from './utils';
88

99
export interface UseVoicePlayerProps {
@@ -35,10 +35,13 @@ export const useVoicePlayer = ({
3535
play,
3636
pause,
3737
stop,
38+
reset,
3839
voicePlayerStore,
3940
} = useVoicePlayerContext();
4041
const { isRecordable } = useVoiceRecorderContext();
4142
const currentAudioUnit = voicePlayerStore?.audioStorage?.[groupKey] || AudioUnitDefaultValue();
43+
const currentAudioUnitRef = useRef(currentAudioUnit);
44+
currentAudioUnitRef.current = currentAudioUnit;
4245

4346
const playVoicePlayer = () => {
4447
if (!isRecordable) {
@@ -62,9 +65,13 @@ export const useVoicePlayer = ({
6265
useEffect(() => {
6366
return () => {
6467
if (audioFile || audioFileUrl) {
65-
// Can't get the current AudioPlayer through the React hooks(useReducer or useState) in this scope
68+
// Pause via DOM because reset() captured in this closure has stale currentPlayer
6669
const voiceAudioPlayerElement = document.getElementById(VOICE_PLAYER_AUDIO_ID);
6770
(voiceAudioPlayerElement as HTMLAudioElement)?.pause?.();
71+
const status = currentAudioUnitRef.current?.playingStatus;
72+
if (status && status !== VOICE_PLAYER_STATUS.IDLE) {
73+
reset?.(groupKey);
74+
}
6875
}
6976
};
7077
}, []);
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/lib/Sendbird/__tests__/SendbirdProvider.spec.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,71 @@ describe('SendbirdProvider', () => {
5555
expect(mockActions.connect).toHaveBeenCalledWith(
5656
expect.objectContaining({
5757
appId: 'mockAppId',
58+
isNewApp: true,
5859
userId: 'mockUserId',
5960
}),
6061
);
6162
});
6263

64+
it('should preserve the legacy isNewApp behavior across app and user changes', () => {
65+
const { rerender } = render(
66+
<SendbirdContextProvider appId="mockAppId" userId="mockUserId">
67+
<div data-testid="child">Child Component</div>
68+
</SendbirdContextProvider>,
69+
);
70+
71+
expect(mockActions.connect).toHaveBeenNthCalledWith(1, expect.objectContaining({
72+
appId: 'mockAppId',
73+
isNewApp: true,
74+
userId: 'mockUserId',
75+
}));
76+
77+
rerender(
78+
<SendbirdContextProvider appId="mockAppId" userId="nextUserId">
79+
<div data-testid="child">Child Component</div>
80+
</SendbirdContextProvider>,
81+
);
82+
83+
expect(mockActions.connect).toHaveBeenNthCalledWith(2, expect.objectContaining({
84+
appId: 'mockAppId',
85+
isNewApp: false,
86+
userId: 'nextUserId',
87+
}));
88+
89+
rerender(
90+
<SendbirdContextProvider appId="nextAppId" userId="nextUserId">
91+
<div data-testid="child">Child Component</div>
92+
</SendbirdContextProvider>,
93+
);
94+
95+
expect(mockActions.connect).toHaveBeenNthCalledWith(3, expect.objectContaining({
96+
appId: 'nextAppId',
97+
isNewApp: true,
98+
userId: 'nextUserId',
99+
}));
100+
});
101+
102+
it('should reconnect on StrictMode remount with the same appId and userId', () => {
103+
render(
104+
<React.StrictMode>
105+
<SendbirdContextProvider appId="mockAppId" userId="mockUserId">
106+
<div data-testid="child">Child Component</div>
107+
</SendbirdContextProvider>
108+
</React.StrictMode>,
109+
);
110+
111+
expect(mockActions.connect).toHaveBeenCalledTimes(2);
112+
expect(mockActions.connect).toHaveBeenNthCalledWith(1, expect.objectContaining({
113+
appId: 'mockAppId',
114+
isNewApp: true,
115+
userId: 'mockUserId',
116+
}));
117+
expect(mockActions.connect).toHaveBeenNthCalledWith(2, expect.objectContaining({
118+
appId: 'mockAppId',
119+
userId: 'mockUserId',
120+
}));
121+
});
122+
63123
it('should call disconnect on unmount', () => {
64124
const { unmount } = render(
65125
<SendbirdContextProvider appId="mockAppId" userId="mockUserId">

0 commit comments

Comments
 (0)