text: /**
   * @license
   * Copyright (C) 2016 The Android Open Source Project
   *
   * Licensed under the Apache License, Version 2.0 (the "License");
   * you may not use this file except in compliance with the License.
   * You may obtain a copy of the License at
   *
   * http://www.apache.org/licenses/LICENSE-2.0
   *
   * Unless required by applicable law or agreed to in writing, software
   * distributed under the License is distributed on an "AS IS" BASIS,
   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   * See the License for the specific language governing permissions and
   * limitations under the License.
   */
  (function() {
    'use strict';
  
    const UNRESOLVED_EXPAND_COUNT = 5;
    const NEWLINE_PATTERN = /\n/g;
  
    Polymer({
      is: 'gr-comment-thread',
  
      /**
       * Fired when the thread should be discarded.
       *
       * @event thread-discard
       */
  
      /**
       * Fired when a comment in the thread is permanently modified.
       *
       * @event thread-changed
       */
  
       /**
        * gr-comment-thread exposes the following attributes that allow a
        * diff widget like gr-diff to show the thread in the right location:
        *
        * line-num:
        *     1-based line number or undefined if it refers to the entire file.
        *
        * comment-side:
        *     "left" or "right". These indicate which of the two diffed versions
        *     the comment relates to. In the case of unified diff, the left
        *     version is the one whose line number column is further to the left.
        *
        * range:
        *     The range of text that the comment refers to (start_line,
        *     start_character, end_line, end_character), serialized as JSON. If
        *     set, range's end_line will have the same value as line-num. Line
        *     numbers are 1-based, char numbers are 0-based. The start position
        *     (start_line, start_character) is inclusive, and the end position
        *     (end_line, end_character) is exclusive.
        */
      properties: {
        changeNum: String,
        comments: {
          type: Array,
          value() { return []; },
        },
        /**
         * @type {?{start_line: number, start_character: number, end_line: number,
         *          end_character: number}}
         */
        range: {
          type: Object,
          reflectToAttribute: true,
        },
        keyEventTarget: {
          type: Object,
          value() { return document.body; },
        },
        commentSide: {
          type: String,
          reflectToAttribute: true,
        },
        patchNum: String,
        path: String,
        projectName: {
          type: String,
          observer: '_projectNameChanged',
        },
        hasDraft: {
          type: Boolean,
          notify: true,
          reflectToAttribute: true,
        },
        isOnParent: {
          type: Boolean,
          value: false,
        },
        parentIndex: {
          type: Number,
          value: null,
        },
        rootId: {
          type: String,
          notify: true,
          computed: '_computeRootId(comments.*)',
        },
        /**
         * If this is true, the comment thread also needs to have the change and
         * line properties property set
         */
        showFilePath: {
          type: Boolean,
          value: false,
        },
        /** Necessary only if showFilePath is true or when used with gr-diff */
        lineNum: {
          type: Number,
          reflectToAttribute: true,
        },
        unresolved: {
          type: Boolean,
          notify: true,
          reflectToAttribute: true,
        },
        _showActions: Boolean,
        _lastComment: Object,
        _orderedComments: Array,
        _projectConfig: Object,
      },
  
      behaviors: [
        Gerrit.KeyboardShortcutBehavior,
        Gerrit.PathListBehavior,
      ],
  
      listeners: {
        'comment-update': '_handleCommentUpdate',
      },
  
      observers: [
        '_commentsChanged(comments.*)',
      ],
  
      keyBindings: {
        'e shift+e': '_handleEKey',
      },
  
      attached() {
        this._getLoggedIn().then(loggedIn => {
          this._showActions = loggedIn;
        });
        this._setInitialExpandedState();
      },
  
      addOrEditDraft(opt_lineNum, opt_range) {
        const lastComment = this.comments[this.comments.length - 1] || {};
        if (lastComment.__draft) {
          const commentEl = this._commentElWithDraftID(
              lastComment.id || lastComment.__draftID);
          commentEl.editing = true;
  
          // If the comment was collapsed, re-open it to make it clear which
          // actions are available.
          commentEl.collapsed = false;
        } else {
          const range = opt_range ? opt_range :
              lastComment ? lastComment.range : undefined;
          const unresolved = lastComment ? lastComment.unresolved : undefined;
          this.addDraft(opt_lineNum, range, unresolved);
        }
      },
  
      addDraft(opt_lineNum, opt_range, opt_unresolved) {
        const draft = this._newDraft(opt_lineNum, opt_range);
        draft.__editing = true;
        draft.unresolved = opt_unresolved === false ? opt_unresolved : true;
        this.push('comments', draft);
      },
  
      fireRemoveSelf() {
        this.dispatchEvent(new CustomEvent('thread-discard',
            {detail: {rootId: this.rootId}, bubbles: false}));
      },
  
      _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
        return Gerrit.Nav.getUrlForDiffById(changeNum,
            projectName, path, patchNum,
            null, this.lineNum);
      },
  
      _computeDisplayPath(path) {
        const lineString = this.lineNum ? `#${this.lineNum}` : '';
        return this.computeDisplayPath(path) + lineString;
      },
  
      _getLoggedIn() {
        return this.$.restAPI.getLoggedIn();
      },
  
      _commentsChanged() {
        this._orderedComments = this._sortedComments(this.comments);
        this.updateThreadProperties();
      },
  
      updateThreadProperties() {
        if (this._orderedComments.length) {
          this._lastComment = this._getLastComment();
          this.unresolved = this._lastComment.unresolved;
          this.hasDraft = this._lastComment.__draft;
        }
      },
  
      _hideActions(_showActions, _lastComment) {
        return !_showActions || !_lastComment || !!_lastComment.__draft;
      },
  
      _getLastComment() {
        return this._orderedComments[this._orderedComments.length - 1] || {};
      },
  
      _handleEKey(e) {
        if (this.shouldSuppressKeyboardShortcut(e)) { return; }
  
        // Don’t preventDefault in this case because it will render the event
        // useless for other handlers (other gr-comment-thread elements).
        if (e.detail.keyboardEvent.shiftKey) {
          this._expandCollapseComments(true);
        } else {
          if (this.modifierPressed(e)) { return; }
          this._expandCollapseComments(false);
        }
      },
  
      _expandCollapseComments(actionIsCollapse) {
        const comments =
            Polymer.dom(this.root).querySelectorAll('gr-comment');
        for (const comment of comments) {
          comment.collapsed = actionIsCollapse;
        }
      },
  
      /**
       * Sets the initial state of the comment thread.
       * Expands the thread if one of the following is true:
       * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
       * thread is unresolved,
       * - it's a robot comment.
       */
      _setInitialExpandedState() {
        if (this._orderedComments) {
          for (let i = 0; i < this._orderedComments.length; i++) {
            const comment = this._orderedComments[i];
            const isRobotComment = !!comment.robot_id;
            // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
            const resolvedThread = !this.unresolved ||
                  this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
            comment.collapsed = !isRobotComment && resolvedThread;
          }
        }
      },
  
      _sortedComments(comments) {
        return comments.slice().sort((c1, c2) => {
          const c1Date = c1.__date || util.parseDate(c1.updated);
          const c2Date = c2.__date || util.parseDate(c2.updated);
          const dateCompare = c1Date - c2Date;
          // Ensure drafts are at the end. There should only be one but in edge
          // cases could be more. In the unlikely event two drafts are being
          // compared, use the typical date compare.
          if (c2.__draft && !c1.__draft ) { return -1; }
          if (c1.__draft && !c2.__draft ) { return 1; }
          if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; }
          // If same date, fall back to sorting by id.
          return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
        });
      },
  
      _createReplyComment(parent, content, opt_isEditing,
          opt_unresolved) {
        this.$.reporting.recordDraftInteraction();
        const reply = this._newReply(
            this._orderedComments[this._orderedComments.length - 1].id,
            parent.line,
            content,
            opt_unresolved,
            parent.range);
  
        // If there is currently a comment in an editing state, add an attribute
        // so that the gr-comment knows not to populate the draft text.
        for (let i = 0; i < this.comments.length; i++) {
          if (this.comments[i].__editing) {
            reply.__otherEditing = true;
            break;
          }
        }
  
        if (opt_isEditing) {
          reply.__editing = true;
        }
  
        this.push('comments', reply);
  
        if (!opt_isEditing) {
          // Allow the reply to render in the dom-repeat.
          this.async(() => {
            const commentEl = this._commentElWithDraftID(reply.__draftID);
            commentEl.save();
          }, 1);
        }
      },
  
      _isDraft(comment) {
        return !!comment.__draft;
      },
  
      /**
       * @param {boolean=} opt_quote
       */
      _processCommentReply(opt_quote) {
        const comment = this._lastComment;
        let quoteStr;
        if (opt_quote) {
          const msg = comment.message;
          quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
        }
        this._createReplyComment(comment, quoteStr, true, comment.unresolved);
      },
  
      _handleCommentReply(e) {
        this._processCommentReply();
      },
  
      _handleCommentQuote(e) {
        this._processCommentReply(true);
      },
  
      _handleCommentAck(e) {
        const comment = this._lastComment;
        this._createReplyComment(comment, 'Ack', false, false);
      },
  
      _handleCommentDone(e) {
        const comment = this._lastComment;
        this._createReplyComment(comment, 'Done', false, false);
      },
  
      _handleCommentFix(e) {
        const comment = e.detail.comment;
        const msg = comment.message;
        const quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
        const response = quoteStr + 'Please Fix';
        this._createReplyComment(comment, response, false, true);
      },
  
      _commentElWithDraftID(id) {
        const els = Polymer.dom(this.root).querySelectorAll('gr-comment');
        for (const el of els) {
          if (el.comment.id === id || el.comment.__draftID === id) {
            return el;
          }
        }
        return null;
      },
  
      _newReply(inReplyTo, opt_lineNum, opt_message, opt_unresolved,
          opt_range) {
        const d = this._newDraft(opt_lineNum);
        d.in_reply_to = inReplyTo;
        d.range = opt_range;
        if (opt_message != null) {
          d.message = opt_message;
        }
        if (opt_unresolved !== undefined) {
          d.unresolved = opt_unresolved;
        }
        return d;
      },
  
      /**
       * @param {number=} opt_lineNum
       * @param {!Object=} opt_range
       */
      _newDraft(opt_lineNum, opt_range) {
        const d = {
          __draft: true,
          __draftID: Math.random().toString(36),
          __date: new Date(),
          path: this.path,
          patchNum: this.patchNum,
          side: this._getSide(this.isOnParent),
          __commentSide: this.commentSide,
        };
        if (opt_lineNum) {
          d.line = opt_lineNum;
        }
        if (opt_range) {
          d.range = opt_range;
        }
        if (this.parentIndex) {
          d.parent = this.parentIndex;
        }
        return d;
      },
  
      _getSide(isOnParent) {
        if (isOnParent) { return 'PARENT'; }
        return 'REVISION';
      },
  
      _computeRootId(comments) {
        // Keep the root ID even if the comment was removed, so that notification
        // to sync will know which thread to remove.
        if (!comments.base.length) { return this.rootId; }
        const rootComment = comments.base[0];
        return rootComment.id || rootComment.__draftID;
      },
  
      _handleCommentDiscard(e) {
        const diffCommentEl = Polymer.dom(e).rootTarget;
        const comment = diffCommentEl.comment;
        const idx = this._indexOf(comment, this.comments);
        if (idx == -1) {
          throw Error('Cannot find comment ' +
              JSON.stringify(diffCommentEl.comment));
        }
        this.splice('comments', idx, 1);
        if (this.comments.length === 0) {
          this.fireRemoveSelf();
        }
        this._handleCommentSavedOrDiscarded(e);
  
        // Check to see if there are any other open comments getting edited and
        // set the local storage value to its message value.
        for (const changeComment of this.comments) {
          if (changeComment.__editing) {
            const commentLocation = {
              changeNum: this.changeNum,
              patchNum: this.patchNum,
              path: changeComment.path,
              line: changeComment.line,
            };
            return this.$.storage.setDraftComment(commentLocation,
                changeComment.message);
          }
        }
      },
  
      _handleCommentSavedOrDiscarded(e) {
        this.dispatchEvent(new CustomEvent('thread-changed',
            {detail: {rootId: this.rootId, path: this.path},
              bubbles: false}));
      },
  
      _handleCommentUpdate(e) {
        const comment = e.detail.comment;
        const index = this._indexOf(comment, this.comments);
        if (index === -1) {
          // This should never happen: comment belongs to another thread.
          console.warn('Comment update for another comment thread.');
          return;
        }
        this.set(['comments', index], comment);
        // Because of the way we pass these comment objects around by-ref, in
        // combination with the fact that Polymer does dirty checking in
        // observers, the this.set() call above will not cause a thread update in
        // some situations.
        this.updateThreadProperties();
      },
  
      _indexOf(comment, arr) {
        for (let i = 0; i < arr.length; i++) {
          const c = arr[i];
          if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
              (c.id != null && c.id == comment.id)) {
            return i;
          }
        }
        return -1;
      },
  
      _computeHostClass(unresolved) {
        return unresolved ? 'unresolved' : '';
      },
  
      /**
       * Load the project config when a project name has been provided.
       * @param {string} name The project name.
       */
      _projectNameChanged(name) {
        if (!name) { return; }
        this.$.restAPI.getProjectConfig(name).then(config => {
          this._projectConfig = config;
        });
      },
    });
  })();
  