jfilter/frag-den-staat-app

View on GitHub
src/components/screens/FoiRequestDetails/index.js

Summary

Maintainability
F
3 days
Test Coverage
import {
  ActivityIndicator,
  Linking,
  Platform,
  Share,
  Text,
  TouchableHighlight,
  View,
  StyleSheet,
} from 'react-native';
import { Divider, Icon } from 'react-native-elements';
import { NavigationActions } from 'react-navigation';
import Accordion from 'react-native-collapsible/Accordion';
import PropTypes from 'prop-types';
import React from 'react';
import moment from 'moment';
import Hyperlink from 'react-native-hyperlink';

import { ORIGIN } from '../../../globals';
import { breakLongWords } from '../../../utils/strings';
import { getPrintableStatus, jurisdictionNameFromUrl } from '../../../utils';
import {
  greyLight,
  primaryColor,
  primaryColorLight,
} from '../../../globals/colors';
import { spaceMore } from '../../../globals/content';
import { styles } from './styles';
import { styles as tableStyles } from '../../library/Table/styles';
import BlankContainer from '../../library/BlankContainer';
import BodyText from '../../library/BodyText';
import Heading from '../../library/Heading';
import I18n from '../../../i18n';
import Link from '../../library/Link';
import NavBarIcon from '../../foiRequests/NavBarIcon';
import StandardButton from '../../library/StandardButton';
import SubHeading from '../../library/SubHeading';
import Table from '../../library/Table';
import FollowingIcon from '../../../containers/foiRequests/FollowingIcon';
import FollowingNumber from '../../../containers/foiRequests/FollowingNumber';

const stylesTouchableFlat = StyleSheet.flatten(styles.touchable);
const stylesMsgHeaderFlat = StyleSheet.flatten(styles.msgHeader);

