assemblymade/meta

View on GitHub
app/assets/javascripts/components/news_feed/news_feed_item_comments.js.jsx

Summary

Maintainability
D
2 days
Test Coverage
// TODO This lib is in application.js (chrislloyd)
// var marked = require('marked')

var ActivityFeedComment = require('../activity_feed_comment.js.jsx');
var BountyStore = require('../../stores/bounty_store');
var Comment = require('../comment.js.jsx');
var DiscussionActions = require('../../actions/discussion_action_creators');
var DiscussionStore = require('../../stores/discussion_store');
var Drawer = require('../ui/drawer.js.jsx');
var Icon = require('../ui/icon.js.jsx');
var IdeaSharePanel = require('../ideas/idea_share_panel.js.jsx');
var NewComment = require('./new_comment.js.jsx');
var NewCommentActionCreators = require('../../actions/new_comment_action_creators');
var NewsFeedItemEvent = require('./news_feed_item_event.js.jsx');
var NewsFeedItemBountyClose = require('./news_feed_item_bounty_close.js.jsx');
var NewsFeedItemBountyCommentReference = require('./news_feed_item_bounty_comment_reference.js.jsx');
var NewsFeedItemBountyReopen = require('./news_feed_item_bounty_reopen.js.jsx');
var NewsFeedItemBountyReviewReady = require('./news_feed_item_bounty_review_ready.js.jsx');
var NewsFeedItemBountyTagChange = require('./news_feed_item_bounty_tag_change.js.jsx');
var NewsFeedItemBountyTimelineItem = require('./news_feed_item_bounty_timeline_item.js.jsx');
var NewsFeedItemBountyTitleChange = require('./news_feed_item_bounty_title_change.js.jsx');
var NewsFeedItemBountyWin = require('./news_feed_item_bounty_win.js.jsx');
var ProductStore = require('../../stores/product_store');
var ReadReceipts = require('../read_receipts.js.jsx');
var Routes = require('../../routes');
var Spinner = require('../spinner.js.jsx');
var SvgIcon = require('../ui/svg_icon.js.jsx');
var Timeline = require('../ui/timeline.js.jsx')
var UserStore = require('../../stores/user_store');

