Expensify / App

Welcome to New Expensify: a complete re-imagination of financial collaboration, centered around chat. Help us build the next generation of Expensify by sharing feedback and contributing to the code.
https://new.expensify.com
MIT License
3.03k stars 2.54k forks source link

[HOLD for payment 2023-01-23] [$4000] mWeb - Copy to email/URL is not working correctly - reported by @mateusbra #8311

Closed kavimuru closed 1 year ago

kavimuru commented 2 years ago

If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!


Issue was found when executing #8195

Action Performed:

  1. Log in on New Expensify.
  2. Open a report.
  3. Type an URL and an e-mail on chat.

Expected Result:

The message "Copy e-mail to clipboard" displayed when you try to copy an email and "copy URL to clipboard" tapping URL

Actual Result:

The message "Copy to clipboard" displayed when you try to copy an email. Sometimes it shows as regular context menu

Workaround:

Unknown

Platform:

Where is this issue occurring?

Version Number: 1.1.46-0

Reproducible in staging?: y

Reproducible in production?: y (New feature)

Email or phone of affected tester (no customers):

Logs: https://stackoverflow.com/c/expensify/questions/4856

Notes/Photos/Videos:

https://user-images.githubusercontent.com/43996225/160028142-3fe96a20-6e66-4605-ae2a-618bc322f957.mp4

https://user-images.githubusercontent.com/43996225/160028204-1fe2a591-3466-430e-9802-147d0baa43ee.mp4

Expensify/Expensify Issue URL:

Issue reported by: Reported by @mateusbra https://expensify.slack.com/archives/C01GTK53T8Q/p1647474115298459

Slack conversation:

View all open jobs on GitHub Issue was found when executing #8195

Upwork Automation - Do Not Edit

parasharrajat commented 1 year ago

Raised https://expensify.slack.com/archives/C01GTK53T8Q/p1656349294099259.

marcochavezf commented 1 year ago

Responding in the thread

ahmdshrif commented 1 year ago

Proposal

it's a small problem and does not need all these changes.

Problem

the 2 events trigger ( long-press on link, long-press on the comment) the link event first and then the comment event. and both contextMenu is show at the same time but the second one will override the first one

Screen Shot 2022-07-14 at 4 16 14 PM

Solution :

just prevent event default on the first long press (that will be the child ) we already do this on native and that is why it works on native.

https://github.com/Expensify/App/blob/ce899227ca6d3823e2865a5e194b429f1263db30/src/components/PressableWithSecondaryInteraction/index.native.js#L24

changes :

on

https://github.com/Expensify/App/blob/ce899227ca6d3823e2865a5e194b429f1263db30/src/components/PressableWithSecondaryInteraction/index.js#L38

    callSecondaryInteractionWithMappedEvent(e) {
        if ((e.nativeEvent.state !== State.ACTIVE) || hasHoverSupport()) {
            return;
        }
+        e.preventDefault();

     ...
       }

Result :

https://user-images.githubusercontent.com/21364901/179004902-7fe25915-c68f-491c-80d9-04fb93369ea1.mov

cc : @parasharrajat

marcochavezf commented 1 year ago

Not overdue, we have a new proposal to be reviewed.

marcochavezf commented 1 year ago

Hi @parasharrajat, what do you think about this proposal?

parasharrajat commented 1 year ago

Checking in a hour.

parasharrajat commented 1 year ago

@ahmdshrif Does not work on Native and this issue also exists on native.

https://user-images.githubusercontent.com/24370807/179632298-2d72fca9-4eee-4055-9fda-599a3d11e344.mp4

ahmdshrif commented 1 year ago

yes, this proposal for web only because the issue says it's mWeb.

Where is this issue occurring? Mobile Web

on native, it's another root cause but I think it is worth bouns.

any way.

Proposal

( for native) the root cause is the child of LongPressGestureHandler is a text . and it need a container for it so we can use view or pressable and that will work

as we use text component for aligning we will have issues with aligning but we can solve it by customizing comment rendering.

changes :

https://github.com/Expensify/App/blob/6369e8e3136b95e03205c5bde751028734600071/src/components/PressableWithSecondaryInteraction/index.native.js#L29 replace node with pressable this will make it work but in some edge cases it's conflict with parent longPress . so we can add different longPress timeout to make child work first in all cases ( default is 400 ms ) .

 <LongPressGestureHandler
        ...
+      minDurationMs={props.inline ? 400 : 500}
    >
-        <Node 
+        <Pressable
            ref={props.forwardedRef}
            onPress={props.onPress}
            onPressIn={props.onPressIn}
            onPressOut={props.onPressOut}
            // eslint-disable-next-line react/jsx-props-no-spreading
            {...(_.omit(props, 'onLongPress'))}
        >
            {props.children}
+        </Pressable>

this change will make links work fine on native but the text algin will need more work.

I fix it by customizing the comment rendered on HTML engine and wrap comment children on view with alginContent center.