class FoiRequestDetails extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      escalatedPublicBodyName: null,
      fetchingEscaltedPublicBody: false,
      sections: [0],
    };
  }

  async componentDidMount() {
    const locale = I18n.currentLocale().substring(0, 2);
    if (locale === 'de') {
      moment.locale('de');
    } else {
      moment.locale('en');
    }

    const { fetchSingleFoiRequest } = this.props;
    if (fetchSingleFoiRequest !== null && fetchSingleFoiRequest !== undefined)
      fetchSingleFoiRequest(this.props.request.id);
  }

  // used to determine the correct height when expanding a message
  itemHeights = new Map();
  scrollToYOffset = 0;

  _onLayout = (event, index) => {
    event.persist(); // to use values later on
    this.itemHeights.set(index, event.nativeEvent.layout.height);
  };

  _renderMessageHeader = (msg, index) => (
    <View
      key={msg.key}
      style={[tableStyles.row, styles.msgHeader]}
      onLayout={event => this._onLayout(event, index)}
    >
      <Text style={[tableStyles.item1, styles.link]}>
        {`${moment(msg.timestamp).format('DD.MM.YYYY')}`}
      </Text>
      <View style={[tableStyles.item2, styles.item2]}>
        <Text
          style={{
            color: primaryColor,
            flexShrink: Platform.OS === 'ios' ? 1 : 1.1, // for some strange reason, this fixes a bug of the attach icon overlapping on Android
          }}
        >
          {msg.sender}
        </Text>
        {msg.attachments.length > 0 && (
          <Icon name="attach-file" size={20} color={primaryColor} />
        )}
      </View>
    </View>
  );

  _renderAttachments = attachments => {
    return attachments.map((att, index) => {
      const isPdf = att.filetype === 'application/pdf';
      let viewPdfButton;

      if (isPdf) {
        viewPdfButton = (
          <StandardButton
            title={I18n.t('foiRequestDetails.viewPdf')}
            onPress={() =>
              this.props.navigateToPdfViewer({
                url: att.url,
                fileUrl: att.fileUrl,
              })
            }
            icon={{ name: 'remove-red-eye', color: primaryColor }}
          />
        );
      }

      return (
        <View key={att.id}>
          <View style={styles.attachmentsRowLabel}>
            <View>
              <Icon name="attach-file" />
            </View>
            <View>
              <Text style={styles.hotfixTextPadding}>
                {index +
                  1 +
                  '. ' +
                  att.name.replace(/_|-/g, ' ').replace('.pdf', '')}
              </Text>
            </View>
          </View>
          <View style={styles.attachmentsRowButton}>
            <View>
              <StandardButton
                title={I18n.t('foiRequestDetails.download')}
                icon={{ name: 'file-download', color: primaryColor }}
                onPress={() => Linking.openURL(att.fileUrl)}
              />
            </View>
            {viewPdfButton}
          </View>
          <Divider style={styles.dividerAttachments} />
        </View>
      );
    });
  };

  _renderMessageContent = msg => {
    const escalation = msg.isEscalation && (
      <View style={tableStyles.row}>
        <Text style={tableStyles.item1}>
          {I18n.t('foiRequestDetails.esclatedTo')}
        </Text>
        <Text style={tableStyles.item2}>
          {this.state.escalatedPublicBodyName || '...'}
        </Text>
      </View>
    );

    if (msg.isEscalation) {
      // very dirty solution, ideally, should be done without state but via redux
      if (
        !this.state.escalatedPublicBodyName &&
        !this.state.fetchingEscaltedPublicBody
      ) {
        fetch(msg.recipientPublicBody)
          .then(response => response.json())
          .then(responseJson => {
            this.setState({
              escalatedPublicBodyName: responseJson.name,
              fetchingEscaltedPublicBody: false,
            });
          });
      }
    }

    return (
      <View key={msg.key} style={styles.msgContent}>
        {this._renderAttachments(msg.attachments)}
        <View style={tableStyles.row}>
          <Text style={tableStyles.item1}>
            {I18n.t('foiRequestDetails.from')}
          </Text>
          <Text selectable style={tableStyles.item2}>
            {msg.sender}
          </Text>
        </View>
        <View style={tableStyles.row}>
          <Text style={tableStyles.item1}>
            {I18n.t('foiRequestDetails.on')}
          </Text>
          <Text selectable style={tableStyles.item2}>
            {moment(msg.timestamp).format('LLLL')}
          </Text>
        </View>
        {escalation}
        <View style={tableStyles.row}>
          <Text style={tableStyles.item1}>
            {I18n.t('foiRequestDetails.subject')}
          </Text>
          <Text selectable style={tableStyles.item2}>
            {msg.subject}
          </Text>
        </View>
        <Divider style={styles.dividerBeforeMessageContent} />
        <Hyperlink linkDefault linkStyle={{ color: primaryColor }}>
          <BodyText>{msg.content}</BodyText>
        </Hyperlink>
      </View>
    );
  };

  _renderTable = () => {
    const {
      status,
      resolution,
      refusal_reason: refusalReason,
      costs,
      last_message: lastMessage,
      first_message: firstMessage,
      due_date: dueDate,
      id,
    } = this.props.request;
    const { law } = this.props;

    const { realStatus } = getPrintableStatus(status, resolution);

    const tableData = [
      {
        label: I18n.t('status'),
        value: <Text selectable>{I18n.t(realStatus)}</Text>,
      },
    ];

    if (refusalReason) {
      tableData.push({
        label: I18n.t('foiRequestDetails.refusalReason'),
        value: <Text selectable>{refusalReason}</Text>,
      });
    }

    if (costs && costs !== 0) {
      tableData.push({
        label: I18n.t('foiRequestDetails.costs'),
        value: <Text selectable>{costs} €</Text>,
      });
    }

    tableData.push({
      label: I18n.t('foiRequestDetails.startedOn'),
      value: <Text selectable>{moment(firstMessage).format('LLL')}</Text>,
    });

    tableData.push({
      label: I18n.t('foiRequestDetails.lastMessage'),
      value: <Text selectable>{moment(lastMessage).format('LLL')}</Text>,
    });

    if (dueDate) {
      tableData.push({
        label: I18n.t('foiRequestDetails.dueDate'),
        value: <Text selectable>{moment(dueDate).format('LL')}</Text>,
      });
    }

    if (law != null) {
      const { name: lawName, site_url: lawUrl } = law;
      // currently, the API does not provide links for combined laws
      if (lawName && lawUrl) {
        tableData.push({
          label: I18n.t('foiRequestDetails.law'),
          value: <Link label={breakLongWords(lawName)} url={lawUrl} />,
        });
      } else if (lawName) {
        tableData.push({
          label: I18n.t('foiRequestDetails.law'),
          value: <Text selectable>{lawName}</Text>,
        });
      }
    } else {
      tableData.push({
        label: I18n.t('foiRequestDetails.law'),
        value: <Text>..</Text>,
      });
    }

    tableData.push({
      label: 'Follower',
      value: <FollowingNumber id={id} />,
    });

    return (
      <View style={styles.table}>
        <Table data={tableData} />
      </View>
    );
  };

  _onChange = sections => {
    if (sections.length === 0) return;
    this.setState({ sections });
    const index = sections[0];

    const itemsAbove = Array.from(this.itemHeights)
      .sort((x, y) => x[0] - y[0]) // sort by index
      .slice(0, index) // cut away items below
      .map(x => x[1]); // select only heights

    const itemsAboveHeigt =
      index === 0 ? 0 : itemsAbove.reduce((prev, cur) => prev + cur); // sum up heights

    const additionalOffset =
      index *
        (stylesTouchableFlat.marginTop + 2 * stylesMsgHeaderFlat.borderWidth) +
      stylesTouchableFlat.marginTop;
    setTimeout(
      () =>
        this.scrollView.scrollTo({
          x: 0,
          y: itemsAboveHeigt + this.scrollToYOffset + additionalOffset,
          animated: true,
        }),
      700 // should be a little bit higher that the time for collapsing
    );
  };

  _renderMessages = () => {
    const { messages } = this.props;
    if (messages === null) {
      return (
        <ActivityIndicator animating size="large" color={primaryColorLight} />
      );
    }

    // check if there are still old messages in state
    const messageRequestId = parseInt(
      messages[0].request.split('/').reverse()[1], // second last element
      10
    );

    if (messageRequestId !== this.props.request.id) {
      return (
        <ActivityIndicator animating size="large" color={primaryColorLight} />
      );
    }

    const filtedMessages = messages.filter(x => !x.not_publishable);

    const messagesPrintable = filtedMessages.map(
      ({
        id,
        sender,
        subject,
        content,
        timestamp,
        is_response: isResponse,
        attachments,
        content_hidden: contentHidden,
        is_escalation: isEscalation,
        recipient_public_body: recipientPublicBody,
      }) => {
        const filteredAttachments = attachments
          .filter(x => x.approved)
          .map(x => {
            return {
              key: x.id,
              url: x.site_url,
              fileUrl: x.file_url,
              name: x.name,
              filetype: x.filetype,
            };
          });

        let proccesedContent = content;
        if (contentHidden) {
          proccesedContent = I18n.t('foiRequestDetails.notYetVisible');
        } else {
          if (!isResponse) {
            // cut away signature of FdS
            const lastIndex = proccesedContent.lastIndexOf('--');
            if (lastIndex !== -1) {
              proccesedContent = proccesedContent.substring(0, lastIndex);
            }
          }
          // more than 2 line breaks to 2 line breaks
          proccesedContent = proccesedContent.replace(/\n\s*\n\s*\n/g, '\n\n');
          proccesedContent = proccesedContent.trim();
        }

        return {
          key: id,
          sender,
          subject,
          content: proccesedContent,
          timestamp,
          isEscalation,
          recipientPublicBody,
          attachments: filteredAttachments,
        };
      }
    );

    return (
      <View style={styles.msgContainer}>
        <Accordion
          align="center"
          duration={300}
          onChange={this._onChange}
          sections={messagesPrintable.reverse()} // show latest messages first
          renderHeader={this._renderMessageHeader}
          renderContent={this._renderMessageContent}
          underlayColor={greyLight}
          activeSections={this.state.sections}
          touchableProps={{
            style: styles.touchable,
            hitSlop: {
              top: spaceMore / 2,
              bottom: spaceMore / 2,
              left: spaceMore / 2,
              right: spaceMore / 2,
            },
          }}
        />
      </View>
    );
  };

  render() {
    const { title, public_body: publicBody, description } = this.props.request;
    let subheading = (
      <SubHeading style={styles.subheading}>
        {I18n.t('foiRequestDetails.notYetSpecified')}
      </SubHeading>
    );
    if (publicBody) {
      const {
        id: publicBodyId,
        name: publicBodyName,
        jurisdiction,
      } = publicBody;
      const jurisdictionName = jurisdictionNameFromUrl(jurisdiction);

      subheading = (
        <View>
          <TouchableHighlight
            style={{
              alignSelf: 'center',
            }}
            underlayColor={greyLight}
            onPress={() => this.props.navigateToPublicBody({ publicBodyId })}
          >
            <View>
              <SubHeading style={[styles.subheading, styles.link]}>
                {publicBodyName}
              </SubHeading>
            </View>
          </TouchableHighlight>
          <Text selectable style={[styles.subheadingJurisdiction]}>
            ({jurisdictionName})
          </Text>
        </View>
      );
    }

    return (
      <BlankContainer
        scrollViewRef={el => {
          this.scrollView = el;
        }}
      >
        <View
          onLayout={event => {
            this.scrollToYOffset = event.nativeEvent.layout.height;
          }}
        >
          <Heading style={styles.heading}>{title}</Heading>
          <View>
            <Text style={styles.subheadingTo}>
              {I18n.t('foiRequestDetails.to')}
            </Text>
            {subheading}
          </View>
          {this._renderTable()}
          <View style={styles.summary}>
            <Hyperlink linkDefault linkStyle={{ color: primaryColor }}>
              <BodyText>{description}</BodyText>
            </Hyperlink>
          </View>
        </View>
        {this._renderMessages()}
      </BlankContainer>
    );
  }
}