var NewsFeedItemComments = React.createClass({
  propTypes: {
    commentable: React.PropTypes.bool,
    item: React.PropTypes.object.isRequired,
    showAllComments: React.PropTypes.bool,
    showQuestionButtons: React.PropTypes.bool
  },

  componentDidMount: function() {
    if (_reach(this.props, 'item.target.type') === 'task') {
      BountyStore.addChangeListener(this.getBountyState);
    }

    DiscussionStore.addChangeListener(this.getDiscussionState);
    DiscussionActions.discussionSelected(this.props.item.id)
  },

  componentDidUpdate: function(prevProps, prevState) {
    if (window.location.hash) {
      if (this.state.comments.length > prevState.comments.length) {
        $(document.body).animate({
          'scrollTop':   ($('#' + window.location.hash.substring(1)).offset() || {}).top
        }, 500);
      }
    }
  },

  componentWillUnmount: function() {
    if (_reach(this.props, 'item.target.type') === 'task') {
      BountyStore.removeChangeListener(this.getBountyState);
    }

    DiscussionStore.removeChangeListener(this.getDiscussionState);
    setTimeout(() => DiscussionActions.discussionClosed(this.props.item.id),1)
  },

  fetchCommentsFromServer: function(e) {
    e && e.stopPropagation();

    if (this.isMounted()) {
      this.setState({ loading: true });
    }

    DiscussionActions.fetchCommentsFromServer(
      this.props.item.id
    );
  },

  getBountyState: function() {
    if (!this.isMounted()) {
      return
    }

    var state = BountyStore.getState();
    var comments = this.state.comments;

    // FIXME: (pletcher) We should probably not mutate the instance of
    // NFIComments' state. Instead, we should request the new comments from the
    // server and render them.
    switch (state) {
      case 'closed':
      case 'resolved':
        var closedEvent = this.renderOptimisticClosedEvent();

        if (!closedEvent) {
          return;
        }

        comments.push(closedEvent);
        break;
      case 'open':
        var reopenedEvent = this.renderOptimisticReopenedEvent();

        if (!reopenedEvent) {
          return;
        }

        comments.push(reopenedEvent);
        break;
      case 'reviewing':
        var reviewReadyEvent = this.renderOptimisticReviewReadyEvent();

        if (!reviewReadyEvent) {
          return;
        }

        comments.push(reviewReadyEvent);
        break;
    }

    this.setState({
      comments: comments
    });
  },

  getDefaultProps: function() {
    return {
      commentable: false,
      showQuestionButtons: false
    };
  },

  getDiscussionState: function(e) {
    var itemId = this.props.item.id;
    var comments = DiscussionStore.getComments(itemId);
    var events = DiscussionStore.getEvents(itemId);

    // FIXME: When `last_comment` is no longer serialized on each item
    // but set in the store, remove this check
    if (!this.props.showAllComments) {
      return;
    }

    // FIXME (pletcher): comments are taking too long to render, which makes for
    // a weird 1-second-ish jump between when the spinner stops and when
    // comments appear
    this.setState({
      comment: '',
      comments: comments.confirmed,
      events: events,
      loading: false,
      numberOfComments: this.state.numberOfComments + comments.confirmed.length,
      optimisticComments: comments.optimistic
    });
  },

  getInitialState: function() {
    var item = this.props.item;
    var showAllComments = this.props.showAllComments;
    var lastComment = item.last_comment;

    var showCommentsAfter;
    if (!showAllComments) {
      showCommentsAfter = +(lastComment ? new Date(lastComment.created_at) : Date.now());
    } else {
      showCommentsAfter = 0;
    }

    return {
      comments: showAllComments ? [] : (lastComment ? [lastComment] : []),
      events: [],
      numberOfComments: item.comments_count,
      optimisticComments: [],
      showCommentsAfter: showCommentsAfter,
      showSharePanel: false,
      url: Routes.discussion_comments_path({discussion_id: this.props.item.id})
    };
  },

  handleAnswerQuestionClick: function(username, e) {
    e.stopPropagation();

    var $commentBox = $(this.refs['new-comment'].getDOMNode());

    $('html, body').animate({
      scrollTop: $commentBox.offset().top
    }, 'fast');

    $('#event_comment_body').focus();

    var item = this.props.item;
    var thread = item.id;
    var text = '@' + username + ', ';

    NewCommentActionCreators.updateComment(thread, text);
  },

  handleShareQuestionClick: function(e) {
    e.stopPropagation();

    this.setState({
      showSharePanel: !this.state.showSharePanel
    });
  },

  render: function() {
    return (
      <div>
        {this.renderComments()}
        <div className="py3 border-top">
          {this.renderNewCommentForm()}
        </div>
      </div>
    );
  },

  renderComments: function() {
    if (this.state.loading) {
      return <Spinner />;
    }

    var confirmedComments = this.renderConfirmedComments();
    var optimisticComments = this.renderOptimisticComments();
    var comments = confirmedComments.concat(optimisticComments);

    if (!comments.length && this.props.showAllComments) {
      return <div className="py2" />;
    }

    return (
      <div>
        {this.renderLoadMoreButton()}
        <Timeline>
          {confirmedComments}
          {optimisticComments}
        </Timeline>
      </div>
    );
  },

  renderConfirmedComments: function() {
    var showAllComments = this.props.showAllComments;
    var renderIfAfter = this.state.showCommentsAfter;
    var comments = this.state.comments.concat(this.state.events).sort(_sort);
    var awardUrl;

    // Sometimes a type of 'task_decorator' is found; the indexOf() check
    // accounts for that case as well as just plain 'task'.
    if ((_reach(this.props, 'item.target.type') || '').indexOf('task') > -1) {
      awardUrl = _reach(this.props, 'item.target.url') + '/award';
    }

    var self = this;
    var renderedComments = comments.map(function(comment, i) {
      if (!showAllComments) {
        if (comment.type !== 'news_feed_item_comment') {
          return null;
        }

        return <ActivityFeedComment
            author={comment.user}
            body={comment.markdown_body}
            heartable={true}
            heartableId={comment.id} />
      }

      if (new Date(comment.created_at) >= renderIfAfter) {
        var editUrl = Routes.discussion_comment_path({
          discussion_id: self.props.item.id,
          id: comment.id
        });

        return parseEvent(comment, awardUrl, editUrl);
      }
    });

    if (showAllComments &&
        this.props.showQuestionButtons &&
        comments.length === 1 &&
        comments[0].body.indexOf('?') > -1) {
      var comment = comments[0];
      var questionButtons = (
        <div className="clearfix mb3 ml4">
          <div className="left">
            <button className="pill-button pill-button-theme-white pill-button-border pill-button-shadow mr3"
                onClick={this.handleAnswerQuestionClick.bind(this, comment.user.username)}>
              <span style={{ fontSize: '1.2rem', lineHeight: '2rem' }}>Answer this question</span>
            </button>
          </div>
        </div>
      );

      renderedComments.push(questionButtons);
    }

    return renderedComments;
  },

  renderLoadMoreButton: function() {
    if (this.props.showAllComments) {
      return;
    }

    var numberOfComments = this.state.numberOfComments;

    if (numberOfComments > this.state.comments.length) {
      return (
        <a className="block mt2 fs3 gray-2 clickable"
            onClick={this.triggerModal}>
          <span className="pr2 gray-2 fs5">
            <Icon icon="comment" />
          </span>
          View all {numberOfComments} comments
        </a>
      );
    }
  },

  renderNewCommentForm: function() {
    var item = this.props.item;

    if (this.props.commentable) {
      // FIXME: (pletcher) We shouldn't be passing the url in as a prop
      var url = Routes.discussion_comments_path({
        discussion_id: item.id
      });

      return <NewComment
          {...this.props}
          canContainWork={item.target && item.target.type.indexOf('task') > -1}
          url={url}
          thread={item.id}
          ref="new-comment" />
    }
  },

  renderOptimisticComments: function() {
    return this.state.optimisticComments.map(function(comment) {
      return (
        <Timeline.Item key={comment.id}>
          <Comment author={comment.user} body={marked(comment.body)} optimistic={true} key={comment.id} />
        </Timeline.Item>
      )
    });
  },

  renderOptimisticClosedEvent: function() {
    return this.renderOptimisticEvent('Event::Close');
  },

  renderOptimisticEvent: function(type) {
    var created_at = new Date(Date.now() + 500);

    // when we get the event back in the confirmation, set the award_url
    // so that the buttons show up
    return {
      type: type,
      actor: UserStore.getUser(),
      // award_url: this.props.url + '/award',
      created_at: created_at,
      id: created_at.toISOString()
    }
  },

  renderOptimisticReopenedEvent: function() {
    return this.renderOptimisticEvent('Event::Reopen');
  },

  renderOptimisticReviewReadyEvent: function() {
    return this.renderOptimisticEvent('Event::ReviewReady');
  },

  renderRuler: function() {
    if (this.state.comments.length > 0) {
      var classes = React.addons.classSet({
        mb0: true,
        mt3: true,
        'border-gray-5': true,
        _mrn4: true,
        _mln4: true,
      });
      return <hr className={classes} />;
    }
  },

  triggerModal: function(e) {
    e.stopPropagation();

    this.props.triggerModal();
  }
});

