Skip to content

Commit fa1546f

Browse files
committed
refactor: clean hooks up properly
1 parent 0949395 commit fa1546f

File tree

5 files changed

+156
-87
lines changed

5 files changed

+156
-87
lines changed

package/src/components/Message/hooks/useMessageActionHandlers.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useMemo } from 'react';
22
import { Alert } from 'react-native';
33

4-
import { UserResponse } from 'stream-chat';
4+
import { LocalMessage, UserResponse } from 'stream-chat';
55

66
import { useUserMuteActive } from './useUserMuteActive';
77

@@ -12,10 +12,20 @@ import type { MessageContextValue } from '../../../contexts/messageContext/Messa
1212
import type { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext';
1313

1414
import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext';
15-
import { usePortalClosingKeyboardSafeCallback, useStableCallback } from '../../../hooks';
15+
import {
16+
useAfterKeyboardOpenCallback,
17+
usePortalSettledCallback,
18+
useStableCallback,
19+
} from '../../../hooks';
1620
import { useTranslatedMessage } from '../../../hooks/useTranslatedMessage';
1721
import { NativeHandlers } from '../../../native';
1822

23+
const useWithPortalKeyboardSafety = <T extends unknown[]>(callback: (...args: T) => void) => {
24+
const callbackAfterKeyboardOpen = useAfterKeyboardOpenCallback(callback);
25+
26+
return usePortalSettledCallback(callbackAfterKeyboardOpen);
27+
};
28+
1929
export const useMessageActionHandlers = ({
2030
channel,
2131
client,
@@ -114,7 +124,10 @@ export const useMessageActionHandlers = ({
114124
}
115125
});
116126

117-
const handleEditMessage = usePortalClosingKeyboardSafeCallback(() => setEditingState(message));
127+
const setEditingMessage = useStableCallback((messageToEdit: LocalMessage = message) => {
128+
setEditingState(messageToEdit);
129+
});
130+
const handleEditMessage = useWithPortalKeyboardSafety(setEditingMessage);
118131

119132
const handleFlagMessage = useStableCallback(() => {
120133
if (!message.id) {

package/src/hooks/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ export * from './useStableCallback';
77
export * from './useLoadingImage';
88
export * from './useMessageReminder';
99
export * from './useQueryReminders';
10+
export * from './useAfterKeyboardOpenCallback';
1011
export * from './useClientNotifications';
1112
export * from './useInAppNotificationsState';
12-
export * from './usePortalClosingKeyboardSafeCallback';
13+
export * from './usePortalSettledCallback';
1314
export * from './useRAFCoalescedValue';
1415
export * from './useAudioPlayerControl';
1516
export * from './useAttachmentPickerState';
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { useEffect, useRef } from 'react';
2+
import { EventSubscription, Keyboard, Platform } from 'react-native';
3+
4+
import { useKeyboardVisibility } from './useKeyboardVisibility';
5+
6+
import { useStableCallback } from './useStableCallback';
7+
8+
import { KeyboardControllerPackage } from '../components/KeyboardCompatibleView/KeyboardControllerAvoidingView';
9+
import { useMessageInputContext } from '../contexts/messageInputContext/MessageInputContext';
10+
11+
/**
12+
* A utility hook that returns a stable callback which focuses the message input
13+
* and invokes the callback once the keyboard is open.
14+
*
15+
* @param callback - callback we want to run once the keyboard is ready
16+
* @returns A stable callback that will wait for the keyboard to be open before executing.
17+
*/
18+
export const useAfterKeyboardOpenCallback = <T extends unknown[]>(
19+
callback: (...args: T) => void,
20+
) => {
21+
const isKeyboardVisible = useKeyboardVisibility();
22+
const { inputBoxRef } = useMessageInputContext();
23+
const keyboardSubscriptionRef = useRef<EventSubscription | undefined>(undefined);
24+
const stableCallback = useStableCallback(callback);
25+
26+
/** Clears the pending keyboard listener, if any. */
27+
const clearKeyboardSubscription = useStableCallback(() => {
28+
keyboardSubscriptionRef.current?.remove();
29+
keyboardSubscriptionRef.current = undefined;
30+
});
31+
32+
useEffect(() => clearKeyboardSubscription, [clearKeyboardSubscription]);
33+
34+
return useStableCallback((...args: T) => {
35+
clearKeyboardSubscription();
36+
37+
const runCallback = () => {
38+
clearKeyboardSubscription();
39+
stableCallback(...args);
40+
};
41+
42+
if (!inputBoxRef.current) {
43+
runCallback();
44+
return;
45+
}
46+
47+
if (isKeyboardVisible) {
48+
inputBoxRef.current.focus();
49+
runCallback();
50+
return;
51+
}
52+
53+
const keyboardEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
54+
55+
keyboardSubscriptionRef.current = KeyboardControllerPackage?.KeyboardEvents
56+
? KeyboardControllerPackage.KeyboardEvents.addListener(keyboardEvent, runCallback)
57+
: Keyboard.addListener(keyboardEvent, runCallback);
58+
59+
inputBoxRef.current.focus();
60+
});
61+
};

package/src/hooks/usePortalClosingKeyboardSafeCallback.ts

Lines changed: 0 additions & 83 deletions
This file was deleted.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { useEffect, useRef } from 'react';
2+
import { Platform } from 'react-native';
3+
4+
import { useStableCallback } from './useStableCallback';
5+
6+
/**
7+
* Number of frames we wait before invoking input focus sensitive work after the
8+
* overlay closes.
9+
*/
10+
const SETTLE_FRAMES = Platform.OS === 'android' ? 2 : 0;
11+
12+
/**
13+
* Runs a callback after a fixed number of animation frames.
14+
*
15+
* We use RAFs here because the settling work we care about is tied to the next
16+
* rendered frames after the overlay close transition.
17+
*
18+
* @param callback - callback to run once the frame budget has elapsed
19+
* @param frames - number of frames to wait
20+
* @param rafIds - accumulator used for later cancellation/cleanup
21+
*/
22+
const scheduleAfterFrames = (callback: () => void, frames: number, rafIds: number[]) => {
23+
if (frames <= 0) {
24+
callback();
25+
return;
26+
}
27+
28+
const rafId = requestAnimationFrame(() => scheduleAfterFrames(callback, frames - 1, rafIds));
29+
rafIds.push(rafId);
30+
};
31+
32+
/**
33+
* Returns a stable callback that is safe to run after a `PortalWhileClosingView`
34+
* has settled back into its original tree.
35+
*
36+
* Some followup actions are sensitive to that handoff window. If they run
37+
* while a view is still being returned from a portal host to its in place host,
38+
* they can target a node that is about to be reattached. On Android, that is
39+
* especially noticeable with focus sensitive work, where the target can lose
40+
* focus again mid keyboard animation.
41+
*
42+
* Two frames are intentional here:
43+
* - frame 1 lets the portal retarget and React commit the component tree
44+
* - frame 2 lets the native view hierarchy settle in its final host
45+
*
46+
* iOS does not currently need this settle window for this flow.
47+
*
48+
* A good example is the message composer edit action: after closing the message
49+
* overlay, we wait for the portal handoff to settle before focusing the input
50+
* and opening the keyboard. Doing this prematurely will result in the keyboard
51+
* being immediately closed.
52+
*
53+
* Another good example would be having a button wrapped in a `PortalWhileClosingView`,
54+
* that possibly renders (or morphs into) something when pressed. Handling `onPress`
55+
* prematurely here may lead to the morphed button rendering into a completely different
56+
* part of the UI hierarchy, causing unknown behaviour. This hook prevents that from
57+
* happening.
58+
*
59+
* @param callback - callback we want to invoke once the portal handoff has settled
60+
* @returns A stable callback gated behind the portal settle window.
61+
*/
62+
export const usePortalSettledCallback = <T extends unknown[]>(callback: (...args: T) => void) => {
63+
const rafIdsRef = useRef<number[]>([]);
64+
const stableCallback = useStableCallback(callback);
65+
66+
const clearScheduledFrames = useStableCallback(() => {
67+
rafIdsRef.current.forEach((rafId) => cancelAnimationFrame(rafId));
68+
rafIdsRef.current = [];
69+
});
70+
71+
useEffect(() => clearScheduledFrames, [clearScheduledFrames]);
72+
73+
return useStableCallback((...args: T) => {
74+
clearScheduledFrames();
75+
scheduleAfterFrames(() => stableCallback(...args), SETTLE_FRAMES, rafIdsRef.current);
76+
});
77+
};

0 commit comments

Comments
 (0)