FoiRequestDetails.navigationOptions = ({ navigation }) => {
  const { params, routeName } = navigation.state;
  let requestId = null;

  // sometimes we get the request via the nav prop and sometimes only the id
  if (params.request != null) {
    requestId = params.request.id;
  } else if (params.foiRequestId != null) {
    requestId = params.foiRequestId;
  }

  const url = `${ORIGIN}/a/${requestId}`;

  function share() {
    Share.share(
      {
        ...Platform.select({
          ios: {
            url,
          },
          android: {
            message: url,
          },
        }),
        title: 'FragDenStaat',
      },
      {
        ...Platform.select({
          android: {
            // Android only:
            dialogTitle: `Share: ${url}`,
          },
        }),
      }
    );
  }

  let iconName = 'share';
  let iconType = 'material';

  // platform specific share button
  if (Platform.OS === 'ios') {
    iconName = 'ios-share';
    iconType = 'ionicon';
  }

  let openInBrowserIcon = { iconName: 'open-in-browser', iconType: 'material' };

  if (Platform.OS === 'ios') {
    openInBrowserIcon = {
      iconName: 'ios-browsers',
      iconType: 'ionicon',
    };
  }

  const openInBrowser = () =>
    NavigationActions.navigate({
      routeName: routeName.startsWith('Search') // hacky
        ? 'SearchFoiRequestWebView'
        : 'FoiRequestsWebView',
      params: { uri: url },
    });

  return {
    headerBackTitle: null, // can't set specific title when going outside the app so remove it for now
    title: I18n.t('request'),
    headerRight: (
      <View style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
        <FollowingIcon id={requestId} />

        <NavBarIcon
          {...openInBrowserIcon}
          onPress={() => navigation.dispatch(openInBrowser())}
        />
        <NavBarIcon iconName={iconName} iconType={iconType} onPress={share} />
      </View>
    ),
  };
};

