iteratehq/react-native-iterate

View on GitHub
src/components/Prompt/index.tsx

Summary

Maintainability
A
1 hr
Test Coverage
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import {
  Animated,
  Appearance,
  Image,
  StyleSheet,
  Text,
  TouchableHighlight,
  View,
  PanResponder,
} from 'react-native';
import { connect } from 'react-redux';

import { Colors, Themes } from '../../constants';
import type { State } from '../../redux';
import { showSurvey } from '../../redux';
import type { EdgeInsets } from '../../types';
import type { Survey } from '../../types';

import PromptButton from './Button';
import type { Dispatch } from '../../iterate';
import Iterate from '../../iterate';
import markdown from '../../markdown';
import { InteractionEvents } from '../../interaction-events';
import type { InteractionEventSource } from '../../interaction-events';

type Props = {
  dispatchShowSurvey: (Survey: Survey) => void;
  onDismiss: (source: InteractionEventSource) => void;
  safeAreaInsets: EdgeInsets;
  survey?: Survey;
};

const ANIMATION_DURATION = 300;
const DISMISSED_POSITION = 500;
const DISPLAYED_POSITION = 0;

const Prompt: (Props: Props) => JSX.Element = ({
  dispatchShowSurvey,
  onDismiss,
  safeAreaInsets,
  survey,
}) => {
  const promptAnimation = useRef(
    new Animated.Value(DISMISSED_POSITION)
  ).current;

  const panResponder = useMemo(
    () =>
      PanResponder.create({
        onStartShouldSetPanResponder: () => true,
        onPanResponderMove: (_, gestureState) => {
          promptAnimation.setValue(
            Math.max(DISPLAYED_POSITION, DISPLAYED_POSITION + gestureState.dy)
          );
        },
        onPanResponderRelease: (_, gesture) => {
          const shouldDismiss = gesture.vy > DISPLAYED_POSITION;
          Animated.spring(promptAnimation, {
            toValue: shouldDismiss ? DISMISSED_POSITION : DISPLAYED_POSITION,
            velocity: gesture.vy,
            tension: 2,
            friction: 8,
            useNativeDriver: true,
          }).start(({ finished }) => {
            if (finished && shouldDismiss) {
              onDismiss('prompt');
            }
          });
        },
      }),
    [onDismiss, promptAnimation]
  );

  useEffect(() => {
    Animated.timing(promptAnimation, {
      toValue: DISPLAYED_POSITION,
      duration: ANIMATION_DURATION,
      useNativeDriver: true,
    }).start();
  }, [promptAnimation]);

  const onDismissAnimated = useCallback(() => {
    Animated.timing(promptAnimation, {
      toValue: DISMISSED_POSITION,
      duration: ANIMATION_DURATION,
      useNativeDriver: true,
    }).start();

    setTimeout(() => {
      onDismiss('prompt');
    }, ANIMATION_DURATION);
  }, [onDismiss, promptAnimation]);

  const showSurveyButtonClicked = useCallback(() => {
    Animated.timing(promptAnimation, {
      toValue: DISMISSED_POSITION,
      duration: ANIMATION_DURATION,
      useNativeDriver: true,
    }).start();

    setTimeout(() => {
      if (survey != null) {
        dispatchShowSurvey(survey);
        InteractionEvents.SurveyDisplayed(survey);
      }
    }, ANIMATION_DURATION);
  }, [dispatchShowSurvey, promptAnimation, survey]);

  const theme = Appearance.getColorScheme();
  const promptBackgroundColor =
    theme === Themes.Dark ? Colors.LightBlack : Colors.White;
  const promptTextColor = theme === Themes.Dark ? Colors.White : Colors.Black;
  const shadowOpacity = theme === Themes.Dark ? 0.8 : 0.4;

  const paddingBottom = safeAreaInsets.bottom > 0 ? safeAreaInsets.bottom : 20;

  const promptTextStyle = [
    styles.promptText,
    {
      color: promptTextColor,
    },
    // To correctly render markdown, only set the fontFamily if it's not null
    Iterate.surveyTextFont?.postscriptName != null
      ? {
          fontFamily: Iterate.surveyTextFont?.postscriptName,
        }
      : null,
  ];

  return (
    <Animated.View
      style={{
        ...styles.promptContainer,
        transform: [
          {
            translateY: promptAnimation,
          },
        ],
      }}
      {...panResponder.panHandlers}
    >
      <View
        style={[
          styles.prompt,
          {
            backgroundColor: promptBackgroundColor,
            shadowOpacity: shadowOpacity,
          },
        ]}
      >
        <View style={{ paddingBottom }}>
          <CloseButton onPress={onDismissAnimated} />
          {markdown.Render(survey?.prompt?.message ?? '', {
            body: [
              promptTextStyle,
              {
                marginBottom: styles.promptText.marginBottom - 7, // Account for the bottom margin in the last paragraph
              },
            ],
            paragraph: {
              justifyContent: 'center',
              marginTop: 0,
              marginBottom: 7,
            },
            link: {
              textDecorationLine: 'none',
              color: survey?.color ?? '#7457be',
            },
          }) || (
            <Text style={promptTextStyle}>{survey?.prompt?.message ?? ''}</Text>
          )}
          <PromptButton
            text={`${survey?.prompt?.button_text || ''}`}
            color={`${survey?.color || '#7457be'}`}
            colorDark={survey?.color_dark}
            onPress={showSurveyButtonClicked}
          />
        </View>
      </View>
    </Animated.View>
  );
};

const styles = StyleSheet.create({
  promptContainer: {
    zIndex: 2,
    width: '100%',
    position: 'absolute',
    bottom: 0,
    display: 'flex',
    alignItems: 'center',
  },
  prompt: {
    borderTopLeftRadius: 10,
    borderTopRightRadius: 10,
    elevation: 20,
    shadowColor: '#000000',
    shadowRadius: 4,
    shadowOffset: {
      width: 0,
      height: 2,
    },
    maxWidth: 420,
    width: '100%',
  },
  promptText: {
    fontSize: 16,
    marginLeft: 40,
    marginRight: 40,
    marginTop: 24,
    marginBottom: 16,
    lineHeight: 23,
    textAlign: 'center',
  },
});

const CloseButton = ({ onPress }: { onPress: () => void }) => {
  const theme = Appearance.getColorScheme();
  const backgroundColor =
    theme === Themes.Dark ? Colors.LightBlack : Colors.Grey;

  return (
    <TouchableHighlight
      style={[closeButtonStyles.closeButton, { backgroundColor }]}
      onPress={onPress}
    >
      <Image source={require('./images/close.png')} />
    </TouchableHighlight>
  );
};

const closeButtonStyles = StyleSheet.create({
  closeButton: {
    borderRadius: 999,
    position: 'absolute',
    padding: 7,
    top: 8,
    right: 8,
  },
});

const mapStateToProps = ({ safeAreaInsets, survey }: State) => ({
  safeAreaInsets,
  survey,
});

const mapDispatchToProps = (dispatch: Dispatch) => ({
  dispatchShowSurvey: (survey: Survey) => {
    dispatch(showSurvey(survey));
  },
});

export default connect(mapStateToProps, mapDispatchToProps)(Prompt);