Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions package/src/__tests__/offline-support/offline-feature.js
Original file line number Diff line number Diff line change
Expand Up @@ -338,10 +338,13 @@ export const Generic = () => {
act(() => dispatchConnectionChangedEvent(chatClient));
await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true));

await waitFor(async () => {
expect(screen.getByTestId('channel-list')).toBeTruthy();
await expectAllChannelsWithStateToBeInDB(screen.queryAllByLabelText);
});
await waitFor(
async () => {
expect(screen.getByTestId('channel-list')).toBeTruthy();
await expectAllChannelsWithStateToBeInDB(screen.queryAllByLabelText);
},
{ timeout: 5000 },
);
});

it('should fetch channels from the db correctly even if they are empty', async () => {
Expand Down
14 changes: 12 additions & 2 deletions package/src/components/Message/hooks/useMessageActionHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,20 @@ import type { MessageContextValue } from '../../../contexts/messageContext/Messa
import type { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext';

import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext';
import { useStableCallback } from '../../../hooks';
import {
useAfterKeyboardOpenCallback,
usePortalSettledCallback,
useStableCallback,
} from '../../../hooks';
import { useTranslatedMessage } from '../../../hooks/useTranslatedMessage';
import { NativeHandlers } from '../../../native';

const useWithPortalKeyboardSafety = <T extends unknown[]>(callback: (...args: T) => void) => {
const callbackAfterKeyboardOpen = useAfterKeyboardOpenCallback(callback);

return usePortalSettledCallback(callbackAfterKeyboardOpen);
};

export const useMessageActionHandlers = ({
channel,
client,
Expand Down Expand Up @@ -114,7 +124,7 @@ export const useMessageActionHandlers = ({
}
});

const handleEditMessage = useStableCallback(() => {
const handleEditMessage = useWithPortalKeyboardSafety(() => {
setEditingState(message);
});

Expand Down
8 changes: 1 addition & 7 deletions package/src/components/MessageInput/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,6 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => {
closePollCreationDialog,
CreatePollContent,
createPollOptionGap,
editing,
InputView,
MessageComposerLeadingView,
MessageComposerTrailingView,
Expand Down Expand Up @@ -272,12 +271,6 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => {
[closeAttachmentPicker],
);

useEffect(() => {
if (editing && inputBoxRef.current) {
inputBoxRef.current.focus();
}
}, [editing, inputBoxRef]);

/**
* Effect to get the draft data for legacy thread composer and set it to message composer.
* TODO: This can be removed once we remove legacy thread composer.
Expand Down Expand Up @@ -746,6 +739,7 @@ export const MessageComposer = (props: MessageComposerProps) => {
closePollCreationDialog,
compressImageQuality,
CreatePollContent,
// TODO: probably not needed anymore, please check
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.

It's definitely not used by the underlying unmemoized component but let's do the cleanup in a separate PR to make sure some parts of the state do not weirdly depend on this rerender.

editing,
Input,
InputView,
Expand Down
2 changes: 2 additions & 0 deletions package/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ export * from './useStableCallback';
export * from './useLoadingImage';
export * from './useMessageReminder';
export * from './useQueryReminders';
export * from './useAfterKeyboardOpenCallback';
export * from './useClientNotifications';
export * from './useInAppNotificationsState';
export * from './usePortalSettledCallback';
export * from './useRAFCoalescedValue';
export * from './useAudioPlayerControl';
export * from './useAttachmentPickerState';
Expand Down
62 changes: 62 additions & 0 deletions package/src/hooks/useAfterKeyboardOpenCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useEffect, useRef } from 'react';
import { EventSubscription, Keyboard, Platform } from 'react-native';

import { useKeyboardVisibility } from './useKeyboardVisibility';

import { useStableCallback } from './useStableCallback';

import { KeyboardControllerPackage } from '../components/KeyboardCompatibleView/KeyboardControllerAvoidingView';
import { useMessageInputContext } from '../contexts/messageInputContext/MessageInputContext';

/**
* A utility hook that returns a stable callback which focuses the message input
* and invokes the callback once the keyboard is open.
*
* @param callback - callback we want to run once the keyboard is ready
* @returns A stable callback that will wait for the keyboard to be open before executing.
*/
export const useAfterKeyboardOpenCallback = <T extends unknown[]>(
callback: (...args: T) => void,
) => {
const isKeyboardVisible = useKeyboardVisibility();
const { inputBoxRef } = useMessageInputContext();
const keyboardSubscriptionRef = useRef<EventSubscription | undefined>(undefined);
// This callback runs from a keyboard event listener, so it must stay fresh across rerenders.
const stableCallback = useStableCallback(callback);

/** Clears the pending keyboard listener, if any. */
const clearKeyboardSubscription = useStableCallback(() => {
keyboardSubscriptionRef.current?.remove();
keyboardSubscriptionRef.current = undefined;
});

useEffect(() => clearKeyboardSubscription, [clearKeyboardSubscription]);

return useStableCallback((...args: T) => {
clearKeyboardSubscription();

const runCallback = () => {
clearKeyboardSubscription();
stableCallback(...args);
};

if (!inputBoxRef.current) {
runCallback();
return;
}

if (isKeyboardVisible) {
inputBoxRef.current.focus();
runCallback();
return;
}

const keyboardEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';

keyboardSubscriptionRef.current = KeyboardControllerPackage?.KeyboardEvents
? KeyboardControllerPackage.KeyboardEvents.addListener(keyboardEvent, runCallback)
: Keyboard.addListener(keyboardEvent, runCallback);

inputBoxRef.current.focus();
});
};
78 changes: 78 additions & 0 deletions package/src/hooks/usePortalSettledCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useEffect, useRef } from 'react';
import { Platform } from 'react-native';

import { useStableCallback } from './useStableCallback';

/**
* Number of frames we wait before invoking input focus sensitive work after the
* overlay closes.
*/
const SETTLE_FRAMES = Platform.OS === 'android' ? 2 : 0;

/**
* Runs a callback after a fixed number of animation frames.
*
* We use RAFs here because the settling work we care about is tied to the next
* rendered frames after the overlay close transition.
*
* @param callback - callback to run once the frame budget has elapsed
* @param frames - number of frames to wait
* @param rafIds - accumulator used for later cancellation/cleanup
*/
const scheduleAfterFrames = (callback: () => void, frames: number, rafIds: number[]) => {
if (frames <= 0) {
callback();
return;
}

const rafId = requestAnimationFrame(() => scheduleAfterFrames(callback, frames - 1, rafIds));
rafIds.push(rafId);
};

/**
* Returns a stable callback that is safe to run after a `PortalWhileClosingView`
* has settled back into its original tree.
*
* Some followup actions are sensitive to that handoff window. If they run
* while a view is still being returned from a portal host to its in place host,
* they can target a node that is about to be reattached. On Android, that is
* especially noticeable with focus sensitive work, where the target can lose
* focus again mid keyboard animation.
*
* Two frames are intentional here:
* - frame 1 lets the portal retarget and React commit the component tree
* - frame 2 lets the native view hierarchy settle in its final host
*
* iOS does not currently need this settle window for this flow.
*
* A good example is the message composer edit action: after closing the message
* overlay, we wait for the portal handoff to settle before focusing the input
* and opening the keyboard. Doing this prematurely will result in the keyboard
* being immediately closed.
*
* Another good example would be having a button wrapped in a `PortalWhileClosingView`,
* that possibly renders (or morphs into) something when pressed. Handling `onPress`
* prematurely here may lead to the morphed button rendering into a completely different
* part of the UI hierarchy, causing unknown behaviour. This hook prevents that from
* happening.
*
* @param callback - callback we want to invoke once the portal handoff has settled
* @returns A stable callback gated behind the portal settle window.
*/
export const usePortalSettledCallback = <T extends unknown[]>(callback: (...args: T) => void) => {
const rafIdsRef = useRef<number[]>([]);
// This callback runs from deferred RAF work, so it must stay fresh across rerenders.
const stableCallback = useStableCallback(callback);

const clearScheduledFrames = useStableCallback(() => {
rafIdsRef.current.forEach((rafId) => cancelAnimationFrame(rafId));
rafIdsRef.current = [];
});

useEffect(() => clearScheduledFrames, [clearScheduledFrames]);

return useStableCallback((...args: T) => {
clearScheduledFrames();
scheduleAfterFrames(() => stableCallback(...args), SETTLE_FRAMES, rafIdsRef.current);
});
};
Loading