FoiRequestDetails.propTypes = {
  navigateToPdfViewer: PropTypes.func.isRequired,
  navigateToPublicBody: PropTypes.func.isRequired,
  request: PropTypes.shape({
    public_body: PropTypes.object.isRequired,
    description: PropTypes.string.isRequired,
    costs: PropTypes.number,
    id: PropTypes.number.isRequired,
    last_message: PropTypes.string,
    first_message: PropTypes.string.isRequired,
    due_date: PropTypes.string.isRequired,
    jurisdiction: PropTypes.string.isRequired,
    law: PropTypes.string.isRequired,
    title: PropTypes.string.isRequired,
    status: PropTypes.string.isRequired,
    resolution: PropTypes.string,
    refusal_reason: PropTypes.string,
    messages: PropTypes.arrayOf(
      PropTypes.shape({
        content: PropTypes.string,
        subject: PropTypes.string.isRequired,
        id: PropTypes.number.isRequired,
        not_publishable: PropTypes.bool.isRequired,
        sender: PropTypes.string.isRequired,
        timestamp: PropTypes.string.isRequired,
        attachments: PropTypes.arrayOf(
          PropTypes.shape({
            id: PropTypes.number.isRequired,
            approved: PropTypes.bool.isRequired,
            filetype: PropTypes.string.isRequired,
            name: PropTypes.string.isRequired,
            file_url: PropTypes.string.isRequired,
          })
        ),
      })
    ),
  }).isRequired,
  fetchSingleFoiRequest: PropTypes.func,
};

FoiRequestDetails.defaultProps = {
  fetchSingleFoiRequest: null,
  messages: [],
};

export default FoiRequestDetails;