const CommentRender = props => (
    <View style={{alignContent: 'center', flexDirection: 'row'}}>
        { _.map(props.tnode.children, node => (<TNodeChildrenRenderer tnode={node} />))}
    </View>
);

full changes are here https://github.com/ahmdshrif/App/compare/main...ahmdshrif:App:fix/link-longpress ( i include support for multiLine comment also here )

cc: @parasharrajat @marcochavezf.

parasharrajat commented 1 year ago

I agree issue details are incomplete but many times it is case that only a single platform is mentioned but all issues need the be fixed on all platforms.

As the same issue occur on native too, it needs the be fixed as well.

At this time, scope of the issue is not extended so I don't see a bonus but it can be.

@kavimuru could you please add Android and iOS platform as well?

mdneyazahmad commented 1 year ago

On my android device production app (v1.1.79-17), it sometime works and sometime does not work.

cc: @parasharrajat @ahmdshrif

ahmdshrif commented 1 year ago

thanks @mdneyazahmad yes that's correct. it was random and happened on both android and ios

but if you check the last changes on my branch. it works on all cases as expected.

@parasharrajat when you test please test with the all changes on the branch I fix some issues you will find when testing. ( sub-issue (like style) can be discussed on PR later so I don't include in the proposal ) .

here is the result.

https://user-images.githubusercontent.com/21364901/179976184-05849db0-7002-40af-bdd0-c6a582b0922c.mov

parasharrajat commented 1 year ago

@ahmdshrif Could you please only suggest changes that are necessary to fix this issue in your proposal?

Change LongPressGestureHandler to Pressable.

Also, if you look back the history, you made this change to allow propagation of long press for Attachments. (Don't remember exactly but you can find it) so that should not break.

I do not understand the purpose of CommentRender. What is going on here and why? I don't HTML manipulation is necessary for this issue at all.

   const nodes = props.tnode.children[0].children;
    _.map(nodes, (node) => {
        if (node.tagName !== 'br') {
            currentLine.push(node);
            return;
        }
        lines.push(currentLine);
        currentLine = [];
    });
    if (currentLine.length > 0) {
        lines.push(currentLine);
    }
ahmdshrif commented 1 year ago

@parasharrajat yes I remember my change and I don't break it.

the simple proposal for test is this change in PressableWithSecondaryInteraction/index.native.js

  <LongPressGestureHandler
        onHandlerStateChange={(e) => {
            if (e.nativeEvent.state !== State.ACTIVE) {
                return;
            }
            e.preventDefault();
            HapticFeedback.trigger();
            props.onSecondaryInteraction(e);
        }}
+        minDurationMs={props.inline ? 400 : 500}
    >
-        <node
+        <Pressable
            ref={props.forwardedRef}
            onPress={props.onPress}
            onPressIn={props.onPressIn}
            onPressOut={props.onPressOut}
            // eslint-disable-next-line react/jsx-props-no-spreading
            {...(_.omit(props, 'onLongPress'))}
        >
            {props.children}
+        </Pressable>
-        </node>
    </LongPressGestureHandler>

and in PressableWithSecondaryInteraction/index..js

  callSecondaryInteractionWithMappedEvent(e) {
        if ((e.nativeEvent.state !== State.ACTIVE) || hasHoverSupport()) {
            return;
        }
+        e.preventDefault();

     ...
       }

this will break the text alignment (as you see in screenshots) so other code to fix this alignment. I can explain more about how it work but for now, let's get confirmation about the main fix for long-press.

Screen Shot 2022-07-20 at 4 37 33 PM

arielgreen commented 1 year ago

Reposted since the job closed https://www.upwork.com/jobs/~0106b378e281080b23

b1tjoy commented 1 year ago

PROPOSAL

Problem

We use LongPressGestureHandler to replace Pressable in PR https://github.com/Expensify/App/pull/8018, and this PR fix https://github.com/Expensify/App/issues/7462. In that issue, long press on image doesn't open the context menu, but act like short press and open image thumbnail. If we fix issue 7462 with a solution which does not replace Pressable with LongPressGestureHandler, our current issue will be fixed too.

The root cause of issue 7462 is the inner TouchableWithoutFocus's onPress handler conflicts with outer Pressable onLongPress handler, they both use react-native Gesture Responder System, and they can not coordinate with each other.

Solution

If we use RNGH's TapGestureHandler to replace TouchableOpacity, and revert PR https://github.com/Expensify/App/pull/8018, both https://github.com/Expensify/App/issues/7462 and https://github.com/Expensify/App/issues/8311 will be fixed. Because RNGH does not use Gesture Responder System of react-native, press and long press can work with each other.

Here is the solution:

  1. Revert changes made by PR https://github.com/Expensify/App/pull/8018
  2. Use TapGestureHandler in TouchableWithoutFocus.js as the following code

Replace https://github.com/Expensify/App/blob/03b953404d3fc17d03fc5635322f4e12c25afb7b/src/components/TouchableWithoutFocus.js#L39-L41 With

<TapGestureHandler
  onHandlerStateChange={(event) => {
    if (event.nativeEvent.state === State.ACTIVE) {
      this.touchableRef.blur();
      this.props.onPress();
    }
  }}
>
  <View ref={el => this.touchableRef = el} style={this.props.styles}>
    {this.props.children}
  </View>
</TapGestureHandler>

Since PR https://github.com/Expensify/App/pull/8018 was merged in 1.1.43-0, I use tag 1.1.42-6 in our repo above to verify the solution. I will post a complete proposal based on main repo later.

Screenshot

The screenshot was also taken with tag 1.1.42-6 to demonstrate the solution can fix both issues, will update with new screenshot later.

https://user-images.githubusercontent.com/103875612/182896277-acdd24ac-a956-452d-9558-a6ec26876776.mp4

parasharrajat commented 1 year ago

I don't like the idea of manipulating the HTML whose rendering should be handled by the render-html. Also especially when the HTML is prepared by us. This just seems like a hack. So if a solution requires hacks( it is already out of the question).

No one has responded to the discussion. I will bump this again.

@arielgreen We can cap the price of this issue at whatever it is now until the discussion is concluded.

b1tjoy commented 1 year ago

UPDATE

This is a update of proposal https://github.com/Expensify/App/issues/8311#issuecomment-1205476421, change the solution code based on the latest main branch.

PROPOSAL

Problem

We use LongPressGestureHandler to replace Pressable in PR https://github.com/Expensify/App/pull/8018, and this PR fix https://github.com/Expensify/App/issues/7462. In that issue, long press on image doesn't open the context menu, but act like short press and open image thumbnail. If we fix issue 7462 with a solution which does not replace Pressable with LongPressGestureHandler, our current issue will be fixed too.

The root cause of issue 7462 is the inner PressableWithoutFocus's onPress handler conflicts with outer Pressable onLongPress handler, they both use react-native Gesture Responder System, and they can not coordinate with each other.

Solution

If we revert PR https://github.com/Expensify/App/pull/8018, and use RNGH's TapGestureHandler to replace Pressable, both https://github.com/Expensify/App/issues/7462 and https://github.com/Expensify/App/issues/8311 will be fixed. Because RNGH does not use Gesture Responder System of react-native, inner press handler and outer long press handler can work with each other.

  1. Revert PR https://github.com/Expensify/App/pull/8018

https://github.com/Expensify/App/blob/97eb8bb0019068b484035a17a64e4ecdeb147092/src/components/PressableWithSecondaryInteraction/index.js

diff --git a/src/components/PressableWithSecondaryInteraction/index.js b/src/components/PressableWithSecondaryInteraction/index.js
index a6b21a59e..78168a9f4 100644
--- a/src/components/PressableWithSecondaryInteraction/index.js
+++ b/src/components/PressableWithSecondaryInteraction/index.js
@@ -1,7 +1,6 @@
 import _ from 'underscore';
 import React, {Component} from 'react';
 import {Pressable} from 'react-native';
-import {LongPressGestureHandler, State} from 'react-native-gesture-handler';
 import * as pressableWithSecondaryInteractionPropTypes from './pressableWithSecondaryInteractionPropTypes';
 import styles from '../../styles/styles';
 import hasHoverSupport from '../../libs/hasHoverSupport';
@@ -12,7 +11,6 @@ import hasHoverSupport from '../../libs/hasHoverSupport';
 class PressableWithSecondaryInteraction extends Component {
     constructor(props) {
         super(props);
-        this.callSecondaryInteractionWithMappedEvent = this.callSecondaryInteractionWithMappedEvent.bind(this);
         this.executeSecondaryInteractionOnContextMenu = this.executeSecondaryInteractionOnContextMenu.bind(this);
     }

@@ -27,27 +25,6 @@ class PressableWithSecondaryInteraction extends Component {
         this.pressableRef.removeEventListener('contextmenu', this.executeSecondaryInteractionOnContextMenu);
     }

-    /**
-     * @param {Object} e
-     */
-    callSecondaryInteractionWithMappedEvent(e) {
-        if ((e.nativeEvent.state !== State.ACTIVE) || hasHoverSupport()) {
-            return;
-        }
-
-        // Map gesture event to normal Responder event
-        const {
-            absoluteX, absoluteY, locationX, locationY,
-        } = e.nativeEvent;
-        const mapEvent = {
-            ...e,
-            nativeEvent: {
-                ...e.nativeEvent, pageX: absoluteX, pageY: absoluteY, x: locationX, y: locationY,
-            },
-        };
-        this.props.onSecondaryInteraction(mapEvent);
-    }
-
     /**
      * @param {contextmenu} e - A right-click MouseEvent.
      * https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event
@@ -65,19 +42,23 @@ class PressableWithSecondaryInteraction extends Component {

         // On Web, Text does not support LongPress events thus manage inline mode with styling instead of using Text.
         return (
-            <LongPressGestureHandler onHandlerStateChange={this.callSecondaryInteractionWithMappedEvent}>
-                <Pressable
-                    style={this.props.inline && styles.dInline}
-                    onPressIn={this.props.onPressIn}
-                    onPressOut={this.props.onPressOut}
-                    onPress={this.props.onPress}
-                    ref={el => this.pressableRef = el}
-                    // eslint-disable-next-line react/jsx-props-no-spreading
-                    {...defaultPressableProps}
-                >
-                    {this.props.children}
-                </Pressable>
-            </LongPressGestureHandler>
+            <Pressable
+                style={this.props.inline && styles.dInline}
+                onPressIn={this.props.onPressIn}
+                onLongPress={(e) => {
+                    if (hasHoverSupport()) {
+                        return;
+                    }
+                    this.props.onSecondaryInteraction(e);
+                }}
+                onPressOut={this.props.onPressOut}
+                onPress={this.props.onPress}
+                ref={el => this.pressableRef = el}
+                // eslint-disable-next-line react/jsx-props-no-spreading
+                {...defaultPressableProps}
+            >
+                {this.props.children}
+            </Pressable>
         );
     }
 }

https://github.com/Expensify/App/blob/main/src/components/PressableWithSecondaryInteraction/index.native.js

diff --git a/src/components/PressableWithSecondaryInteraction/index.native.js b/src/components/PressableWithSecondaryInteraction/index.native.js
index efb68e52e..8c6c145cc 100644
--- a/src/components/PressableWithSecondaryInteraction/index.native.js
+++ b/src/components/PressableWithSecondaryInteraction/index.native.js
@@ -1,7 +1,6 @@
 import _ from 'underscore';
 import React, {forwardRef} from 'react';
 import {Pressable} from 'react-native';
-import {LongPressGestureHandler, State} from 'react-native-gesture-handler';
 import * as pressableWithSecondaryInteractionPropTypes from './pressableWithSecondaryInteractionPropTypes';
 import Text from '../Text';
 import HapticFeedback from '../../libs/HapticFeedback';
@@ -16,28 +15,21 @@ const PressableWithSecondaryInteraction = (props) => {
     // Use Text node for inline mode to prevent content overflow.
     const Node = props.inline ? Text : Pressable;
     return (
-        <LongPressGestureHandler
-            onHandlerStateChange={(e) => {
-                if (e.nativeEvent.state !== State.ACTIVE) {
-                    return;
-                }
+        <Node
+            ref={props.forwardedRef}
+            onPress={props.onPress}
+            onPressIn={props.onPressIn}
+            onLongPress={(e) => {
                 e.preventDefault();
                 HapticFeedback.trigger();
                 props.onSecondaryInteraction(e);
             }}
+            onPressOut={props.onPressOut}
+        // eslint-disable-next-line react/jsx-props-no-spreading
+            {...(_.omit(props, 'onLongPress'))}
         >
-            <Node
-                ref={props.forwardedRef}
-                onPress={props.onPress}
-                onPressIn={props.onPressIn}
-                onPressOut={props.onPressOut}
-            // eslint-disable-next-line react/jsx-props-no-spreading
-                {...(_.omit(props, 'onLongPress'))}
-            >
-                {props.children}
-            </Node>
-        </LongPressGestureHandler>
-
+            {props.children}
+        </Node>
     );
 };
  1. Use TapGestureHandler to handle press event in https://github.com/Expensify/App/blob/main/src/components/PressableWithoutFocus.js
diff --git a/src/components/PressableWithoutFocus.js b/src/components/PressableWithoutFocus.js
index 35a9cce99..87cf6eb1c 100644
--- a/src/components/PressableWithoutFocus.js
+++ b/src/components/PressableWithoutFocus.js
@@ -1,6 +1,7 @@
 import React from 'react';
-import {Pressable} from 'react-native';
+import {View} from 'react-native';
 import PropTypes from 'prop-types';
+import {State, TapGestureHandler} from 'react-native-gesture-handler';

 const propTypes = {
     /** Element that should be clickable  */
@@ -33,16 +34,21 @@ class PressableWithoutFocus extends React.Component {
         this.pressAndBlur = this.pressAndBlur.bind(this);
     }

-    pressAndBlur() {
+    pressAndBlur(event) {
+        if (event.nativeEvent.state !== State.ACTIVE) {
+            return;
+        }
         this.pressableRef.blur();
         this.props.onPress();
     }

     render() {
         return (
-            <Pressable onPress={this.pressAndBlur} ref={el => this.pressableRef = el} style={this.props.styles}>
-                {this.props.children}
-            </Pressable>
+            <TapGestureHandler onHandlerStateChange={this.pressAndBlur}>
+                <View ref={el => this.pressableRef = el} style={this.props.styles}>
+                    {this.props.children}
+                </View>
+            </TapGestureHandler>
         );
     }
 }

Screenshot for issue 7462

Long press on image opens context menu, press on image opens attachment model.

iOS native screenshot

https://user-images.githubusercontent.com/103875612/183334489-eeb890cc-3c37-4544-8532-ec4451e53323.mp4

Android native screenshot

https://user-images.githubusercontent.com/103875612/183339358-af324fcf-fbb3-4a3d-ad8d-24d12bc52a25.mp4

mWeb screenshot

https://user-images.githubusercontent.com/103875612/183388873-263bda0e-75b4-4574-9894-5d3d1631cdd0.mp4

Screenshot for issue 8311

Long press on email/URL opens Copy to clipboard context menu, long press on message opens full context menu.

iOS native screenshot

https://user-images.githubusercontent.com/103875612/183334520-f9d26a97-e408-4603-a55a-9d66e9e771f2.mp4

Android native screenshot

https://user-images.githubusercontent.com/103875612/183339381-efa8f4b9-63d1-4834-987c-2f32b4ab9bce.mp4

mWeb screenshot

https://user-images.githubusercontent.com/103875612/183388904-e8766be9-109d-492e-8a7e-ac9fe9bbf53c.mp4

parasharrajat commented 1 year ago

Interesting solution. I will check it today.

ahmdshrif commented 1 year ago

@parasharrajat we already have this alignment issue and if we can avoid it now it will appear in future changes.

I know you refactor this file in your PR but take a look at this comment. https://github.com/Expensify/App/blob/97eb8bb0019068b484035a17a64e4ecdeb147092/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js#L58

the issue is htmlRender map comment container to text and text can be use to align the text if all child is text only not text inside any container. for example : this will be aligned

<Text >
        <Text >text 1 </Text>
        <Text >text 2 </Text>
</Text>

but this will not be aligned in android

<Text >
       <View>
        <Text>text 1 </Text>
      </View>
      <Text>text 2</Text>
</Text>

check this snack on android

and the second example happened with us in this case and on the text link case.

I think we need to get out of this issue and have some flexibility to add wherever we need on the comment. we just need to change the container of the comment text to a view component not Text with some alignment style . as I post before.

and HTMLRender manages this part so I don't think if we add some customize to the container and keep HTMLRender to managing the child is a bad change.

Here is the snake result on android: Screenshot_20220808-095615_Expo Go.jpg

parasharrajat commented 1 year ago

Sorry, I do not get all of what you have said above. But overall View inside Text does not support inline styles on RN nor does it on RNRH. If you want to fix these Alignment issues then the best way would be to send a PR to RNRH and change the structure as you like.

ahmdshrif commented 1 year ago

but this issue appears only because we customize some tags. so I don't think we need to change the lib structure to meet our customize tags .maybe it will conflict with someone else customization.

arielgreen commented 1 year ago

not overdue

songlang1994 commented 1 year ago

Proposal

LongPressGestureHandler was introduced in PR https://github.com/Expensify/App/pull/8018 which tried to resolve the nested Pressable issue.

import {Pressable} from 'react-native'

<Pressable onPress={} onLongPress={}>  // <-- onPress & onLongPress won't be triggered if user tap the inner Pressable
    <Pressable onPress={}></Pressable>
</Pressable>

We can wrap Pressable to make onPress and onLongPress events bubble up, just like in a normal browser environment. Then replace all Pressable (from react-native) with the new wrapped version and everything should be work as expected.

Verified on mWeb and Android.

// File: src/components/Pressable.js
import _ from 'underscore';
import React, {useContext, useImperativeHandle, useRef} from 'react';
import {Pressable as RNPressable} from 'react-native';

const Context = React.createContext(null);

// Accepts the same parameters as react-native `Pressable` but `onPress` & `onLongPress` will bubble up
// to the root `Pressable` unless `event.stopPropagation()` is called.
function Pressable(props) {
    const defaultPressableProps = _.omit(props, ['onPress', 'onLongPress', 'children']);

    const parentRef = useContext(Context);
    const ref = useRef(null);

    const onPressProxy = (event) => {
        if (props.onPress) {
            props.onPress(event);
        }

        if (event.isPropagationStopped()) {
            return;
        }
        setTimeout(() => {
            if (!(parentRef && parentRef.current)) {
                return;
            }
            parentRef.current.onPress(event);
        }, 0);
    };

    const onLongPressProxy = (event) => {
        if (props.onLongPress) {
            props.onLongPress(event);
        }

        if (event.isPropagationStopped()) {
            return;
        }
        setTimeout(() => {
            if (!(parentRef && parentRef.current)) {
                return;
            }
            parentRef.current.onLongPress(event);
        }, 0);
    };

    useImperativeHandle(ref, () => ({
        onPress: onPressProxy,
        onLongPress: onLongPressProxy,
    }));

    return (
        <RNPressable
            ref={props.forwardedRef}
            // eslint-disable-next-line react/jsx-props-no-spreading
            {...defaultPressableProps}
            onPress={onPressProxy}
            onLongPress={onLongPressProxy}
        >
            <Context.Provider value={ref}>
                {props.children}
            </Context.Provider>
        </RNPressable>
    );
}

Pressable.propTypes = RNPressable.propTypes;
export default React.forwardRef((props, ref) => (
    // eslint-disable-next-line react/jsx-props-no-spreading
    <Pressable {...props} forwardedRef={ref} />
));
parasharrajat commented 1 year ago

@songlang1994 Can't we just do it for PressableWithSecondaryInteraction? I don't think that modifying the native Pressable behavior is a good idea. it could have unknown side effects. They must have planned it this way for some reason.

Side-Note: we already have a proposal above https://github.com/Expensify/App/issues/8311#issuecomment-1207653523 which looks promising to me. I am yet to review that.

songlang1994 commented 1 year ago

Can't we just do it for PressableWithSecondaryInteraction?

At least we need to do it for PressableWithSecondaryInteraction and PressableWithoutFocus (used by ImageRenderer). Another way is creating a separated PressableWithoutFocus2 for ImageRender If you're worried about directly modifing PressableWithout too much scope.

The above proposal https://github.com/Expensify/App/issues/8311#issuecomment-1207653523 says to use a different gesture handler component to handle the nesting case. I don't think it's general enough. It can only handle 2 level nesting and the inner Pressable can not stop the event propagation.

<TapGestureHandler> <-- react-native-gesture-handler
  <Pressable></Pressable> <-- react-native
</TapGestureHandler>

Update

If you don't want to change the default behavior of react-native Pressable, I recommend renaming the new component to NestablePressable and using it only in necessary components.

marcochavezf commented 1 year ago

not overdue, hi @parasharrajat what do you think about the NestablePressable strategy that @songlang1994 mentioned in the last comment? Seems that a different nested Pressable is a good idea to prevent events bubble up.

parasharrajat commented 1 year ago

So, the internal discussion is long dead.

Overall, the proposal looks like a generic solution that can be applied in many places but it makes use of a few things that we do not ready to adopt vastly. Context API and use of setTimout & Hooks. But it seems like there is a proper reason for using the setTimeout here. IMO, bubbling in the next tick.

Meanwhile @songlang1994 can you drop the use of hooks in your proposal?

I will have to discuss this internally with other engineers before I make any choices.

songlang1994 commented 1 year ago

@parasharrajat The Context API is required but the Hooks API is not. We can use a class component to achieve the same purpose. Verified on mWeb and Android.

// File: src/components/NestablePressable.js

import _ from 'underscore';
import React from 'react';
import {Pressable as RNPressable} from 'react-native';

const Context = React.createContext(null);

class NestablePressable extends React.Component {
    constructor(props) {
        super(props);
        this.onPress = this.onPress.bind(this);
        this.onLongPress = this.onLongPress.bind(this);
    }

    onPress(event) {
        this.callEventHandler('onPress', event);
    }

    onLongPress(event) {
        this.callEventHandler('onLongPress', event);
    }

    getAncestorComponent() {
        return this.context;
    }

    callEventHandler(name, event) {
        if (this.props[name]) {
            this.props[name](event);
        }

        if (event.isPropagationStopped()) {
            return;
        }

        setTimeout(() => {
            if (!this.getAncestorComponent()) {
                return;
            }
            this.getAncestorComponent()[name](event);
        }, 0);
    }

    render() {
        const defaultPressableProps = _.omit(this.props, ['onPress', 'onLongPress', 'children']);

        return (
            <RNPressable
                ref={this.props.forwardedRef}
                // eslint-disable-next-line react/jsx-props-no-spreading
                {...defaultPressableProps}
                onPress={this.onPress}
                onLongPress={this.onLongPress}
            >
                <Context.Provider value={this}>
                    {this.props.children}
                </Context.Provider>
            </RNPressable>
        );
    }
}

NestablePressable.contextType = Context;
export default React.forwardRef((props, ref) => (
    // eslint-disable-next-line react/jsx-props-no-spreading
    <NestablePressable {...props} forwardedRef={ref} />
));
parasharrajat commented 1 year ago

Thanks, I will open a discussion shortly for this.

melvin-bot[bot] commented 1 year ago

@marcochavezf, @parasharrajat, @arielgreen Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!

parasharrajat commented 1 year ago

Checking it asap. This is in top 5 on my list.

arielgreen commented 1 year ago

@parasharrajat do you have an update?

melvin-bot[bot] commented 1 year ago

@marcochavezf, @parasharrajat, @arielgreen Whoops! This issue is 2 days overdue. Let's get this updated quick!

parasharrajat commented 1 year ago

Opening a discussion today and share the link here.

parasharrajat commented 1 year ago

@b1tjoy @songlang1994 Are you guys on our Slack? if so, please share your handles so that I can tag you in the discussion. Feel free to add your points if I missed any.

https://expensify.slack.com/archives/C01GTK53T8Q/p1662392381977999

b1tjoy commented 1 year ago

@parasharrajat I am not on Slack, could you please invite me to join the Slack Channel. I sent email to contributors@expensify.com as https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md requested long time ago but didn't get the invitation. My email address is hicfpx@gmail.com. Thank you!

parasharrajat commented 1 year ago

cc: @arielgreen https://github.com/Expensify/App/issues/8311#issuecomment-1237242322

songlang1994 commented 1 year ago

I'm in the same situation as above. Could you please invite me too?

Email: songlang1994@outlook.com

arielgreen commented 1 year ago

@songlang1994 @b1tjoy please link your Upwork profile as well. You can access it by clicking your avatar in the upper right corner then clicking your name in the upper right corner, it'll look like: https://www.upwork.com/freelancers/%5C%5C%5C%5C%5C%5C%5C%5C~019c6cd28a0575c222

b1tjoy commented 1 year ago

@arielgreen Upwork profile DELETED

arielgreen commented 1 year ago

@b1tjoy you should be added shortly

songlang1994 commented 1 year ago

@arielgreen Upwork profile: https://www.upwork.com/freelancers/~018c1e5b7b47a9c2c0

arielgreen commented 1 year ago

done

parasharrajat commented 1 year ago

Quick update, the discussion has received some feedback. I will ping on the thread to get more ideas in.

songlang1994 commented 1 year ago

@parasharrajat I read the discussion that you were concerned about using setTimeout, so I refactored NestablePressable to invoke all event handlers in the gesture handler component (a.k.a the inner component). This avoids using setTimeout and make the code cleaner.

// File: src/components/NestablePressable.js

import _ from 'underscore';
import React from 'react';
import {Pressable as RNPressable} from 'react-native';

const Context = React.createContext(null);

class NestablePressable extends React.Component {
    constructor(props) {
        super(props);
        this.dispatchPressEvent = this.dispatchPressEvent.bind(this);
        this.dispatchLongPressEvent = this.dispatchLongPressEvent.bind(this);
    }

    getAncestorComponent() {
        return this.context;
    }

    dispatchPressEvent(event) {
        this.callEventHandlers('onPress', event);
    }

    dispatchLongPressEvent(event) {
        this.callEventHandlers('onLongPress', event);
    }

    callEventHandlers(name, event) {
        // eslint-disable-next-line consistent-this
        let target = this;

        while (target) {
            try {
                const handler = target.props[name];
                if (handler) {
                    handler(event);
                }
            } catch (err) {
                console.error(err);
            }
            if (event.isPropagationStopped()) {
                break;
            }
            target = target.getAncestorComponent();
        }
    }

    render() {
        const defaultPressableProps = _.omit(this.props, ['onPress', 'onLongPress', 'children']);

        return (
            <RNPressable
                ref={this.props.forwardedRef}
                // eslint-disable-next-line react/jsx-props-no-spreading
                {...defaultPressableProps}
                onPress={this.dispatchPressEvent}
                onLongPress={this.dispatchLongPressEvent}
            >
                <Context.Provider value={this}>
                    {this.props.children}
                </Context.Provider>
            </RNPressable>
        );
    }
}

NestablePressable.propTypes = RNPressable.propTypes;
NestablePressable.contextType = Context;
export default React.forwardRef((props, ref) => (
    // eslint-disable-next-line react/jsx-props-no-spreading
    <NestablePressable {...props} forwardedRef={ref} />
));
parasharrajat commented 1 year ago

Great. I think you should point out this on the discussion so that others are aware of changes to the proposal.

marcochavezf commented 1 year ago

Commenting on the slack discussion.

marcochavezf commented 1 year ago

Not overdue, discussing on slack an alternative solution

songlang1994 commented 1 year ago

Updated Proposal

According to the discussion in previous several days, we decided to change the popover menu behavior to “App should also show generic options at the same time when the user aux-click/long-press on a link/email/image element”. This proposal is based on my previous comment (https://github.com/Expensify/App/issues/8311#issuecomment-1239023718) to implements the new behavior.

Video demos

Click to watch the videos. https://user-images.githubusercontent.com/19236594/190606528-9079c9f7-5193-4354-96af-1d0e9e88a69c.mp4 https://user-images.githubusercontent.com/19236594/190607430-65bcfb83-9a40-4377-8e07-ae76b22c4363.mp4

Steps

  1. Create a new file src/pages/home/report/ReportActionItemContext.js
import {createContext} from 'react';
export default createContext(null);
  1. In src/pages/home/report/ReportActionItem.js, refine showPopover and pass it through ReportActionItemContext. All descendant elements need to show the popover through ReportActionItem.showPopover.
import ReportActionItemContext from './ReportActionItemContext';

class ReportActionItem extends Component {
    //...

    /*
    showPopover(event) {
        // Block menu on the message being Edited
        if (this.props.draftMessage) {
            return;
        }
        const selection = SelectionScraper.getCurrentSelection();
        ReportActionContextMenu.showContextMenu(
            ContextMenuActions.CONTEXT_MENU_TYPES.REPORT_ACTION,
            event,
            selection,
            this.popoverAnchor,
            this.props.reportID,
            this.props.action,
            this.props.draftMessage,
            this.checkIfContextMenuActive,
            this.checkIfContextMenuActive,
        );
    } */

    showPopover(event, type, selection, anchor) {
        // Block menu on the message being Edited
        if (this.props.draftMessage) {
            return;
        }
        ReportActionContextMenu.showContextMenu(
            type || ContextMenuActions.CONTEXT_MENU_TYPES.REPORT_ACTION,
            event,
            selection || SelectionScraper.getCurrentSelection(),
            anchor || this.popoverAnchor,
            this.props.reportID,
            this.props.action,
            this.props.draftMessage,
            this.checkIfContextMenuActive,
            this.checkIfContextMenuActive,
        );
    }

    // ...
    render() {
        return (
            <PressableWithSecondaryInteraction>
                <ReportActionItemContext.Provider value={{showPopover: this.showPopover}}>
                    // ...
                </ReportActionItemContext.Provider>
            </PressableWithSecondaryInteraction>
        )
    }
}
  1. In src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js, consume showPopover from the context.
import ReportActionItemContext from '../../pages/home/report/ReportActionItemContext';

// In order to reduce the sample code I used the hook API. It can be completely replaced by class component.
const BaseAnchorForCommentsOnly = (props) => {
    // ...
    const context = useContext(ReportActionItemContext);

    return (
        <PressableWithSecondaryInteraction
            onSecondaryInteraction={(event) => {
                if (!context) {
                    return;
                }
                context.showPopover(
                    event,
                    Str.isValidEmail(props.displayName) ? ContextMenuActions.CONTEXT_MENU_TYPES.EMAIL : ContextMenuActions.CONTEXT_MENU_TYPES.LINK,
                    props.href,
                    lodashGet(linkRef, 'current'),
                );
            }}
        >
            // ...
        </PressableWithSecondaryInteraction>
    );
};
  1. In src/pages/home/report/ContextMenu/ContextMenuActions.js, change shouldShow property.

    --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js
    +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js
    @@ -88,8 +88,7 @@ export default [
         icon: Expensicons.Clipboard,
         successTextTranslateKey: 'reportActionContextMenu.copied',
         successIcon: Expensicons.Checkmark,
    -        shouldShow: (type, reportAction) => (type === CONTEXT_MENU_TYPES.REPORT_ACTION
    -            && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU
    +        shouldShow: (type, reportAction) => (reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU
             && !ReportUtils.isReportMessageAttachment(_.last(lodashGet(reportAction, ['message'], [{}])))),
    
         // If return value is true, we switch the `text` and `icon` on
    @@ -132,7 +131,7 @@ export default [
    
             // Only hide the copylink menu item when context menu is opened over img element.
             const isAttachmentTarget = lodashGet(menuTarget, 'tagName') === 'IMG' && isAttachment;
    -            return Permissions.canUseCommentLinking(betas) && type === CONTEXT_MENU_TYPES.REPORT_ACTION && !isAttachmentTarget;
    +            return Permissions.canUseCommentLinking(betas) && !isAttachmentTarget;
         },
         onPress: (closePopover, {reportAction, reportID}) => {
             Environment.getEnvironmentURL()
    @@ -149,7 +148,7 @@ export default [
         textTranslateKey: 'reportActionContextMenu.markAsUnread',
         icon: Expensicons.Mail,
         successIcon: Expensicons.Checkmark,
    -        shouldShow: type => type === CONTEXT_MENU_TYPES.REPORT_ACTION,
    +        shouldShow: () => true,
         onPress: (closePopover, {reportAction, reportID}) => {
             Report.markCommentAsUnread(reportID, reportAction.sequenceNumber);
             if (closePopover) {
    @@ -162,9 +161,7 @@ export default [
     {
         textTranslateKey: 'reportActionContextMenu.editComment',
         icon: Expensicons.Pencil,
    -        shouldShow: (type, reportAction) => (
    -            type === CONTEXT_MENU_TYPES.REPORT_ACTION && ReportUtils.canEditReportAction(reportAction)
    -        ),
    +        shouldShow: (type, reportAction) => ReportUtils.canEditReportAction(reportAction),
         onPress: (closePopover, {reportID, reportAction, draftMessage}) => {
             const editAction = () => Report.saveReportActionDraft(
                 reportID,
    @@ -186,8 +183,7 @@ export default [
     {
         textTranslateKey: 'reportActionContextMenu.deleteComment',
         icon: Expensicons.Trashcan,
    -        shouldShow: (type, reportAction) => type === CONTEXT_MENU_TYPES.REPORT_ACTION
    -            && ReportUtils.canDeleteReportAction(reportAction),
    +        shouldShow: (type, reportAction) => ReportUtils.canDeleteReportAction(reportAction),
         onPress: (closePopover, {reportID, reportAction}) => {
             if (closePopover) {
                 // Hide popover, then call showDeleteConfirmModal
melvin-bot[bot] commented 1 year ago

@marcochavezf, @parasharrajat, @arielgreen Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!