Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

35380 virtual viewport on report screen #38755

Merged
merged 10 commits into from
Mar 26, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ const useEmojiPickerMenu = () => {
const [preferredSkinTone] = usePreferredEmojiSkinTone();
const {windowHeight} = useWindowDimensions();
const StyleUtils = useStyleUtils();
const listStyle = StyleUtils.getEmojiPickerListHeight(isListFiltered, windowHeight);
/**
* At EmojiPicker has set innerContainerStyle with maxHeight: '95%' by styles.popoverInnerContainer
* to avoid the list style to be cut off due to the list height being larger than the container height
* so we need to calculate listStyle based on the height of the window and innerContainerStyle at the EmojiPicker
*/
const listStyle = StyleUtils.getEmojiPickerListHeight(isListFiltered, windowHeight * 0.95);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With some small screen size devices (ie: iPhone SE), we might need to reduce the max height when the keyboard opens.
More details:
#53218 (comment)


useEffect(() => {
setFilteredEmojis(allEmojis);
Expand Down
7 changes: 7 additions & 0 deletions src/hooks/useViewportOffsetTop/index.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Native doesn't support DOM so default value is 0
*/

export default function useViewportOffsetTop(): number {
return 0;
}
47 changes: 47 additions & 0 deletions src/hooks/useViewportOffsetTop/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {useEffect, useRef, useState} from 'react';
import addViewportResizeListener from '@libs/VisualViewport';

/**
* A hook that returns the offset of the top edge of the visual viewport
*/
export default function useViewportOffsetTop(shouldAdjustScrollView = false): number {
const [viewportOffsetTop, setViewportOffsetTop] = useState(0);
const initialHeight = useRef(window.visualViewport?.height ?? window.innerHeight).current;
const cachedDefaultOffsetTop = useRef<number>(0);
useEffect(() => {
const updateOffsetTop = (event?: Event) => {
let targetOffsetTop = window.visualViewport?.offsetTop ?? 0;
if (event?.target instanceof VisualViewport) {
targetOffsetTop = event.target.offsetTop;
}

if (shouldAdjustScrollView && window.visualViewport) {
const adjustScrollY = Math.round(initialHeight - window.visualViewport.height);
if (cachedDefaultOffsetTop.current === 0) {
cachedDefaultOffsetTop.current = targetOffsetTop;
}

if (adjustScrollY > targetOffsetTop) {
setViewportOffsetTop(adjustScrollY);
} else if (targetOffsetTop !== 0 && adjustScrollY === targetOffsetTop) {
setViewportOffsetTop(cachedDefaultOffsetTop.current);
} else {
setViewportOffsetTop(targetOffsetTop);
}
} else {
setViewportOffsetTop(targetOffsetTop);
}
};
updateOffsetTop();
return addViewportResizeListener(updateOffsetTop);
}, [initialHeight, shouldAdjustScrollView]);

useEffect(() => {
if (!shouldAdjustScrollView) {
return;
}
window.scrollTo({top: viewportOffsetTop});
}, [shouldAdjustScrollView, viewportOffsetTop]);

return viewportOffsetTop;
}
160 changes: 84 additions & 76 deletions src/pages/home/ReportScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ import ScreenWrapper from '@components/ScreenWrapper';
import TaskHeaderActionButton from '@components/TaskHeaderActionButton';
import withCurrentReportID from '@components/withCurrentReportID';
import type {CurrentReportIDContextValue} from '@components/withCurrentReportID';
import withViewportOffsetTop from '@components/withViewportOffsetTop';
import type {ViewportOffsetTopProps} from '@components/withViewportOffsetTop';
import useAppFocusEvent from '@hooks/useAppFocusEvent';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@hooks/useThemeStyles';
import useViewportOffsetTop from '@hooks/useViewportOffsetTop';
import useWindowDimensions from '@hooks/useWindowDimensions';
import Timing from '@libs/actions/Timing';
import * as Browser from '@libs/Browser';
import Navigation from '@libs/Navigation/Navigation';
import clearReportNotifications from '@libs/Notification/clearReportNotifications';
import reportWithoutHasDraftSelector from '@libs/OnyxSelectors/reportWithoutHasDraftSelector';
Expand All @@ -55,6 +55,9 @@ import {ActionListContext, ReactionListContext} from './ReportScreenContext';
import type {ActionListContextType, ReactionListRef, ScrollPosition} from './ReportScreenContext';

type ReportScreenOnyxProps = {
/** Get modal status */
modal: OnyxEntry<OnyxTypes.Modal>;
suneox marked this conversation as resolved.
Show resolved Hide resolved

/** Tells us if the sidebar has rendered */
isSidebarLoaded: OnyxEntry<boolean>;

Expand Down Expand Up @@ -93,7 +96,7 @@ type OnyxHOCProps = {

type ReportScreenNavigationProps = StackScreenProps<CentralPaneNavigatorParamList, typeof SCREENS.REPORT>;

type ReportScreenProps = OnyxHOCProps & ViewportOffsetTopProps & CurrentReportIDContextValue & ReportScreenOnyxProps & ReportScreenNavigationProps;
type ReportScreenProps = OnyxHOCProps & CurrentReportIDContextValue & ReportScreenOnyxProps & ReportScreenNavigationProps;

/** Get the currently viewed report ID as number */
function getReportID(route: ReportScreenNavigationProps['route']): string {
Expand Down Expand Up @@ -131,7 +134,7 @@ function ReportScreen({
markReadyForHydration,
policies = {},
isSidebarLoaded = false,
viewportOffsetTop,
modal,
isComposerFullSize = false,
userLeavingStatus = false,
currentReportID = '',
Expand Down Expand Up @@ -259,6 +262,8 @@ function ReportScreen({
Timing.start(CONST.TIMING.CHAT_RENDER);
Performance.markStart(CONST.TIMING.CHAT_RENDER);
}
const [isComposerFocus, setIsComposerFocus] = useState(false);
const viewportOffsetTop = useViewportOffsetTop(Browser.isMobileSafari() && isComposerFocus && !modal?.willAlertModalBecomeVisible);

const {reportPendingAction, reportErrors} = ReportUtils.getReportOfflinePendingActionAndErrors(report);
const screenWrapperStyle: ViewStyle[] = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}];
Expand Down Expand Up @@ -644,6 +649,8 @@ function ReportScreen({

{isCurrentReportLoadedFromOnyx ? (
<ReportFooter
onComposerFocus={() => setIsComposerFocus(true)}
onComposerBlur={() => setIsComposerFocus(false)}
report={report}
pendingAction={reportPendingAction}
isComposerFullSize={!!isComposerFullSize}
Expand All @@ -663,81 +670,82 @@ function ReportScreen({

ReportScreen.displayName = 'ReportScreen';

export default withViewportOffsetTop(
withCurrentReportID(
withOnyx<ReportScreenProps, ReportScreenOnyxProps>(
{
isSidebarLoaded: {
key: ONYXKEYS.IS_SIDEBAR_LOADED,
},
sortedAllReportActions: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`,
canEvict: false,
selector: (allReportActions: OnyxEntry<OnyxTypes.ReportActions>) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true),
},
report: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`,
allowStaleData: true,
selector: reportWithoutHasDraftSelector,
},
reportMetadata: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_METADATA}${getReportID(route)}`,
initialValue: {
isLoadingInitialReportActions: true,
isLoadingOlderReportActions: false,
isLoadingNewerReportActions: false,
},
},
isComposerFullSize: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${getReportID(route)}`,
initialValue: false,
},
betas: {
key: ONYXKEYS.BETAS,
},
policies: {
key: ONYXKEYS.COLLECTION.POLICY,
allowStaleData: true,
},
accountManagerReportID: {
key: ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID,
initialValue: null,
},
userLeavingStatus: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${getReportID(route)}`,
initialValue: false,
export default withCurrentReportID(
withOnyx<ReportScreenProps, ReportScreenOnyxProps>(
{
modal: {
key: ONYXKEYS.MODAL,
},
isSidebarLoaded: {
key: ONYXKEYS.IS_SIDEBAR_LOADED,
},
sortedAllReportActions: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`,
canEvict: false,
selector: (allReportActions: OnyxEntry<OnyxTypes.ReportActions>) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true),
},
report: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`,
allowStaleData: true,
selector: reportWithoutHasDraftSelector,
},
reportMetadata: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_METADATA}${getReportID(route)}`,
initialValue: {
isLoadingInitialReportActions: true,
isLoadingOlderReportActions: false,
isLoadingNewerReportActions: false,
},
parentReportAction: {
key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : 0}`,
selector: (parentReportActions: OnyxEntry<OnyxTypes.ReportActions>, props: WithOnyxInstanceState<ReportScreenOnyxProps>): OnyxEntry<OnyxTypes.ReportAction> => {
const parentReportActionID = props?.report?.parentReportActionID;
if (!parentReportActionID) {
return null;
}
return parentReportActions?.[parentReportActionID] ?? null;
},
canEvict: false,
},
isComposerFullSize: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${getReportID(route)}`,
initialValue: false,
},
betas: {
key: ONYXKEYS.BETAS,
},
policies: {
key: ONYXKEYS.COLLECTION.POLICY,
allowStaleData: true,
},
accountManagerReportID: {
key: ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID,
initialValue: null,
},
userLeavingStatus: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${getReportID(route)}`,
initialValue: false,
},
parentReportAction: {
key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : 0}`,
selector: (parentReportActions: OnyxEntry<OnyxTypes.ReportActions>, props: WithOnyxInstanceState<ReportScreenOnyxProps>): OnyxEntry<OnyxTypes.ReportAction> => {
const parentReportActionID = props?.report?.parentReportActionID;
if (!parentReportActionID) {
return null;
}
return parentReportActions?.[parentReportActionID] ?? null;
},
canEvict: false,
},
true,
)(
memo(
ReportScreen,
(prevProps, nextProps) =>
prevProps.isSidebarLoaded === nextProps.isSidebarLoaded &&
lodashIsEqual(prevProps.sortedAllReportActions, nextProps.sortedAllReportActions) &&
lodashIsEqual(prevProps.reportMetadata, nextProps.reportMetadata) &&
prevProps.isComposerFullSize === nextProps.isComposerFullSize &&
lodashIsEqual(prevProps.betas, nextProps.betas) &&
lodashIsEqual(prevProps.policies, nextProps.policies) &&
prevProps.accountManagerReportID === nextProps.accountManagerReportID &&
prevProps.userLeavingStatus === nextProps.userLeavingStatus &&
prevProps.currentReportID === nextProps.currentReportID &&
prevProps.viewportOffsetTop === nextProps.viewportOffsetTop &&
lodashIsEqual(prevProps.parentReportAction, nextProps.parentReportAction) &&
lodashIsEqual(prevProps.route, nextProps.route) &&
lodashIsEqual(prevProps.report, nextProps.report),
),
},
true,
)(
memo(
ReportScreen,
(prevProps, nextProps) =>
prevProps.isSidebarLoaded === nextProps.isSidebarLoaded &&
lodashIsEqual(prevProps.sortedAllReportActions, nextProps.sortedAllReportActions) &&
lodashIsEqual(prevProps.reportMetadata, nextProps.reportMetadata) &&
prevProps.isComposerFullSize === nextProps.isComposerFullSize &&
lodashIsEqual(prevProps.betas, nextProps.betas) &&
lodashIsEqual(prevProps.policies, nextProps.policies) &&
prevProps.accountManagerReportID === nextProps.accountManagerReportID &&
prevProps.userLeavingStatus === nextProps.userLeavingStatus &&
prevProps.currentReportID === nextProps.currentReportID &&
lodashIsEqual(prevProps.modal, nextProps.modal) &&
lodashIsEqual(prevProps.parentReportAction, nextProps.parentReportAction) &&
lodashIsEqual(prevProps.route, nextProps.route) &&
lodashIsEqual(prevProps.report, nextProps.report),
),
),
);
35 changes: 24 additions & 11 deletions src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ type ReportActionComposeProps = ReportActionComposeOnyxProps &

/** Whether the report is ready for display */
isReportReadyForDisplay?: boolean;

/** A method to call when the input is focus */
onComposerFocus?: () => void;

/** A method to call when the input is blur */
onComposerBlur?: () => void;
};

// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
Expand All @@ -107,6 +113,8 @@ function ReportActionCompose({
isReportReadyForDisplay = true,
isEmptyChat,
lastReportAction,
onComposerFocus,
onComposerBlur,
}: ReportActionComposeProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
Expand Down Expand Up @@ -293,20 +301,25 @@ function ReportActionCompose({
isKeyboardVisibleWhenShowingModalRef.current = true;
}, []);

const onBlur = useCallback((event: NativeSyntheticEvent<TextInputFocusEventData>) => {
const webEvent = event as unknown as FocusEvent;
setIsFocused(false);
if (suggestionsRef.current) {
suggestionsRef.current.resetSuggestions();
}
if (webEvent.relatedTarget && webEvent.relatedTarget === actionButtonRef.current) {
isKeyboardVisibleWhenShowingModalRef.current = true;
}
}, []);
const onBlur = useCallback(
(event: NativeSyntheticEvent<TextInputFocusEventData>) => {
const webEvent = event as unknown as FocusEvent;
setIsFocused(false);
onComposerBlur?.();
if (suggestionsRef.current) {
suggestionsRef.current.resetSuggestions();
}
if (webEvent.relatedTarget && webEvent.relatedTarget === actionButtonRef.current) {
isKeyboardVisibleWhenShowingModalRef.current = true;
}
},
[onComposerBlur],
);

const onFocus = useCallback(() => {
setIsFocused(true);
}, []);
onComposerFocus?.();
}, [onComposerFocus]);

// resets the composer to normal size when
// the send button is pressed.
Expand Down
10 changes: 10 additions & 0 deletions src/pages/home/report/ReportFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ type ReportFooterProps = ReportFooterOnyxProps & {

/** Whether the composer is in full size */
isComposerFullSize?: boolean;

/** A method to call when the input is focus */
onComposerFocus: () => void;

/** A method to call when the input is blur */
onComposerBlur: () => void;
};

function ReportFooter({
Expand All @@ -63,6 +69,8 @@ function ReportFooter({
isReportReadyForDisplay = true,
listHeight = 0,
isComposerFullSize = false,
onComposerBlur,
onComposerFocus,
}: ReportFooterProps) {
const styles = useThemeStyles();
const {isOffline} = useNetwork();
Expand Down Expand Up @@ -137,6 +145,8 @@ function ReportFooter({
<ReportActionCompose
// @ts-expect-error TODO: Remove this once ReportActionCompose (https://github.com/Expensify/App/issues/31984) is migrated to TypeScript.
onSubmit={onSubmitComment}
onComposerFocus={onComposerFocus}
onComposerBlur={onComposerBlur}
reportID={report.reportID}
report={report}
isEmptyChat={isEmptyChat}
Expand Down
Loading