Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
93 changes: 75 additions & 18 deletions src/components/ReportActionItem/TaskPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {delegateEmailSelector} from '@selectors/Account';
import React from 'react';
import React, {useState} from 'react';
import {View} from 'react-native';
import type {StyleProp, ViewStyle} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
Expand All @@ -23,6 +23,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import Accessibility from '@libs/Accessibility';
import {callFunctionIfActionIsAllowed} from '@libs/actions/Session';
import {canActionTask, completeTask, getTaskAssigneeAccountID, reopenTask} from '@libs/actions/Task';
import ControlSelection from '@libs/ControlSelection';
Expand All @@ -34,6 +35,7 @@ import Navigation from '@libs/Navigation/Navigation';
import Parser from '@libs/Parser';
import {getOriginalMessage} from '@libs/ReportActionsUtils';
import {isCanceledTaskReport, isOpenTaskReport, isReportManager} from '@libs/ReportUtils';
import shouldBreakAccessibilityGrouping from '@libs/shouldBreakAccessibilityGrouping';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Report, ReportAction} from '@src/types/onyx';
Expand Down Expand Up @@ -63,6 +65,8 @@ function TaskPreview({action, chatReportID, currentUserPersonalDetails, isHovere
const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
const theme = useTheme();
const isScreenReaderActive = Accessibility.useScreenReaderStatus();
const shouldBreakGrouping = shouldBreakAccessibilityGrouping();
const {originalReportID, anchor: contextMenuAnchorRef, shouldDisplayContextMenu = true} = useShowContextMenuState();
const {checkIfContextMenuActive, onShowContextMenu} = useShowContextMenuActions();
const originalMessage = getOriginalMessage(action);
Expand All @@ -88,9 +92,22 @@ function TaskPreview({action, chatReportID, currentUserPersonalDetails, isHovere
// The reportAction might not contain details regarding the taskReport
// Only the direct parent reportAction will contain details about the taskReport
// Other linked reportActions will only contain the taskReportID and we will grab the details from there
const isTaskCompleted = !isEmptyObject(taskReport)
const isTaskCompletedFromOnyx = !isEmptyObject(taskReport)
? taskReport?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && taskReport.statusNum === CONST.REPORT.STATUS_NUM.APPROVED
: action?.childStateNum === CONST.REPORT.STATE_NUM.APPROVED && action?.childStatusNum === CONST.REPORT.STATUS_NUM.APPROVED;

// Local state provides immediate feedback for VoiceOver after toggling the checkbox,
// since Onyx updates asynchronously and the screen reader would announce the stale state.
const [prevIsTaskCompletedFromOnyx, setPrevIsTaskCompletedFromOnyx] = useState(isTaskCompletedFromOnyx);
const [localIsTaskCompleted, setLocalIsTaskCompleted] = useState(isTaskCompletedFromOnyx);

if (prevIsTaskCompletedFromOnyx !== isTaskCompletedFromOnyx) {
setPrevIsTaskCompletedFromOnyx(isTaskCompletedFromOnyx);
setLocalIsTaskCompleted(isTaskCompletedFromOnyx);
}
Comment on lines +101 to +107
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This local state update logic now runs on every platform, not just iOS.
If completeTask/reopenTask ever fails or is queued offline and the underlying Onyx state doesn't flip, the local state might read "checked" briefly until the next re-render syncs back. Please verify this case.
And consider scoping this to shouldBreakGrouping only.

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.

Scoped. setLocalIsTaskCompleted is only called on iOS + VoiceOver. On all other platforms, isTaskCompleted resolves directly to the Onyx value.


const isTaskCompleted = shouldBreakGrouping && isScreenReaderActive ? localIsTaskCompleted : isTaskCompletedFromOnyx;

const parentReportAction = useParentReportAction(taskContextReport);
const taskAssigneeAccountID = getTaskAssigneeAccountID(taskContextReport, parentReportAction) ?? action?.childManagerAccountID ?? CONST.DEFAULT_NUMBER_ID;
const parentReport = useParentReport(taskContextReport?.reportID);
Expand All @@ -106,6 +123,7 @@ function TaskPreview({action, chatReportID, currentUserPersonalDetails, isHovere
const iconWrapperStyle = StyleUtils.getTaskPreviewIconWrapper(hasAssignee ? avatarSize : undefined);

const shouldShowGreenDotIndicator = isOpenTaskReport(taskContextReport, action) && isReportManager(taskContextReport);
const taskAccessibilityLabel = taskTitleWithoutImage ? `${translate('task.task')}: ${taskTitleWithoutImage}` : translate('task.task');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Raw html is announced here.

Image

if (isDeletedParentAction) {
return <RenderHTML html={`<deleted-action>${translate('parentReportAction.deletedTask')}</deleted-action>`} />;
}
Expand All @@ -121,6 +139,7 @@ function TaskPreview({action, chatReportID, currentUserPersonalDetails, isHovere
return (
<View style={[styles.chatItemMessage, !hasAssignee && styles.mv1]}>
<PressableWithoutFeedback
accessible={shouldBreakGrouping ? false : undefined}
onPress={() => Navigation.navigate(getReportRouteForCurrentContext({reportID: taskReportID}))}
onPressIn={() => canUseTouchScreen() && ControlSelection.block()}
onPressOut={() => ControlSelection.unblock()}
Expand All @@ -135,7 +154,7 @@ function TaskPreview({action, chatReportID, currentUserPersonalDetails, isHovere
shouldUseHapticsOnLongPress
style={[styles.flexRow, styles.justifyContentBetween, style]}
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('task.task')}
accessibilityLabel={taskAccessibilityLabel}
sentryLabel={CONST.SENTRY_LABEL.TASK.PREVIEW_CARD}
>
<View style={[styles.flex1, styles.flexRow, styles.alignItemsStart, styles.mr2]}>
Expand All @@ -145,32 +164,70 @@ function TaskPreview({action, chatReportID, currentUserPersonalDetails, isHovere
isChecked={isTaskCompleted}
disabled={!isTaskActionable}
onPress={callFunctionIfActionIsAllowed(() => {
if (shouldBreakGrouping && isScreenReaderActive) {
setLocalIsTaskCompleted((prev) => !prev);
}
if (isTaskCompleted) {
reopenTask(taskContextReport, parentReport, currentUserPersonalDetails.accountID, delegateEmail, taskReportID);
} else {
completeTask(taskContextReport, parentReport?.hasOutstandingChildTask ?? false, hasOutstandingChildTask, parentReportAction, delegateEmail, taskReportID);
}
})}
accessibilityLabel={translate('task.task')}
accessibilityLabel={taskAccessibilityLabel}
sentryLabel={CONST.SENTRY_LABEL.TASK.PREVIEW_CHECKBOX}
/>
</View>
{hasAssignee && (
<UserDetailsTooltip accountID={taskAssigneeAccountID}>
<View>
<Avatar
containerStyles={[styles.mr2, isTaskCompleted ? styles.opacitySemiTransparent : undefined]}
source={avatar}
size={avatarSize}
avatarID={taskAssigneeAccountID}
type={CONST.ICON_TYPE_AVATAR}
/>
{shouldBreakGrouping ? (
<View
accessible
accessibilityRole={CONST.ROLE.BUTTON}
accessibilityLabel={taskAccessibilityLabel}
accessibilityActions={[{name: 'activate'}]}
onAccessibilityAction={(event) => {
if (event.nativeEvent.actionName !== 'activate') {
return;
}
Navigation.navigate(getReportRouteForCurrentContext({reportID: taskReportID}));
}}
style={[styles.flex1, styles.flexRow, styles.alignItemsStart]}
>
{hasAssignee && (
<UserDetailsTooltip accountID={taskAssigneeAccountID}>
<View>
<Avatar
containerStyles={[styles.mr2, isTaskCompleted ? styles.opacitySemiTransparent : undefined]}
source={avatar}
size={avatarSize}
avatarID={taskAssigneeAccountID}
type={CONST.ICON_TYPE_AVATAR}
/>
</View>
</UserDetailsTooltip>
)}
<View style={[styles.alignSelfCenter, styles.flex1]}>
<RenderHTML html={getTaskHTML()} />
</View>
</UserDetailsTooltip>
</View>
) : (
<>
{hasAssignee && (
<UserDetailsTooltip accountID={taskAssigneeAccountID}>
<View>
<Avatar
containerStyles={[styles.mr2, isTaskCompleted ? styles.opacitySemiTransparent : undefined]}
source={avatar}
size={avatarSize}
avatarID={taskAssigneeAccountID}
type={CONST.ICON_TYPE_AVATAR}
/>
</View>
</UserDetailsTooltip>
)}
<View style={[styles.alignSelfCenter, styles.flex1]}>
<RenderHTML html={getTaskHTML()} />
</View>
</>
)}
<View style={[styles.alignSelfCenter, styles.flex1]}>
<RenderHTML html={getTaskHTML()} />
</View>
</View>
{shouldShowGreenDotIndicator && (
<View style={iconWrapperStyle}>
Expand Down
Loading
Loading