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
2 changes: 1 addition & 1 deletion src/components/FlashList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {FlashList as ShopifyFlashList} from '@shopify/flash-list';
import React from 'react';

function FlashList<T>({onScroll: onScrollProp, inverted, ...restProps}: FlashListProps<T>) {
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted});
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!inverted});

const handleScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
onScrollProp?.(e);
Expand Down
2 changes: 1 addition & 1 deletion src/components/FlatList/FlatList/index.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function CustomFlatList<T>({
setIsScrolling(false);
};

const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal, inverted: restProps.inverted});
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal && !!restProps.inverted});
const handleScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
onScrollProp?.(e);
emitComposerScrollEvents();
Expand Down
2 changes: 1 addition & 1 deletion src/components/FlatList/FlatList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ function MVCPFlatList<T>({
};
}, []);

const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted: restProps.inverted});
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!restProps.inverted});
const handleScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
onScrollProp?.(e);
prepareForMaintainVisibleContentPosition();
Expand Down
4 changes: 2 additions & 2 deletions src/components/KeyboardDismissibleFlatList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import {useAnimatedScrollHandler, useComposedEventHandler} from 'react-native-re

import {useKeyboardDismissibleFlatListActions} from './KeyboardDismissibleFlatListContext';

function KeyboardDismissibleFlatList<T>({onScroll: onScrollProp, inverted, ref, ...restProps}: AnimatedFlatListWithCellRendererProps<T>) {
function KeyboardDismissibleFlatList<T>({onScroll: onScrollProp, ref, ...restProps}: AnimatedFlatListWithCellRendererProps<T>) {
const {onScroll: onScrollHandleKeyboard} = useKeyboardDismissibleFlatListActions();

const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted});
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!restProps.inverted});