module.exports = window.NewsFeedItemComments = NewsFeedItemComments;

function parseEvent(event, awardUrl, editUrl) {
  var renderedEvent;

  switch(event.type) {
  case 'Event::Allocation':
    renderedEvent = null;
    break;
  case 'Event::Close':
    renderedEvent = <NewsFeedItemBountyClose {...event} />;
    break;
  case 'Event::CommentReference':
    renderedEvent = <NewsFeedItemBountyCommentReference {...event} />;
    break;
  case 'Event::Reopen':
    renderedEvent = <NewsFeedItemBountyReopen {...event} />;
    break;
  case 'Event::ReviewReady':
    renderedEvent = <NewsFeedItemBountyReviewReady {...event} />;
    break;
  case 'Event::TagChange':
    // don't render tag change events
    // renderedEvent = <NewsFeedItemBountyTagChange {...event} />;
    // See TODO in NewsFeedItemBountyTagChange
    break;
  case 'Event::TitleChange':
    renderedEvent = <NewsFeedItemBountyTitleChange {...event} />;
    break;
  case 'Event::Unallocation':
    renderedEvent = null
    break;
  case 'Event::Win':
    renderedEvent = <NewsFeedItemEvent timestamp={event.timestamp}>
      <NewsFeedItemBountyWin {...event} />
    </NewsFeedItemEvent>
    break;
  case 'news_feed_item_comment':
    renderedEvent = <Comment
        {...event}
        author={event.user}
        awardUrl={awardUrl}
        body={event.markdown_body}
        editUrl={editUrl}
        rawBody={event.body}
        timestamp={event.created_at}
        heartable={true} />;
    break;
  default:
    if (!event.actor) {
      break;
    }

    renderedEvent = <NewsFeedItemBountyTimelineItem {...event} />;
    break;
  }

  if (renderedEvent) {
    return (
      <Timeline.Item key={event.id}>
        {renderedEvent}
      </Timeline.Item>
    );
  }
}

function _reach(obj, prop) {
  var props = prop.split('.');

  while (props.length) {
    var p = props.shift();

    if (obj && obj.hasOwnProperty(p)) {
      obj = obj[p]
    } else {
      obj = undefined;
      break;
    }
  }

  return obj;
}

function _sort(a, b) {
  var aDate = +new Date(a.created_at);
  var bDate = +new Date(b.created_at);

  return aDate > bDate ? 1 : bDate > aDate ? -1 : 0;
}