const additionalOnScroll = useAnimatedScrollHandler({
onScroll: emitComposerScrollEvents,
Expand Down
11 changes: 4 additions & 7 deletions src/hooks/useEmitComposerScrollEvents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,16 @@ import {DeviceEventEmitter} from 'react-native';

type UseEmitComposerScrollEventsOptions = {
enabled?: boolean;
inverted: boolean | null | undefined;
};

/**
* This is used to trigger scroll behavior in the composer on web. On native, this is a no-op.
* The scroll events are only emitted when the list is inverted, since it is only used in the report screen in combination with the composer.
* Since our custom FlatList implementation can either be a `KeyboardDismissibleFlatList` or a regular `FlatList`,
* we need to emit the scroll events inside the scroll handler of the specific implementation.
* @param inverted - Whether the list is inverted.
* @returns A function that can be used to emit the scroll events.
*/
function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOptions) {
const {enabled = true, inverted} = options ?? {};
const {enabled = true} = options ?? {};

const lastScrollEvent = useRef<number | null>(null);
const scrollEndTimeout = useRef<NodeJS.Timeout | null>(null);
Expand All @@ -28,7 +25,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption
* invokes the onScroll callback function from props.
*/
const onScroll = () => {
if (!enabled || !inverted) {
if (!enabled) {
return;
}

Expand All @@ -44,7 +41,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption
* Emits when the scrolling has ended.
*/
const onScrollEnd = () => {
if (!enabled || !inverted) {
if (!enabled) {
return;
}

Expand All @@ -67,7 +64,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption
*
*/
const emitComposerScrollEvents = () => {
if (!enabled || !inverted) {
if (!enabled) {
return;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type NotificationPermissionModule from './types';

const NotificationPermissionNative: NotificationPermissionModule = {
getStatusSync: () => 'denied',
getStatus: () => Promise.resolve('denied'),
request: () => Promise.resolve('denied'),
};
Expand Down
13 changes: 9 additions & 4 deletions src/libs/Notification/notificationPermission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ function toStatus(permission: NotificationPermission): NotificationPermissionSta
return 'default';
}

function getStatusSync(): NotificationPermissionStatus {
if (typeof window === 'undefined' || !window.Notification) {
return 'denied';
}
return toStatus(Notification.permission);
}

const NotificationPermission: NotificationPermissionModule = {
getStatusSync,
getStatus(): Promise<NotificationPermissionStatus> {
if (typeof window === 'undefined' || !window.Notification) {
return Promise.resolve('denied');
}
return Promise.resolve(toStatus(Notification.permission));
return Promise.resolve(getStatusSync());
},

request(): Promise<NotificationPermissionStatus> {
Expand Down
1 change: 1 addition & 0 deletions src/libs/Notification/notificationPermission/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
type NotificationPermissionStatus = 'granted' | 'denied' | 'default';

type NotificationPermissionModule = {
getStatusSync: () => NotificationPermissionStatus;
getStatus: () => Promise<NotificationPermissionStatus>;
request: () => Promise<NotificationPermissionStatus>;
};
Expand Down
2 changes: 1 addition & 1 deletion src/libs/actions/Report/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5600,7 +5600,7 @@ function showReportActionNotification(
LocalNotification.showCommentNotification(report, reportAction, onClick, derivedReportName, formatPhoneNumber);
}

notifyNewAction(reportID, undefined, reportAction.actorAccountID === currentUserAccountID);
notifyNewAction(reportID, undefined, reportAction.actorAccountID === currentUserAccountID, 'realtime');
}

/** Clear the errors associated with the IOUs of a given report. */
Expand Down
7 changes: 4 additions & 3 deletions src/libs/actions/Report/reportActionSubscribers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
*/
import type ReportAction from '@src/types/onyx/ReportAction';

type SubscriberCallback = (isFromCurrentUser: boolean, reportAction: ReportAction | undefined) => void;
type ActionEventSource = 'local' | 'realtime';
type SubscriberCallback = (isFromCurrentUser: boolean, reportAction: ReportAction | undefined, source?: ActionEventSource) => void;

type ActionSubscriber = {
reportID: string;
Expand All @@ -26,15 +27,15 @@ function subscribeToNewActionEvent(reportID: string, callback: SubscriberCallbac
}

/** Notify the ReportActionsList that a new comment has arrived */
function notifyNewAction(reportID: string | string[] | undefined, reportAction: ReportAction | undefined, isFromCurrentUser: boolean) {
function notifyNewAction(reportID: string | string[] | undefined, reportAction: ReportAction | undefined, isFromCurrentUser: boolean, source: ActionEventSource = 'local') {
if (!reportID) {
return;
}
const ids = Array.isArray(reportID) ? reportID : [reportID];
for (const id of ids) {
const actionSubscriber = newActionSubscribers.find((subscriber) => subscriber.reportID === id);
if (actionSubscriber) {
actionSubscriber.callback(isFromCurrentUser, reportAction);
actionSubscriber.callback(isFromCurrentUser, reportAction, source);
}
}
}
Expand Down
30 changes: 14 additions & 16 deletions src/libs/focusComposerWithDelay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,14 @@ import setTextInputSelection from './setTextInputSelection';

/**
* Create a function that focuses the composer.
* A getter resolves the current input after the delay, since editing can replace it during layout.
* @param [canFocusAfterDelay] Re-checked when the delay is up, so a composer that lost its focus claim meanwhile does not take focus back
*/
function focusComposerWithDelay(textInput: InputType | null, delay: number = CONST.COMPOSER_FOCUS_DELAY, canFocusAfterDelay?: () => boolean): FocusComposerWithDelay {
function getIsFocused() {
if (textInput && 'isFocused' in textInput) {
return textInput.isFocused();
}
return false;
}

function focusComposerWithDelay(
textInput: InputType | null | (() => InputType | null),
delay: number = CONST.COMPOSER_FOCUS_DELAY,
canFocusAfterDelay?: () => boolean,
): FocusComposerWithDelay {
/**
* Focus the text input
* @param [shouldDelay] Impose delay before focusing the text input
Expand All @@ -35,8 +33,12 @@ function focusComposerWithDelay(textInput: InputType | null, delay: number = CON
return;
}

function focusAndUpdateSelection(input: InputType) {
if (getIsFocused()) {
function focusAndUpdateSelection() {
const input = typeof textInput === 'function' ? textInput() : textInput;
if (!input) {
return;
}
if ('isFocused' in input && input.isFocused()) {
if (forceKeyboardIfAlreadyFocused) {
requestKeyboardForFocusedComposer(input, forcedSelectionRange);
}
Expand All @@ -50,23 +52,19 @@ function focusComposerWithDelay(textInput: InputType | null, delay: number = CON
}

if (!shouldDelay) {
focusAndUpdateSelection(textInput);
focusAndUpdateSelection();
return;
}

await Promise.all([ComposerFocusManager.isReadyToFocus(), isWindowReadyToFocus()]);

if (!textInput) {
return;
}

// When the closing modal has a focused text input focus() needs a delay to properly work.
// Setting 150ms here is a temporary workaround for the Android HybridApp. It should be reverted once we identify the real root cause of this issue: https://github.com/Expensify/App/issues/56311.
setTimeout(() => {
if (canFocusAfterDelay && !canFocusAfterDelay()) {
return;
}
focusAndUpdateSelection(textInput);
focusAndUpdateSelection();
}, delay);
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ function ComposerWithSuggestions({
const delay = shouldDelayAutoFocusRef.current ? CONST.ANIMATED_TRANSITION : CONST.COMPOSER_FOCUS_DELAY;
// Another composer taking focus releases the claim, so it has to be read when the focus lands rather than now.
const canFocusAfterDelay = isInSidePanelRef.current ? () => !!ReportActionComposeFocusManager.sidePanelComposerRef.current : undefined;
focusComposerWithDelay(composerRef.current, delay, canFocusAfterDelay)(shouldDelay, forcedSelectionRange, forceKeyboardIfAlreadyFocused).catch(() => {});
focusComposerWithDelay(() => composerRef.current, delay, canFocusAfterDelay)(shouldDelay, forcedSelectionRange, forceKeyboardIfAlreadyFocused).catch(() => {});
}, []);

const shouldIgnoreEditSelectionResetRef = useRef(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {useEffect, useState} from 'react';
function useShouldShowEnableNotificationsBanner(report: OnyxEntry<Report>): boolean {
const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);
const [hasDismissed] = useOnyx(ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER);
const [permissionStatus, setPermissionStatus] = useState<NotificationPermissionStatus | undefined>();
// Reserve the banner's space before the report list makes its initial scroll-to-end measurement.
const [permissionStatus, setPermissionStatus] = useState<NotificationPermissionStatus>(() => NotificationPermission.getStatusSync());

const isConcierge = isConciergeChatReport(report, conciergeReportID);

Expand Down
33 changes: 32 additions & 1 deletion tests/navigation/LinkedActionNotFoundGuardTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const NAVIGATOR_KEY = 'test-navigator-key';
const mockSetParams = jest.fn();
const mockCleanStaleBackToParam = jest.fn();
const mockIsNavigationReady = jest.fn(() => Promise.resolve());
const mockChildrenMount = jest.fn();
const mockChildrenUnmount = jest.fn();

jest.mock('@libs/Navigation/Navigation', () => ({
__esModule: true,
Expand Down Expand Up @@ -43,7 +45,7 @@ jest.mock('@react-navigation/native', () => {
useRoute: () => ({
key: ROUTE_KEY,
name: 'Report',
params: mockRouteParams,
params: {...mockRouteParams},
}),
useNavigation: () => ({
getState: () => ({key: NAVIGATOR_KEY}),
Expand Down Expand Up @@ -145,6 +147,10 @@ function createReportAction(overrides: Partial<ReportAction> = {}): ReportAction
}

function TestChildren() {
React.useEffect(() => {
mockChildrenMount();
return () => mockChildrenUnmount();
}, []);
return <View testID="test-children" />;
}

Expand All @@ -170,13 +176,38 @@ describe('LinkedActionNotFoundGuard', () => {
mockSetParams.mockReset();
mockCleanStaleBackToParam.mockClear();
mockIsNavigationReady.mockClear();
mockChildrenMount.mockClear();
mockChildrenUnmount.mockClear();
mockRouteParams.reportID = REPORT_ID;
mockRouteParams.reportActionID = REPORT_ACTION_ID;
mockLinkedAction = createReportAction();
mockIsLoadingInitialReportActions = false;
mockIsReportActionVisible = true;
});

it('preserves the report content when a link is cleared or a different link is opened', () => {
// Given a linked report has mounted its list and composer.
const view = render(
<LinkedActionNotFoundGuard>
<TestChildren />
</LinkedActionNotFoundGuard>,
);

// When sending clears the link, and the reader subsequently opens another link.
for (const reportActionID of ['', 'another-action', undefined, REPORT_ACTION_ID]) {
mockRouteParams.reportActionID = reportActionID;
view.rerender(
<LinkedActionNotFoundGuard>
<TestChildren />
</LinkedActionNotFoundGuard>,
);

// Then the report keeps its mounted list, editor, and keyboard state.
expect(mockChildrenMount).toHaveBeenCalledTimes(1);
expect(mockChildrenUnmount).not.toHaveBeenCalled();
}
});

it('renders children when linked action exists', () => {
render(
<LinkedActionNotFoundGuard>
Expand Down
Loading
Loading