(function (global, factory) {
    typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('@beanbag/spina')) :
    typeof define === 'function' && define.amd ? define(['exports', '@beanbag/spina'], factory) :
    (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.RB = global.RB || {}, global.Spina));
})(this, (function (exports, spina) { 'use strict';

    var _dec$6, _class$f, _dec2$2, _class2$6, _dec3$2, _class3$4, _dec4$2, _class4$3, _class5$3, _class6$3, _class7$2, _dec5$2, _class8$2, _dec6$1, _class9$1, _dec7$1, _class10$1, _class11$1, _dec8, _class12$1, _dec9, _class13$1, _dec10, _class14$1, _dec11, _class15, _dec12, _class16, _dec13, _class17, _dec14, _class18, _dec15, _class19;
    /**
     * Base class for all field views.
     */
    let BaseFieldView = (_dec$6 = spina.spina({
      prototypeAttrs: ['editableProp', 'useExtraData']
    }), _dec$6(_class$f = class BaseFieldView extends spina.BaseView {
      /**
       * The name of the property in the model for if this field is editable.
       */
      static editableProp = 'editable';

      /** Whether the contents of the field should be stored in extraData. */
      static useExtraData = true;

      /**********************
       * Instance variables *
       **********************/

      /** The ID of the field. */

      /**
       * Initialize the view.
       *
       * Args:
       *     options (BaseFieldViewOptions):
       *         Options for the view.
       */
      initialize(options) {
        this.fieldID = options.fieldID;
        this.fieldLabel = options.fieldLabel || null;
        this.jsonFieldName = options.jsonFieldName || this.jsonFieldName || this.fieldID;
        this.$el.data('field-id', this.fieldID);
      }

      /**
       * The name of the attribute within the model.
       *
       * Returns:
       *     string:
       *     The name of the attribute that this field will reflect.
       */
      fieldName() {
        /*
         * This implementation will convert names with underscores to camel
         * case. This covers the typical naming between Python and JavaScript.
         * If subclasses need something different, they can override this with
         * either a new function or a regular attribute.
         */
        if (this._fieldName === undefined) {
          this._fieldName = this.fieldID.replace(/_(.)/g, (m, c) => c.toUpperCase());
        }
        return this._fieldName;
      }

      /**
       * Load the stored value for the field.
       *
       * This will load from the draft if representing a built-in field
       * (``useExtraData === false``) or from extra_data if a custom field
       * (``useExtraData === true``).
       *
       * Args:
       *     options (GetDraftFieldOptions):
       *         Options for :js:func:`RB.ReviewRequestEditor.getDraftField`.
       *
       * Returns:
       *     *:
       *     The stored value for the field.
       */
      _loadValue() {
        let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
        const fieldName = this.useExtraData ? this.jsonFieldName : _.result(this, 'fieldName');
        return this.model.getDraftField(fieldName, _.defaults({
          useExtraData: this.useExtraData
        }, options));
      }

      /**
       * Save a new value for the field.
       *
       * Args:
       *     value (*):
       *         The new value for the field.
       *
       *     options (SetDraftFieldOptions):
       *         Options for the save operation.
       *
       * Returns:
       *     Promise:
       *     A promise which resolves when the operation is complete.
       */
      _saveValue(value) {
        let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
        return this.model.setDraftField(_.result(this, 'fieldName'), value, _.defaults({
          jsonFieldName: this.jsonFieldName,
          useExtraData: this.useExtraData
        }, options));
      }

      /**
       * Return whether the field has an unsaved editor open.
       *
       * This should be overridden by subclasses, if necessary.
       *
       * Returns:
       *     boolean:
       *     Whether the field is unsaved.
       */
      needsSave() {
        return false;
      }

      /**
       * Finish the field's save operation.
       *
       * This should be overridden by subclasses, if necessary.
       */
      finishSave() {
        // Intentionally left blank.
      }
    }) || _class$f);

    /**
     * A field view for text-based fields.
     */
    let TextFieldView = (_dec2$2 = spina.spina({
      prototypeAttrs: ['autocomplete', 'multiline', 'useEditIconOnly']
    }), _dec2$2(_class2$6 = class TextFieldView extends BaseFieldView {
      /**
       * Autocomplete definitions.
       *
       * This should be overridden by subclasses.
       */
      static autocomplete = null;

      /** Whether the view is multi-line or single line. */
      static multiline = false;

      /**
       * Whether edits should be triggered only by clicking on the icon.
       *
       * If this is true, edits can only be triggered by clicking on the icon.
       * If this is false, clicks on the field itself will also trigger an edit.
       */
      static useEditIconOnly = false;

      /**********************
       * Instance variables *
       **********************/

      /** Whether the field allows Markdown-formatted text. */
      allowRichText = false;

      /** The inline editor view. */

      /**
       * The model attribute for if this field is rich text.
       *
       * This is the name of the attribute which indicates whether the field
       * contains Markdown-formatted text or plain text.
       *
       * Returns:
       *     string:
       *     The name of the model atribute indicating whether the field contains
       *     rich text.
       */
      richTextAttr() {
        return this.allowRichText ? `${_.result(this, 'fieldName')}RichText` : null;
      }

      /**
       * Initialize the view.
       *
       * Args:
       *     options (BaseFieldViewOptions):
       *         Options for the view. See the parent class for details.
       */
      initialize(options) {
        super.initialize(options);
        this.jsonTextTypeFieldName = this.jsonFieldName === 'text' ? 'text_type' : `${this.jsonFieldName}_text_type`;
      }

      /**
       * Return the type to use for the inline editor view.
       *
       * Returns:
       *     function:
       *     The constructor for the inline editor class to instantiate.
       */
      _getInlineEditorClass() {
        return this.allowRichText ? RB.RichTextInlineEditorView : RB.InlineEditorView;
      }

      /**
       * Render the view.
       */
      onInitialRender() {
        if (!this.$el.hasClass('editable')) {
          return;
        }
        const fieldName = _.result(this, 'fieldName');
        const EditorClass = this._getInlineEditorClass();
        const inlineEditorOptions = {
          deferEventSetup: this.autocomplete !== null,
          editIconClass: 'rb-icon rb-icon-edit',
          el: this.$el,
          enabled: this.model.get(this.editableProp),
          fieldLabel: this.fieldLabel,
          formClass: `${this.$el.prop('id')}-editor`,
          hasShortButtons: !this.multiline,
          multiline: this.multiline,
          showRequiredFlag: this.$el.hasClass('required'),
          useEditIconOnly: this.useEditIconOnly
        };
        if (this.allowRichText) {
          _.extend(inlineEditorOptions, {
            fieldName: this.fieldName,
            hasRawValue: true,
            matchHeight: false,
            rawValue: this._loadValue({
              useRawTextValue: true
            }) || '',
            textEditorOptions: {
              minHeight: 0,
              richText: this._loadRichTextValue()
            }
          });
        }
        this.inlineEditorView = new EditorClass(inlineEditorOptions);
        this.inlineEditorView.render();
        this.listenTo(this.inlineEditorView, 'beginEdit', () => this.model.incr('editCount'));
        this.listenTo(this.inlineEditorView, 'resize', () => this.trigger('resize'));
        this.listenTo(this.inlineEditorView, 'cancel', () => {
          this.trigger('resize');
          this.model.decr('editCount');
        });
        this.listenTo(this.inlineEditorView, 'complete', value => {
          this.trigger('resize');
          this.model.decr('editCount');
          const saveOptions = {
            allowMarkdown: this.allowRichText
          };
          if (this.allowRichText) {
            saveOptions.richText = this.inlineEditorView.textEditor.richText;
            saveOptions.jsonTextTypeFieldName = this.jsonTextTypeFieldName;
          }
          this._saveValue(value, saveOptions).then(() => {
            this._formatField();
            this.trigger('fieldSaved');
          }).catch(err => {
            this._formatField();
            this.trigger('fieldError', err.message);
          });
        });
        if (this.autocomplete !== null) {
          this._buildAutoComplete();
          this.inlineEditorView.setupEvents();
        }
        this.listenTo(this.model, `change:${this.editableProp}`, (model, editable) => {
          if (editable) {
            this.inlineEditorView.enable();
          } else {
            this.inlineEditorView.disable();
          }
        });
        this.listenTo(this.model, `fieldChanged:${fieldName}`, this._formatField);
      }

      /**
       * Convert an item to a hyperlink.
       *
       * Args:
       *     item (object):
       *         The item to link. The content is up to the caller.
       *
       *     options (object):
       *         Options to control the linking behavior.
       *
       * Option Args:
       *     cssClass (string, optional):
       *         The optional CSS class to add to the link.
       *
       *     makeItemText (function, optional):
       *         A function that takes the item and returns the text for the
       *         link. If not specified, the item itself will be used as the
       *         text.
       *
       *     makeItemURL (function, optional):
       *         A function that takes the item and returns the URL for the link.
       *         If not specified, the item itself will be used as the URL.
       *
       * Returns:
       *     jQuery:
       *     The resulting link element wrapped in jQuery.
       */
      _convertToLink(item) {
        let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
        if (!item) {
          return $();
        }
        const $link = $('<a/>').attr('href', options.makeItemURL ? options.makeItemURL(item) : item).text(options.makeItemText ? options.makeItemText(item) : item);
        if (options.cssClass) {
          $link.addClass(options.cssClass);
        }
        return $link;
      }

      /**
       * Add auto-complete functionality to the field.
       */
      _buildAutoComplete() {
        const ac = this.autocomplete;
        const reviewRequest = this.model.get('reviewRequest');
        this.inlineEditorView.$field.rbautocomplete({
          cmp: ac.cmp,
          error: xhr => {
            let text;
            try {
              text = JSON.parse(xhr.responseText).err.msg;
            } catch (e) {
              text = `HTTP ${xhr.status} ${xhr.statusText}`;
            }
            alert(text);
          },
          extraParams: ac.extraParams,
          formatItem: data => {
            let s = data[ac.nameKey];
            if (ac.descKey && data[ac.descKey]) {
              s += ` <span>(${_.escape(data[ac.descKey])})</span>`;
            }
            return s;
          },
          matchCase: false,
          multiple: true,
          parse: data => {
            const items = _.isFunction(ac.fieldName) ? ac.fieldName(data) : data[ac.fieldName];
            return items.map(item => {
              if (ac.parseItem) {
                item = ac.parseItem(item);
              }
              return {
                data: item,
                result: item[ac.nameKey],
                value: item[ac.nameKey]
              };
            });
          },
          url: SITE_ROOT + reviewRequest.get('localSitePrefix') + 'api/' + (ac.resourceName || ac.fieldName) + '/',
          width: 350
        }).on('autocompleteshow', () => {
          /*
           * Add the footer to the bottom of the results pane the
           * first time it's created.
           *
           * Note that we may have multiple .ui-autocomplete-results
           * elements, and we don't necessarily know which is tied to
           * this. So, we'll look for all instances that don't contain
           * a footer.
           */
          const resultsPane = $('.ui-autocomplete-results:not(' + ':has(.ui-autocomplete-footer))');
          if (resultsPane.length > 0) {
            $('<div/>').addClass('ui-autocomplete-footer').text(gettext("Press Tab to auto-complete.")).appendTo(resultsPane);
          }
        });
      }

      /**
       * Format the contents of the field.
       *
       * This will apply the contents of the model attribute to the field
       * element. If the field defines a ``formatValue`` method, this will use
       * that to do the formatting. Otherwise, the element will just be set to
       * contain the text of the value.
       */
      _formatField() {
        const value = this._loadValue();
        if (_.isFunction(this.formatValue)) {
          this.formatValue(value);
        } else {
          this.$el.text(value);
        }
      }

      /**
       * Return whether the field has an unsaved editor open.
       *
       * Returns:
       *     boolean:
       *     Whether the field is unsaved.
       */
      needsSave() {
        return this.inlineEditorView && this.inlineEditorView.isDirty();
      }

      /**
       * Finish the field's save operation.
       */
      finishSave() {
        const value = this.inlineEditorView.submit({
          preventEvents: true
        });
        if (value) {
          this.trigger('resize');
          this.model.decr('editCount');
          const saveOptions = {
            allowMarkdown: this.allowRichText
          };
          if (this.allowRichText) {
            saveOptions.richText = this.inlineEditorView.textEditor.richText;
            saveOptions.jsonTextTypeFieldName = this.jsonTextTypeFieldName;
          }
          return this._saveValue(value, saveOptions).then(() => {
            this._formatField();
            this.trigger('fieldSaved');
          }).catch(err => {
            this._formatField();
            this.trigger('fieldError', err.message);
          });
        } else {
          return Promise.resolve();
        }
      }

      /**
       * Load the rich text value for the field.
       *
       * This will look up the rich text boolean attribute for built-in
       * fields or the text type information in extra_data, returning
       * whether the field is set to use rich text.
       *
       * Returns:
       *     boolean:
       *     Whether the field is set for rich text. This will be
       *     ``undefined`` if an explicit value isn't stored.
       */
      _loadRichTextValue() {
        if (this.useExtraData) {
          const textTypeFieldName = this.jsonTextTypeFieldName;
          const textType = this.model.getDraftField(textTypeFieldName, {
            useExtraData: true,
            useRawTextValue: true
          });
          if (textType === undefined) {
            return undefined;
          }
          console.assert(textType === 'plain' || textType === 'markdown', `Text type "${textType}" in field "${textTypeFieldName}" ` + `not supported.`);
          return textType === 'markdown';
        } else {
          return this.model.getDraftField(_.result(this, 'richTextAttr'));
        }
      }
    }) || _class2$6);

    /**
     * A field view for multiline text-based fields.
     */
    let MultilineTextFieldView = (_dec3$2 = spina.spina({
      prototypeAttrs: ['multiline']
    }), _dec3$2(_class3$4 = class MultilineTextFieldView extends TextFieldView {
      static multiline = true;
      allowRichText = null;

      /**
       * Initialize the view.
       *
       * Args:
       *     options (BaseFieldViewOptions):
       *         Options for the view.
       */
      initialize(options) {
        super.initialize(options);

        /*
         * If this field is coming from an extension which doesn't specify any
         * JS-side version, we need to pull some data out of the markup.
         */
        if (this.allowRichText === null) {
          this.allowRichText = this.$el.data('allow-markdown');
          const reviewRequest = this.model.get('reviewRequest');
          const extraData = reviewRequest.draft.get('extraData');
          const rawValue = this.$el.data('raw-value');
          extraData[this.jsonFieldName] = rawValue !== undefined ? rawValue || '' : this.$el.text();
          this.$el.removeAttr('data-raw-value');
          if (this.allowRichText) {
            extraData[this.jsonTextTypeFieldName] = this.$el.hasClass('rich-text') ? 'markdown' : 'plain';
          }
        }
      }

      /**
       * Linkify a block of text.
       *
       * This turns URLs, /r/#/ paths, and bug numbers into clickable links. It's
       * a wrapper around RB.formatText that handles passing in the bug tracker.
       *
       * Args:
       *     options (object):
       *         Options for the text formatting.
       *
       * Option Args:
       *     newText (string, optional):
       *         The new text to format into the element. If not specified, the
       *         existing contents of the element are used.
       */
      formatText() {
        let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
        const reviewRequest = this.model.get('reviewRequest');
        options = _.defaults({
          bugTrackerURL: reviewRequest.get('bugTrackerURL'),
          isHTMLEncoded: true
        }, options);
        if (this.allowRichText) {
          options.richText = this._loadRichTextValue();
        }
        RB.formatText(this.$el, options);
        this.$('img').on('load', () => this.trigger('resize'));
      }

      /**
       * Render the view.
       */
      onInitialRender() {
        super.onInitialRender();
        this.formatText();
      }

      /**
       * Format the value into the field.
       *
       * Args:
       *     data (object):
       *         The new value of the field.
       */
      formatValue(data) {
        if (this.allowRichText) {
          this.formatText({
            newText: data
          });
        }
      }
    }) || _class3$4);

    /**
     * A field view for fields that include multiple comma-separated values.
     */
    let CommaSeparatedValuesTextFieldView = (_dec4$2 = spina.spina({
      prototypeAttrs: ['useEditIconOnly']
    }), _dec4$2(_class4$3 = class CommaSeparatedValuesTextFieldView extends TextFieldView {
      static useEditIconOnly = true;

      /**
       * Convert an array of items to a list of hyperlinks.
       *
       * Args:
       *     list (Array);
       *         An array of items. The contents of the item is up to the caller.
       *
       *     options (object):
       *         Options to control the linking behavior.
       *
       * Option Args:
       *     cssClass (string, optional):
       *         The optional CSS class to add for each link.
       *
       *     makeItemText (function, optional):
       *         A function that takes an item and returns the text for the link.
       *         If not specified, the item itself will be used as the text.
       *
       *     makeItemURL (function, optional):
       *         A function that takes an item and returns the URL for the link.
       *         If not specified, the item itself will be used as the URL.
       *
       * Returns:
       *     jQuery:
       *     The resulting link elements in a jQuery list.
       */
      _urlizeList(list) {
        let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
        let $links = $();
        if (list) {
          for (let i = 0; i < list.length; i++) {
            $links = $links.add(this._convertToLink(list[i], options));
            if (i < list.length - 1) {
              $links = $links.add(document.createTextNode(', '));
            }
          }
        }
        return $links;
      }

      /**
       * Format the value into the field.
       *
       * Args:
       *     data (Array):
       *         The new value of the field.
       */
      formatValue(data) {
        data = data || [];
        this.$el.html(data.join(', '));
      }
    }) || _class4$3);

    /**
     * A field view for checkbox fields.
     */
    let CheckboxFieldView = spina.spina(_class5$3 = class CheckboxFieldView extends BaseFieldView {
      /**
       * Render the field.
       */
      onInitialRender() {
        this.$el.change(() => {
          this._saveValue(this.$el.is(':checked')).then(() => this.trigger('fieldSaved')).catch(err => this.trigger('fieldError', err.message));
        });
      }
    }) || _class5$3;

    /**
     * A field view for dropdown fields.
     */
    let DropdownFieldView = spina.spina(_class6$3 = class DropdownFieldView extends BaseFieldView {
      /**
       * Render the field.
       */
      onInitialRender() {
        super.onInitialRender();
        this.$el.change(() => {
          this._saveValue(this.$el.val()).then(() => this.trigger('fieldSaved')).catch(err => this.trigger('fieldError', err.message));
        });
      }
    }) || _class6$3;

    /**
     * A field view for date fields.
     */
    let DateFieldView = spina.spina(_class7$2 = class DateFieldView extends TextFieldView {
      /**
       * Render the field.
       */
      onInitialRender() {
        super.onInitialRender();
        this.inlineEditorView.$field.datepicker({
          changeMonth: true,
          changeYear: true,
          dateFormat: $.datepicker.ISO_8601,
          onSelect: (dateText, instance) => {
            if (dateText !== instance.lastVal) {
              this.inlineEditorView._dirty = true;
            }
          },
          showButtonPanel: true
        });
      }

      /**
       * Save a new value for the field.
       *
       * Args:
       *     value (*):
       *         The new value for the field.
       *
       *     options (object):
       *         Options for the save operation.
       *
       * Returns:
       *     Promise:
       *     A promise which resolves when the operation is complete.
       */
      _saveValue(value) {
        let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
        const m = moment(value, 'YYYY-MM-DD', true);
        if (!m.isValid()) {
          value = '';
          this.$el.text('');
        }
        return super._saveValue(value, options);
      }
    }) || _class7$2;

    /**
     * The "Branch" field.
     */
    let BranchFieldView = (_dec5$2 = spina.spina({
      prototypeAttrs: ['useExtraData']
    }), _dec5$2(_class8$2 = class BranchFieldView extends TextFieldView {
      static useExtraData = false;
    }) || _class8$2);

    /**
     * The "Bugs" field.
     */
    let BugsFieldView = (_dec6$1 = spina.spina({
      prototypeAttrs: ['useExtraData']
    }), _dec6$1(_class9$1 = class BugsFieldView extends CommaSeparatedValuesTextFieldView {
      static useExtraData = false;

      /**
       * Format the value into the field.
       *
       * Args:
       *     data (Array):
       *         The new value of the field.
       */
      formatValue(data) {
        data = data || [];
        const reviewRequest = this.model.get('reviewRequest');
        const bugTrackerURL = reviewRequest.get('bugTrackerURL');
        if (bugTrackerURL) {
          this.$el.empty().append(this._urlizeList(data, {
            cssClass: 'bug',
            makeItemURL: item => bugTrackerURL.replace('--bug_id--', item)
          })).find('.bug').bug_infobox();
        } else {
          this.$el.text(data.join(', '));
        }
      }
    }) || _class9$1);

    /**
     * The change description field.
     */
    let ChangeDescriptionFieldView = (_dec7$1 = spina.spina({
      prototypeAttrs: ['useExtraData']
    }), _dec7$1(_class10$1 = class ChangeDescriptionFieldView extends MultilineTextFieldView {
      static useExtraData = false;

      /**********************
       * Instance variables *
       **********************/

      allowRichText = true;
      jsonFieldName = 'changedescription';
    }) || _class10$1);

    /**
     * The commit list field.
     *
     * This provides expand/collapse functionality for commit messages that are
     * more than a single line.
     */
    let CommitListFieldView = spina.spina(_class11$1 = class CommitListFieldView extends BaseFieldView {
      /**********************
       * Instance variables *
       **********************/

      #commitListView = null;

      /**
       * Render the field.
       */
      onInitialRender() {
        super.onInitialRender();

        /*
         * We needn't render the view because it has already been rendered by
         * the server.
         */
        this.#commitListView = new RB.DiffCommitListView({
          el: this.$('.commit-list'),
          model: new RB.DiffCommitList({
            commits: this.model.get('commits'),
            isInterdiff: false
          })
        });
      }
    }) || _class11$1;

    /**
     * The close description field.
     */
    let CloseDescriptionFieldView = (_dec8 = spina.spina({
      prototypeAttrs: ['editableProp', 'useExtraData']
    }), _dec8(_class12$1 = class CloseDescriptionFieldView extends MultilineTextFieldView {
      static editableProp = 'statusEditable';
      static useExtraData = false;

      /**********************
       * Instance variables *
       **********************/

      allowRichText = true;
      /**
       * Save a new value for the field.
       *
       * Args:
       *     value (*):
       *         The new value for the field.
       *
       *     options (object):
       *         Options for the save operation.
       *
       * Returns:
       *     Promise:
       *     A promise which resolves when the operation is complete.
       */
      _saveValue(value) {
        let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
        return this.model.get('reviewRequest').close(_.defaults({
          description: value,
          postData: {
            force_text_type: 'html',
            include_text_types: 'raw'
          },
          type: this.closeType
        }, options));
      }
    }) || _class12$1);

    /**
     * The "Depends On" field.
     */
    let DependsOnFieldView = (_dec9 = spina.spina({
      prototypeAttrs: ['autocomplete', 'useEditIconOnly', 'useExtraData']
    }), _dec9(_class13$1 = class DependsOnFieldView extends CommaSeparatedValuesTextFieldView {
      static autocomplete = {
        cmp: (term, a, b) => b.data.id - a.data.id,
        descKey: 'id',
        display_name: 'summary',
        extraParams: {
          summary: 1
        },
        fieldName: data => data.search.review_requests,
        nameKey: 'id',
        parseItem: item => {
          item.id = item.id.toString();
          item.display_name = item.summary;
          return item;
        },
        resourceName: 'search'
      };
      static useEditIconOnly = true;
      static useExtraData = false;

      /**
       * Format the value into the field.
       *
       * Args:
       *     data (Array):
       *         The new value of the field.
       */
      formatValue(data) {
        data = data || [];
        this.$el.empty().append(this._urlizeList(data, {
          cssClass: 'review-request-link',
          makeItemText: item => item.id,
          makeItemURL: item => item.url
        })).find('.review-request-link').review_request_infobox();
      }
    }) || _class13$1);

    /**
     * The "Description" field.
     */
    let DescriptionFieldView = (_dec10 = spina.spina({
      prototypeAttrs: ['useExtraData']
    }), _dec10(_class14$1 = class DescriptionFieldView extends MultilineTextFieldView {
      static useExtraData = false;
      allowRichText = true;
    }) || _class14$1);

    /**
     * The "Owner" field.
     */
    let OwnerFieldView = (_dec11 = spina.spina({
      prototypeAttrs: ['autocomplete', 'useEditIconOnly', 'useExtraData']
    }), _dec11(_class15 = class OwnerFieldView extends TextFieldView {
      static autocomplete = {
        cmp: (term, a, b) => {
          /*
           * Sort the results with username matches first (in alphabetical
           * order), followed by real name matches (in alphabetical order).
           */
          const aUsername = a.data.username;
          const bUsername = b.data.username;
          const aFullname = a.data.fullname;
          const bFullname = a.data.fullname;
          if (aUsername.indexOf(term) === 0) {
            if (bUsername.indexOf(term) === 0) {
              return aUsername.localeCompare(bUsername);
            }
            return -1;
          } else if (bUsername.indexOf(term) === 0) {
            return 1;
          } else {
            return aFullname.localeCompare(bFullname);
          }
        },
        descKey: 'fullname',
        extraParams: {
          fullname: 1
        },
        fieldName: 'users',
        nameKey: 'username'
      };
      static useEditIconOnly = true;
      static useExtraData = false;

      /**
       * Format the value into the field.
       *
       * Args:
       *     data (string):
       *         The new value of the field.
       */
      formatValue(data) {
        const $link = this._convertToLink(data, {
          cssClass: 'user',
          makeItemText: item => item.title,
          makeItemURL: item => {
            const href = item.href;
            return href.substr(href.indexOf('/users'));
          }
        });
        this.$el.empty().append($link.user_infobox());
      }
    }) || _class15);

    /**
     * The "Summary" field.
     */
    let SummaryFieldView = (_dec12 = spina.spina({
      prototypeAttrs: ['useExtraData']
    }), _dec12(_class16 = class SummaryFieldView extends TextFieldView {
      static useExtraData = false;
    }) || _class16);

    /**
     * The "Groups" field.
     */
    let TargetGroupsFieldView = (_dec13 = spina.spina({
      prototypeAttrs: ['autocomplete', 'useEditIconOnly', 'useExtraData']
    }), _dec13(_class17 = class TargetGroupsFieldView extends CommaSeparatedValuesTextFieldView {
      static autocomplete = {
        descKey: 'display_name',
        extraParams: {
          displayname: 1
        },
        fieldName: 'groups',
        nameKey: 'name'
      };
      static useEditIconOnly = true;
      static useExtraData = false;

      /**
       * Format the value into the field.
       *
       * Args:
       *     data (Array):
       *         The new value of the field.
       */
      formatValue(data) {
        data = data || [];
        this.$el.empty().append(this._urlizeList(data, {
          makeItemText: item => item.name,
          makeItemURL: item => item.url
        }));
      }
    }) || _class17);

    /**
     * The "People" field.
     */
    let TargetPeopleFieldView = (_dec14 = spina.spina({
      prototypeAttrs: ['autocomplete', 'useEditIconOnly', 'useExtraData']
    }), _dec14(_class18 = class TargetPeopleFieldView extends CommaSeparatedValuesTextFieldView {
      static autocomplete = {
        cmp: (term, a, b) => {
          /*
           * Sort the results with username matches first (in alphabetical
           * order), followed by real name matches (in alphabetical order).
           */
          const aUsername = a.data.username;
          const bUsername = b.data.username;
          const aFullname = a.data.fullname;
          const bFullname = a.data.fullname;
          if (aUsername.indexOf(term) === 0) {
            if (bUsername.indexOf(term) === 0) {
              return aUsername.localeCompare(bUsername);
            }
            return -1;
          } else if (bUsername.indexOf(term) === 0) {
            return 1;
          } else {
            return aFullname.localeCompare(bFullname);
          }
        },
        descKey: 'fullname',
        extraParams: {
          fullname: 1
        },
        fieldName: 'users',
        nameKey: 'username'
      };
      static useEditIconOnly = true;
      static useExtraData = false;

      /**
       * Format the value into the field.
       *
       * Args:
       *     data (Array):
       *         The new value of the field.
       */
      formatValue(data) {
        data = data || [];
        this.$el.empty().append(this._urlizeList(data, {
          cssClass: 'user',
          makeItemText: item => item.username,
          makeItemURL: item => item.url
        })).find('.user').user_infobox();
      }
    }) || _class18);

    /**
     * The "Testing Done" field.
     */
    let TestingDoneFieldView = (_dec15 = spina.spina({
      prototypeAttrs: ['useExtraData']
    }), _dec15(_class19 = class TestingDoneFieldView extends MultilineTextFieldView {
      static useExtraData = false;
      allowRichText = true;
    }) || _class19);

    const reviewRequestFieldViews = /*#__PURE__*/Object.defineProperty({
        __proto__: null,
        BaseFieldView,
        BranchFieldView,
        BugsFieldView,
        ChangeDescriptionFieldView,
        CheckboxFieldView,
        CloseDescriptionFieldView,
        CommaSeparatedValuesTextFieldView,
        CommitListFieldView,
        DateFieldView,
        DependsOnFieldView,
        DescriptionFieldView,
        DropdownFieldView,
        MultilineTextFieldView,
        OwnerFieldView,
        SummaryFieldView,
        TargetGroupsFieldView,
        TargetPeopleFieldView,
        TestingDoneFieldView,
        TextFieldView
    }, Symbol.toStringTag, { value: 'Module' });

    var _dec$5, _class$e, _dec2$1, _class2$5, _dec3$1, _class3$3, _dec4$1, _class4$2, _dec5$1, _class5$2, _dec6, _class6$2, _class7$1, _dec7, _class8$1;


    const REVIEW_DOCS_URL = `${MANUAL_URL}users/#reviewing-code-and-documents`;

    /**
     * Base class for displaying a comment in the review dialog.
     */
    let BaseCommentView = (_dec$5 = spina.spina({
      prototypeAttrs: ['editorTemplate', 'thumbnailTemplate']
    }), _dec$5(_class$e = class BaseCommentView extends spina.BaseView {
      static tagName = 'li';
      static events = {
        'click .delete-comment': '_deleteComment'
      };

      /** The template to use for rendering the comment editor. */
      static editorTemplate = _.template(`<div class="edit-fields">
 <div class="edit-field">
  <div class="comment-text-field">
   <label class="comment-label" for="<%= id %>">
    <%- commentText %>
    <a href="#" role="button" class="delete-comment"
       aria-label="<%- deleteCommentText %>"
       title="<%- deleteCommentText %>"
       ><span class="fa fa-trash-o" aria-hidden="true"></span></a>
   </label>
   <pre id="<%= id %>" class="reviewtext rich-text"
        data-rich-text="true"><%- text %></pre>
  </div>
 </div>
 <div class="edit-field">
  <input class="issue-opened" id="<%= issueOpenedID %>"
         type="checkbox">
  <label for="<%= issueOpenedID %>"><%- openAnIssueText %></label>
  <% if (showVerify) { %>
   <input class="issue-verify" id="<%= verifyIssueID %>"
          type="checkbox">
   <label for="<%= verifyIssueID %>"><%- verifyIssueText %></label>
  <% } %>
 </div>
</div>`);

      /** The template to use for rendering comment thumbnails. */
      static thumbnailTemplate = null;

      /**********************
       * Instance variables *
       **********************/

      /** The checkbox for whether to open an issue. */
      $issueOpened = null;

      /** The comment editor. */
      $editor = null;

      /** The inline editor. */
      inlineEditorView = null;

      /** The text editor. */
      textEditor = null;

      /** Checkbox for controlling whether issue verification is required. */
      #$issueVerify = null;

      /** Views added by extension hooks. */
      #hookViews = [];

      /** The original state of the comment extra data field. */
      #origExtraData;

      /**
       * Initialize the view.
       */
      initialize(options) {
        this.#origExtraData = _.clone(this.model.get('extraData'));
      }

      /**
       * Remove the view.
       *
       * Returns:
       *     BaseCommentView:
       *     This object, for chaining.
       */
      remove() {
        this.#hookViews.forEach(view => view.remove());
        this.#hookViews = [];
        return super.remove();
      }

      /**
       * Return whether or not the comment needs to be saved.
       *
       * The comment will need to be saved if the inline editor is currently
       * open.
       *
       * Returns:
       *     boolean:
       *     Whether the comment needs to be saved.
       */
      needsSave() {
        return this.inlineEditorView.isDirty() || !_.isEqual(this.model.get('extraData'), this.#origExtraData);
      }

      /**
       * Save the final state of the view.
       *
       * Saves the inline editor and notifies the caller when the model is
       * synced.
       *
       * Args:
       *     options (object):
       *         Options for the model save operation.
       *
       * Returns:
       *     Promise:
       *     A promise which resolves when the operation is complete.
       */
      save(options) {
        /*
         * If the inline editor needs to be saved, ask it to do so. This will
         * call this.model.save(). If it does not, just save the model
         * directly.
         */
        return new Promise(resolve => {
          if (this.inlineEditorView.isDirty()) {
            this.model.once('sync', () => resolve());
            this.inlineEditorView.submit();
          } else {
            resolve(this.model.save(_.extend({
              attrs: ['forceTextType', 'includeTextTypes', 'extraData']
            }, options)));
          }
        });
      }

      /**
       * Render the comment view.
       */
      onInitialRender() {
        this.$el.addClass('draft').append(this.renderThumbnail()).append(this.editorTemplate({
          commentText: gettext("Comment"),
          deleteCommentText: gettext("Delete comment"),
          id: _.uniqueId('draft_comment_'),
          issueOpenedID: _.uniqueId('issue-opened'),
          openAnIssueText: gettext("Open an Issue"),
          showVerify: RB.EnabledFeatures.issueVerification,
          text: this.model.get('text'),
          verifyIssueID: _.uniqueId('issue-verify'),
          verifyIssueText: RB.CommentDialogView._verifyIssueText
        })).find('time.timesince').timesince().end();
        this.$issueOpened = this.$('.issue-opened').prop('checked', this.model.get('issueOpened')).change(() => {
          this.model.set('issueOpened', this.$issueOpened.prop('checked'));
          if (!this.model.isNew()) {
            /*
             * We don't save the issueOpened attribute for unsaved
             * models because the comment won't exist yet. If we did,
             * clicking cancel when creating a new comment wouldn't
             * delete the comment.
             */
            this.model.save({
              attrs: ['forceTextType', 'includeTextTypes', 'issueOpened']
            });
          }
        });
        this.#$issueVerify = this.$('.issue-verify').prop('checked', this.model.requiresVerification()).change(() => {
          const extraData = _.clone(this.model.get('extraData'));
          extraData.require_verification = this.#$issueVerify.prop('checked');
          this.model.set('extraData', extraData);
          if (!this.model.isNew()) {
            /*
             * We don't save the extraData attribute for unsaved models
             * because the comment won't exist yet. If we did, clicking
             * cancel when creating a new comment wouldn't delete the
             * comment.
             */
            this.model.save({
              attrs: ['forceTextType', 'includeTextTypes', 'extra_data.require_verification']
            });
          }
        });
        const $editFields = this.$('.edit-fields');
        this.$editor = this.$('pre.reviewtext');
        this.inlineEditorView = new RB.RichTextInlineEditorView({
          editIconClass: 'rb-icon rb-icon-edit',
          el: this.$editor,
          multiline: true,
          notifyUnchangedCompletion: true,
          textEditorOptions: {
            bindRichText: {
              attrName: 'richText',
              model: this.model
            }
          }
        });
        this.inlineEditorView.render();
        this.textEditor = this.inlineEditorView.textEditor;
        this.listenTo(this.inlineEditorView, 'complete', value => {
          const attrs = ['forceTextType', 'includeTextTypes', 'richText', 'text'];
          if (this.model.isNew()) {
            /*
             * If this is a new comment, we have to send whether or not an
             * issue was opened because toggling the issue opened checkbox
             * before it is completed won't save the status to the server.
             */
            attrs.push('extra_data.require_verification', 'issueOpened');
          }
          this.model.set({
            richText: this.textEditor.richText,
            text: value
          });
          this.model.save({
            attrs: attrs
          });
        });
        this.listenTo(this.model, `change:${this._getRawValueFieldsName()}`, this._updateRawValue);
        this._updateRawValue();
        this.listenTo(this.model, 'saved', this.renderText);
        this.renderText();
        this.listenTo(this.model, 'destroying', () => this.stopListening(this.model));
        RB.ReviewDialogCommentHook.each(hook => {
          const HookView = hook.get('viewType');
          const hookView = new HookView({
            extension: hook.get('extension'),
            model: this.model
          });
          this.#hookViews.push(hookView);
          $('<div class="edit-field"/>').append(hookView.$el).appendTo($editFields);
          hookView.render();
        });
      }

      /**
       * Render the thumbnail for this comment.
       *
       * Returns:
       *     jQuery:
       *     The rendered thumbnail element.
       */
      renderThumbnail() {
        if (this.thumbnailTemplate === null) {
          return null;
        }
        return $(this.thumbnailTemplate(this.model.attributes));
      }

      /**
       * Render the text for this comment.
       */
      renderText() {
        const reviewRequest = this.model.get('parentObject').get('parentObject');
        if (this.$editor) {
          RB.formatText(this.$editor, {
            bugTrackerURL: reviewRequest.get('bugTrackerURL'),
            isHTMLEncoded: true,
            newText: this.model.get('text'),
            richText: this.model.get('richText')
          });
        }
      }

      /**
       * Delete the comment associated with the model.
       */
      _deleteComment() {
        if (confirm(gettext("Are you sure you want to delete this comment?"))) {
          this.model.destroy();
        }
      }

      /**
       * Update the stored raw value of the comment text.
       *
       * This updates the raw value stored in the inline editor as a result of a
       * change to the value in the model.
       */
      _updateRawValue() {
        if (this.$editor) {
          this.inlineEditorView.options.hasRawValue = true;
          this.inlineEditorView.options.rawValue = this.model.get(this._getRawValueFieldsName()).text;
        }
      }

      /**
       * Return the field name for the raw value.
       *
       * Returns:
       *     string:
       *     The field name to use, based on the whether the user wants to use
       *     Markdown or not.
       */
      _getRawValueFieldsName() {
        return RB.UserSession.instance.get('defaultUseRichText') ? 'markdownTextFields' : 'rawTextFields';
      }
    }) || _class$e);
    /**
     * Options for the DiffCommentView.
     *
     * Version Added:
     *     6.0
     */
    /**
     * Displays a view for diff comments.
     */
    let DiffCommentView = (_dec2$1 = spina.spina({
      prototypeAttrs: ['thumbnailTemplate']
    }), _dec2$1(_class2$5 = class DiffCommentView extends BaseCommentView {
      static thumbnailTemplate = _.template(`<div class="review-dialog-comment-diff"
     id="review_draft_comment_container_<%= id %>">
 <table class="sidebyside loading">
  <thead>
   <tr>
    <th class="filename"><%- revisionText %></th>
   </tr>
  </thead>
  <tbody>
   <% for (var i = 0; i < numLines; i++) { %>
    <tr><td><pre>&nbsp;</pre></td></tr>
   <% } %>
  </tbody>
 </table>
</div>`);

      /** The stored view options. */

      /**
       * Initialize the view.
       *
       * Args:
       *     options (DiffCommentViewOptions):
       *         Options for the view.
       */
      initialize(options) {
        this.options = options;
        super.initialize(options);
      }

      /**
       * Render the comment view.
       *
       * After rendering, this will queue up a load of the diff fragment
       * to display. The view will show a spinner until the fragment has
       * loaded.
       */
      onInitialRender() {
        super.onInitialRender();
        const fileDiffID = this.model.get('fileDiffID');
        const interFileDiffID = this.model.get('interFileDiffID');
        this.options.diffQueue.queueLoad(this.model.id, interFileDiffID ? fileDiffID + '-' + interFileDiffID : fileDiffID);
      }

      /**
       * Render the thumbnail.
       *
       * Returns:
       *     jQuery:
       *     The rendered thumbnail element.
       */
      renderThumbnail() {
        const fileDiff = this.model.get('fileDiff');
        const interFileDiff = this.model.get('interFileDiff');
        let revisionText;
        if (interFileDiff) {
          revisionText = interpolate(gettext("%(filename)s (Diff revisions %(fileDiffRevision)s - %(interFileDiffRevision)s)"), {
            fileDiffRevision: fileDiff.get('sourceRevision'),
            filename: fileDiff.get('destFilename'),
            interfileDiffRevision: interFileDiff.get('sourceRevision')
          }, true);
        } else {
          revisionText = interpolate(gettext("%(filename)s (Diff revision %(fileDiffRevision)s)"), {
            fileDiffRevision: fileDiff.get('sourceRevision'),
            filename: fileDiff.get('destFilename')
          }, true);
        }
        return $(this.thumbnailTemplate({
          id: this.model.get('id'),
          numLines: this.model.getNumLines(),
          revisionText: revisionText
        }));
      }
    }) || _class2$5);

    /**
     * Displays a view for file attachment comments.
     */
    let FileAttachmentCommentView = (_dec3$1 = spina.spina({
      prototypeAttrs: ['thumbnailTemplate']
    }), _dec3$1(_class3$3 = class FileAttachmentCommentView extends BaseCommentView {
      static thumbnailTemplate = _.template(`<div class="file-attachment">
 <span class="filename">
  <a href="<%- reviewURL %>"><%- linkText %></a>
 </span>
 <span class="diffrevision"><%- revisionsStr %></span>
 <div class="thumbnail"><%= thumbnailHTML %></div>
</div>`);

      /**
       * Render the thumbnail.
       *
       * Returns:
       *     jQuery:
       *     The rendered thumbnail element.
       */
      renderThumbnail() {
        const fileAttachment = this.model.get('fileAttachment');
        const diffAgainstFileAttachment = this.model.get('diffAgainstFileAttachment');
        const revision = fileAttachment.get('revision');
        let revisionsStr;
        if (!revision) {
          /* This predates having a revision. Don't show anything. */
          revisionsStr = '';
        } else if (diffAgainstFileAttachment) {
          const revision1 = diffAgainstFileAttachment.get('revision');
          revisionsStr = interpolate(gettext("(Revisions %(revision1)s - %(revision)s)"), {
            "revision1": revision1,
            "revision": revision
          }, true);
        } else {
          revisionsStr = interpolate(gettext("(Revision %(revision)s)"), {
            "revision": revision
          }, true);
        }
        return $(this.thumbnailTemplate(_.defaults({
          revisionsStr: revisionsStr
        }, this.model.attributes)));
      }
    }) || _class3$3);
    /**
     * Displays a view for general comments.
     */
    let GeneralCommentView = (_dec4$1 = spina.spina({
      prototypeAttrs: ['thumbnailTemplate']
    }), _dec4$1(_class4$2 = class GeneralCommentView extends BaseCommentView {
      static thumbnailTemplate = null;
    }) || _class4$2);
    /**
     * Displays a view for screenshot comments.
     */
    let ScreenshotCommentView = (_dec5$1 = spina.spina({
      prototypeAttrs: ['thumbnailTemplate']
    }), _dec5$1(_class5$2 = class ScreenshotCommentView extends BaseCommentView {
      static thumbnailTemplate = _.template(`<div class="screenshot">
 <span class="filename">
  <a href="<%- screenshot.reviewURL %>"><%- displayName %></a>
 </span>
 <img src="<%= thumbnailURL %>" width="<%= width %>"
      height="<%= height %>" alt="<%- displayName %>" />
</div>`);

      /**
       * Render the thumbnail.
       *
       * Returns:
       *     jQuery:
       *     The rendered thumbnail element.
       */
      renderThumbnail() {
        const screenshot = this.model.get('screenshot');
        return $(this.thumbnailTemplate(_.defaults({
          displayName: screenshot.getDisplayName(),
          screenshot: screenshot.attributes
        }, this.model.attributes)));
      }
    }) || _class5$2);
    /**
     * Options for the HeaderFooterCommentView.
     *
     * Version Added:
     *     6.0
     */
    /**
     * The header or footer for a review.
     */
    let HeaderFooterCommentView = (_dec6 = spina.spina({
      prototypeAttrs: ['editorTemplate']
    }), _dec6(_class6$2 = class HeaderFooterCommentView extends spina.BaseView {
      static tagName = 'li';
      static events = {
        'click .add-link': 'openEditor'
      };
      static editorTemplate = _.template(`<div class="edit-fields">
 <div class="edit-field">
  <div class="add-link-container">
   <a href="#" class="add-link"><%- linkText %></a>
  </div>
  <div class="comment-text-field">
   <label for="<%= id %>" class="comment-label">
    <%- commentText %>
   </label>
   <pre id="<%= id %>" class="reviewtext rich-text"
        data-rich-text="true"><%- text %></pre>
  </div>
 </div>
</div>`);

      /**********************
       * Instance variables *
       **********************/

      /** The editor element. */
      $editor = null;

      /** The text to show in the label for the comment field. */

      /** The text editor view. */
      textEditor = null;

      /** The container element for the editor. */
      #$editorContainer = null;

      /** The container element for the "add" link. */
      #$linkContainer = null;

      /**
       * Initialize the view.
       *
       * Args:
       *     options (HeaderFooterCommentViewOptions):
       *         Options for the view.
       */
      initialize(options) {
        this.propertyName = options.propertyName;
        this.richTextPropertyName = options.richTextPropertyName;
        this.linkText = options.linkText;
        this.commentText = options.commentText;
      }

      /**
       * Set the text of the link.
       *
       * Args:
       *     linkText (string):
       *         The text to show in the "add" link.
       */
      setLinkText(linkText) {
        this.$('.add-link').text(linkText);
      }

      /**
       * Render the view.
       */
      onInitialRender() {
        const text = this.model.get(this.propertyName);
        this.$el.addClass('draft').append(this.editorTemplate({
          commentText: this.commentText,
          id: this.propertyName,
          linkText: this.linkText,
          text: text || ''
        })).find('time.timesince').timesince().end();
        this.$editor = this.$('pre.reviewtext');
        this.inlineEditorView = new RB.RichTextInlineEditorView({
          editIconClass: 'rb-icon rb-icon-edit',
          el: this.$editor,
          multiline: true,
          notifyUnchangedCompletion: true,
          textEditorOptions: {
            bindRichText: {
              attrName: this.richTextPropertyName,
              model: this.model
            }
          }
        });
        this.inlineEditorView.render();
        this.textEditor = this.inlineEditorView.textEditor;
        this.listenTo(this.inlineEditorView, 'complete', value => {
          this.model.set(this.propertyName, value);
          this.model.set(this.richTextPropertyName, this.textEditor.richText);
          this.model.save({
            attrs: [this.propertyName, this.richTextPropertyName, 'forceTextType', 'includeTextTypes']
          });
        });
        this.listenTo(this.inlineEditorView, 'cancel', () => {
          if (!this.model.get(this.propertyName)) {
            this.#$editorContainer.hide();
            this.#$linkContainer.show();
          }
        });
        this.#$editorContainer = this.$('.comment-text-field');
        this.#$linkContainer = this.$('.add-link-container');
        this.listenTo(this.model, `change:${this._getRawValueFieldsName()}`, this._updateRawValue);
        this._updateRawValue();
        this.listenTo(this.model, 'saved', this.renderText);
        this.renderText();
      }

      /**
       * Render the text for this comment.
       */
      renderText() {
        if (this.$editor) {
          const text = this.model.get(this.propertyName);
          if (text) {
            const reviewRequest = this.model.get('parentObject');
            this.#$editorContainer.show();
            this.#$linkContainer.hide();
            RB.formatText(this.$editor, {
              bugTrackerURL: reviewRequest.get('bugTrackerURL'),
              isHTMLEncoded: true,
              newText: text,
              richText: this.model.get(this.richTextPropertyName)
            });
          } else {
            this.#$editorContainer.hide();
            this.#$linkContainer.show();
          }
        }
      }

      /**
       * Return whether or not the comment needs to be saved.
       *
       * The comment will need to be saved if the inline editor is currently
       * open.
       *
       * Returns:
       *     boolean:
       *     Whether the comment needs to be saved.
       */
      needsSave() {
        return this.inlineEditorView.isDirty();
      }

      /**
       * Save the final state of the view.
       *
       * Returns:
       *     Promise:
       *     A promise which resolves when the operation is complete.
       */
      save() {
        return new Promise(resolve => {
          this.model.once('sync', () => resolve());
          this.inlineEditorView.submit();
        });
      }

      /**
       * Open the editor.
       *
       * This is used for the 'Add ...' link handler, as well as for the default
       * state of the dialog when there are no comments.
       *
       * Args:
       *     ev (Event):
       *         The event that triggered the action.
       *
       * Returns:
       *     boolean:
       *     false, always.
       */
      openEditor(ev) {
        this.#$linkContainer.hide();
        this.#$editorContainer.show();
        this.inlineEditorView.startEdit();
        if (ev) {
          ev.preventDefault();
        }
        return false;
      }

      /**
       * Delete the comment.
       *
       * This is a no-op, since headers and footers can't be deleted.
       */
      _deleteComment() {}

      /**
       * Update the stored raw value of the comment text.
       *
       * This updates the raw value stored in the inline editor as a result of a
       * change to the value in the model.
       */
      _updateRawValue() {
        if (this.$editor) {
          const rawValues = this.model.get(this._getRawValueFieldsName());
          this.inlineEditorView.options.hasRawValue = true;
          this.inlineEditorView.options.rawValue = rawValues[this.propertyName];
        }
      }

      /**
       * Return the field name for the raw value.
       *
       * Returns:
       *     string:
       *     The field name to use, based on the whether the user wants to use
       *     Markdown or not.
       */
      _getRawValueFieldsName() {
        return RB.UserSession.instance.get('defaultUseRichText') ? 'markdownTextFields' : 'rawTextFields';
      }
    }) || _class6$2);
    /**
     * View to show tips to the user about creating reviews.
     *
     * Version Added:
     *     6.0
     */
    let TipsSlideshowView = spina.spina(_class7$1 = class TipsSlideshowView extends RB.SlideshowView {
      static className = 'rb-c-alert -is-info';
      static template = `<div class="rb-c-slideshow -is-auto-cycled">
 <span class="rb-c-alert__close"
       role="button"
       aria-label="${gettext("Close")}"
       title="${gettext("Close")}"
       tabindex="0"></span>
 <div class="rb-c-alert__content">
  <div class="rb-c-alert__heading">
   <nav class="rb-c-slideshow__nav">
    <label for="">${gettext("Tip:")}</label>
    <a class="rb-c-slideshow__nav-prev"
       href="#"
       role="button"
       aria-label="${gettext("Previous")}"
       title="${gettext("Previous")}">
     <span class="fa fa-caret-left"></span>
    </a>
    <a class="rb-c-slideshow__nav-next"
       href="#"
       role="button"
       aria-label="${gettext("Next")}"
       title="${gettext("Next")}">
     <span class="fa fa-caret-right"></span>
    </a>
   </nav>
  </div>
  <ul class="rb-c-slideshow__slides">
  </ul>
 </div>
</div>`;
      #tips = [gettext("To add a comment to a code change or text file attachment, click on a line number or click and drag over multiple line numbers in the diff viewer. You'll be able to see and edit the comment from both the diff viewer and here in the review dialog."), gettext("When reviewing image file attachments, add comments by clicking and dragging out a region."), gettext("To add comments that aren't tied to a specific code change or file attachment, click on the \"Add General Comment\" button at the bottom of the review dialog or in the Review menu. This is useful for comments that apply to the review request as a whole or for ones that don't refer to any specific areas."), gettext("For file attachments that don't have a review interface, you can add a comment through the \"Add Comment\" button when hovering over the file attachment. The comment can then be seen and edited here in the review dialog."), gettext("Until you publish your review, your review and any comments in it are only visible to you and can be edited freely. After you publish, your review will be visible to others on the review request and you won't be able to edit it anymore."), gettext("Use a \"Ship It!\" in your review to show your approval for a review request. This can be toggled at the top of this review dialog, or you can quickly post a \"Ship It!\" review with no other content in it by clicking on the \"Ship It!\" action in the Review menu."), gettext("The optional header and footer fields can be useful for providing a summary or conclusion for your review, or for encouraging remarks."), interpolate(gettext("For more information on reviewing code and documents, visit our <a href=\"%(REVIEW_DOCS_URL)s\">documentation</a>."), {
        "REVIEW_DOCS_URL": REVIEW_DOCS_URL
      }, true)];
      static events = {
        'click .rb-c-alert__close': '_onCloseClicked'
      };

      /**
       * Render the view.
       */
      onInitialRender() {
        this.$el.html(TipsSlideshowView.template).attr({
          'aria-label': gettext("Tips"),
          'aria-roledescription': 'carousel'
        });
        const $slides = this.$('.rb-c-slideshow__slides');
        const li = `<li class="rb-c-slideshow__slide"
    role="group"
    aria-hidden="false"
    aria-roledescription="slide">`;
        for (const tip of this.#tips) {
          $(li).html(tip).appendTo($slides);
        }
        super.onInitialRender();
      }

      /**
       * Handler for when the close icon is clicked.
       *
       * Args:
       *     ev (JQuery.ClickEvent):
       *         The event.
       */
      _onCloseClicked(ev) {
        ev.stopPropagation();
        ev.preventDefault();
        this.trigger('hide');
      }
    }) || _class7$1;
    /**
     * Options for the ReviewDialogView.
     *
     * Version Added:
     *     6.0
     */
    /**
     * Creates a dialog for modifying a draft review.
     *
     * This provides editing capabilities for creating or modifying a new
     * review. The list of comments are retrieved from the server, providing
     * context for the comments.
     */
    let ReviewDialogView = (_dec7 = spina.spina({
      prototypeAttrs: ['template']
    }), _dec7(_class8$1 = class ReviewDialogView extends spina.BaseView {
      static id = 'review-form-comments';
      static className = 'review';
      static template = _.template(`<div class="edit-field">
 <input id="id_shipit" type="checkbox" />
 <label for="id_shipit"><%- shipItText %></label>
</div>
<div class="review-dialog-hooks-container"></div>
<div class="edit-field body-top"></div>
<ol id="review-dialog-body-top-comments" class="review-comments"></ol>
<ol id="review-dialog-general-comments" class="review-comments"></ol>
<ol id="review-dialog-screenshot-comments" class="review-comments"></ol>
<ol id="review-dialog-file-attachment-comments" class="review-comments"></ol>
<ol id="review-dialog-diff-comments" class="review-comments"></ol>
<ol id="review-dialog-body-bottom-comments" class="review-comments"></ol>
<div class="spinner"><span class="djblets-o-spinner"></span></div>
<div class="edit-field body-bottom"></div>`);

      /** The review dialog instance. */
      static instance = null;

      /**
       * Create the review dialog.
       *
       * Args:
       *     options (ReviewDialogViewOptions):
       *         Options for the dialog.
       *
       * Returns:
       *     ReviewDialogView:
       *     The new dialog instance.
       */
      static create(options) {
        console.assert(!this.instance, 'A ReviewDialogView is already opened');
        console.assert(options.review, 'A review must be specified');
        const dialog = new this({
          container: options.container,
          model: options.review,
          reviewRequestEditor: options.reviewRequestEditor
        });
        this.instance = dialog;
        dialog.render();
        dialog.on('closed', () => {
          this.instance = null;
        });
        return dialog;
      }

      /**********************
       * Instance variables *
       **********************/

      /** The view options. */

      /** The elements for the diff comment views. */
      #$diffComments = $();

      /** The dialog element. */
      #$dlg = null;

      /** The elements for the file attachment comment views. */
      #$fileAttachmentComments = $();

      /** The elements for the general comment views. */
      #$generalComments = $();

      /** The elements for the screenshot comment views. */
      #$screenshotComments = $();

      /** The default for whether to use rich text (Markdown). */
      #defaultUseRichText;

      /** The queue for loading diff fragments. */
      #diffQueue;

      /** The set of additional views added by extension hooks. */
      #hookViews = [];

      /** The publish button. */
      #publishButton = null;

      /** Additional data to send when calling this.model.ready(). */
      #queryData;

      /** The buttons for the dialog. */
      _$buttons = null;

      /** The "ship it" checkbox. */
      _$shipIt = null;

      /** The loading spinner. */
      _$spinner = null;

      /** The view for the review header editor. */

      /** The set of views for all comments. */
      _commentViews = [];

      /** The collection of diff comments. */

      /** The link to show the tips carousel when hidden. */
      #showTips = null;

      /** The carousel showing tips for creating reviews. */
      #tipsView = null;

      /**
       * Initialize the review dialog.
       *
       * Args:
       *     options (ReviewDialogViewOptions):
       *         Options for the view.
       */
      initialize(options) {
        this.options = options;
        const reviewRequest = this.model.get('parentObject');
        this.#diffQueue = new RB.DiffFragmentQueueView({
          containerPrefix: 'review_draft_comment_container',
          queueName: 'review_draft_diff_comments',
          reviewRequestPath: reviewRequest.get('reviewURL')
        });
        this._diffCommentsCollection = new RB.ResourceCollection([], {
          extraQueryData: {
            'order-by': 'filediff,first_line'
          },
          model: RB.DiffComment,
          parentResource: this.model
        });
        this._bodyTopView = new HeaderFooterCommentView({
          commentText: gettext("Header"),
          linkText: gettext("Add header"),
          model: this.model,
          propertyName: 'bodyTop',
          richTextPropertyName: 'bodyTopRichText'
        });
        this._bodyBottomView = new HeaderFooterCommentView({
          commentText: gettext("Footer"),
          linkText: gettext("Add footer"),
          model: this.model,
          propertyName: 'bodyBottom',
          richTextPropertyName: 'bodyBottomRichText'
        });
        this.listenTo(this._diffCommentsCollection, 'add', comment => {
          const view = new DiffCommentView({
            diffQueue: this.#diffQueue,
            model: comment
          });
          this._renderComment(view, this.#$diffComments);
        });
        this._fileAttachmentCommentsCollection = new RB.ResourceCollection([], {
          model: RB.FileAttachmentComment,
          parentResource: this.model
        });
        this.listenTo(this._fileAttachmentCommentsCollection, 'add', comment => {
          const view = new FileAttachmentCommentView({
            model: comment
          });
          this._renderComment(view, this.#$fileAttachmentComments);
        });
        this._generalCommentsCollection = new RB.ResourceCollection([], {
          model: RB.GeneralComment,
          parentResource: this.model
        });
        this.listenTo(this._generalCommentsCollection, 'add', comment => {
          const view = new GeneralCommentView({
            model: comment
          });
          this._renderComment(view, this.#$generalComments);
        });
        this._screenshotCommentsCollection = new RB.ResourceCollection([], {
          model: RB.ScreenshotComment,
          parentResource: this.model
        });
        this.listenTo(this._screenshotCommentsCollection, 'add', comment => {
          const view = new ScreenshotCommentView({
            model: comment
          });
          this._renderComment(view, this.#$screenshotComments);
        });
        this.#defaultUseRichText = RB.UserSession.instance.get('defaultUseRichText');
        this.#queryData = {
          'force-text-type': 'html'
        };
        if (this.#defaultUseRichText) {
          this.#queryData['include-text-types'] = 'raw,markdown';
        } else {
          this.#queryData['include-text-types'] = 'raw';
        }
        this._setTextTypeAttributes(this.model);
        this.options.reviewRequestEditor.incr('editCount');
      }

      /**
       * Remove the dialog from the DOM.
       *
       * This will remove all the extension hook views from the dialog,
       * and then remove the dialog itself.
       *
       * Returns:
       *     ReviewDialogView:
       *     This object, for chaining.
       */
      remove() {
        if (this.#publishButton) {
          this.#publishButton.remove();
          this.#publishButton = null;
        }
        this.#hookViews.forEach(view => view.remove());
        this.#hookViews = [];
        return super.remove();
      }

      /**
       * Close the review dialog.
       *
       * The dialog will be removed from the screen, and the "closed"
       * event will be triggered.
       */
      close() {
        this.options.reviewRequestEditor.decr('editCount');
        this.#$dlg.modalBox('destroy');
        this.trigger('closed');
        this.remove();
      }

      /**
       * Render the dialog.
       *
       * The dialog will be shown on the screen, and the comments from
       * the server will begin loading and rendering.
       */
      onInitialRender() {
        this.$el.html(this.template({
          addFooterText: gettext("Add footer"),
          addHeaderText: gettext("Add header"),
          markdownDocsURL: MANUAL_URL + 'users/markdown/',
          markdownText: gettext("Markdown Reference"),
          shipItText: gettext("Ship It")
        }));
        this.#$diffComments = this.$('#review-dialog-diff-comments');
        this.#$fileAttachmentComments = this.$('#review-dialog-file-attachment-comments');
        this.#$generalComments = this.$('#review-dialog-general-comments');
        this.#$screenshotComments = this.$('#review-dialog-screenshot-comments');
        this._$spinner = this.$('.spinner');
        this._$shipIt = this.$('#id_shipit');
        const $hooksContainer = this.$('.review-dialog-hooks-container');
        this.#tipsView = new TipsSlideshowView({
          autoCycleTimeMS: 5000
        });
        this.#tipsView.render();
        this.#tipsView.$el.hide().prependTo(this.$el);
        this.listenTo(this.#tipsView, 'hide', () => {
          RB.UserSession.instance.set('showReviewDialogTips', false);
          this.#updateTipsVisibility(false);
        });
        this.#showTips = $('<a href="#" id="show-review-tips" role="button">').text(gettext("Show tips")).hide().prependTo(this.$el).click(e => {
          e.stopPropagation();
          e.preventDefault();
          this.#updateTipsVisibility(true);
          RB.UserSession.instance.set('showReviewDialogTips', true);
        });
        this.#updateTipsVisibility(RB.UserSession.instance.get('showReviewDialogTips'));
        RB.ReviewDialogHook.each(hook => {
          const HookView = hook.get('viewType');
          const hookView = new HookView({
            extension: hook.get('extension'),
            model: this.model
          });
          this.#hookViews.push(hookView);
          $hooksContainer.append(hookView.$el);
          hookView.render();
        });
        this._bodyTopView.$el.appendTo(this.$('#review-dialog-body-top-comments'));
        this._bodyBottomView.$el.appendTo(this.$('#review-dialog-body-bottom-comments'));

        /*
         * Even if the model is already loaded, we may not have the right text
         * type data. Force it to reload.
         */
        this.model.set('loaded', false);
        this.model.ready({
          data: this.#queryData
        }).then(() => {
          this._renderDialog();
          this._bodyTopView.render();
          this._bodyBottomView.render();
          if (this.model.isNew() || this.model.get('bodyTop') === '') {
            this._bodyTopView.openEditor();
          }
          if (this.model.isNew()) {
            this._$spinner.remove();
            this._$spinner = null;
            this._handleEmptyReview();
            this.trigger('loadCommentsDone');
          } else {
            this._$shipIt.prop('checked', this.model.get('shipIt'));
            this._loadComments();
          }
          this.listenTo(this.model, 'change:bodyBottom', this._handleEmptyReview);
        });
      }

      /**
       * Load the comments from the server.
       *
       * This will begin chaining together the loads of each set of
       * comment types. Each loaded comment will be rendered to the
       * dialog once loaded.
       */
      async _loadComments() {
        const collections = [this._screenshotCommentsCollection, this._fileAttachmentCommentsCollection, this._diffCommentsCollection];
        if (RB.EnabledFeatures.generalComments) {
          /*
           * Prepend the General Comments so they're fetched and shown
           * first.
           */
          collections.unshift(this._generalCommentsCollection);
        }
        const loadCollections = collections.map(async collection => {
          await collection.fetchAll({
            data: this.#queryData
          });
          if (collection === this._diffCommentsCollection) {
            this.#diffQueue.loadFragments();
          }
        });
        try {
          await Promise.all(loadCollections);
          this._$spinner.remove();
          this._$spinner = null;
          this._handleEmptyReview();
          this.trigger('loadCommentsDone');
        } catch (err) {
          alert(err.message); // TODO: provide better output.
        }
      }

      /**
       * Properly set the view when the review is empty.
       */
      _handleEmptyReview() {
        /*
         * We only display the bottom textarea if we have comments or the user
         * has previously set the bottom textarea -- we don't want the user to
         * not be able to remove their text.
         */
        if (this._commentViews.length === 0 && !this.model.get('bodyBottom')) {
          this._bodyBottomView.$el.hide();
          this._bodyTopView.setLinkText(gettext("Add text"));
        }
      }

      /**
       * Render a comment to the dialog.
       *
       * Args:
       *     view (BaseCommentView):
       *         The view to render.
       *
       *     $container (jQuery):
       *         The container to add the view to.
       */
      _renderComment(view, $container) {
        this._setTextTypeAttributes(view.model);
        this._commentViews.push(view);
        this.listenTo(view.model, 'destroyed', () => {
          view.$el.fadeOut({
            complete: () => {
              view.remove();
              this._handleEmptyReview();
            }
          });
          this._commentViews = _.without(this._commentViews, view);
        });
        $container.append(view.$el);
        view.render();
        this.#$dlg.scrollTop(view.$el.position().top + this.#$dlg.getExtents('p', 't'));
      }

      /**
       * Render the dialog.
       *
       * This will create and render a dialog to the screen, adding
       * this view's element as the child.
       */
      _renderDialog() {
        const $leftButtons = $('<div class="review-dialog-buttons-left"/>');
        const $rightButtons = $('<div class="review-dialog-buttons-right"/>');
        const buttons = [$leftButtons, $rightButtons];
        if (RB.EnabledFeatures.generalComments) {
          $leftButtons.append($('<input type="button" />').val(gettext("Add General Comment")).attr('title', gettext("Add a new general comment to the review")).click(() => this.#onAddCommentClicked()));
        }
        $rightButtons.append($('<div id="review-form-publish-split-btn-container" />'));
        $rightButtons.append($('<input type="button"/>').val(gettext("Discard Review")).click(() => this._onDiscardClicked()));
        $rightButtons.append($('<input type="button"/>').val(gettext("Close")).click(() => {
          this._saveReview(false);
          return false;
        }));
        const reviewRequest = this.model.get('parentObject');
        this.#$dlg = $('<div/>').attr('id', 'review-form').append(this.$el).modalBox({
          boxID: 'review-form-modalbox',
          buttons: buttons,
          container: this.options.container || 'body',
          stretchX: true,
          stretchY: true,
          title: interpolate(gettext("Review for: %(value1)s"), {
            "value1": reviewRequest.get('summary')
          }, true)
        }).on('close', () => this._saveReview(false)).attr('scrollTop', 0).trigger('ready');

        /* Must be done after the dialog is rendered. */

        this.#publishButton = new RB.MenuButtonView({
          ariaMenuLabel: gettext("More publishing options"),
          direction: 'up',
          el: $('#review-form-publish-split-btn-container'),
          menuItems: [{
            onClick: () => {
              this._saveReview(true, {
                publishToOwnerOnly: true
              });
              this.close();
              return false;
            },
            text: gettext("... and only e-mail the owner")
          }, {
            onClick: () => {
              this._saveReview(true, {
                publishAndArchive: true
              });
              this.close();
              return false;
            },
            text: gettext("... and archive the review request")
          }],
          onPrimaryButtonClick: () => {
            this._saveReview(true);
            return false;
          },
          text: gettext("Publish Review")
        });
        this.#publishButton.render();
        this._$buttons = this.#$dlg.modalBox('buttons');
      }

      /**
       * Handle a click on the "Add Comment" button.
       *
       * Returns:
       *     boolean:
       *     This always returns false to indicate that the dialog should not
       *     close.
       */
      #onAddCommentClicked() {
        const comment = this.model.createGeneralComment(undefined, RB.UserSession.instance.get('commentsOpenAnIssue'));
        this._generalCommentsCollection.add(comment);
        this._bodyBottomView.$el.show();
        this._commentViews[this._commentViews.length - 1].inlineEditorView.startEdit();
        return false;
      }

      /**
       * Handle a click on the "Discard Review" button.
       *
       * Prompts the user to confirm that they want the review discarded.
       * If they confirm, the review will be discarded.
       *
       * Returns:
       *     boolean:
       *     This always returns false to indicate that the dialog should not
       *     close.
       */
      _onDiscardClicked() {
        const $cancelButton = $('<input type="button">').val(gettext("Cancel"));
        const $discardButton = $('<input type="button">').val(gettext("Discard")).click(async () => {
          this.close();
          await this.model.destroy();
          RB.ClientCommChannel.getInstance().reload();
          if (!RB.EnabledFeatures.unifiedBanner) {
            RB.DraftReviewBannerView.instance.hideAndReload();
          }
        });
        $('<p/>').text(gettext("If you discard this review, all related comments will be permanently deleted.")).modalBox({
          buttons: [$cancelButton, $discardButton],
          title: gettext("Are you sure you want to discard this review?")
        });
        return false;
      }

      /**
       * Save the review.
       *
       * First, this loops over all the comment editors and saves any which are
       * still in the editing phase.
       *
       * Args:
       *     publish (boolean):
       *         Whether the review should be published.
       *
       *     options (object):
       *         Options for the model save operation.
       *
       * Returns:
       *     Promise:
       *     A promise which resolves when the operation is complete.
       */
      _saveReview(publish) {
        let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
        if (publish && options.publishToOwnerOnly) {
          this.model.set('publishToOwnerOnly', true);
        }
        if (publish && options.publishAndArchive) {
          this.model.set('publishAndArchive', true);
        }
        this._$buttons.prop('disabled');
        let madeChanges = false;
        $.funcQueue('reviewForm').clear();
        function maybeSave(view) {
          if (view.needsSave()) {
            $.funcQueue('reviewForm').add(() => {
              madeChanges = true;
              view.save().then(() => $.funcQueue('reviewForm').next());
            });
          }
        }
        maybeSave(this._bodyTopView);
        maybeSave(this._bodyBottomView);
        this._commentViews.forEach(view => maybeSave(view));
        $.funcQueue('reviewForm').add(() => {
          const shipIt = this._$shipIt.prop('checked');
          const saveFunc = publish ? this.model.publish : this.model.save;
          if (this.model.get('public') === publish && this.model.get('shipIt') === shipIt) {
            $.funcQueue('reviewForm').next();
          } else {
            madeChanges = true;
            this.model.set({
              shipIt: shipIt
            });
            saveFunc.call(this.model, {
              attrs: ['forceTextType', 'includeTextTypes', 'public', 'publishAndArchive', 'publishToOwnerOnly', 'shipIt']
            }).then(() => $.funcQueue('reviewForm').next()).catch(err => {
              console.error('Failed to save review', err);
            });
          }
        });
        $.funcQueue('reviewForm').add(() => {
          this.close();
          if (RB.EnabledFeatures.unifiedBanner) {
            if (publish) {
              // Reload the page.
              RB.navigateTo(this.model.get('parentObject').get('reviewURL'));
            }
          } else {
            const reviewBanner = RB.DraftReviewBannerView.instance;
            if (reviewBanner) {
              if (publish) {
                reviewBanner.hideAndReload();
              } else if (this.model.isNew() && !madeChanges) {
                reviewBanner.hide();
              } else {
                reviewBanner.show();
              }
            }
          }
          $.funcQueue('reviewForm').next();
        });
        return new Promise(resolve => {
          $.funcQueue('reviewForm').add(() => resolve());
          $.funcQueue('reviewForm').start();
        });
      }

      /**
       * Set the text attributes on a model for forcing and including types.
       *
       * Args:
       *     model (Backbone.Model):
       *         The model to set the text type attributes on.
       */
      _setTextTypeAttributes(model) {
        model.set({
          forceTextType: 'html',
          includeTextTypes: this.#defaultUseRichText ? 'raw,markdown' : 'raw'
        });
      }

      /**
       * Update the visibility of the tips box.
       *
       * Args:
       *     show (boolean):
       *         Whether to show the tips.
       */
      #updateTipsVisibility(show) {
        this.#tipsView.$el.toggle(show);
        this.#showTips.toggle(!show);
      }
    }) || _class8$1);

    var _class$d, _class2$4, _class3$2, _class4$1, _class5$1, _class6$1, _class7, _class8, _class9, _class10, _class11, _class12, _class13, _class14;
    /**
     * The view to manage the archive menu.
     *
     * Version Added:
     *     6.0
     */
    let ArchiveMenuActionView = spina.spina(_class$d = class ArchiveMenuActionView extends RB.Actions.MenuActionView {
      static events = {
        'click': 'onClick',
        'focusout': 'onFocusOut',
        'keydown': 'onKeyDown',
        'keyup': 'onKeyUp',
        'mouseenter': 'openMenu',
        'mouseleave': 'closeMenu',
        'touchend .menu-title': 'onTouchEnd'
      };

      /**********************
       * Instance variables *
       **********************/
      #activationKeyDown = false;
      #reviewRequest;

      /**
       * Initialize the view.
       */
      initialize() {
        super.initialize();
        const page = RB.PageManager.getPage();
        const reviewRequestEditor = page.getReviewRequestEditorModel();
        this.#reviewRequest = reviewRequestEditor.get('reviewRequest');
        this.listenTo(this.#reviewRequest, 'change:visibility', this.render);
      }

      /**
       * Render the view.
       */
      onRender() {
        super.onRender();
        const visibility = this.#reviewRequest.get('visibility');
        const visible = visibility === RB.ReviewRequest.VISIBILITY_VISIBLE;
        this.$('.rb-icon').toggleClass('rb-icon-archive-on', !visible).toggleClass('rb-icon-archive-off', visible).attr('title', visible ? gettext("Unarchive review request") : gettext("Archive review request"));
      }

      /**
       * Handle a click event.
       *
       * Args:
       *     e (MouseEvent):
       *         The event object.
       */
      async onClick(e) {
        if (!this.#activationKeyDown) {
          e.preventDefault();
          e.stopPropagation();
          const visibility = this.#reviewRequest.get('visibility');
          const visible = visibility === RB.ReviewRequest.VISIBILITY_VISIBLE;
          const collection = visibility === RB.ReviewRequest.VISIBILITY_MUTED ? RB.UserSession.instance.mutedReviewRequests : RB.UserSession.instance.archivedReviewRequests;
          if (visible) {
            await collection.addImmediately(this.#reviewRequest);
          } else {
            await collection.removeImmediately(this.#reviewRequest);
          }
          this.#reviewRequest.set('visibility', visible ? RB.ReviewRequest.VISIBILITY_ARCHIVED : RB.ReviewRequest.VISIBILITY_VISIBLE);
        }
      }

      /**
       * Handle a keydown event.
       *
       * We use this to track whether the activation keys are being pressed
       * (Enter or Space) so that we can avoid triggering the default click
       * behavior, which is a shortcut to the archive functionality.
       *
       * Args:
       *     e (KeyboardEvent):
       *         The event object.
       */
      onKeyDown(e) {
        if (e.key === 'Enter' || e.key === 'Space') {
          this.#activationKeyDown = true;
        }
        super.onKeyDown(e);
      }

      /**
       * Handle a keyup event.
       */
      onKeyUp() {
        this.#activationKeyDown = false;
      }

      /**
       * Handle a touchstart event.
       */
      onTouchStart() {
        // Do nothing.
      }

      /**
       * Handle a touchend event.
       *
       * Args:
       *     e (TouchEvent):
       *         The event object.
       */
      onTouchEnd(e) {
        /*
         * With mouse clicks, we allow users to click on the menu header itself
         * as a shortcut for just choosing archive, but with touch events we
         * can't do that because then the user would never have access to the
         * menu.
         *
         * If we allow this event to run the default handler, it would also
         * give us a 'click' event after.
         */
        e.preventDefault();
        if (this.menu.isOpen) {
          this.closeMenu();
        } else {
          this.openMenu();
        }
      }
    }) || _class$d;

    /**
     * Base class for archive views.
     *
     * Version Added:
     *     6.0
     */
    let BaseVisibilityActionView = spina.spina(_class2$4 = class BaseVisibilityActionView extends RB.Actions.ActionView {
      static events = {
        'click': '_toggle'
      };

      /**********************
       * Instance variables *
       **********************/

      /** The collection to use for making changes to the visibility. */

      /** The visibility type controlled by this action. */
      visibilityType = RB.ReviewRequest.VISIBILITY_ARCHIVED;
      #reviewRequest;

      /**
       * Initialize the view.
       */
      initialize() {
        super.initialize();
        const page = RB.PageManager.getPage();
        const reviewRequestEditor = page.getReviewRequestEditorModel();
        this.#reviewRequest = reviewRequestEditor.get('reviewRequest');
        this.listenTo(this.#reviewRequest, 'change:visibility', this.render);
      }

      /**
       * Render the view.
       *
       * Returns:
       *     BaseVisibilityActionView:
       *     This object, for chaining.
       */
      onRender() {
        this.$('span').text(this.getLabel(this.#reviewRequest.get('visibility')));
      }

      /**
       * Return the label to use for the menu item.
       *
       * Args:
       *     visibility (number):
       *         The visibility state of the review request.
       *
       * Returns:
       *     string:
       *     The label to show based on the current visibility state.
       */

      /**
       * Toggle the archive state of the review request.
       *
       * Args:
       *     e (Event):
       *         The event that triggered the action.
       */
      async _toggle(e) {
        e.preventDefault();
        e.stopPropagation();
        const visibility = this.#reviewRequest.get('visibility');
        const visible = visibility !== this.visibilityType;
        if (visible) {
          await this.collection.addImmediately(this.#reviewRequest);
        } else {
          await this.collection.removeImmediately(this.#reviewRequest);
        }
        this.#reviewRequest.set('visibility', visible ? this.visibilityType : RB.ReviewRequest.VISIBILITY_VISIBLE);
      }
    }) || _class2$4;
    /**
     * Archive action view.
     *
     * Version Added:
     *     6.0
     */
    let ArchiveActionView = spina.spina(_class3$2 = class ArchiveActionView extends BaseVisibilityActionView {
      /**********************
       * Instance variables *
       **********************/

      /** The collection to use for making changes to the visibility. */
      collection = RB.UserSession.instance.archivedReviewRequests;

      /**
       * Return the label to use for the menu item.
       *
       * Args:
       *     visibility (number):
       *         The visibility state of the review request.
       *
       * Returns:
       *     string:
       *     The text to use for the label.
       */
      getLabel(visibility) {
        return visibility === this.visibilityType ? gettext("Unarchive") : gettext("Archive");
      }
    }) || _class3$2;

    /**
     * Mute action view.
     *
     * Version Added:
     *     6.0
     */
    let MuteActionView = spina.spina(_class4$1 = class MuteActionView extends BaseVisibilityActionView {
      /**********************
       * Instance variables *
       **********************/

      /** The collection to use for making changes to the visibility. */
      collection = RB.UserSession.instance.mutedReviewRequests;

      /** The visibility type controlled by this action. */
      visibilityType = RB.ReviewRequest.VISIBILITY_MUTED;

      /**
       * Return the label to use for the menu item.
       *
       * Args:
       *     visibility (number):
       *         The visibility state of the review request.
       *
       * Returns:
       *     string:
       *     The text to use for the label.
       */
      getLabel(visibility) {
        return visibility === this.visibilityType ? gettext("Unmute") : gettext("Mute");
      }
    }) || _class4$1;

    /**
     * Action view to create a blank review.
     *
     * Version Added:
     *     6.0
     */
    let CreateReviewActionView = spina.spina(_class5$1 = class CreateReviewActionView extends RB.Actions.MenuItemActionView {
      /**********************
       * Instance variables *
       **********************/

      #pendingReview;
      #reviewRequestEditor;

      /**
       * Initialize the view.
       *
       * Args:
       *     options (object):
       *         Options to pass through to the parent class.
       */
      initialize(options) {
        super.initialize(options);
        const page = RB.PageManager.getPage();
        this.#pendingReview = page.pendingReview;
        this.#reviewRequestEditor = page.reviewRequestEditorView.model;
      }

      /**
       * Render the action.
       *
       * Returns:
       *     CreateReviewActionView:
       *     This object, for chaining.
       */
      onInitialRender() {
        this.listenTo(this.#pendingReview, 'saved destroy sync', this.#update);
        this.#update();
      }

      /**
       * Update the visibility state of the action.
       *
       * This will show the action only when there's no existing pending review.
       */
      #update() {
        this.$el.parent().toggle(this.#pendingReview.isNew());
      }

      /**
       * Handle activation of the action.
       */
      activate() {
        this.#pendingReview.save().then(() => {
          ReviewDialogView.create({
            review: this.#pendingReview,
            reviewRequestEditor: this.#reviewRequestEditor
          });
        });
      }
    }) || _class5$1;

    /**
     * Action view to pop up the edit review dialog.
     *
     * Version Added:
     *     6.0
     */
    let EditReviewActionView = spina.spina(_class6$1 = class EditReviewActionView extends RB.Actions.MenuItemActionView {
      /**********************
       * Instance variables *
       **********************/

      #pendingReview;
      #reviewRequestEditor;

      /**
       * Create the view.
       *
       * Args:
       *     options (object):
       *         Options to pass through to the parent class.
       */
      initialize(options) {
        super.initialize(options);
        const page = RB.PageManager.getPage();
        this.#pendingReview = page.pendingReview;
        this.#reviewRequestEditor = page.reviewRequestEditorView.model;
      }

      /**
       * Render the action.
       */
      onInitialRender() {
        this.listenTo(this.#pendingReview, 'saved destroy sync', this.#update);
        this.#update();
      }

      /**
       * Update the visibility state of the action.
       */
      #update() {
        this.$el.parent().toggle(!this.#pendingReview.isNew());
      }

      /**
       * Handle the action activation.
       */
      activate() {
        ReviewDialogView.create({
          review: this.#pendingReview,
          reviewRequestEditor: this.#reviewRequestEditor
        });
      }
    }) || _class6$1;

    /**
     * Action view to add a general comment.
     *
     * Version Added:
     *     6.0
     */
    let AddGeneralCommentActionView = spina.spina(_class7 = class AddGeneralCommentActionView extends RB.Actions.MenuItemActionView {
      /**
       * Handle the action activation.
       */
      activate() {
        RB.PageManager.getPage().addGeneralComment();
      }
    }) || _class7;

    /**
     * Action view to mark a review request as "Ship It".
     *
     * Version Added:
     *     6.0
     */
    let ShipItActionView = spina.spina(_class8 = class ShipItActionView extends RB.Actions.MenuItemActionView {
      /**
       * Handle the action activation.
       */
      activate() {
        RB.PageManager.getPage().shipIt();
      }
    }) || _class8;

    /**
     * Action view for the review menu.
     *
     * Version Added:
     *     6.0
     */
    let ReviewMenuActionView = spina.spina(_class9 = class ReviewMenuActionView extends RB.Actions.MenuActionView {
      /**********************
       * Instance variables *
       **********************/

      /** The event overlay when the menu is shown in mobile mode. */
      #overlay = null;

      /**
       * Close the menu.
       */
      closeMenu() {
        super.closeMenu();
        if (this.#overlay) {
          this.#overlay.remove();
          this.#overlay = null;
        }
      }

      /**
       * Handle a touchstart event.
       *
       * Args:
       *     e (TouchEvent):
       *         The touch event.
       */
      onTouchStart(e) {
        super.onTouchStart(e);
        if (this.menu.isOpen) {
          this.#overlay = new RB.OverlayView();
          this.#overlay.$el.appendTo('body');
          this.listenTo(this.#overlay, 'click', () => {
            this.closeMenu();
          });
        }
      }
    }) || _class9;

    /**
     * Action view for the "Add File" command.
     *
     * Version Added:
     *     6.0
     */
    let AddFileActionView = spina.spina(_class10 = class AddFileActionView extends RB.Actions.MenuItemActionView {
      /**
       * Handle the action activation.
       */
      activate() {
        const page = RB.PageManager.getPage();
        const uploadDialog = new RB.UploadAttachmentView({
          reviewRequestEditor: page.reviewRequestEditorView.model
        });
        uploadDialog.show();
      }
    }) || _class10;

    /**
     * Action view for the "Update Diff" command.
     *
     * Version Added:
     *     6.0
     */
    let UpdateDiffActionView = spina.spina(_class11 = class UpdateDiffActionView extends RB.Actions.MenuItemActionView {
      /**
       * Handle the action activation.
       */
      activate() {
        const page = RB.PageManager.getPage();
        const reviewRequestEditor = page.reviewRequestEditorView.model;
        const reviewRequest = reviewRequestEditor.get('reviewRequest');
        const updateDiffView = new RB.UpdateDiffView({
          model: new RB.UploadDiffModel({
            changeNumber: reviewRequest.get('commitID'),
            repository: reviewRequest.get('repository'),
            reviewRequest: reviewRequest
          })
        });
        updateDiffView.render();
      }
    }) || _class11;

    /**
     * Action view for the "Close > Discarded" command.
     *
     * Version Added:
     *     6.0
     */
    let CloseDiscardedActionView = spina.spina(_class12 = class CloseDiscardedActionView extends RB.Actions.MenuItemActionView {
      /**
       * Handle the action activation.
       */
      activate() {
        const page = RB.PageManager.getPage();
        const reviewRequestEditor = page.reviewRequestEditorView.model;
        const reviewRequest = reviewRequestEditor.get('reviewRequest');
        const confirmText = gettext("Are you sure you want to discard this review request?");
        if (confirm(confirmText)) {
          reviewRequest.close({
            type: RB.ReviewRequest.CLOSE_DISCARDED
          }).catch(err => this.model.trigger('closeError', err.message));
        }
      }
    }) || _class12;

    /**
     * Action view for the "Close > Completed" command.
     *
     * Version Added:
     *     6.0
     */
    let CloseCompletedActionView = spina.spina(_class13 = class CloseCompletedActionView extends RB.Actions.MenuItemActionView {
      /**
       * Handle the action activation.
       */
      activate() {
        const page = RB.PageManager.getPage();
        const reviewRequestEditor = page.reviewRequestEditorView.model;
        const reviewRequest = reviewRequestEditor.get('reviewRequest');

        /*
         * This is a non-destructive event, so don't confirm unless there's
         * a draft.
         */
        let submit = true;
        if (reviewRequestEditor.get('hasDraft')) {
          submit = confirm(gettext("You have an unpublished draft. If you close this review request, the draft will be discarded. Are you sure you want to close the review request?"));
        }
        if (submit) {
          reviewRequest.close({
            type: RB.ReviewRequest.CLOSE_SUBMITTED
          }).catch(err => this.model.trigger('closeError', err.message));
        }
      }
    }) || _class13;

    /**
     * Action view for the "Close > Delete Permanently" command.
     *
     * Version Added:
     *     6.0
     */
    let DeleteActionView = spina.spina(_class14 = class DeleteActionView extends RB.Actions.MenuItemActionView {
      /**
       * Handle the action activation.
       */
      activate() {
        const page = RB.PageManager.getPage();
        const reviewRequestEditor = page.reviewRequestEditorView.model;
        const reviewRequest = reviewRequestEditor.get('reviewRequest');
        const $dlg = $('<p>').text(gettext("This deletion cannot be undone. All diffs and reviews will be deleted as well.")).modalBox({
          buttons: [$(`<input type="button" value="${gettext("Cancel")}"/>`), $(`<input type="button" value="${gettext("Delete")}"/>`).click(() => reviewRequest.destroy({
            buttons: $('input', $dlg.modalBox('buttons'))
          }).then(() => RB.navigateTo(SITE_ROOT)))],
          title: gettext("Are you sure you want to delete this review request?")
        }).on('close', () => $dlg.modalBox('destroy'));
      }
    }) || _class14;

    var _dec$4, _class$c;

    /**
     * Represents the state for editing a new or existing draft comment.
     *
     * From here, a comment can be created, edited, or deleted.
     *
     * This will provide state on what actions are available on a comment,
     * informative text, dirty states, existing published comments on the
     * same region this comment is on, and more.
     */
    let CommentEditor = (_dec$4 = spina.spina({
      mixins: [RB.ExtraDataMixin]
    }), _dec$4(_class$c = class CommentEditor extends spina.BaseModel {
      /**
       * Return the default values for the model attributes.
       *
       * Returns:
       *     CommentEditorAttrs:
       *     The default values for the attributes.
       */
      static defaults() {
        const userSession = RB.UserSession.instance;
        return {
          canDelete: false,
          canEdit: undefined,
          canSave: false,
          comment: null,
          dirty: false,
          editing: false,
          extraData: {},
          openIssue: userSession.get('commentsOpenAnIssue'),
          publishedComments: [],
          publishedCommentsType: null,
          requireVerification: false,
          // TODO: add a user preference for this
          reviewRequest: null,
          reviewRequestEditor: null,
          richText: userSession.get('defaultUseRichText'),
          text: ''
        };
      }

      /**
       * Initialize the comment editor.
       */
      initialize() {
        this.listenTo(this, 'change:comment', this.#updateFromComment);
        this.#updateFromComment();

        /*
         * Unless a canEdit value is explicitly given, we want to compute
         * the proper state.
         */
        if (this.get('canEdit') === undefined) {
          this.#updateCanEdit();
        }
        this.listenTo(this, 'change:dirty', (model, dirty) => {
          const reviewRequestEditor = this.get('reviewRequestEditor');
          if (reviewRequestEditor) {
            if (dirty) {
              reviewRequestEditor.incr('editCount');
            } else {
              reviewRequestEditor.decr('editCount');
            }
          }
        });
        this.listenTo(this, 'change:openIssue change:requireVerification change:richText ' + 'change:text', () => {
          if (this.get('editing')) {
            this.set('dirty', true);
            this.#updateState();
          }
        });
        this.#updateState();
        this._setupExtraData();
      }

      /**
       * Set the editor to begin editing a new or existing comment.
       */
      beginEdit() {
        console.assert(this.get('canEdit'), 'beginEdit() called when canEdit is false.');
        console.assert(this.get('comment'), 'beginEdit() called when no comment was first set.');
        this.set({
          dirty: false,
          editing: true
        });
        this.#updateState();
      }

      /**
       * Delete the current comment, if it can be deleted.
       *
       * This requires that there's a saved comment to delete.
       *
       * The editor will be marked as closed, requiring a new call to beginEdit.
       *
       * Returns:
       *     Promise:
       *     A promise which resolves when the operation is complete.
       */
      async deleteComment() {
        console.assert(this.get('canDelete'), 'deleteComment() called when canDelete is false.');
        const comment = this.get('comment');
        await comment.destroy();
        this.trigger('deleted');
        this.close();
      }

      /**
       * Cancel editing of a comment.
       *
       * If there's a saved comment and it's been made empty, it will end
       * up being deleted. Then this editor will be marked as closed,
       * requiring a new call to beginEdit.
       */
      cancel() {
        this.stopListening(this, 'change:comment');
        const comment = this.get('comment');
        if (comment) {
          comment.destroyIfEmpty();
          this.trigger('canceled');
        }
        this.close();
      }

      /**
       * Close editing of the comment.
       *
       * The comment state will be reset, and the "closed" event will be
       * triggered.
       *
       * To edit a comment again after closing it, the proper state must be
       * set again and beginEdit must be called.
       */
      close() {
        /* Set this first, to prevent dirty firing. */
        this.set('editing', false);
        this.set({
          comment: null,
          dirty: false,
          extraData: new RB.ExtraData(),
          text: ''
        });
        this.trigger('closed');
      }

      /**
       * Save the comment.
       *
       * If this is a new comment, it will be created on the server.
       * Otherwise, the existing comment will be updated.
       *
       * The editor will not automatically be marked as closed. That is up
       * to the caller.
       *
       * Version Changed:
       *     5.0:
       *     Deprecated callbacks and added a promise return value.
       *
       * Args:
       *     options (object, optional):
       *         Options for the save operation.
       *
       *     context (object, optional):
       *         The context to use when calling callbacks.
       *
       * Returns:
       *     Promise:
       *     A promise which resolves when the operation is complete.
       */
      async save() {
        let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
        let context = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : undefined;
        if (_.isFunction(options.success) || _.isFunction(options.error) || _.isFunction(options.complete)) {
          console.warn('RB.CommentEditor.save was called using ' + 'callbacks. Callers should be updated to use ' + 'promises instead.');
          return RB.promiseToCallbacks(options, context, newOptions => this.save(newOptions));
        }
        console.assert(this.get('canSave'), 'save() called when canSave is false.');
        const extraData = _.clone(this.get('extraData'));
        extraData.require_verification = this.get('requireVerification');
        const comment = this.get('comment');
        comment.set({
          extraData: extraData,
          includeTextTypes: 'html,raw,markdown',
          issueOpened: this.get('openIssue'),
          richText: this.get('richText'),
          text: this.get('text')
        });
        await comment.save();
        this.set('dirty', false);
        this.trigger('saved');
      }

      /**
       * Update the state of the editor from the currently set comment.
       */
      async #updateFromComment() {
        const oldComment = this.previous('comment');
        const comment = this.get('comment');
        if (oldComment) {
          oldComment.destroyIfEmpty();
        }
        if (comment) {
          const defaults = _.result(this, 'defaults');
          const defaultRichText = defaults.richText;

          /*
           * Set the attributes based on what we know at page load time.
           *
           * Note that it is *possible* that the comments will have changed
           * server-side since loading the page (if the user is reviewing
           * the same diff in two tabs). However, it's unlikely.
           *
           * Doing this before the ready() call ensures that we'll have the
           * text and state up-front and that it won't overwrite what the
           * user has typed after load.
           *
           * Note also that we'll always want to use our default richText
           * value if it's true, and we'll fall back on the comment's value
           * if false. This is so that we can keep a consistent experience
           * when the "Always edit Markdown by default" value is set.
           */
          this.set({
            dirty: false,
            extraData: comment.get('extraData'),
            openIssue: comment.get('issueOpened') === null ? defaults.openIssue : comment.get('issueOpened'),
            requireVerification: comment.requiresVerification(),
            richText: defaultRichText || !!comment.get('richText')
          });

          /*
           * We'll try to set the one from the appropriate text fields, if it
           * exists and is not empty. If we have this, then it came from a
           * previous save. If we don't have it, we'll fall back to "text",
           * which should be normalized content from the initial page load.
           */
          const textFields = comment.get('richText') || !defaultRichText ? comment.get('rawTextFields') : comment.get('markdownTextFields');
          this.set('text', !_.isEmpty(textFields) ? textFields.text : comment.get('text'));
          await comment.ready();
          this.#updateState();
        }
      }

      /**
       * Update the canEdit state of the editor.
       *
       * This is based on the authentication state of the user, and
       * whether or not there's an existing draft for the review request.
       */
      #updateCanEdit() {
        const userSession = RB.UserSession.instance;
        this.set('canEdit', userSession.get('authenticated') && !userSession.get('readOnly'));
      }

      /**
       * Update the capability states of the editor.
       *
       * Some of the can* properties will change to reflect the various
       * actions that can be performed with the editor.
       */
      #updateState() {
        const canEdit = this.get('canEdit');
        const editing = this.get('editing');
        const comment = this.get('comment');
        this.set({
          canDelete: canEdit && editing && comment && !comment.isNew(),
          canSave: canEdit && editing && this.get('text') !== ''
        });
      }
    }) || _class$c);

    var _class$b, _class2$3;
    /**
     * Displays a list of existing comments within a comment dialog.
     *
     * Each comment in the list is an existing, published comment that a user
     * has made. They will be displayed, along with any issue states and
     * identifying information, and links for viewing the comment on the review
     * or replying to it.
     *
     * This is used internally in CommentDialogView.
     */
    let CommentsListView = spina.spina(_class$b = class CommentsListView extends spina.BaseView {
      static itemTemplate = _.template(`<li class="<%= itemClass %>">
 <h2>
  <%- comment.user.name %>
  <span class="actions">
   <a class="comment-list-view-action" href="<%= comment.url %>"><%- viewText %></a>
   <a class="comment-list-reply-action"
      href="<%= reviewRequestURL %>?reply_id=<%= comment.reply_to_id || comment.comment_id %>&reply_type=<%= replyType %>"
      ><%- replyText %></a>
  </span>
 </h2>
 <pre><%- comment.text %></pre>
</li>`);
      static replyText = gettext("Reply");
      static viewText = gettext("View");

      /**********************
       * Instance variables *
       **********************/

      /** Options for the view. */

      /**
       * Initialize the view.
       *
       * Args:
       *     options (CommentsListViewOptions):
       *         Options for the view.
       */
      initialize(options) {
        this.options = options;
      }

      /**
       * Set the list of displayed comments.
       *
       * Args:
       *     comments (Array of object):
       *         The serialized comments.
       *
       *     replyType (string):
       *         The type of comment, for use in creating replies.
       */
      setComments(comments, replyType) {
        if (comments.length === 0) {
          return;
        }
        const reviewRequestURL = this.options.reviewRequestURL;
        const commentIssueManager = this.options.commentIssueManager;
        const interactive = this.options.issuesInteractive;
        let odd = true;
        let $items = $();
        _.each(comments, serializedComment => {
          const commentID = serializedComment.comment_id;
          const $item = $(CommentsListView.itemTemplate({
            comment: serializedComment,
            itemClass: odd ? 'odd' : 'even',
            replyText: CommentsListView.replyText,
            replyType: replyType,
            reviewRequestURL: reviewRequestURL,
            viewText: CommentsListView.viewText
          }));
          if (serializedComment.issue_opened) {
            const commentIssueBar = new RB.CommentIssueBarView({
              commentID: commentID,
              commentIssueManager: commentIssueManager,
              commentType: replyType,
              interactive: interactive,
              issueStatus: serializedComment.issue_status,
              reviewID: serializedComment.review_id
            });
            commentIssueBar.render().$el.appendTo($item);

            /*
             * Update the serialized comment's issue status whenever
             * the real comment's status is changed so we will
             * display it correctly the next time we render it.
             */
            this.listenTo(commentIssueManager, 'issueStatusUpdated', comment => {
              if (comment.id === commentID) {
                serializedComment.issue_status = comment.get('issueStatus');
              }
            });
          }
          $items = $items.add($item);
          odd = !odd;
        });
        this.$el.empty().append($items);
      }
    }) || _class$b;
    /**
     * Options for the CommentDialogView.
     */
    /**
     * A dialog that allows for creating, editing or deleting draft comments on
     * a diff or file attachment. The dialog can be moved around on the page.
     *
     * Any existing comments for the selected region will be displayed alongside
     * the dialog for reference. However, this dialog is not intended to be
     * used to reply to those comments.
     */
    let CommentDialogView = spina.spina(_class2$3 = class CommentDialogView extends spina.BaseView {
      static className = 'comment-dlg';
      static events = {
        'click .buttons .cancel': '_onCancelClicked',
        'click .buttons .close': '_onCancelClicked',
        'click .buttons .delete': '_onDeleteClicked',
        'click .buttons .save': 'save',
        'keydown .comment-text-field': '_onTextKeyDown'
      };

      /** The singleton instance. */
      static _instance = null;
      static DIALOG_TOTAL_HEIGHT = 350;
      static DIALOG_NON_EDITABLE_HEIGHT = 120;
      static DIALOG_READ_ONLY_HEIGHT = 104;
      static SLIDE_DISTANCE = 10;
      static COMMENTS_BOX_WIDTH = 280;
      static FORM_BOX_WIDTH = 450;
      static _cancelText = gettext("Cancel");
      static _closeText = gettext("Close");
      static _deleteText = gettext("Delete");
      static _enableMarkdownText = gettext("Enable <u>M</u>arkdown");
      static _loginTextTemplate = gettext("You must <a href=\"%s\">log in</a> to post a comment.");
      static _markdownText = gettext("Markdown");
      static _openAnIssueText = gettext("Open an <u>I</u>ssue");
      static _otherReviewsText = gettext("Other reviews");
      static _saveText = gettext("Save");
      static _shouldExitText = gettext("You have unsaved changes. Are you sure you want to exit?");
      static _verifyIssueText = gettext("Require Verification");
      static _yourCommentText = gettext("Your comment");
      static _yourCommentDirtyText = gettext("Your comment (unsaved)");
      static template = _.template(`<div class="other-comments">
 <h1 class="title"><%- otherReviewsText %></h1>
 <ul></ul>
</div>
<form method="post">
 <h1 class="comment-dlg-header">
  <span class="title"></span>
  <% if (canEdit) { %>
   <a class="markdown-info" href="<%- markdownDocsURL %>"
      target="_blank"><%- markdownText %></a>
  <% } %>
 </h1>
 <% if (!authenticated) { %>
  <p class="login-text"><%= loginText %></p>
 <% } else if (deletedWarning) { %>
  <p class="deleted-warning"><%= deletedWarning %></p>
 <% } else if (readOnly) { %>
  <p class="read-only-text"><%= readOnlyText %></p>
 <% } else if (draftWarning) { %>
  <p class="draft-warning"><%= draftWarning %></p>
 <% } %>
 <div class="comment-dlg-body">
  <div class="comment-text-field"></div>
  <ul class="comment-dlg-options">
   <li class="comment-issue-options">
    <input type="checkbox" id="comment_issue">
    <label for="comment_issue" accesskey="i"><%= openAnIssueText %></label>
    <% if (showVerify) { %>
     <input type="checkbox" id="comment_issue_verify">
     <label for="comment_issue_verify"><%= verifyIssueText %></label>
    <% } %>
   </li>
   <li class="comment-markdown-options">
    <input type="checkbox" id="enable_markdown">
    <label for="enable_markdown" accesskey="m"><%= enableMarkdownText %></label>
   </li>
  </ul>
 </div>
 <div class="comment-dlg-footer">
  <div class="buttons">
   <input type="button" class="save" value="<%- saveButton %>"
          disabled="true">
   <input type="button" class="cancel" value="<%- cancelButton %>">
   <input type="button" class="delete" value="<%- deleteButton %>"
          disabled="true">
   <input type="button" class="close" value="<%- closeButton %>">
  </div>
 </div>
</form>`);

      /**
       * Create and shows a new comment dialog and associated model.
       *
       * This is a class method that handles providing a comment dialog
       * ready to use with the given state.
       *
       * Only one comment dialog can appear on the screen at any given time
       * when using this.
       *
       * Args:
       *     options (CommentDialogViewCreationOptions):
       *         Options for the view construction.
       */
      static create(options) {
        console.assert(options.comment, 'A comment must be specified');
        const reviewRequestEditor = options.reviewRequestEditor || RB.PageManager.getPage().model.reviewRequestEditor;
        options.animate = options.animate !== false;
        const dlg = new this({
          animate: options.animate,
          commentIssueManager: options.commentIssueManager || reviewRequestEditor.get('commentIssueManager'),
          deletedWarning: options.deletedWarning,
          draftWarning: options.draftWarning,
          model: new CommentEditor({
            comment: options.comment,
            publishedComments: options.publishedComments || undefined,
            publishedCommentsType: options.publishedCommentsType || undefined,
            reviewRequest: reviewRequestEditor.get('reviewRequest'),
            reviewRequestEditor: reviewRequestEditor
          })
        });
        dlg.render().$el.css('z-index', 999) // XXX Use classes for z-indexes.
        .appendTo(options.container || document.body);
        options.position = options.position || {};
        if (_.isFunction(options.position)) {
          options.position(dlg);
        } else if (options.position.beside) {
          dlg.positionBeside(options.position.beside.el, options.position.beside);
        } else {
          let x = options.position.x;
          let y = options.position.y;
          if (x === undefined) {
            /* Center it. */
            x = $(document).scrollLeft() + ($(window).width() - dlg.$el.width()) / 2;
          }
          if (y === undefined) {
            /* Center it. */
            y = $(document).scrollTop() + ($(window).height() - dlg.$el.height()) / 2;
          }
          dlg.move(x, y);
        }
        dlg.on('closed', () => CommentDialogView._instance = null);
        const instance = CommentDialogView._instance;
        const showCommentDlg = function showCommentDlg() {
          try {
            dlg.open();
          } catch (e) {
            dlg.close();
            throw e;
          }
          CommentDialogView._instance = dlg;
        };
        if (instance) {
          instance.on('closed', showCommentDlg);
          instance.close();
        } else {
          showCommentDlg();
        }
        return dlg;
      }

      /**********************
       * Instance variables *
       **********************/

      /** The buttons on the dialog. */

      #$draftWarning;

      /**
       * Initialize the view.
       *
       * Args:
       *     options (CommentDialogViewOptions):
       *         Options for the view.
       */
      initialize(options) {
        this.options = options;
      }

      /**
       * Render the view.
       */
      onInitialRender() {
        const model = this.model;
        const userSession = RB.UserSession.instance;
        const reviewRequest = model.get('reviewRequest');
        const reviewRequestEditor = model.get('reviewRequestEditor');
        const deletedWarning = this.options.deletedWarning;
        if (deletedWarning) {
          /* Block commenting on deleted objects. */
          model.set('canEdit', false);
        }
        this.$el.hide().html(CommentDialogView.template({
          authenticated: userSession.get('authenticated'),
          canEdit: model.get('canEdit'),
          cancelButton: CommentDialogView._cancelText,
          closeButton: CommentDialogView._closeText,
          deleteButton: CommentDialogView._deleteText,
          deletedWarning: deletedWarning,
          draftWarning: this.options.draftWarning,
          enableMarkdownText: CommentDialogView._enableMarkdownText,
          loginText: interpolate(CommentDialogView._loginTextTemplate, [userSession.get('loginURL')]),
          markdownDocsURL: MANUAL_URL + 'users/markdown/',
          markdownText: CommentDialogView._markdownText,
          openAnIssueText: CommentDialogView._openAnIssueText,
          otherReviewsText: CommentDialogView._otherReviewsText,
          readOnly: userSession.get('readOnly'),
          readOnlyText: gettext("Review Board is currently in read-only mode."),
          saveButton: CommentDialogView._saveText,
          showVerify: RB.EnabledFeatures.issueVerification,
          verifyIssueText: CommentDialogView._verifyIssueText
        }));
        this._$commentsPane = this.$('.other-comments');
        this._$draftForm = this.$('form');
        this._$body = this._$draftForm.children('.comment-dlg-body');
        this._$header = this._$draftForm.children('.comment-dlg-header');
        this._$footer = this._$draftForm.children('.comment-dlg-footer');
        this._$title = this._$header.children('.title');
        this._$commentOptions = this._$body.children('.comment-dlg-options');
        this._$issueOptions = this._$commentOptions.children('.comment-issue-options').bindVisibility(model, 'canEdit');
        this._$markdownOptions = this._$commentOptions.children('.comment-markdown-options').bindVisibility(model, 'canEdit');
        this._$issueField = this._$issueOptions.find('#comment_issue').bindProperty('checked', model, 'openIssue').bindProperty('disabled', model, 'editing', {
          elementToModel: false,
          inverse: true
        });
        this._$issueVerificationField = this._$issueOptions.find('#comment_issue_verify').bindProperty('checked', model, 'requireVerification').bindProperty('disabled', model, 'editing', {
          elementToModel: false,
          inverse: true
        });
        this._$enableMarkdownField = this._$markdownOptions.find('#enable_markdown').bindProperty('checked', model, 'richText').bindProperty('disabled', model, 'editing', {
          elementToModel: false,
          inverse: true
        });
        this.#$draftWarning = this.$('.draft-warning');
        this.$buttons = this._$footer.find('.buttons');
        this.$saveButton = this.$buttons.find('input.save').bindVisibility(model, 'canEdit').bindProperty('disabled', model, 'canSave', {
          elementToModel: false,
          inverse: true
        });
        this.$cancelButton = this.$buttons.find('input.cancel').bindVisibility(model, 'canEdit');
        this.$deleteButton = this.$buttons.find('input.delete').bindVisibility(model, 'canDelete').bindProperty('disabled', model, 'canDelete', {
          elementToModel: false,
          inverse: true
        });
        this.$closeButton = this.$buttons.find('input.close').bindVisibility(model, 'canEdit', {
          inverse: true
        });
        this.commentsList = new CommentsListView({
          commentIssueManager: this.options.commentIssueManager,
          el: this._$commentsPane.find('ul'),
          issuesInteractive: reviewRequestEditor.get('editable'),
          reviewRequestURL: reviewRequest.get('reviewURL')
        });

        /*
         * We need to handle keypress here, rather than in events above,
         * because jQuery will actually handle it. Backbone fails to.
         */
        this._textEditor = new RB.TextEditorView({
          autoSize: false,
          bindRichText: {
            attrName: 'richText',
            model: model
          },
          el: this._$draftForm.find('.comment-text-field'),
          minHeight: 0,
          text: model.get('text')
        });
        this._textEditor.render();
        this._textEditor.show();
        this._textEditor.$el.bindVisibility(model, 'canEdit');
        this.listenTo(this._textEditor, 'change', () => model.set('text', this._textEditor.getText()));
        this._textEditor.bindRichTextCheckbox(this._$enableMarkdownField);
        this._textEditor.bindRichTextVisibility(this._$draftForm.find('.markdown-info'));
        this.listenTo(model, 'change:text', () => this._textEditor.setText(model.get('text')));
        this.listenTo(model, 'change:richText', this.#handleResize);
        this.$el.css('position', 'absolute').mousedown(evt => {
          /*
           * Prevent this from reaching the selection area, which will
           * swallow the default action for the mouse down.
           */
          evt.stopPropagation();
        }).resizable({
          handles: $.support.touch ? 'grip,se' : 'grip,n,e,s,w,se,sw,ne,nw',
          resize: _.bind(this.#handleResize, this),
          transparent: true
        }).proxyTouchEvents();
        this._$header.css('cursor', 'move');
        this.$el.draggable({
          handle: '.comment-dlg-header'
        });
        this.listenTo(model, 'change:dirty', this.#updateTitle);
        this.#updateTitle();
        this.listenTo(model, 'change:publishedComments', () => this.#onPublishedCommentsChanged());
        this.#onPublishedCommentsChanged();

        /* Add any hooks. */
        RB.CommentDialogHook.each(hook => {
          const HookViewType = hook.get('viewType');
          const hookView = new HookViewType({
            commentDialog: this,
            commentEditor: model,
            el: this.el,
            extension: hook.get('extension')
          });
          hookView.render();
        });
      }

      /**
       * Callback for when the Save button is pressed.
       *
       * Saves the comment, creating it if it's new, and closes the dialog.
       */
      save() {
        /*
         * Set this immediately, in case new text has been set in the editor
         * that we haven't been notified about yet.
         */
        this.model.set('text', this._textEditor.getText());
        if (this.model.get('canSave')) {
          this.model.save().catch(err => {
            alert(gettext("Error saving comment:") + err.message);
          });
          this.close();
        }
      }

      /**
       * Open the comment dialog and focuses the text field.
       */
      open() {
        function openDialog() {
          this.$el.scrollIntoView();
          this._textEditor.focus();
        }
        if (this.options.animate) {
          this.$el.css({
            opacity: 0,
            top: parseInt(this.$el.css('top'), 10) - CommentDialogView.SLIDE_DISTANCE
          });
        }
        this.$el.show();
        this.#handleResize();
        if (this.model.get('canEdit')) {
          this.model.beginEdit();
        }
        if (this.options.animate) {
          this.$el.animate({
            opacity: 1,
            top: `+=${CommentDialogView.SLIDE_DISTANCE}px`
          }, 350, 'swing', _.bind(openDialog, this));
        } else {
          openDialog.call(this);
        }
      }

      /**
       * Close the comment dialog, discarding the comment block if empty.
       *
       * This can optionally take a callback and context to notify when the
       * dialog has been closed.
       *
       * Args:
       *     onClosed (function, optional):
       *         An optional callback to call once the dialog has been closed.
       *
       *     context (object, optional):
       *         Context to use when calling ``onClosed``.
       */
      close() {
        let onClosed = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : undefined;
        let context = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
        function closeDialog() {
          this.model.close();
          this.$el.remove();
          this.trigger('closed');
          if (_.isFunction(onClosed)) {
            onClosed.call(context);
          }
        }
        if (this.options.animate && this.$el.is(':visible')) {
          this.$el.animate({
            opacity: 0,
            top: `-=${CommentDialogView.SLIDE_DISTANCE}px`
          }, 350, 'swing', _.bind(closeDialog, this));
        } else {
          closeDialog.call(this);
        }
      }

      /**
       * Move the comment dialog to the given coordinates.
       *
       * Args:
       *     x (number):
       *         The X-coordinate to move the dialog to.
       *
       *     y (number):
       *         The Y-coordinate to move the dialog to.
       */
      move(x, y) {
        this.$el.move(x, y);
      }

      /**
       * Position the dialog beside an element.
       *
       * This takes the same arguments that $.fn.positionToSide takes.
       *
       * Args:
       *     $el (jQuery):
       *        The element to move the dialog next to.
       *
       *     options (object):
       *         Options for the ``positionToSide`` call.
       */
      positionBeside($el, options) {
        this.$el.positionToSide($el, options);
      }

      /**
       * Update the title of the comment dialog, based on the current state.
       */
      #updateTitle() {
        this._$title.text(this.model.get('dirty') ? CommentDialogView._yourCommentDirtyText : CommentDialogView._yourCommentText);
      }

      /**
       * Callback for when the list of published comments changes.
       *
       * Sets the list of comments in the CommentsList, and factors in some
       * new layout properties.
       */
      #onPublishedCommentsChanged() {
        const comments = this.model.get('publishedComments') || [];
        this.commentsList.setComments(comments, this.model.get('publishedCommentsType'));
        const showComments = comments.length > 0;
        this._$commentsPane.toggle(showComments);

        /* Do this here so that calculations can be done before open() */
        let width = CommentDialogView.FORM_BOX_WIDTH;
        if (showComments) {
          width += CommentDialogView.COMMENTS_BOX_WIDTH;
        }
        let height;
        if (this.model.get('canEdit')) {
          height = CommentDialogView.DIALOG_TOTAL_HEIGHT;
        } else if (RB.UserSession.instance.get('readOnly')) {
          height = CommentDialogView.DIALOG_READ_ONLY_HEIGHT;
        } else {
          height = CommentDialogView.DIALOG_NON_EDITABLE_HEIGHT;
        }
        this.$el.width(width).height(height);
      }

      /**
       * Handle the resize of the comment dialog.
       *
       * This will lay out the elements in the dialog appropriately.
       */
      #handleResize() {
        const height = this.$el.height();
        let width = this.$el.width();
        let commentsWidth = 0;
        if (this._$commentsPane.is(':visible')) {
          this._$commentsPane.outerWidth(CommentDialogView.COMMENTS_BOX_WIDTH).outerHeight(height).move(0, 0, 'absolute');
          const $commentsList = this.commentsList.$el;
          $commentsList.height(this._$commentsPane.height() - $commentsList.position().top);
          commentsWidth = this._$commentsPane.outerWidth(true);
          width -= commentsWidth;
        }
        this._$draftForm.outerWidth(width).outerHeight(height).move(commentsWidth, 0, 'absolute');
        const warningHeight = this.#$draftWarning.outerHeight(true) || 0;
        const $textField = this._textEditor.$el;
        this._textEditor.setSize(this._$body.width() - $textField.getExtents('b', 'lr'), this._$draftForm.height() - this._$header.outerHeight() - this._$commentOptions.outerHeight() - this._$footer.outerHeight() - warningHeight - $textField.getExtents('b', 'tb'));
      }

      /**
       * Callback for when the Cancel button is pressed.
       *
       * Cancels the comment (which may delete the comment block, if it's new)
       * and closes the dialog.
       */
      _onCancelClicked() {
        let shouldExit = true;
        if (this.model.get('dirty')) {
          shouldExit = confirm(CommentDialogView._shouldExitText);
        }
        if (shouldExit) {
          this.model.cancel();
          this.close();
        }
      }

      /**
       * Callback for when the Delete button is pressed.
       *
       * Deletes the comment and closes the dialog.
       */
      _onDeleteClicked() {
        if (this.model.get('canDelete')) {
          this.model.deleteComment();
          this.close();
        }
      }

      /**
       * Callback for keydown events in the text field.
       *
       * If the Escape key is pressed, the dialog will be closed.
       * If the Control-Enter or Alt-I keys are pressed, we'll handle them
       * specially. Control-Enter is the same thing as clicking Save.
       *
       * metaKey used as alternative for Mac key shortcut philosophy.
       * metaKey is only fired on keydown in Chrome and Brave.
       *
       * The keydown event won't be propagated to the parent elements.
       *
       * Args:
       *     e (KeyboardEvent):
       *         The keydown event.
       */
      _onTextKeyDown(e) {
        if (e.key === 'Escape') {
          e.preventDefault();
          e.stopPropagation();
          this._onCancelClicked();
        } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
          e.preventDefault();
          e.stopPropagation();
          this.save();
        } else if (e.key === 'i' && (e.metaKey || e.altKey)) {
          e.preventDefault();
          e.stopPropagation();
          this.model.set('openIssue', !this.model.get('openIssue'));
        } else if (e.key === 'm' && (e.metaKey || e.altKey)) {
          e.preventDefault();
          e.stopPropagation();
          this.model.set('richText', !this.model.get('richText'));
        }
      }
    }) || _class2$3;

    var _class$a;

    /**
     * Options for the DiffComplexityIconView.
     *
     * Version Added:
     *     6.0
     */

    /**
     * Renders an icon showing the general complexity of a diff.
     *
     * This icon is a pie graph showing the percentage of inserts vs deletes
     * vs replaces. The size of the white inner radius is a relative indicator
     * of how large the change is for the file, representing the unchanged lines.
     * Smaller inner radiuses indicate much larger changes, whereas larger
     * radiuses represent smaller changes.
     *
     * Callers are not required to supply the total number of lines or the number
     * of replaces, allowing this to be used when only the most basic insert and
     * delete counts are available.
     */
    let DiffComplexityIconView = spina.spina(_class$a = class DiffComplexityIconView extends spina.BaseView {
      static ICON_SIZE = 20;
      static _iconColors = null;

      /**********************
       * Instance variables *
       **********************/

      /** The number of deleted lines. */
      numDeletes = null;

      /** The number of inserted lines. */
      numInserts = null;

      /** The number of replaced lines. */
      numReplaces = null;

      /** The total number of lines in the file. */
      totalLines = null;

      /**
       * Initialize the view.
       *
       * Each of the provided values will be normalized to something
       * the view expects.
       *
       * Args:
       *     options (DiffComplexityIconViewOptions):
       *         Options for the view.
       */
      initialize(options) {
        this.numInserts = options.numInserts || 0;
        this.numDeletes = options.numDeletes || 0;
        this.numReplaces = options.numReplaces || 0;
        this.totalLines = options.totalLines || null;
      }

      /**
       * Render the icon.
       */
      onInitialRender() {
        const numTotal = this.numInserts + this.numDeletes + this.numReplaces;
        const numInsertsPct = this.numInserts / numTotal;
        const numDeletesPct = this.numDeletes / numTotal;
        const numReplacesPct = this.numReplaces / numTotal;
        const minValue = 360 * 0.15;
        const innerRadius = 0.5 * (this.totalLines === null ? 1 : (this.totalLines - numTotal) / this.totalLines);
        const iconColors = DiffComplexityIconView.getIconColors();
        this.$el.width(DiffComplexityIconView.ICON_SIZE).height(DiffComplexityIconView.ICON_SIZE).plot([{
          color: iconColors.insertColor,
          data: this.#clampValue(numInsertsPct * 360, minValue)
        }, {
          color: iconColors.deleteColor,
          data: this.#clampValue(numDeletesPct * 360, minValue)
        }, {
          color: iconColors.replaceColor,
          data: this.#clampValue(numReplacesPct * 360, minValue)
        }], {
          series: {
            pie: {
              innerRadius: innerRadius,
              radius: 0.8,
              show: true
            }
          }
        });
      }

      /**
       * Clamp the number to be, at minimum, minValue, unless it is 0.
       *
       * Args:
       *     val (number):
       *         The number to clamp.
       *
       *     minValue (number):
       *         The minimum to clamp ``val`` to.
       *
       * Returns:
       *     number:
       *     The clamped number.
       */
      #clampValue(val, minValue) {
        return val === 0 ? 0 : Math.max(val, minValue);
      }

      /**
       * Return the colors used for the complexity icons.
       *
       * This will create a temporary icon on the DOM and apply the CSS
       * styles for each type of change the icon can show. It will then
       * copy these colors, caching them for all future icons, and return
       * them.
       *
       * Returns:
       *     object:
       *     An object containing the colors to use for the icon.
       */
      static getIconColors() {
        if (!DiffComplexityIconView._iconColors) {
          const $iconColor = $('<div/>').hide().appendTo(document.body);
          $iconColor[0].className = 'diff-changes-icon-insert';
          const insertColor = $iconColor.css('color');
          $iconColor[0].className = 'diff-changes-icon-replace';
          const replaceColor = $iconColor.css('color');
          $iconColor[0].className = 'diff-changes-icon-delete';
          const deleteColor = $iconColor.css('color');
          $iconColor.remove();
          DiffComplexityIconView._iconColors = {
            deleteColor,
            insertColor,
            replaceColor
          };
        }
        return DiffComplexityIconView._iconColors;
      }
    }) || _class$a;

    var _class$9, _class2$2, _class3$1;
    /**
     * A view for a dropdown menu within the unified banner.
     *
     * Version Added:
     *     6.0
     */
    let DraftModeMenu = spina.spina(_class$9 = class DraftModeMenu extends spina.BaseView {
      static className = 'rb-c-unified-banner__menu';

      /**
       * The events to listen to.
       */
      static events = {
        'focusout': '_onFocusOut',
        'keydown': '_onKeyDown',
        'mouseenter': '_openMenu',
        'mouseleave': '_closeMenu'
      };
      static modelEvents = {
        'change:draftModes change:selectedDraftMode': '_update'
      };

      /**********************
       * Instance variables *
       **********************/

      #$arrow;
      #$label;
      #menuView;

      /**
       * Render the view.
       */
      onInitialRender() {
        const label = gettext("Mode");
        this.#menuView = new RB.MenuView({
          $controller: this.$el,
          ariaLabel: label
        });
        this.$el.html(`<a class="rb-c-unified-banner__mode" tabindex="0">
 <span class="rb-c-unified-banner__menu-label">
  <span class="rb-icon rb-icon-edit-review"></span>
  ${label}
 </span>
 <span class="rb-icon rb-icon-dropdown-arrow"></span>
</a>`);
        this.#menuView.renderInto(this.$el);
        this.#$label = this.$('.rb-c-unified-banner__menu-label');
        this.#$arrow = this.$('.rb-icon-dropdown-arrow');
        this._update();
      }

      /**
       * Open the menu.
       */
      _openMenu() {
        if (this.#menuView.$el.children().length > 0) {
          this.#menuView.open({
            animate: false
          });
        }
      }

      /**
       * Close the menu.
       */
      _closeMenu() {
        if (this.#menuView.$el.children().length > 0) {
          this.#menuView.close({
            animate: false
          });
        }
      }

      /**
       * Handle a focus-out event.
       *
       * Args:
       *     evt (FocusEvent):
       *         The event object.
       */
      _onFocusOut(evt) {
        evt.stopPropagation();

        /*
         * Only close the menu if the focus has moved to something outside of
         * this component.
         */
        const currentTarget = evt.currentTarget;
        if (!currentTarget.contains(evt.relatedTarget)) {
          this.#menuView.close({
            animate: false
          });
        }
      }

      /**
       * Handle a key down event.
       *
       * When the menu has focus, this will take care of handling keyboard
       * operations, allowing the menu to be opened or closed. Opening the menu
       * will transfer the focus to the menu items.
       *
       * Args:
       *     evt (KeyboardEvent):
       *         The keydown event.
       */
      _onKeyDown(evt) {
        if (evt.key === 'ArrowDown' || evt.key === 'ArrowUp' || evt.key === 'Enter' || evt.key === ' ') {
          evt.preventDefault();
          evt.stopPropagation();
          this.#menuView.open({
            animate: false
          });
          this.#menuView.focusFirstItem();
        } else if (evt.key === 'Escape') {
          evt.preventDefault();
          evt.stopPropagation();
          this.#menuView.close({
            animate: false
          });
        }
      }

      /**
       * Update the state of the draft mode selector.
       */
      _update() {
        const model = this.model;
        const draftModes = model.get('draftModes');
        const selectedDraftMode = model.get('selectedDraftMode');
        this.#menuView.clearItems();
        for (let i = 0; i < draftModes.length; i++) {
          const text = draftModes[i].text;
          if (i === selectedDraftMode) {
            this.#$label.html(`<span class="rb-icon rb-icon-edit-review"></span>
${text}`);
          } else {
            this.#menuView.addItem({
              onClick: () => model.set('selectedDraftMode', i),
              text: text
            });
          }
        }
        this.#$arrow.toggle(draftModes.length > 1);
      }
    }) || _class$9;
    /**
     * The publish button.
     *
     * Version Added:
     *     6.0
     */
    let PublishButtonView = spina.spina(_class2$2 = class PublishButtonView extends RB.MenuButtonView {
      static modelEvents = {
        'change:draftModes change:selectedDraftMode': '_update'
      };

      /**********************
       * Instance variables *
       **********************/

      #$archiveCheckbox;
      #$trivialCheckbox;

      /**
       * Initialize the view.
       */
      initialize() {
        super.initialize({
          ariaMenuLabel: gettext("Publish All"),
          hasPrimaryButton: true,
          menuIconClass: 'fa fa-gear',
          menuType: RB.MenuType.Button,
          onPrimaryButtonClick: this.#onPublishClicked,
          text: gettext("Publish All")
        });
      }

      /**
       * Render the view.
       */
      onInitialRender() {
        super.onInitialRender();
        this.#$trivialCheckbox = $('<input checked type="checkbox" id="publish-button-trivial">');
        this.#$archiveCheckbox = $('<input type="checkbox" id="publish-button-archive">');
        const reviewRequestEditor = this.model.get('reviewRequestEditor');
        if (reviewRequestEditor.get('showSendEmail')) {
          const $onlyEmail = this.menu.addItem().append(this.#$trivialCheckbox);
          $('<label for="publish-button-trivial">').text(gettext("Send E-Mail")).appendTo($onlyEmail);
        }
        const $archive = this.menu.addItem().append(this.#$archiveCheckbox);
        $('<label for="publish-button-archive">').text(gettext("Archive after publishing")).appendTo($archive);
        this._update();
      }

      /**
       * Callback for when the publish button is clicked.
       */
      #onPublishClicked() {
        this.trigger('publish', {
          archive: this.#$archiveCheckbox.is(':checked'),
          trivial: !this.#$trivialCheckbox.is(':checked')
        });
      }

      /**
       * Update the state of the publish button.
       */
      _update() {
        const draftModes = this.model.get('draftModes');
        const selectedDraftMode = this.model.get('selectedDraftMode');
        if (!this.rendered || draftModes.length === 0) {
          return;
        }
        if (draftModes[selectedDraftMode].multiple) {
          this.$primaryButton.text(gettext("Publish All"));
        } else {
          this.$primaryButton.text(gettext("Publish"));
        }
      }
    }) || _class2$2;
    /**
     * Options for the unified banner view.
     *
     * Version Added:
     *     6.0
     */
    /**
     * The unified banner.
     *
     * This is a unified, multi-mode banner that provides basic support for
     * publishing, editing, and discarding reviews, review requests, and
     * review replies.
     *
     * The banner displays at the top of the page under the topbar and floats to
     * the top of the browser window when the user scrolls down.
     *
     * Version Added:
     *     6.0
     */
    let UnifiedBannerView = spina.spina(_class3$1 = class UnifiedBannerView extends RB.FloatingBannerView {
      static instance = null;
      static events = {
        'click #btn-review-request-discard': '_discardDraft'
      };
      static modelEvents = {
        'change': '_update',
        'change:selectedDraftMode': '_scrollToReviewReply'
      };

      /**********************
       * Instance variables *
       **********************/

      #$changedesc;
      #$discardButton;
      #$draftActions;
      #$interdiffLink;
      #$modeSelector;
      #$review;
      #$reviewActions;
      #modeMenu;
      #publishButton;
      #reviewRequestEditorView;

      /**
       * Reset the UnifiedBannerView instance.
       *
       * This is used in unit tests to reset the state after tests run.
       */
      static resetInstance() {
        if (this.instance !== null) {
          this.instance.remove();
          this.instance = null;
        }
      }

      /**
       * Return the UnifiedBannerView instance.
       *
       * If the banner does not yet exist, this will create it.
       *
       * Args:
       *     required (boolean, optional):
       *         Whether the instance is required to exist.
       *
       * Returns:
       *     RB.UnifiedBannerView:
       *     The banner view.
       */
      static getInstance() {
        let required = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
        if (required) {
          console.assert(this.instance, 'Unified banner instance has not been created');
        }
        return this.instance;
      }

      /**
       * Initialize the banner.
       *
       * Args:
       *     options (object):
       *         Options for the banner. See :js:class:`RB.FloatingBannerView`
       *         for details.
       */
      initialize(options) {
        super.initialize(_.defaults(options, {
          $floatContainer: $('#page-container'),
          noFloatContainerClass: 'collapsed'
        }));
        this.#reviewRequestEditorView = options.reviewRequestEditorView;
        UnifiedBannerView.instance = this;
      }

      /**
       * Remove the banner from the DOM.
       *
       * This will stop tracking for the content viewport and then remove
       * the element.
       *
       * Returns:
       *     UnifiedBannerView:
       *     This instance, for chaining.
       */
      remove() {
        RB.contentViewport.untrackElement(this.el);
        return super.remove();
      }

      /**
       * Render the banner.
       */
      onInitialRender() {
        if (!RB.UserSession.instance.get('authenticated')) {
          return;
        }
        super.onInitialRender();
        const model = this.model;
        this.#$modeSelector = this.$('.rb-c-unified-banner__mode-selector');
        this.#$draftActions = this.$('.rb-c-unified-banner__draft-actions');
        this.#$review = this.$('.rb-c-unified-banner__review');
        this.#$reviewActions = this.$('.rb-c-unified-banner__review-actions');
        this.#$changedesc = this.$('.rb-c-unified-banner__changedesc');
        this.#$interdiffLink = $(`<div class="rb-c-unified-banner__interdiff-link">
${gettext("This draft adds a new diff.")}
<a>${gettext("Show changes")}</a>
</div>`).appendTo(this.#$changedesc);
        this.#modeMenu = new DraftModeMenu({
          model: model
        });
        this.#modeMenu.renderInto(this.#$modeSelector);
        this.#publishButton = new PublishButtonView({
          model: model
        });
        this.#publishButton.$el.prependTo(this.#$draftActions);
        this.listenTo(this.#publishButton, 'publish', this.publish);
        this.#publishButton.render();
        this.#$discardButton = this.$('#btn-review-request-discard');
        const reviewRequestEditor = model.get('reviewRequestEditor');
        const reviewRequest = model.get('reviewRequest');
        const $changeDescription = this.$('#field_change_description').html(reviewRequestEditor.get('changeDescriptionRenderedText')).toggleClass('editable', reviewRequestEditor.get('mutableByUser')).toggleClass('rich-text', reviewRequest.get('changeDescriptionRichText'));
        this.#reviewRequestEditorView.addFieldView(new ChangeDescriptionFieldView({
          el: $changeDescription,
          fieldID: 'change_description',
          model: reviewRequestEditor
        }));
        RB.contentViewport.trackElement({
          el: this.el,
          side: 'top'
        });
      }

      /**
       * Handle re-renders.
       */
      onRender() {
        this._update();
      }

      /**
       * Update the state of the banner.
       */
      _update() {
        if (!this.rendered) {
          return;
        }
        const model = this.model;
        const draftModes = model.get('draftModes');
        const selectedDraftMode = model.get('selectedDraftMode');
        const numDrafts = model.get('numDrafts');
        const reviewRequest = model.get('reviewRequest');
        const reviewRequestPublic = reviewRequest.get('public');
        this.#$discardButton.toggle(draftModes.length > 0 && !draftModes[selectedDraftMode].multiple);
        this.#$modeSelector.toggle(numDrafts > 0);
        this.#$draftActions.toggle(numDrafts > 0);
        this.#$changedesc.toggle(reviewRequestPublic && draftModes.length > 0 && draftModes[selectedDraftMode].hasReviewRequest);
        const interdiffLink = reviewRequest.draft.get('interdiffLink');
        if (interdiffLink) {
          this.#$interdiffLink.show().children('a').attr('href', interdiffLink);
        } else {
          this.#$interdiffLink.hide();
        }
        this.$el.toggleClass('-has-draft', reviewRequestPublic === false || numDrafts > 0).toggleClass('-has-multiple', numDrafts > 1).show();
      }

      /**
       * Return the height of the banner.
       *
       * Args:
       *     withDock (boolean, optional):
       *         Whether to include the dock portion of the banner in the height
       *         value.
       *
       * Returns:
       *     number:
       *     The height of the banner, in pixels.
       */
      getHeight() {
        let withDock = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true;
        if (withDock) {
          return this.$el.outerHeight();
        } else {
          return this.#$review.outerHeight();
        }
      }

      /**
       * Return the dock element.
       *
       * Returns:
       *     JQuery:
       *     The dock element.
       */
      getDock() {
        const $dock = this.$('.rb-c-unified-banner__dock');
        console.assert($dock.length === 1);
        return $dock;
      }

      /**
       * Publish the current draft.
       *
       * This triggers an event which is handled by ReviewRequestEditorView.
       */
      async publish(options) {
        const model = this.model;
        const selectedDraftMode = model.get('selectedDraftMode');
        const draftModes = model.get('draftModes');
        const draftMode = draftModes[selectedDraftMode];
        const reviewRequestEditor = model.get('reviewRequestEditor');
        const reviewRequest = reviewRequestEditor.get('reviewRequest');
        const pendingReview = model.get('pendingReview');
        const reviewReplyDrafts = model.get('reviewReplyDrafts');
        const reviews = [];
        const reviewRequests = [];
        RB.ClientCommChannel.getInstance().reload();
        if (draftMode.hasReviewRequest) {
          await reviewRequest.ready();
          reviewRequests.push(reviewRequest.get('id'));
        }
        if (draftMode.hasReview) {
          await pendingReview.ready();
          reviews.push(pendingReview.get('id'));
        }
        if (draftMode.singleReviewReply !== undefined) {
          const reply = reviewReplyDrafts[draftMode.singleReviewReply];
          await reply.ready();
          reviews.push(reply.get('id'));
        } else if (draftMode.hasReviewReplies) {
          for (const reply of reviewReplyDrafts) {
            await reply.ready();
            reviews.push(reply.get('id'));
          }
        }
        await this.#reviewRequestEditorView.saveOpenEditors();
        try {
          await this.#runPublishBatch(reviewRequest.get('localSitePrefix'), reviewRequests, reviews, !!options.trivial, !!options.archive);
        } catch (err) {
          alert(err);
        }
        RB.navigateTo(reviewRequest.get('reviewURL'));
      }

      /**
       * Run the publish batch operation.
       *
       * Args:
       *     localSitePrefix (string):
       *         The URL prefix for the local site, if present.
       *
       *     reviewRequests (Array of number):
       *         The set of review request IDs to publish.
       *
       *     reviews (Array of number):
       *         The set of review IDs to publish.
       *
       *     trivial (boolean):
       *         Whether to suppress notification e-mails.
       *
       *     archive (boolean):
       *         Whether to archive the affected review request after publishing.
       *
       * Returns:
       *     Promise:
       *     A promise which resolves when the operation is complete or rejects
       *     with an error string.
       */
      #runPublishBatch(localSitePrefix, reviewRequests, reviews, trivial, archive) {
        return new Promise((resolve, reject) => {
          RB.apiCall({
            data: {
              batch: JSON.stringify({
                archive: archive,
                op: 'publish',
                review_requests: reviewRequests,
                reviews: reviews,
                trivial: trivial
              })
            },
            prefix: localSitePrefix,
            url: `${SITE_ROOT}r/_batch/`,
            error: xhr => {
              const rsp = xhr.responseJSON;
              if (rsp && rsp.stat) {
                reject(rsp.error);
              } else {
                console.error('Failed to run publish batch operation', xhr);
                reject(xhr.statusText);
              }
            },
            success: () => {
              resolve();
            }
          });
        });
      }

      /**
       * Discard the current draft.
       *
       * Depending on the selected view mode, this will either discard the
       * pending review, discard the current review request draft, or close the
       * (unpublished) review request as discarded.
       */
      async _discardDraft() {
        const model = this.model;
        const selectedDraftMode = model.get('selectedDraftMode');
        const draftModes = model.get('draftModes');
        const draftMode = draftModes[selectedDraftMode];
        const reviewRequest = model.get('reviewRequest');
        RB.ClientCommChannel.getInstance().reload();
        try {
          if ((await this._confirmDiscard(draftMode)) === false) {
            return;
          }
          if (draftMode.hasReview) {
            const pendingReview = model.get('pendingReview');
            await pendingReview.destroy();
            RB.navigateTo(reviewRequest.get('reviewURL'));
          } else if (draftMode.hasReviewRequest) {
            if (!reviewRequest.get('public')) {
              await reviewRequest.close({
                type: RB.ReviewRequest.CLOSE_DISCARDED
              });
            } else if (!reviewRequest.draft.isNew()) {
              await reviewRequest.draft.destroy();
            }
            RB.navigateTo(reviewRequest.get('reviewURL'));
          } else if (draftMode.singleReviewReply !== undefined) {
            const reviewReplyDrafts = model.get('reviewReplyDrafts');
            const reply = reviewReplyDrafts[draftMode.singleReviewReply];
            await reply.destroy();
          } else {
            console.error('Discard reached with no active drafts.');
          }
        } catch (err) {
          alert(err.xhr.errorText);
        }
      }

      /**
       * Ask the user to confirm a discard operation.
       *
       * Args:
       *     draftMode (DraftMode):
       *         The current draft mode being discarded.
       *
       * Returns:
       *     Promise:
       *     A promise which resolves to either ``true`` (proceed) or ``false``
       *     (cancel).
       */
      _confirmDiscard(draftMode) {
        return new Promise((resolve, reject) => {
          const text = draftMode.hasReview ? gettext("If you discard this review, all unpublished comments will be deleted.") : gettext("If you discard this review request draft, all unpublished data will be deleted.");
          const title = draftMode.hasReview ? gettext("Are you sure you want to discard this review?") : gettext("Are you sure you want to discard this review request draft?");
          const $dlg = $('<p>').text(text).modalBox({
            buttons: [$('<input type="button">').val(gettext("Cancel")).click(() => resolve(false)), $('<input type="button">').val(gettext("Discard")).click(() => resolve(true))],
            title: title
          }).on('close', () => {
            $dlg.modalBox('destroy');
            resolve(false);
          });
        });
      }

      /**
       * Handler for when the selected draft mode changes.
       *
       * If the newly selected mode is a review reply, scroll the document to
       * that review.
       */
      _scrollToReviewReply() {
        const selectedDraftMode = this.model.get('selectedDraftMode');
        const draftModes = this.model.get('draftModes');
        const draftMode = draftModes[selectedDraftMode];
        if (draftMode.singleReviewReply !== undefined) {
          const reviewReplyDrafts = this.model.get('reviewReplyDrafts');
          const reply = reviewReplyDrafts[draftMode.singleReviewReply];
          const originalReview = reply.get('parentObject').get('id');
          const $review = $(`#review${originalReview}`);
          const reviewTop = $review.offset().top;
          const bannerHeight = this.$el.outerHeight(true);
          $(document).scrollTop(reviewTop - bannerHeight - 20);
        }
      }
    }) || _class3$1;

    var _class$8;

    /**
     * Options for the DiffFileIndexView.
     *
     * Version Added:
     *     6.0
     */

    /**
     * Displays the file index for the diffs on a page.
     *
     * The file page lists the names of the files, as well as a little graph
     * icon showing the relative size and complexity of a file, a list of chunks
     * (and their types), and the number of lines added and removed.
     */
    let DiffFileIndexView = spina.spina(_class$8 = class DiffFileIndexView extends spina.BaseView {
      static chunkTemplate = _.template('<a href="#<%= chunkID %>" class="<%= className %>"> </a>');
      static itemTemplate = _.template(`<tr class="loading
 <% if (newfile) { %>new-file<% } %>
 <% if (binary) { %>binary-file<% } %>
 <% if (deleted) { %>deleted-file<% } %>
 <% if (destFilename !== depotFilename) { %>renamed-file<% } %>
 ">
 <td class="diff-file-icon">
  <span class="djblets-o-spinner"></span>
 </td>
 <td class="diff-file-info">
  <a href="#<%- index %>"><%- destFilename %></a>
  <% if (destFilename !== depotFilename) { %>
  <span class="diff-file-rename"><%- wasText %></span>
  <% } %>
 </td>
 <td class="diff-chunks-cell">
  <% if (binary) { %>
   <%- binaryFileText %>
  <% } else if (deleted) { %>
   <%- deletedFileText %>
  <% } else { %>
   <div class="diff-chunks"></div>
  <% } %>
 </td>
</tr>`);
      static dockTemplate = `
        <div class="rb-c-diff-file-index-dock">
         <div class="rb-c-diff-file-index-dock__table"></div>
         <a href="#" class="rb-c-diff-file-index-dock__disclosure">
          <span class="fa fa-bars"></span>
         </a>
        </div>
    `;

      /**********************
       * Instance variables *
       **********************/

      #isDocked = false;
      #isDockExpanded = false;
      #lastDockHeight;
      #diffFiles = new Map();
      #unifiedBannerView;
      #$bannerDock = null;
      #$dockContainer = null;
      #$dockTable = null;
      #$floatSpacer = null;
      #$items = null;
      #$itemsTable = null;
      #indexExtents = new Map();
      #diffExtents = new Map();

      /**
       * An ID for the queued updateLayout call.
       *
       * This is used to manage and re-schedule :js:meth:`updateLayout` calls.
       *
       * Version Added:
       *     6.0
       */
      #queueLayoutID = null;

      /**
       * The resize observer tracking changes to diff extents.
       *
       * Version Added:
       *     6.0
       */
      #resizeObserver = null;

      /**
       * Initialize the view.
       *
       * Args:
       *     options (DiffFileIndexViewOptions):
       *         Options for the view.
       */
      initialize(options) {
        this.collection = options.collection;
        this.listenTo(this.collection, 'reset update', this.#update);
      }

      /**
       * Remove the view from the DOM.
       *
       * Returns:
       *     DiffFileIndexView:
       *     This object, for chaining.
       */
      remove() {
        $(window).off(`scroll.${this.cid}`);
        if (this.#$floatSpacer !== null) {
          this.#$floatSpacer.remove();
          this.#$floatSpacer = null;
        }
        if (this.#$dockContainer !== null) {
          this.#$dockContainer.remove();
          this.#$dockContainer = null;
        }
        if (this.#resizeObserver !== null) {
          this.#resizeObserver.disconnect();
          this.#resizeObserver = null;
        }
        if (this.#queueLayoutID !== null) {
          cancelAnimationFrame(this.#queueLayoutID);
          this.#queueLayoutID = null;
        }
        this.#$bannerDock = null;
        this.#$dockTable = null;
        this.#$items = null;
        this.#$itemsTable = null;
        this.#indexExtents.clear();
        this.#diffExtents.clear();
        return super.remove();
      }

      /**
       * Render the view to the page.
       */
      onInitialRender() {
        // Remove the spinner.
        this.$el.empty();
        this.#$itemsTable = $('<table class="rb-c-diff-file-index">').appendTo(this.$el);
        this.#$items = this.$('tr');
        this.#unifiedBannerView = UnifiedBannerView.getInstance();

        /*
         * Listen for any resizes on the diffs we're tracking.
         *
         * We'll recalculate all extents any time a diff resizes (due to a
         * window resize or an expand/collapse.
         */
        this.#resizeObserver = new ResizeObserver(() => this.queueUpdateLayout());

        /*
         * Check both the feature and whether the banner exists, because it's
         * possible that it's not instantiated during some unit tests.
         */
        if (RB.EnabledFeatures.unifiedBanner && this.#unifiedBannerView !== null) {
          this.#$bannerDock = this.#unifiedBannerView.getDock();
          this.#$dockContainer = $(DiffFileIndexView.dockTemplate);
          this.#$dockTable = this.#$dockContainer.children('.rb-c-diff-file-index-dock__table');
          this.#$dockContainer.children('.rb-c-diff-file-index-dock__disclosure').click(e => this.#onDisclosureClicked(e));
          this.#$floatSpacer = this.$el.wrap($('<div>')).parent();
          $(window).on(`scroll.${this.cid}`, () => this.#updateFloatPosition());
          _.defer(() => this.#updateFloatPosition());
        }

        // Add the files from the collection
        this.#update();
      }

      /**
       * Clear the loaded diffs.
       */
      clear() {
        this.#diffFiles.clear();
        this.#diffExtents.clear();
        this.#indexExtents.clear();
      }

      /**
       * Add a loaded diff to the index.
       *
       * The reserved entry for the diff will be populated with a link to the
       * diff, and information about the diff.
       *
       * Args:
       *     index (number):
       *         The array index at which to add the new diff.
       *
       *     diffReviewableView (RB.DiffReviewableView):
       *         The view corresponding to the diff file being added.
       */
      addDiff(index, diffReviewableView) {
        const $item = $(this.#$items[index]).removeClass('loading');
        if (diffReviewableView.$el.hasClass('diff-error')) {
          this.#renderDiffError($item);
        } else {
          this.#renderDiffEntry($item, diffReviewableView);
        }
        this.#diffFiles.set(index, diffReviewableView.$el);
        if (this.#unifiedBannerView !== null) {
          this.queueUpdateLayout();
        }
        if (this.#isDocked) {
          this.#updateItemVisibility();
        }
        this.#resizeObserver.observe(diffReviewableView.el);
      }

      /**
       * Update the list of files in the index view.
       */
      #update() {
        const items = this.collection.map(file => $(DiffFileIndexView.itemTemplate(_.defaults({
          binaryFileText: gettext("Binary file"),
          deletedFileText: gettext("Deleted"),
          wasText: interpolate(gettext("Was %(value1)s"), {
            "value1": file.get('depotFilename')
          }, true)
        }, file.attributes))));
        this.#$itemsTable.empty().append(items);
        this.#$items = this.#$itemsTable.find('tr');
      }

      /**
       * Render a diff loading error.
       *
       * An error icon will be displayed in place of the typical complexity
       * icon.
       *
       * Args:
       *     $item (jQuery):
       *         The item in the file index which encountered the error.
       */
      #renderDiffError($item) {
        $item.find('.diff-file-icon').html('<div class="rb-icon rb-icon-warning" />').attr('title', gettext("There was an error loading this diff. See the details below."));
      }

      /**
       * Render the display of a loaded diff.
       *
       * Args:
       *     $item (jQuery):
       *         The item in the file index which was loaded.
       *
       *     diffReviewableView (RB.DiffReviewableView):
       *         The view corresponding to the diff file which was loaded.
       */
      #renderDiffEntry($item, diffReviewableView) {
        const $table = diffReviewableView.$el;
        const fileDeleted = $item.hasClass('deleted-file');
        const fileAdded = $item.hasClass('new-file');
        const linesEqual = $table.data('lines-equal');
        let numDeletes = 0;
        let numInserts = 0;
        let numReplaces = 0;
        let tooltip = '';
        if (fileAdded) {
          numInserts = 1;
        } else if (fileDeleted) {
          numDeletes = 1;
        } else if ($item.hasClass('binary-file')) {
          numReplaces = 1;
        } else {
          const chunksList = [];
          $table.children('tbody').each((i, chunk) => {
            const numRows = chunk.rows.length;
            const $chunk = $(chunk);
            if ($chunk.hasClass('delete')) {
              numDeletes += numRows;
            } else if ($chunk.hasClass('insert')) {
              numInserts += numRows;
            } else if ($chunk.hasClass('replace')) {
              numReplaces += numRows;
            } else {
              return;
            }
            chunksList.push(DiffFileIndexView.chunkTemplate({
              chunkID: chunk.id.substr(5),
              className: chunk.className
            }));
          });

          /* Add clickable blocks for each diff chunk. */
          $item.find('.diff-chunks').html(chunksList.join(''));
        }

        /* Render the complexity icon. */
        const iconView = new DiffComplexityIconView({
          numDeletes: numDeletes,
          numInserts: numInserts,
          numReplaces: numReplaces,
          totalLines: linesEqual + numDeletes + numInserts + numReplaces
        });

        /* Add tooltip for icon */
        if (fileAdded) {
          tooltip = gettext("New file");
        } else if (fileDeleted) {
          tooltip = gettext("Deleted file");
        } else {
          const tooltipParts = [];
          if (numInserts > 0) {
            tooltipParts.push(interpolate(ngettext("%s new line", "%s new lines", numInserts), [numInserts]));
          }
          if (numReplaces > 0) {
            tooltipParts.push(interpolate(ngettext("%s line changed", "%s lines changed", numReplaces), [numReplaces]));
          }
          if (numDeletes > 0) {
            tooltipParts.push(interpolate(ngettext("%s line removed", "%s lines removed", numDeletes), [numDeletes]));
          }
          tooltip = tooltipParts.join(', ');
        }
        $item.find('.diff-file-icon').empty().append(iconView.$el).attr('title', tooltip);
        $item.find('a').click(e => this._onAnchorClicked(e));
        iconView.render();
        this.listenTo(diffReviewableView, 'chunkDimmed chunkUndimmed', chunkID => {
          this.$(`a[href="#${chunkID}"]`).toggleClass('dimmed');
        });
      }

      /**
       * Handler for when an anchor is clicked.
       *
       * Gets the name of the target and emits anchorClicked.
       *
       * Args:
       *     e (JQuery.ClickEvent):
       *         The click event.
       */
      _onAnchorClicked(e) {
        e.preventDefault();
        e.stopPropagation();
        const target = e.target;
        this.trigger('anchorClicked', target.href.split('#')[1]);
      }

      /**
       * Update the position of the file index.
       *
       * If the unified banner is available, this will dock the file index into
       * the banner once it's scrolled past.
       */
      #updateFloatPosition() {
        if (this.$el.parent().length === 0) {
          return;
        }
        const bannerTop = this.#$bannerDock.offset().top;
        const indexHeight = this.#$itemsTable.outerHeight(true);
        const topOffset = this.#$floatSpacer.offset().top - bannerTop;
        const lastRowHeight = this.#$itemsTable.children().last().outerHeight(true);
        if (!this.#isDocked && topOffset + indexHeight - lastRowHeight < 0) {
          /* Transition from undocked -> docked */

          this.$el.addClass('-is-docked');
          this.#isDocked = true;
          this.#$dockContainer.appendTo(this.#$bannerDock);
          this.#$dockTable.append(this.#$itemsTable);
          this.queueUpdateLayout();
          this.#updateItemVisibility();
        } else if (this.#isDocked && topOffset + indexHeight - lastRowHeight >= 0) {
          /* Transition from docked -> undocked. */

          this.#$floatSpacer.height('auto');
          this.$el.removeClass('-is-docked');
          this.#isDocked = false;
          this.#$itemsTable.css('transform', 'inherit').appendTo(this.$el);
          this.#$dockContainer.detach();
        } else if (this.#isDocked) {
          /* Currently docked. Update index scroll. */
          this.#updateItemVisibility();
        }
      }

      /**
       * Immediately update the stored sizes for the file index and diff entries.
       */
      updateLayout() {
        this.#indexExtents.clear();
        this.#diffExtents.clear();
        this.#$floatSpacer.height(this.#$itemsTable.outerHeight(true));
        const indexListTop = this.#$itemsTable.offset().top;
        const $items = this.#$items;
        const indexExtents = this.#indexExtents;
        const diffExtents = this.#diffExtents;
        for (const [i, $diffEl] of this.#diffFiles.entries()) {
          const $indexEl = $items.eq(i);
          const indexHeight = $indexEl.outerHeight();
          const indexTop = $indexEl.offset().top - indexListTop;
          const indexBottom = indexTop + indexHeight;
          indexExtents.set(i, {
            bottom: indexBottom,
            height: indexHeight,
            top: indexTop
          });
          const diffHeight = $diffEl.outerHeight();
          const diffTop = $diffEl.offset().top;
          const diffBottom = diffTop + diffHeight;
          diffExtents.set(i, {
            bottom: diffBottom,
            height: diffHeight,
            top: diffTop
          });
        }
        if (this.#isDocked) {
          this.#updateItemVisibility();
        }
        this.#queueLayoutID = null;
      }

      /**
       * Queue updating the stored sizes for the file index and diff entries.
       *
       * This will update the stored sizes at the next repaint opportunity. if
       * this is called more than once in-between updates, only one attempt
       * will be made, to avoid unnecessary calculations.
       *
       * This is recommended over calling :js:meth:`updateLayout` directly.
       *
       * Version Added:
       *     6.0
       */
      queueUpdateLayout() {
        if (this.#queueLayoutID === null) {
          this.#queueLayoutID = requestAnimationFrame(() => this.updateLayout());
        }
      }

      /**
       * Compute and return the index size for the visible area.
       *
       * Args:
       *     viewportTop (number):
       *         The top of the visible viewport, measured in pixels from the top
       *         of the document.
       *
       *     viewportBottom (number):
       *         The bottom of the visible viewport, measured in pixels from the
       *         top of the document.
       *
       * Returns:
       *     object:
       *     An object including the height and offset for the file index.
       */
      getDockedIndexExtents(viewportTop, viewportBottom) {
        const buffer = 50; // 50px
        let offset = undefined;
        let height = 0;
        let fullLastEntry = true;
        const diffExtents = this.#diffExtents;
        const indexExtents = this.#indexExtents;
        for (let i = 0; i < this.#$items.length; i++) {
          const diffExtent = diffExtents.get(i);
          const indexExtent = indexExtents.get(i);
          if (diffExtent === undefined) {
            /*
             * We may be trying to load an anchor prior to all the diffs
             * being fully loaded.
             */
            continue;
          }
          if (diffExtent.bottom < viewportTop) {
            // This entry is entirely above the visible viewport.
            continue;
          } else if (diffExtent.top > viewportBottom) {
            // This entry is below the visible viewport. We can bail now.
            break;
          }
          if (diffExtent.bottom < viewportTop + buffer) {
            /*
             * The bottom of the diff entry is in the process of being
             * scrolled off (or onto) the screen. Scroll the index
             * entry to match.
             */
            const ratio = (diffExtent.bottom - viewportTop) / buffer;
            const visibleArea = ratio * indexExtent.height;
            offset = indexExtent.bottom - visibleArea;
            height += visibleArea;
          } else if (diffExtent.top > viewportBottom - buffer) {
            /*
             * The top of the diff entry is in the process of being
             * scrolled off (or onto) the screen. Scroll the index
             * entry to match.
             */
            const ratio = (viewportBottom - diffExtent.top) / buffer;
            const visibleArea = ratio * indexExtent.height;
            height += visibleArea;
            fullLastEntry = false;
          } else {
            if (offset === undefined) {
              offset = indexExtent.top;
              if (offset > 0) {
                // Account for the border between <tr> elements.
                offset += 1;
              }
            }
            height += indexExtent.height;
          }
        }
        if (fullLastEntry) {
          // Account for the border between <tr> elements.
          height -= 1;
        }
        return {
          height: height,
          offset: offset
        };
      }

      /**
       * Update the visibility state of the (docked) file list.
       *
       * When the file list is in docked mode, we carefully manage its vertical
       * offset and height in order to keep it in sync with which files are
       * visible on the screen.
       */
      #updateItemVisibility() {
        const $window = $(window);
        const bannerHeight = this.#unifiedBannerView.getHeight(false);
        const viewportTop = this.#$dockContainer.offset().top + bannerHeight;
        const viewportBottom = $window.scrollTop() + $window.height() - bannerHeight;
        const {
          height,
          offset
        } = this.getDockedIndexExtents(viewportTop, viewportBottom);
        if (this.#isDockExpanded) {
          this.#isDockExpanded = false;
          this.#$dockContainer.removeClass('-is-expanded');
        }
        window.requestAnimationFrame(() => {
          this.#lastDockHeight = height;
          this.#$dockTable.css('max-height', height);
          this.#$itemsTable.css('transform', `translateY(-${offset}px)`);
        });
      }

      /**
       * Handler for when the docked disclosure icon is clicked.
       *
       * Args:
       *     e (JQuery.ClickEvent):
       *         The click event.
       */
      #onDisclosureClicked(e) {
        e.preventDefault();
        e.stopPropagation();
        this.#isDockExpanded = !this.#isDockExpanded;
        if (this.#isDockExpanded) {
          this.#$dockTable.css('max-height', this.#$itemsTable.outerHeight());
        } else {
          this.#$dockTable.css('max-height', this.#lastDockHeight);
        }
        this.#$dockContainer.toggleClass('-is-expanded', this.#isDockExpanded);
      }
    }) || _class$8;

    var _class$7;

    /**
     * Model that handles all operations and state for editing review requests.
     *
     * This manages the editing of all fields and objects on a review request,
     * the publishing workflow, and validation.
     */
    let ReviewRequestEditor = spina.spina(_class$7 = class ReviewRequestEditor extends spina.BaseModel {
      static strings = {
        UNBALANCED_EDIT_COUNT: gettext("There is an internal error balancing the edit count")
      };
      static defaults() {
        return {
          changeDescriptionRenderedText: '',
          closeDescriptionRenderedText: '',
          commentIssueManager: null,
          commitMessages: [],
          editCount: 0,
          editable: false,
          fileAttachmentComments: {},
          fileAttachments: null,
          hasDraft: false,
          mutableByUser: false,
          publishing: false,
          reviewRequest: null,
          screenshots: null,
          showSendEmail: false,
          statusEditable: false,
          statusMutableByUser: false
        };
      }

      /**
       * Initialize the editor.
       */
      initialize() {
        const reviewRequest = this.get('reviewRequest');

        // Set up file attachments.
        let fileAttachments = this.get('fileAttachments');
        let allFileAttachments = this.get('allFileAttachments');
        if (fileAttachments === null) {
          fileAttachments = new RB.ResourceCollection([], {
            model: RB.FileAttachment,
            parentResource: reviewRequest.draft
          });
          this.set('fileAttachments', fileAttachments);
        }
        if (allFileAttachments === null) {
          allFileAttachments = new RB.ResourceCollection([], {
            model: RB.FileAttachment,
            parentResource: reviewRequest.draft
          });
          this.set('allFileAttachments', allFileAttachments);
        }
        this.listenTo(fileAttachments, 'add', this._onFileAttachmentOrScreenshotAdded);
        fileAttachments.each(this._onFileAttachmentOrScreenshotAdded.bind(this));
        this.listenTo(fileAttachments, 'remove', this._onFileAttachmentRemoved);

        // Set up screenshots.
        let screenshots = this.get('screenshots');
        if (screenshots === null) {
          screenshots = new Backbone.Collection([], {
            model: RB.Screenshot
          });
          this.set('screenshots', screenshots);
        }
        this.listenTo(screenshots, 'add', this._onFileAttachmentOrScreenshotAdded);
        screenshots.each(this._onFileAttachmentOrScreenshotAdded.bind(this));

        // Connect to other signals.
        this.listenTo(reviewRequest.draft, 'saving', () => this.trigger('saving'));
        this.listenTo(reviewRequest.draft, 'saved', () => this.trigger('saved'));
        this.listenTo(reviewRequest, 'change:state', this._computeEditable);
        this._computeEditable();
      }

      /**
       * Parse the given attributes into model attributes.
       *
       * Args:
       *     attrs (object):
       *        The attributes to parse.
       *
       * Returns:
       *     object:
       *     The parsed attributes.
       */
      parse(attrs) {
        return _.defaults({
          commits: new RB.DiffCommitCollection(attrs.commits || [], {
            parse: true
          })
        }, attrs);
      }

      /**
       * Create a file attachment tracked by the editor.
       *
       * This wraps RB.ReviewRequestDraft.createFileAttachment and stores the
       * file attachment in the fileAttachments collection.
       *
       * This should be used instead of
       * RB.ReviewRequestDraft.createFileAttachment for any existing or newly
       * uploaded file attachments.
       *
       * Args:
       *     attributes (FileAttachmentAttrs, optional):
       *         Model attributes for the new file attachment.
       *
       * Returns:
       *     FileAttachment:
       *     The new file attachment model.
       */
      createFileAttachment() {
        let attributes = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
        const draft = this.get('reviewRequest').draft;
        const fileAttachment = draft.createFileAttachment(attributes);
        const attachmentHistoryID = attributes.attachmentHistoryID;
        const fileAttachments = this.get('fileAttachments');
        if (attachmentHistoryID && attachmentHistoryID > 1) {
          /* We're adding a new revision of an existing attachment. */
          fileAttachment.set({
            state: RB.FileAttachmentStates.NEW_REVISION
          });
          const replacedAttachment = fileAttachments.findWhere({
            attachmentHistoryID: attributes.attachmentHistoryID
          });
          const index = fileAttachments.indexOf(replacedAttachment);

          /*
           * Since we're replacing an attachment instead of actually
           * removing one, we silently remove the existing attachment as to
           * not trigger any standard removal handlers.
           *
           * We do however want to remove the existing attachment's
           * thumbnail, so we fire a "replaceAttachment" signal which will
           * be picked up by the ReviewRequestEditorView to remove the
           * thumbnail. Note that we trigger this signal before adding the
           * new attachment so that the existing thumbnail gets removed
           * before the new thumbnail get added.
           */
          fileAttachments.remove(replacedAttachment, {
            silent: true
          });
          this.trigger('replaceAttachment', replacedAttachment);
          fileAttachments.add(fileAttachment, {
            at: index
          });
        } else {
          fileAttachments.add(fileAttachment);
        }
        return fileAttachment;
      }

      /**
       * Return a field from the draft.
       *
       * This will look either in the draft's data or in the extraData (for
       * custom fields), returning the value provided either when the page
       * was generated or when it was last edited.
       *
       * Args:
       *     fieldName (string):
       *         The name of the field to get.
       *
       *     options (GetDraftFieldOptions, optional):
       *         Options for the operation.
       *
       * Returns:
       *     *:
       *     The value of the field.
       */
      getDraftField(fieldName) {
        let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
        const reviewRequest = this.get('reviewRequest');
        const draft = reviewRequest.draft;
        if (options.useExtraData) {
          let data;
          if (options.useRawTextValue) {
            const rawTextFields = draft.get('rawTextFields');
            if (rawTextFields && rawTextFields.extra_data) {
              data = rawTextFields.extra_data;
            }
          }
          if (!data) {
            data = draft.get('extraData');
          }
          return data[fieldName];
        } else if (fieldName === 'closeDescription' || fieldName === 'closeDescriptionRichText') {
          return reviewRequest.get(fieldName);
        } else {
          return draft.get(fieldName);
        }
      }

      /**
       * Set a field in the draft.
       *
       * If we're in the process of publishing, this will check if we have saved
       * all fields before publishing the draft.
       *
       * Once the field has been saved, two events will be triggered:
       *
       *     * fieldChanged(fieldName, value)
       *     * fieldChanged:<fieldName>(value)
       *
       * Veersion Changed:
       *     6.0:
       *     Removed the callbacks entirely, along with the ``context`` argument.
       *
       * Version Changed:
       *     5.0:
       *     Deprecated callbacks and added a promise return value.
       *
       * Args:
       *     fieldName (string):
       *         The name of the field to set.
       *
       *     value (*):
       *         The value to set in the field.
       *
       *     options (SetDraftFieldOptions, optional):
       *         Options for the set operation.
       *
       * Returns:
       *     Promise:
       *     A promise which resolves when the operation is complete.
       */
      setDraftField(fieldName, value) {
        let options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
        const reviewRequest = this.get('reviewRequest');
        const data = {}; // TODO: add typing once RB.ReviewRequest is TS

        let jsonFieldName = options.jsonFieldName;
        console.assert(!!jsonFieldName, `jsonFieldName must be set when setting draft ` + `field "${fieldName}".`);
        if (options.useExtraData) {
          jsonFieldName = `extra_data.${jsonFieldName}`;
        }
        if (options.allowMarkdown) {
          let jsonTextTypeFieldName = options.jsonTextTypeFieldName;
          console.assert(!!jsonTextTypeFieldName, 'jsonTextTypeFieldName must be set.');
          if (options.useExtraData) {
            jsonTextTypeFieldName = `extra_data.${jsonTextTypeFieldName}`;
          }
          const richText = !!options.richText;
          data[jsonTextTypeFieldName] = richText ? 'markdown' : 'plain';
          data.force_text_type = 'html';
          data.include_text_types = 'raw';
        }
        data[jsonFieldName] = value;
        return reviewRequest.draft.save({
          data
        }).then(() => {
          this.set('hasDraft', true);
          this.trigger('fieldChanged:' + fieldName, value);
          this.trigger('fieldChanged', fieldName, value);
        }).catch(err => {
          let message = '';
          this.set('publishing', false);
          const rsp = err.xhr.errorPayload;

          /*
           * An error can be caused by a 503 when the site is in
           * read-only mode, in which case the fields will be
           * empty.
           */
          if (rsp.fields !== undefined) {
            const fieldValue = rsp.fields[jsonFieldName];
            const fieldValueLen = fieldValue.length;

            /* Wrap each term in quotes or a leading 'and'. */
            _.each(fieldValue, (value, i) => {
              // XXX: This method isn't localizable.
              if (i === fieldValueLen - 1 && fieldValueLen > 1) {
                if (i > 2) {
                  message += ', ';
                }
                message += ` and "${value}"`;
              } else {
                if (i > 0) {
                  message += ', ';
                }
                message += `"${value}"`;
              }
            });
            if (fieldName === 'targetGroups') {
              message = interpolate(ngettext("Group %s does not exist.", "Groups %s do not exist.", fieldValue.length), [message]);
            } else if (fieldName === 'targetPeople') {
              message = interpolate(ngettext("User %s does not exist.", "Users %s do not exist.", fieldValue.length), [message]);
            } else if (fieldName === 'submitter') {
              message = interpolate(gettext("User %s does not exist."), [message]);
            } else if (fieldName === 'dependsOn') {
              message = interpolate(ngettext("Review Request %s does not exist.", "Review Requests %s do not exist.", fieldValue.length), [message]);
            }
          }
          err.message = message;
          return Promise.reject(err);
        });
      }

      /**
       * Publish the draft to the server.
       *
       * This assumes all fields have been saved.
       *
       * If there's an error during saving or validation, the "publishError"
       * event will be triggered with the error message. Otherwise, upon
       * success, the "publish" event will be triggered. However, users will
       * have the chance to cancel the publish in the event that the submitter
       * has been changed.
       *
       * Args:
       *     options (object):
       *         Options for the publish operation.
       *
       * Option Args:
       *     trivial (boolean):
       *         Whether the publish is "trivial" (if true, no e-mail
       *         notifications will be sent).
       */
      async publishDraft() {
        let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
        const reviewRequest = this.get('reviewRequest');
        try {
          await reviewRequest.draft.ensureCreated();
          if (reviewRequest.attributes.links.submitter.title !== reviewRequest.draft.attributes.links.submitter.title) {
            const confirmed = confirm(gettext("Are you sure you want to change the ownership of this review request? Doing so may prevent you from editing the review request afterwards."));
            if (!confirmed) {
              return;
            }
          }
          await reviewRequest.draft.publish({
            trivial: options.trivial ? 1 : 0
          });
          this.trigger('published');
        } catch (err) {
          this.trigger('publishError', err.message);
        }
      }

      /**
       * Increment an attribute by 1.
       *
       * The attribute must be an integer.
       *
       * Args:
       *     attr (string):
       *         The name of the attribute to increment.
       */
      incr(attr) {
        const value = this.get(attr);
        console.assert(_.isNumber(value));
        this.set(attr, value + 1, {
          validate: true
        });
      }

      /**
       * Decrement an attribute by 1.
       *
       * The attribute must be an integer.
       *
       * Args:
       *     attr (string):
       *         The name of the attribute to decrement.
       */
      decr(attr) {
        const value = this.get(attr);
        console.assert(_.isNumber(value));
        this.set(attr, value - 1, {
          validate: true
        });
      }

      /**
       * Validate the given attributes.
       *
       * Args:
       *     attrs (object):
       *         The attributes to validate.
       */
      validate(attrs) {
        const strings = ReviewRequestEditor.strings;
        if (_.has(attrs, 'editCount') && attrs.editCount < 0) {
          return strings.UNBALANCED_EDIT_COUNT;
        }
      }

      /**
       * Compute the editable state of the review request and open/close states.
       *
       * The review request is editable if the user has edit permissions and it's
       * not closed.
       *
       * The close state and accompanying description is editable if the user
       * has the ability to close the review request and it's currently closed.
       */
      _computeEditable() {
        const state = this.get('reviewRequest').get('state');
        const pending = state === RB.ReviewRequest.PENDING;
        this.set({
          editable: this.get('mutableByUser') && pending,
          statusEditable: this.get('statusMutableByUser') && !pending
        });
      }

      /**
       * Handle when a FileAttachment or Screenshot is added.
       *
       * Listens for events on the FileAttachment or Screenshot and relays
       * them to the editor.
       *
       * Args:
       *     attachment (FileAttachment or RB.Screenshot):
       *         The new file attachment or screenshot.
       */
      _onFileAttachmentOrScreenshotAdded(attachment) {
        this.listenTo(attachment, 'saving', () => this.trigger('saving'));
        this.listenTo(attachment, 'saved destroy', () => {
          this.set('hasDraft', true);
          this.trigger('saved');
        });
      }

      /**
       * Handle when a FileAttachment is removed.
       *
       * Version Added:
       *     6.0
       *
       * Args:
       *     fileAttachment (RB.FileAttachment):
       *         The file attachment.
       *
       *     collection (Backbone.Collection):
       *         The collection of all file attachments.
       *
       *     options (object):
       *         Options.
       *
       * Option Args:
       *     index (number):
       *         The index of the file attachment being removed.
       */
      _onFileAttachmentRemoved(fileAttachment, collection, options) {
        const state = fileAttachment.get('state');
        const fileAttachments = this.get('fileAttachments');
        const allFileAttachments = this.get('allFileAttachments');
        if (state === RB.FileAttachmentStates.NEW_REVISION) {
          /*
           * We're removing a new revision of a published file attachment.
           * Add the published file attachment back to the list of file
           * attachments to display it again.
           */
          const historyID = fileAttachment.get('attachmentHistoryID');
          const revision = fileAttachment.get('revision');
          const replacedAttachment = allFileAttachments.findWhere({
            attachmentHistoryID: historyID,
            revision: revision - 1
          });
          fileAttachments.add(replacedAttachment, {
            at: options.index
          });
        } else if (state === RB.FileAttachmentStates.PUBLISHED) {
          /*
           * We're removing a published file attachment. Change its state
           * and add it back to the list to continue displaying it.
           */
          fileAttachment.set({
            state: RB.FileAttachmentStates.PENDING_DELETION
          });
          fileAttachments.add(fileAttachment.clone(), {
            at: options.index
          });
        }
      }
    }) || _class$7;

    var _class$6;

    /** Attributes for the ReviewablePage model. */

    /**
     * A page used for editing, viewing, or reviewing review requests.
     *
     * This is responsible for setting up objects needed for manipulating a
     * review request or related state, for performing reviews, or otherwise
     * handling review-related tasks.
     *
     * This can be used directly or can be subclassed in order to provide
     * additional logic.
     */
    let ReviewablePage = spina.spina(_class$6 = class ReviewablePage extends RB.Page {
      static defaults = {
        checkForUpdates: false,
        checkUpdatesType: null,
        lastActivityTimestamp: null,
        pendingReview: null,
        reviewRequest: null
      };

      /**********************
       * Instance variables *
       **********************/

      /** Manages the issue states for published comments. */

      /**
       * Initialize the page.
       *
       * This will construct a series of objects needed to work with reviews
       * and the review request. It will also begin checking for updates made
       * to the page, notifying the user if anything has changed.
       *
       * Args:
       *     attributes (ReviewablePageAttrs):
       *         Initial attributes passed to the constructor. This is used to
       *         access initial state that won't otherwise be stored in this
       *         page.
       *
       *     options (object):
       *         Options for the page.
       */
      initialize(attributes, options) {
        super.initialize(attributes, options);
        const reviewRequest = this.get('reviewRequest');
        console.assert(reviewRequest, 'The reviewRequest attribute or parse=true must be provided.');
        console.assert(this.get('pendingReview'), 'The pendingReview attribute or parse=true must be provided.');
        this.commentIssueManager = new RB.CommentIssueManager({
          reviewRequest: reviewRequest
        });
        const editorData = attributes.editorData || {};
        const reviewRequestOrDraft = editorData.mutableByUser ? reviewRequest.draft : reviewRequest;
        const fileAttachments = new RB.ResourceCollection(_.map(editorData.fileAttachments, attrs => reviewRequestOrDraft.createFileAttachment(attrs)), {
          model: RB.FileAttachment,
          parentResource: reviewRequestOrDraft
        });
        const allFileAttachments = new RB.ResourceCollection(_.map(editorData.allFileAttachments, attrs => reviewRequestOrDraft.createFileAttachment(attrs)), {
          model: RB.FileAttachment,
          parentResource: reviewRequestOrDraft
        });
        this.reviewRequestEditor = new ReviewRequestEditor(_.defaults({
          allFileAttachments: allFileAttachments,
          commentIssueManager: this.commentIssueManager,
          fileAttachments: fileAttachments,
          reviewRequest: reviewRequest
        }, editorData), {
          parse: true
        });
        this.listenTo(reviewRequest, 'updated', info => this.trigger('reviewRequestUpdated', info));
        if (this.get('checkForUpdates')) {
          this._registerForUpdates();
        }
      }

      /**
       * Post a review marked as Ship It.
       *
       * This will create and publish a review, setting the Ship It state and
       * changing the text to say "Ship It!".
       */
      async markShipIt() {
        const pendingReview = this.get('pendingReview');
        await pendingReview.ready();
        pendingReview.set({
          bodyTop: gettext("Ship It!"),
          shipIt: true
        });
        await pendingReview.publish();
      }

      /**
       * Parse the data for the page.
       *
       * This will take data from the server and turn it into a series of
       * objects and attributes needed for parts of the page.
       *
       * Args:
       *     rsp (object):
       *         The incoming data provided for the page.
       *
       * Returns:
       *     object:
       *     The resulting attributes for the page.
       */
      parse(rsp) {
        let reviewRequestData;
        if (rsp.reviewRequestData) {
          reviewRequestData = _.defaults({
            state: RB.ReviewRequest[rsp.reviewRequestData.state],
            visibility: RB.ReviewRequest['VISIBILITY_' + rsp.reviewRequestData.visibility]
          }, rsp.reviewRequestData);
          if (reviewRequestData.repository) {
            reviewRequestData.repository = new RB.Repository(_.defaults({
              localSitePrefix: rsp.reviewRequestData.localSitePrefix
            }, rsp.reviewRequestData.repository));
          }
        }
        const reviewRequest = new RB.ReviewRequest(reviewRequestData, {
          extraDraftAttrs: rsp.extraReviewRequestDraftData
        });
        return {
          checkForUpdates: rsp.checkForUpdates,
          checkUpdatesType: rsp.checkUpdatesType,
          lastActivityTimestamp: rsp.lastActivityTimestamp,
          pendingReview: reviewRequest.createReview(),
          reviewRequest: reviewRequest
        };
      }

      /**
       * Register for update notification to the review request from the server.
       *
       * The server will be periodically checked for new updates. When a new
       * update arrives, an update bubble will be displayed in the bottom-right
       * of the page, and if the user has allowed desktop notifications in their
       * account settings, a desktop notification will be shown with the update
       * information.
       */
      _registerForUpdates() {
        this.get('reviewRequest').beginCheckForUpdates(this.get('checkUpdatesType'), this.get('lastActivityTimestamp'));
      }
    }) || _class$6;

    var _class$5;

    /** Attributes for the DiffViewerPage model. */

    /**
     * The model for the diff viewer page.
     *
     * This handles all attribute storage and diff context parsing needed to
     * display and update the diff viewer.
     */
    let DiffViewerPage = spina.spina(_class$5 = class DiffViewerPage extends ReviewablePage {
      static defaults = {
        allChunksCollapsed: false,
        canDownloadDiff: false,
        canToggleExtraWhitespace: false,
        filenamePatterns: null,
        numDiffs: 1
      };

      /**********************
       * Instance variables *
       **********************/

      /** The hint for comments in other revisions. */

      /**
       * Handle pre-parse initialization.
       *
       * This defines child objects for managing state related to the page
       * prior to parsing the provided attributes payload and initializing
       * the instance.
       */
      preinitialize() {
        this.commentsHint = new RB.DiffCommentsHint();
        this.commits = new RB.DiffCommitCollection();
        this.commitHistoryDiff = new RB.CommitHistoryDiffEntryCollection();
        this.files = new RB.DiffFileCollection();
        this.pagination = new RB.Pagination();
        this.revision = new RB.DiffRevision();
      }

      /**
       * Initialize the page.
       *
       * This will begin listening for events on the page and set up default
       * state.
       */
      initialize() {
        super.initialize(...arguments);
        this.diffReviewables = new RB.DiffReviewableCollection([], {
          reviewRequest: this.get('reviewRequest')
        });
        this.diffReviewables.watchFiles(this.files);
      }

      /**
       * Parse the data for the page.
       *
       * Args:
       *     rsp (DiffViewerPageParseData):
       *         The payload to parse.
       *
       * Returns:
       *     DiffViewerPageAttrs:
       *     The returned attributes.
       */
      parse(rsp) {
        const attrs = _.extend(this._parseDiffContext(rsp), super.parse(rsp));
        if (rsp.allChunksCollapsed !== undefined) {
          attrs.allChunksCollapsed = rsp.allChunksCollapsed;
        }
        if (rsp.canToggleExtraWhitespace !== undefined) {
          attrs.canToggleExtraWhitespace = rsp.canToggleExtraWhitespace;
        }
        return attrs;
      }

      /**
       * Load a new diff from the server.
       *
       * Args:
       *     options (LoadDiffRevisionOptions):
       *         The options for the diff to load.
       */
      loadDiffRevision() {
        let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
        const reviewRequestURL = this.get('reviewRequest').url();
        const queryData = [];
        if (options.revision) {
          queryData.push({
            name: 'revision',
            value: options.revision
          });
        }
        if (options.interdiffRevision) {
          queryData.push({
            name: 'interdiff-revision',
            value: options.interdiffRevision
          });
        } else {
          if (options.baseCommitID) {
            queryData.push({
              name: 'base-commit-id',
              value: options.baseCommitID
            });
          }
          if (options.tipCommitID) {
            queryData.push({
              name: 'tip-commit-id',
              value: options.tipCommitID
            });
          }
        }
        if (options.page && options.page !== 1) {
          queryData.push({
            name: 'page',
            value: options.page
          });
        }
        if (options.filenamePatterns) {
          queryData.push({
            name: 'filenames',
            value: options.filenamePatterns
          });
        }
        const url = Djblets.buildURL({
          baseURL: `${reviewRequestURL}diff-context/`,
          queryData: queryData
        });
        $.ajax(url).done(rsp => this.set(this._parseDiffContext(rsp.diff_context)));
      }

      /**
       * Parse context for a displayed diff.
       *
       * Args:
       *     rsp (object):
       *         The payload to parse.
       *
       * Returns:
       *     object:
       *     The returned attributes.
       */
      _parseDiffContext(rsp) {
        if (rsp.comments_hint) {
          this.commentsHint.set(this.commentsHint.parse(rsp.comments_hint));
        }
        if (rsp.files) {
          this.files.reset(rsp.files, {
            parse: true
          });
        }
        if (rsp.pagination) {
          this.pagination.set(this.pagination.parse(rsp.pagination));
        }
        if (rsp.revision) {
          this.revision.set(this.revision.parse(rsp.revision));
        }
        this.commitHistoryDiff.reset(rsp.commit_history_diff || [], {
          parse: true
        });
        if (rsp.commits) {
          /*
           * The RB.DiffCommitListView listens for the reset event on the
           * commits collection to trigger a render, so it must be updated
           * **after** the commit history is updated.
           */
          this.commits.reset(rsp.commits, {
            parse: true
          });
        }
        return {
          canDownloadDiff: rsp.revision && rsp.revision.interdiff_revision === null,
          filenamePatterns: rsp.filename_patterns || null,
          numDiffs: rsp.num_diffs || 0
        };
      }
    }) || _class$5;

    var _class$4;
    /**
     * State for the unified banner.
     *
     * Keeps track of drafts for the review request, review, and review replies.
     *
     * Version Added:
     *     6.0
     */
    let UnifiedBanner = spina.spina(_class$4 = class UnifiedBanner extends spina.BaseModel {
      static defaults = {
        draftModes: [],
        numDrafts: 0,
        pendingReview: null,
        reviewReplyDrafts: [],
        reviewRequest: null,
        reviewRequestEditor: null,
        selectedDraftMode: 0
      };

      /**
       * Initialize the Unified Review Banner State.
       *
       * Sets listeners on the saved and destroy events for the review request
       * and review to re-check if the state has at least one draft. At the end
       * of initialization, checks if at least one draft exists already.
       */
      initialize() {
        const reviewRequest = this.get('reviewRequest');
        const pendingReview = this.get('pendingReview');
        console.assert(reviewRequest, 'reviewRequest must be provided');
        console.assert(pendingReview, 'pendingReview must be provided');
        this.listenTo(reviewRequest.draft, 'saved destroyed', this.#updateDraftModes);
        this.listenTo(pendingReview, 'saved destroyed', this.#updateDraftModes);
        Promise.all([reviewRequest.draft.ready(), pendingReview.ready()]).then(() => this.#updateDraftModes());
      }

      /**
       * Update the draft state for the given review reply.
       *
       * Args:
       *     reviewReply (ReviewReply):
       *         The review reply model.
       *
       *     hasReviewReplyDraft (boolean):
       *          Whether the reviewReply passed in has a draft.
       */
      updateReplyDraftState(reviewReply, hasReviewReplyDraft) {
        const reviewReplyDrafts = this.get('reviewReplyDrafts');
        if (hasReviewReplyDraft) {
          if (!reviewReplyDrafts.includes(reviewReply)) {
            reviewReplyDrafts.push(reviewReply);
            this.set('reviewReplyDrafts', reviewReplyDrafts);
          }
        } else {
          this.set('reviewReplyDrafts', _.without(reviewReplyDrafts, reviewReply));
        }
        this.#updateDraftModes();
      }

      /**
       * Update the list of available draft modes.
       */
      #updateDraftModes() {
        const reviewRequest = this.get('reviewRequest');
        const pendingReview = this.get('pendingReview');
        const reviewReplyDrafts = this.get('reviewReplyDrafts');
        const reviewRequestPublic = reviewRequest.get('public');
        const reviewRequestDraft = !reviewRequest.draft.isNew();
        const reviewDraft = !pendingReview.isNew();
        const numReplies = reviewReplyDrafts.length;
        const numDrafts = numReplies + (reviewRequestDraft ? 1 : 0) + (reviewDraft ? 1 : 0);
        const draftModes = [];
        if (!reviewRequestPublic) {
          /* Review request has never been published */

          if (reviewDraft) {
            /* Review request draft + review draft */
            draftModes.push({
              hasReview: true,
              hasReviewReplies: false,
              hasReviewRequest: true,
              multiple: true,
              text: gettext("Draft and review")
            });
            draftModes.push({
              hasReview: false,
              hasReviewReplies: false,
              hasReviewRequest: true,
              multiple: false,
              text: gettext("Review request draft")
            });
            draftModes.push({
              hasReview: true,
              hasReviewReplies: false,
              hasReviewRequest: false,
              multiple: false,
              text: gettext("Review of the change")
            });
          } else {
            draftModes.push({
              hasReview: false,
              hasReviewReplies: false,
              hasReviewRequest: true,
              multiple: false,
              text: gettext("This review request is a draft")
            });
          }
        } else if (reviewRequestDraft) {
          /* Review request draft */

          if (reviewDraft) {
            /* Review request draft + review draft */
            draftModes.push({
              hasReview: false,
              hasReviewReplies: false,
              hasReviewRequest: true,
              multiple: false,
              text: gettext("Review request changes")
            });
            draftModes.push({
              hasReview: true,
              hasReviewReplies: false,
              hasReviewRequest: false,
              multiple: false,
              text: gettext("Review of the change")
            });
            if (numReplies > 0) {
              /* Review request draft + review draft + reply drafts */
              draftModes.unshift({
                hasReview: true,
                hasReviewReplies: true,
                hasReviewRequest: true,
                multiple: true,
                text: interpolate(ngettext("Changes, review, and %(numReplies)s reply", "Changes, review, and %(numReplies)s replies", numReplies), {
                  "numReplies": numReplies
                }, true)
              });
            } else {
              draftModes.unshift({
                hasReview: true,
                hasReviewReplies: false,
                hasReviewRequest: true,
                multiple: true,
                text: gettext("Changes and review")
              });
            }
          } else {
            if (numReplies > 0) {
              /* Review request draft + reply drafts */
              draftModes.push({
                hasReview: false,
                hasReviewReplies: true,
                hasReviewRequest: true,
                multiple: true,
                text: interpolate(ngettext("Changes and %(numReplies)s reply", "Changes and %(numReplies)s replies", numReplies), {
                  "numReplies": numReplies
                }, true)
              });
              draftModes.push({
                hasReview: false,
                hasReviewReplies: false,
                hasReviewRequest: true,
                multiple: false,
                text: gettext("Review request changes")
              });
            } else {
              /* Review request draft only */
              draftModes.push({
                hasReview: false,
                hasReviewReplies: false,
                hasReviewRequest: true,
                multiple: false,
                text: gettext("Your review request has changed")
              });
            }
          }
        } else if (reviewDraft) {
          /* Review draft */

          if (numReplies > 0) {
            /* Review draft + reply drafts */
            draftModes.push({
              hasReview: true,
              hasReviewReplies: true,
              hasReviewRequest: false,
              multiple: true,
              text: interpolate(ngettext("Review and %(numReplies)s reply", "Review and %(numReplies)s replies", numReplies), {
                "numReplies": numReplies
              }, true)
            });
            draftModes.push({
              hasReview: true,
              hasReviewReplies: false,
              hasReviewRequest: false,
              multiple: false,
              text: gettext("Review of the change")
            });
          } else {
            /* Review draft only */
            draftModes.push({
              hasReview: true,
              hasReviewReplies: false,
              hasReviewRequest: false,
              multiple: false,
              text: gettext("Reviewing this change")
            });
          }
        } else {
          if (numReplies > 1) {
            /* Multiple reply drafts */
            draftModes.push({
              hasReview: false,
              hasReviewReplies: true,
              hasReviewRequest: false,
              multiple: true,
              text: interpolate(gettext("%(numReplies)s replies"), {
                "numReplies": numReplies
              }, true)
            });
          }
        }
        for (let i = 0; i < reviewReplyDrafts.length; i++) {
          const replyDraft = reviewReplyDrafts[i];
          const review = replyDraft.get('parentObject');
          draftModes.push({
            hasReview: false,
            hasReviewReplies: true,
            hasReviewRequest: false,
            multiple: false,
            singleReviewReply: i,
            text: interpolate(gettext("Replying to %(value1)s's review"), {
              "value1": review.get('authorName')
            }, true)
          });
        }
        let selectedDraftMode = this.get('selectedDraftMode');
        if (selectedDraftMode >= draftModes.length) {
          selectedDraftMode = 0;
        }
        this.set({
          draftModes,
          numDrafts,
          selectedDraftMode
        });
      }
    }) || _class$4;

    var _dec$3, _class$3;

    /**
     * Options for the FileAttachmentThumbnailView.
     *
     * Version Added:
     *     6.0
     */

    /**
     * Displays a thumbnail depicting a file attachment.
     *
     * There are two ways that Review Board currently renders file attachments.
     * One is on page load (as part of the initial page template), and the other
     * is dynamically (when uploading vs Drag and Drop or manual upload).
     *
     * Depending on the method, we either already have elements to work with,
     * or we don't. In the latter case, it's currently up to the caller to
     * tell us, using the renderThumbnail option.
     *
     * File attachments that aren't already on the page that are currently loading
     * will be displayed as a blank file attachment (no identifying information)
     * with a spinner. When loaded, it will appear as a standard file attachment.
     *
     * The following signals are provided, on top of the standard Backbone.View
     * signals:
     *
     *     * beginEdit
     *       - Editing of the file attachment (caption) has begun.
     *
     *     * endEdit
     *       - Editing of the file attachment (caption) has finished.
     *
     *     * commentSaved
     *       - A draft comment on the file has been saved.
     *         (Only for file attachments without a Review UI.)
     *
     * Version Changed:
     *     6.0:
     *     Deprecated the ``FileAttachmentThumbnail`` name for this view and
     *     renamed to ``FileAttachmentThumbnailView``.
     */
    let FileAttachmentThumbnailView = (_dec$3 = spina.spina({
      prototypeAttrs: ['actionsTemplate', 'states', 'template', 'thumbnailContainerTemplate']
    }), _dec$3(_class$3 = class FileAttachmentThumbnailView extends spina.BaseView {
      static className = 'file-container';
      static events = {
        'click .file-add-comment a': '_onAddCommentClicked',
        'click .file-delete': '_onDeleteClicked',
        'click .file-undo-delete': '_onUndoDeleteClicked',
        'click .file-update a': '_onUpdateClicked'
      };
      static template = _.template(`<div class="file">
 <div class="file-actions-container">
  <ul class="file-actions"></ul>
 </div>
 <div class="file-state-container"></div>
 <div class="file-thumbnail-container"></div>
 <div class="file-caption-container">
  <div class="file-caption can-edit">
   <a href="<%- downloadURL %>" class="<%- captionClass %>">
    <%- caption %>
   </a>
  </div>
 </div>
</div>`);
      static actionsTemplate = _.template(`<% if (loaded) { %>
<%  if (canReview) { %>
<%   if (reviewURL) { %>
<li>
 <a class="file-review" role="button" href="<%- reviewURL %>">
  <span class="fa fa-comment-o" aria-hidden="true"></span>
  <%- reviewText %>
 </a>
</li>
<%   } else { %>
<li class="file-add-comment">
 <a role="button" href="#">
  <span class="fa fa-comment-o" aria-hidden="true"></span>
  <%- commentText %>
 </a>
</li>
<%   } %>
<%  } %>
<li>
 <a class="file-download" role="button" href="<%- downloadURL %>">
  <span class="fa fa-download" aria-hidden="true"></span>
  <%- downloadText %>
 </a>
</li>
<%  if (canUndoDeletion) { %>
    <li class="file-undo-delete">
     <a role="button" href="#">
      <span class="fa fa-undo" aria-hidden="true"></span>
      <%- undoDeleteText %>
     </a>
    </li>
<%  } else if (canEdit) { %>
<%   if (attachmentHistoryID) { %>
<li class="file-update">
 <a role="button" href="#"
    data-attachment-history-id="<%- attachmentHistoryID %>">
  <span class="fa fa-upload" aria-hidden="true"></span>
  <%- updateText %>
 </a>
</li>
<%   } %>
<li class="file-delete">
 <a role="button" href="#">
  <span class="fa fa-trash-o" aria-hidden="true"></span>
  <%- deleteText %>
 </a>
</li>
<%  } %>
<% } %>`);
      static thumbnailContainerTemplate = _.template(`<% if (!loaded) { %>
<span class="djblets-o-spinner"></span>
<% } else { %>
<%     if (reviewURL) { %>
<a href="<%- reviewURL %>" class="file-thumbnail-overlay"></a>
<%     } %>
<%=  thumbnailHTML %>
<% } %>`);

      /** The possible states for the file attachment. */
      static states = RB.FileAttachmentStates;

      /**********************
       * Instance variables *
       **********************/

      /** The view options. */

      /** The file actions container. */
      #$actionsContainer;

      /** The caption. */
      #$caption;

      /** The caption container. */
      #$captionContainer;

      /** The element representing the whole file attachment. */
      #$file;

      /** The container for the state label. */
      #$stateContainer;

      /** The thumbnail container. */
      #$thumbnailContainer;

      /**
       * Whether the user can currently edit the file attachment.
       *
       * This differs from options.canEdit because this may change depending
       * on the file attachment state.
       */
      #canCurrentlyEdit;

      /** The processed comments that are usable in the comment dialog. */
      #comments = [];

      /** Whether the comments have been processed. */
      #commentsProcessed = null;

      /** The current draft comment for the file attachment. */
      #draftComment = null;

      /** Whether the thumbnail supports scrolling. */
      #scrollingThumbnail = null;

      /** The view for the state label. */
      #stateLabelView;

      /** Whether the thumbnail is currently playing a video. */
      #playingVideo = null;

      /**
       * Initialize the view.
       *
       * Args:
       *     options (FileAttachmentThumbnailViewOptions):
       *         Options for the view.
       */
      initialize(options) {
        this.options = options;
        this.#canCurrentlyEdit = this.model.get('state') !== this.states.PENDING_DELETION && options.canEdit;
      }

      /**
       * Render the file attachment, and hooks up all events.
       *
       * If the renderThumbnail option was provided when constructing the view,
       * this will render the thumbnail from scratch, and then dynamically
       * update it as it loads. It will start off displaying with a spinner,
       * if not yet loaded.
       *
       * In either case, this will set up the caption editor and other signals
       * to control the lifetime of the thumbnail.
       */
      onInitialRender() {
        /*
         * Until FileAttachmentThumbnailView is the only thing rendering
         * thumbnails, we'll be in a situation where we may either be working
         * with an existing DOM element (for existing file attachments), or a
         * new one (for newly uploaded file attachments). In the latter case,
         * we'll want to render our own thumbnail.
         */
        if (this.options.renderThumbnail) {
          this._renderContents();
        }
        this.#$captionContainer = this.$('.file-caption');
        this.#$caption = this.#$captionContainer.find('a.edit');
        this.listenTo(this.model, 'destroy', () => {
          this.$el.fadeOut(() => this.remove());
        });
        this.listenTo(this.model, 'change:caption', this._onCaptionChanged);
        this._onCaptionChanged();
        this.$el.hover(this._onHoverIn.bind(this), this._onHoverOut.bind(this));
        if (this.options.renderThumbnail) {
          this.#$actionsContainer = this.$('.file-actions-container');
          this._$actions = this.#$actionsContainer.children('.file-actions');
          this.#$captionContainer = this.$('.file-caption-container');
          this.#$thumbnailContainer = this.$('.file-thumbnail-container');
          this.#$stateContainer = this.$('.file-state-container');
          this.#$file = this.$('.file');
          this._$actions.find('.file-download').bindProperty('href', this.model, 'downloadURL', {
            elementToModel: false
          });
          this.#$caption.bindProperty('href', this.model, 'downloadURL', {
            elementToModel: false
          });
          this.listenTo(this.model, 'change:loaded', this._onLoadedChanged);
          this._onLoadedChanged();
          this.listenTo(this.model, 'change:thumbnailHTML', this._renderThumbnail);
          this._renderThumbnail();
          this.listenTo(this.model, 'change:state', this._onStateChanged);
          this._renderStateLabel();
        }
        this._renderCaptionEditor();
        if (!this.options.renderThumbnail) {
          /*
           * Add any hooks. If renderThumbnail is true then the hooks will
           * have already been added.
          */
          RB.FileAttachmentThumbnailContainerHook.each(hook => {
            const HookViewType = hook.get('viewType');
            const hookView = new HookViewType({
              el: this.el,
              extension: hook.get('extension'),
              fileAttachment: this.model,
              thumbnailView: this
            });
            hookView.render();
          });
        }
      }

      /**
       * Fade the view in.
       */
      fadeIn() {
        this.$el.css('opacity', 0).fadeTo(1000, 1);
      }

      /**
       * Add a new action to the actions menu.
       *
       * Args:
       *     appendToClass (str):
       *         The class of an existing action item for which the new
       *         action item will follow. In the actions menu list, the
       *         new action will appear as the next item after this action.
       *
       *     itemClass (str):
       *         The class of the new action, to set on the list element
       *         that wraps the action. If an action of this class already
       *         exists, it will be removed and replaced by this new action.
       *
       *     itemHTML (str):
       *         The HTML of the new action item.
       *
       * Returns:
       *     jQuery:
       *     The element of the new action item.
       */
      addAction(appendToClass, itemClass, itemHTML) {
        this._$actions.find(`.${itemClass}`).remove();
        const itemTemplate = _.template(`<li class="<%= itemClass %>">
 <%=  itemHTML %>
</li>`);
        const $appendItem = this._$actions.find(`.${appendToClass}`).closest('li');
        const $action = $(itemTemplate({
          itemClass: itemClass,
          itemHTML: itemHTML
        }));
        $appendItem.after($action);
        return $action;
      }

      /**
       * Show the comment dialog for the file attachment.
       *
       * This is only ever used if the file attachment does not have a
       * Review UI for it. A single comment dialog will appear, allowing
       * comments on the file as a whole.
       */
      showCommentDlg() {
        console.assert(!this.model.get('reviewURL'), 'showCommentDlg can only be called if the file ' + 'attachment does not have a review UI');
        this._processComments();
        this._createDraftComment();
        CommentDialogView.create({
          comment: this.#draftComment,
          position: {
            beside: {
              el: this.$el,
              fitOnScreen: true,
              side: 'br'
            }
          },
          publishedComments: this.#comments,
          publishedCommentsType: 'file_attachment_comments'
        });
      }

      /**
       * Process all comments provided when constructing the view.
       *
       * The comments will be made usable by the comment dialog.
       *
       * This is only used if the file attachment does not have a Review UI.
       */
      _processComments() {
        if (this.#commentsProcessed) {
          return;
        }
        const comments = this.options.comments || [];
        comments.forEach(comment => {
          if (comment.localdraft) {
            this._createDraftComment(comment.comment_id, comment.text);
          } else {
            this.#comments.push(comment);
          }
        });
        this.#commentsProcessed = true;
      }

      /**
       * Create a new draft comment with the given ID and text.
       *
       * Only one draft comment can be created at a time.
       *
       * This is only used if the file attachment does not have a Review UI.
       *
       * Args:
       *     commentID (number):
       *         The ID of the draft comment.
       *
       *     text (string):
       *         The comment text.
       */
      _createDraftComment(commentID, text) {
        if (this.#draftComment !== null) {
          return;
        }
        const review = this.options.reviewRequest.createReview();
        this.#draftComment = review.createFileAttachmentComment(commentID, this.model.id);
        if (text) {
          this.#draftComment.set('text', text);
        }
        this.listenTo(this.#draftComment, 'saved', () => this.trigger('commentSaved', this.#draftComment));
      }

      /**
       * Save the given caption value.
       *
       * Version Added:
       *     6.0
       */
      async _saveCaption(val) {
        const model = this.model;

        /*
         * We want to set the caption after ready() finishes, in case
         * it loads state and overwrites.
         */
        await model.ready();
        model.set('caption', val);
        this.trigger('endEdit');
        await model.save({
          attrs: ['caption']
        });
      }

      /**
       * Render the caption editor and set up its event listeners.
       *
       * Version Added:
       *     6.0
       */
      _renderCaptionEditor() {
        if (this.#canCurrentlyEdit === false && this._captionEditorView) {
          /*
           * The view exists but we're not allowed to edit anymore.
           * Remove it.
           */
          this._captionEditorView.remove();
          return;
        } else if (this.#canCurrentlyEdit && this._captionEditorView) {
          /* The view already exists. No op. */
          return;
        } else if (this.#canCurrentlyEdit === false) {
          /* The view doesn't and shouldn't exist. No op. */
          return;
        }
        this._captionEditorView = new RB.InlineEditorView({
          editIconClass: 'rb-icon rb-icon-edit',
          el: this.#$caption,
          showButtons: true
        });
        this._captionEditorView.render();
        this.listenTo(this._captionEditorView, 'beginEditPreShow', () => {
          this.$el.addClass('editing');
          this._stopAnimating();
        });
        this.listenTo(this._captionEditorView, 'beginEdit', () => {
          if (this.#$caption.hasClass('empty-caption')) {
            this._captionEditorView.$field.val('');
          }
          this.trigger('beginEdit');
        });
        this.listenTo(this._captionEditorView, 'cancel', () => {
          this.$el.removeClass('editing');
          this.trigger('endEdit');
        });
        this.listenTo(this._captionEditorView, 'complete', async val => {
          this.$el.removeClass('editing');
          await this._saveCaption(val);
        });
      }

      /**
       * Render the contents of this view's element.
       *
       * This is only done when requested by the caller.
       */
      _renderContents() {
        const model = this.model;
        const caption = model.get('caption');
        const captionText = caption ? caption : gettext("No caption");
        const captionClass = caption ? 'edit' : 'edit empty-caption';
        this.$el.html(this.template(_.defaults({
          caption: captionText,
          captionClass: captionClass
        }, model.attributes))).addClass(this.className);
      }

      /**
       * Render the thumbnail for the file attachment.
       */
      _renderThumbnail() {
        this.#$thumbnailContainer.html(this.thumbnailContainerTemplate(this.model.attributes));
        Djblets.enableRetinaImages(this.#$thumbnailContainer);

        // Disable tabbing to any <a> elements inside the thumbnail.
        this.#$thumbnailContainer.find('a').each((i, el) => {
          el.tabIndex = -1;
        });
      }

      /**
       * Render the state label for the file attachment.
       *
       * Version Added:
       *     6.0
       */
      _renderStateLabel() {
        if (this.#stateLabelView) {
          this.#stateLabelView.remove();
        }
        if (!this.options.canEdit) {
          return;
        }
        const state = this.model.get('state');
        if (state === this.states.PUBLISHED || state === this.states.DELETED) {
          /* Don't display state labels for these types of attachments. */
          return;
        }
        const theme = state === this.states.PENDING_DELETION ? RB.FieldStateLabelThemes.DELETED : RB.FieldStateLabelThemes.DRAFT;
        this.#stateLabelView = new RB.FieldStateLabelView({
          state: this._getStateText(state),
          theme: theme
        });
        this.#stateLabelView.renderInto(this.#$stateContainer);
      }

      /**
       * Handler for when the model's 'state' property changes.
       *
       * Args:
       *     model (FileAttachment):
       *         The file attachment model.
       *
       *     state (string):
       *         The new state value.
       */
      _onStateChanged(model, state) {
        if (state === this.states.PENDING_DELETION) {
          /* Block editing for attachments that are pending deletion. */
          this.#canCurrentlyEdit = false;
        } else if (this.#canCurrentlyEdit !== this.options.canEdit) {
          /* We're not pending deletion, set this back to canEdit. */
          this.#canCurrentlyEdit = this.options.canEdit;
        }
        this._renderStateLabel();
        this._onLoadedChanged();
        this._renderCaptionEditor();
      }

      /**
       * Handler for when the model's 'loaded' property changes.
       *
       * Depending on if the file attachment is now loaded, either a
       * blank spinner thumbnail will be shown, or a full thumbnail.
       */
      _onLoadedChanged() {
        const model = this.model;
        const state = model.get('state');
        const useDraftDelete = state === this.states.NEW || state === this.states.NEW_REVISION || state === this.states.DRAFT;
        const deleteText = useDraftDelete ? gettext("Delete Draft") : gettext("Delete");
        this._$actions.html(this.actionsTemplate(_.defaults({
          canEdit: this.options.canEdit,
          canReview: state !== this.states.DELETED,
          canUndoDeletion: state === this.states.PENDING_DELETION && this.options.canEdit,
          commentText: gettext("Comment"),
          deleteText: deleteText,
          downloadText: gettext("Download"),
          reviewText: gettext("Review"),
          undoDeleteText: gettext("Undo Delete"),
          updateText: gettext("Update")
        }, model.attributes)));

        /*
        * Some hooks may depend on the elements being added above, so
        * render the hooks here too.
        */
        RB.FileAttachmentThumbnailContainerHook.each(hook => {
          const HookViewType = hook.get('viewType');
          const hookView = new HookViewType({
            el: this.el,
            extension: hook.get('extension'),
            fileAttachment: model,
            thumbnailView: this
          });
          hookView.render();
        });
      }

      /**
       * Handler for when the model's caption changes.
       *
       * If a caption is set, the thumbnail will display it. Otherwise,
       * it will display "No caption".
       */
      _onCaptionChanged() {
        const model = this.model;
        const state = model.get('state');
        const caption = model.get('caption');
        const publishedCaption = model.get('publishedCaption');
        const captionHasChanged = caption !== publishedCaption;
        if (caption) {
          this.#$caption.text(caption).removeClass('empty-caption');
        } else {
          this.#$caption.text(gettext("No caption")).addClass('empty-caption');
        }
        if (state === this.states.DRAFT && !captionHasChanged) {
          /*
           * This is a draft but the caption has changed back to the
           * published version. Remove the draft state.
           */
          model.set('state', this.states.PUBLISHED);
        } else if (state === this.states.PUBLISHED && captionHasChanged) {
          /* The caption changed, put this into draft state. */
          model.set('state', this.states.DRAFT);
        }
      }

      /**
       * Handler for the New Comment button.
       *
       * Shows the comment dialog.
       *
       * Args:
       *     e (JQuery.ClickEvent):
       *         The event that triggered the action.
       */
      _onAddCommentClicked(e) {
        e.preventDefault();
        e.stopPropagation();
        this.showCommentDlg();
      }

      /**
       * Handler for the Update button.
       *
       * Shows the upload form.
       *
       * Args:
       *     e (JQuery.ClickEvent):
       *         The event that triggered the action.
       */
      _onUpdateClicked(e) {
        e.preventDefault();
        e.stopPropagation();
        const model = this.model;
        const updateDlg = new RB.UploadAttachmentView({
          attachmentHistoryID: model.get('attachmentHistoryID'),
          presetCaption: model.get('caption'),
          reviewRequestEditor: this.options.reviewRequestEditor
        });
        updateDlg.show();
      }

      /**
       * Handler for the Delete button.
       *
       * Deletes the file attachment from the review request draft.
       *
       * Args:
       *     e (JQuery.ClickEvent):
       *         The event that triggered the action.
       */
      async _onDeleteClicked(e) {
        e.preventDefault();
        e.stopPropagation();
        const model = this.model;
        const state = model.get('state');
        if (state === this.states.DRAFT) {
          /*
           * "Delete" the draft version of the file attachment by reverting
           * to its published caption.
           */
          await this._saveCaption(model.get('publishedCaption'));
        } else {
          model.destroy();
        }
      }

      /**
       * Handler for the Undo Delete button.
       *
       * Undoes the pending deletion of the file attachment, reverting it
       * to its published state.
       *
       * Args:
       *     e (JQuery.ClickEvent):
       *         The event that triggered the action.
       */
      _onUndoDeleteClicked(e) {
        e.preventDefault();
        e.stopPropagation();
        const model = this.model;
        RB.apiCall({
          data: {
            'pending_deletion': false
          },
          error: xhr => {
            const rsp = xhr.responseJSON;
            const caption = model.get('caption');
            if (rsp && rsp.stat) {
              alert(`Failed to undo the deletion of file attachment
"${caption}": ${rsp.err.msg}`);
            } else {
              alert(`Failed to undo the deletion of file attachment
"${caption}": ${xhr.errorText}`);
            }
          },
          method: 'PUT',
          success: () => {
            model.set({
              state: this.states.PUBLISHED
            });
          },
          url: model.url()
        });
      }

      /**
       * Handler for when the mouse hovers over the thumbnail.
       *
       * Determines if we should scroll the thumbnail or not.
       */
      _onHoverIn() {
        const $thumbnail = this.$('.file-thumbnail').children();
        const actionsWidth = this.#$actionsContainer.outerWidth();
        const actionsRight = this.#$file.offset().left + this.#$file.outerWidth() + actionsWidth;
        this.trigger('hoverIn', this.$el);

        /*
         * Position the actions menu to the left or right of the attachment
         * thumbnail.
         */
        if (actionsRight > $(window).width()) {
          this.#$actionsContainer.css('left', -actionsWidth).addClass('left');
        } else {
          this.#$actionsContainer.css('left', '100%').addClass('right');
        }
        if (!this.$el.hasClass('editing') && $thumbnail.length === 1) {
          const thumbnailEl = $thumbnail[0];
          if (thumbnailEl.tagName === 'VIDEO') {
            /* The thumbnail contains a video, so start playing it. */
            const promise = thumbnailEl.play();
            if (promise === undefined) {
              /* Older browsers don't return Promises. */
              this.#playingVideo = true;
            } else {
              promise.then(() => {
                this.#playingVideo = true;
              }).catch(error => {
                /* Ignore the error. We just won't play it. */
                console.error('Unable to play the video attachment: %s', error);
              });
            }
          } else {
            /* Scroll the container to show all available content. */
            const elHeight = this.$el.height();
            const thumbnailHeight = $thumbnail.height() || 0;
            if (thumbnailHeight > elHeight) {
              const distance = elHeight - thumbnailHeight;
              const duration = Math.abs(distance) / 200 * 1000; // 200 pixels/s

              this.#scrollingThumbnail = true;
              $thumbnail.delay(1000).animate({
                'margin-top': distance + 'px'
              }, {
                duration: duration,
                easing: 'linear'
              }).delay(500).animate({
                'margin-top': 0
              }, {
                complete: () => {
                  this.#scrollingThumbnail = false;
                },
                duration: duration,
                easing: 'linear'
              });
            }
          }
        }
      }

      /**
       * Handler for when the mouse stops hovering over the thumbnail.
       *
       * Removes the classes for the actions container, and stops animating
       * the thumbnail contents.
       */
      _onHoverOut() {
        this.trigger('hoverOut');
        this.#$actionsContainer.removeClass('left').removeClass('right');
        this._stopAnimating();
      }

      /**
       * Stop animating this thumbnail.
       *
       * This is when moving the mouse outside of the thumbnail, or when the
       * caption editor is opened.
       */
      _stopAnimating() {
        if (this.#scrollingThumbnail) {
          this.#scrollingThumbnail = false;
          this.$('.file-thumbnail').children().stop(true).animate({
            'margin-top': 0
          }, {
            duration: 100
          });
        } else if (this.#playingVideo) {
          this.#playingVideo = false;
          this.$('video')[0].pause();
        }
      }

      /**
       * Return a localized text for the file attachment state.
       *
       * Args:
       *     state (string):
       *         The state value.
       *
       * Returns:
       *     string:
       *     The localized and human readable text representing the state.
       */
      _getStateText(state) {
        switch (state) {
          case RB.FileAttachmentStates.DELETED:
            return gettext("Deleted");
          case RB.FileAttachmentStates.DRAFT:
            return gettext("Draft");
          case RB.FileAttachmentStates.NEW:
            return gettext("New");
          case RB.FileAttachmentStates.NEW_REVISION:
            return gettext("New Revision");
          case RB.FileAttachmentStates.PENDING_DELETION:
            return gettext("Pending Deletion");
          case RB.FileAttachmentStates.PUBLISHED:
            return gettext("Published");
          default:
            return '';
        }
      }
    }) || _class$3);

    /**
     * This is a legacy alias for the FileAttachmentThumbnailView.
     *
     * Deprecated:
     *     6.0
     */
    const FileAttachmentThumbnail = FileAttachmentThumbnailView;

    var _dec$2, _class$2, _dec2, _class2$1, _dec3, _class3, _dec4, _class4, _dec5, _class5, _class6;
    /**
     * Base class for review request banners.
     *
     * This will render a banner based on the data provided by subclasses,
     * and handle actions and editing of text fields.
     */
    let BannerView = (_dec$2 = spina.spina({
      prototypeAttrs: ['DescriptionFieldViewType', 'actions', 'className', 'describeText', 'descriptionFieldClasses', 'descriptionFieldHTML', 'descriptionFieldID', 'descriptionFieldName', 'showChangesField', 'subtitle', 'template', 'title']
    }), _dec$2(_class$2 = class BannerView extends spina.BaseView {
      static DescriptionFieldViewType = ChangeDescriptionFieldView;
      static actions = [];
      static className = 'banner';
      static describeText = '';
      static descriptionFieldClasses = '';
      static descriptionFieldHTML = '';
      static descriptionFieldID = 'change_description';
      static descriptionFieldName = null;
      static showChangesField = true;
      static subtitle = '';
      static template = _.template(`<h1><%- title %></h1>
<% if (subtitle) { %>
<p><%- subtitle %></p>
<% } %>
<span class="banner-actions">
<% _.each(actions, function(action) { %>
 <input type="button" id="<%= action.id %>"
        value="<%- action.label %>" />
<% }); %>
<% if (showSendEmail) { %>
 <label>
  <input type="checkbox" class="send-email" checked />
  ${gettext("Send E-Mail")}
</label>
<% } %>
</span>
<% if (showChangesField) { %>
 <p><label for="field_<%- descriptionFieldID %>"><%- describeText %></label></p>
 <pre id="field_<%- descriptionFieldID %>"
      class="field field-text-area <%- descriptionFieldClasses %>"
      data-field-id="field_<%- descriptionFieldID %>"
      ><%= descriptionFieldHTML %></pre>
<% } %>`);
      static title = '';

      /**********************
       * Instance variables *
       **********************/

      /** The change description field editor, if present. */

      /** Whether to show the "Send E-mail" checkbox. */
      showSendEmail = false;

      /** The button elements. */

      /**
       * Initialize the banner.
       *
       * Args:
       *     options (BannerViewOptions):
       *         Options for the banner.
       */
      initialize(options) {
        this.reviewRequestEditorView = options.reviewRequestEditorView;
        this.reviewRequestEditor = this.reviewRequestEditorView.model;
        this.reviewRequest = this.reviewRequestEditor.get('reviewRequest');
        this.$buttons = null;
      }

      /**
       * Render the banner.
       *
       * If there's an existing banner on the page, from the generated
       * template, then this will make use of that template. Otherwise,
       * it will construct a new one.
       */
      onInitialRender() {
        const readOnly = RB.UserSession.instance.get('readOnly');
        if (this.$el.children().length === 0) {
          this.$el.html(this.template({
            actions: readOnly ? [] : this.actions,
            describeText: this.describeText,
            descriptionFieldClasses: this.descriptionFieldClasses,
            descriptionFieldHTML: this.descriptionFieldHTML,
            descriptionFieldID: this.descriptionFieldID,
            showChangesField: this.showChangesField && !readOnly,
            showSendEmail: this.showSendEmail,
            subtitle: this.subtitle,
            title: this.title
          }));
        }
        if (this.DescriptionFieldViewType) {
          this.field = new this.DescriptionFieldViewType({
            el: this.$(`#field_${this.descriptionFieldID}`),
            fieldID: this.descriptionFieldID,
            model: this.reviewRequestEditor
          });
          this.reviewRequestEditorView.addFieldView(this.field);
        }
        this.$buttons = this.$('input');
        this.reviewRequestEditor.on('saving destroying', () => this.$buttons.prop('disabled', true));
        this.reviewRequestEditor.on('saved saveFailed destroyed', () => this.$buttons.prop('disabled', false));
      }
    }) || _class$2);
    /**
     * Base class for a banner representing a closed review request.
     *
     * This provides a button for reopening the review request. It's up
     * to subclasses to provide the other details.
     */
    let ClosedBannerView = (_dec2 = spina.spina({
      prototypeAttrs: ['DescriptionFieldViewType', 'actions', 'closeType', 'descriptionFieldClasses', 'descriptionFieldID', 'descriptionFieldName', 'events']
    }), _dec2(_class2$1 = class ClosedBannerView extends BannerView {
      static DescriptionFieldViewType = CloseDescriptionFieldView;
      static actions = [{
        id: 'btn-review-request-reopen',
        label: gettext("Reopen for Review")
      }];
      static closeType = undefined;
      static descriptionFieldID = 'close_description';
      static descriptionFieldName = 'closeDescription';
      static events = {
        'click #btn-review-request-reopen': '_onReopenClicked'
      };

      /**
       * Render the banner.
       */
      onInitialRender() {
        const descriptionFieldClasses = [];
        if (this.reviewRequestEditor.get('statusMutableByUser')) {
          descriptionFieldClasses.push('editable');
        }
        if (this.reviewRequest.get('closeDescriptionRichText')) {
          descriptionFieldClasses.push('rich-text');
        }
        this.descriptionFieldClasses = descriptionFieldClasses.join(' ');
        this.descriptionFieldHTML = this.reviewRequestEditor.get('closeDescriptionRenderedText');
        super.onInitialRender();
        this.field.closeType = this.closeType;
      }

      /**
       * Handle a click on "Reopen for Review".
       *
       * Returns:
       *     boolean:
       *     False, always.
       */
      _onReopenClicked() {
        this.reviewRequest.reopen().catch(err => alert(err.message));
        return false;
      }
    }) || _class2$1);
    /**
     * A banner representing a discarded review request.
     */
    let DiscardedBannerView = (_dec3 = spina.spina({
      prototypeAttrs: ['closeType', 'describeText', 'id', 'title']
    }), _dec3(_class3 = class DiscardedBannerView extends ClosedBannerView {
      static closeType = RB.ReviewRequest.CLOSE_DISCARDED;
      static describeText = gettext("Describe the reason it's discarded (optional):");
      static id = 'discard-banner';
      static title = gettext("This change has been discarded.");
    }) || _class3);
    /**
     * A banner representing a submitted review request.
     */
    let CompletedBannerView = (_dec4 = spina.spina({
      prototypeAttrs: ['closeType', 'describeText', 'id', 'title']
    }), _dec4(_class4 = class CompletedBannerView extends ClosedBannerView {
      static closeType = RB.ReviewRequest.CLOSE_SUBMITTED;
      static describeText = gettext("Describe the completed change (optional):");
      static id = 'submitted-banner';
      static title = gettext("This change has been marked as completed.");
    }) || _class4);
    /**
     * A banner representing a draft of a review request.
     *
     * Depending on the public state of the review request, this will
     * show different text and a different set of buttons.
     */
    let DraftBannerView = (_dec5 = spina.spina({
      prototypeAttrs: ['describeText', 'descriptionFieldID', 'descriptionFieldName', 'events', 'id', 'subtitle', 'title', '_newDraftTemplate']
    }), _dec5(_class5 = class DraftBannerView extends BannerView {
      static describeText = gettext("Describe your changes (optional):");
      static descriptionFieldID = 'change_description';
      static descriptionFieldName = 'changeDescription';
      static events = {
        'click #btn-draft-discard': '_onDiscardDraftClicked',
        'click #btn-draft-publish': '_onPublishDraftClicked',
        'click #btn-review-request-discard': '_onCloseDiscardedClicked'
      };
      static id = 'draft-banner';
      static subtitle = gettext("Be sure to publish when finished.");
      static title = gettext("This review request is a draft.");
      static _newDraftTemplate = _.template(`<div class="interdiff-link">
 <%- newDiffText %>
 <a href="<%- interdiffLink %>"><%- showChangesText %></a>
</div>`);

      /**
       * Initialize the banner.
       *
       * Args:
       *     options (BannerViewOptions):
       *         Options for the view.
       */
      initialize(options) {
        super.initialize(options);
        if (this.reviewRequest.get('public')) {
          this.showSendEmail = this.reviewRequestEditor.get('showSendEmail');
          this.title = gettext("This review request is a draft.");
          this.actions = [{
            id: 'btn-draft-publish',
            label: gettext("Publish Changes")
          }, {
            id: 'btn-draft-discard',
            label: gettext("Discard Draft")
          }];
        } else {
          this.showChangesField = false;
          this.actions = [{
            id: 'btn-draft-publish',
            label: gettext("Publish")
          }, {
            id: 'btn-review-request-discard',
            label: gettext("Discard Review Request")
          }];
        }
      }

      /**
       * Handle a click on "Publish Changes".
       *
       * Begins publishing the review request. If there are any field editors
       * still open, they'll be saved first.
       *
       * Returns:
       *     boolean:
       *     False, always.
       */
      async _onPublishDraftClicked() {
        const $sendEmail = this.$('.send-email');
        await this.reviewRequestEditorView.publishDraft({
          trivial: $sendEmail.length === 1 && !$sendEmail.is(':checked')
        });
        return false;
      }

      /**
       * Handle a click on "Discard Draft".
       *
       * Discards the draft of the review request.
       *
       * Args:
       *     e (Event):
       *         The event which triggered the action.
       */
      async _onDiscardDraftClicked(e) {
        e.preventDefault();
        e.stopPropagation();
        try {
          await this.reviewRequest.draft.destroy();
        } catch (err) {
          alert(err.message);
        }
      }

      /**
       * Handle a click on "Discard Review Request".
       *
       * Returns:
       *     boolean:
       *     False, always.
       */
      _onCloseDiscardedClicked() {
        this.reviewRequest.close({
          type: RB.ReviewRequest.CLOSE_DISCARDED
        }).catch(err => alert(err.message));
        return false;
      }

      /**
       * Render the banner.
       */
      onInitialRender() {
        const descriptionFieldClasses = [];
        if (this.reviewRequestEditor.get('mutableByUser')) {
          descriptionFieldClasses.push('editable');
        }
        const draft = this.reviewRequest.draft;
        if (draft.get('changeDescriptionRichText')) {
          descriptionFieldClasses.push('rich-text');
        }
        this.descriptionFieldClasses = descriptionFieldClasses.join(' ');
        this.descriptionFieldHTML = this.reviewRequestEditor.get('changeDescriptionRenderedText');
        super.onInitialRender();
        const interdiffLink = draft.get('interdiffLink');
        if (interdiffLink) {
          this.$el.append(this._newDraftTemplate({
            interdiffLink: interdiffLink,
            newDiffText: gettext("This draft adds a new diff."),
            showChangesText: gettext("Show changes")
          }));
        }
      }
    }) || _class5);
    /**
     * Manages the user-visible state of an editable review request.
     *
     * This owns the fields, thumbnails, banners, and general interaction
     * around editing a review request.
     */
    let ReviewRequestEditorView = spina.spina(_class6 = class ReviewRequestEditorView extends spina.BaseView {
      /**********************
       * Instance variables *
       **********************/

      /** The element containing the file attachment thumbnails. */
      #$attachments = null;

      /** The parent of the file attachments element. */
      #$attachmentsContainer = null;

      /** The container where banners are added. */
      #$bannersContainer = null;

      /** The main fields of the review request. */
      #$main = null;

      /** The extra fields of the review request. */
      #$extra = null;

      /** The warning message box. */
      #$warning = null;

      /** A mapping from field ID to field view instance. */
      #fieldViews = {};

      /** The views for all of the file attachment thumbnails. */
      #fileAttachmentThumbnailViews = [];

      /** The views for all of the review reply editors. */
      #reviewReplyEditorViews = [];

      /**
       * The active banner, if available.
       *
       * This can be either a close banner or the legacy draft banner. The
       * unified review banner is separate and manages its own lifecycle.
       */
      banner = null;

      /** The review request draft. */

      /**
       * Initialize the view.
       */
      initialize() {
        this.draft = this.model.get('reviewRequest').draft;
      }

      /**
       * Add a view for a field in the review request.
       *
       * Args:
       *     view (BaseFieldView):
       *         The view which handles editing for the field.
       */
      addFieldView(view) {
        this.#fieldViews[view.fieldID] = view;
        view.reviewRequestEditorView = this;
        this.listenTo(view, 'fieldError', err => {
          this.#$warning.delay(6000).fadeOut(400, () => this.#$warning.hide()).html(err.errorText).show();
        });
        this.listenTo(view, 'fieldSaved', this.showBanner);
        if (this.rendered) {
          view.render();
        }
      }

      /**
       * Return the view for the field with the given ID.
       *
       * Args:
       *     fieldID (string):
       *        The ID of the field.
       *
       * Returns:
       *     BaseFieldView:
       *     The view which handles editing for the field.
       */
      getFieldView(fieldID) {
        return this.#fieldViews[fieldID];
      }

      /**
       * Render the editor.
       *
       * This will import all pre-rendered file attachment and screenshot
       * thumbnails, turning them into FileAttachment and Screenshot objects.
       */
      onInitialRender() {
        const reviewRequest = this.model.get('reviewRequest');
        const fileAttachments = this.model.get('fileAttachments');
        const draft = reviewRequest.draft;
        this.#$warning = $('#review-request-warning');
        const $screenshots = $('#screenshot-thumbnails');
        this.#$attachments = $('#file-list');
        this.#$attachmentsContainer = $('#file-list-container');
        this.#$bannersContainer = $('#review-request-banners');
        this.#$main = $('#review-request-main');
        this.#$extra = $('#review-request-extra');

        /*
         * We need to show any banners before we render the fields, since the
         * banners can add their own fields.
         */
        this.showBanner();
        if (this.model.get('editable')) {
          RB.DnDUploader.instance.registerDropTarget(this.#$attachmentsContainer, gettext("Drop to add a file attachment"), this._uploadFile.bind(this));
        }
        this.#$attachments.find('.file-container').remove();
        fileAttachments.each(fileAttachment => this.buildFileAttachmentThumbnail(fileAttachment, fileAttachments, {
          noAnimation: true
        }));
        this.#$attachmentsContainer.find('.djblets-o-spinner').remove();
        this.#$attachmentsContainer.attr('aria-busy', 'false');
        this.listenTo(fileAttachments, 'add', this.buildFileAttachmentThumbnail);
        this.listenTo(fileAttachments, 'remove', this._removeThumbnail);
        this.listenTo(fileAttachments, 'destroy', () => {
          if (fileAttachments.length === 0) {
            this.#$attachmentsContainer.hide();
          }
        });
        this.listenTo(this.model, 'replaceAttachment', this._removeThumbnail);

        /*
         * Import all the screenshots and file attachments rendered onto
         * the page.
         */
        _.each($screenshots.find('.screenshot-container'), this._importScreenshotThumbnail, this);
        _.each($('.binary'), this._importFileAttachmentThumbnail, this);

        // Render all the field views.
        for (const fieldView of Object.values(this.#fieldViews)) {
          fieldView.render();
        }
        this._setupActions();
        this.model.on('publishError', errorText => {
          alert(errorText);
          this.$('#btn-draft-publish').enable();
          this.$('#btn-draft-discard').enable();
        });
        this.model.on('closeError', errorText => alert(errorText));
        this.model.on('saved', this.showBanner, this);
        this.model.on('published', this._refreshPage, this);
        reviewRequest.on('closed reopened', this._refreshPage, this);
        draft.on('destroyed', this._refreshPage, this);
        window.onbeforeunload = this._onBeforeUnload.bind(this);
      }

      /**
       * Warn the user if they try to navigate away with unsaved comments.
       *
       * Args:
       *     evt (Event):
       *         The event that triggered the handler.
       *
       * Returns:
       *     string:
       *     The warning message.
       *
       */
      _onBeforeUnload(evt) {
        if (this.model.get('editCount') > 0) {
          /*
           * On IE, the text must be set in evt.returnValue.
           *
           * On Firefox, it must be returned as a string.
           *
           * On Chrome, it must be returned as a string, but you
           * can't set it on evt.returnValue (it just ignores it).
           */
          const msg = gettext("You have unsaved changes that will be lost if you navigate away from this page.");
          evt = evt || window.event;
          evt.returnValue = msg;
          return msg;
        }
      }

      /**
       * Show a banner for the given state of the review request.
       */
      showBanner() {
        if (this.banner) {
          return;
        }
        const reviewRequest = this.model.get('reviewRequest');
        const state = reviewRequest.get('state');
        let BannerClass;
        if (state === RB.ReviewRequest.CLOSE_SUBMITTED) {
          BannerClass = CompletedBannerView;
        } else if (state === RB.ReviewRequest.CLOSE_DISCARDED) {
          BannerClass = DiscardedBannerView;
        } else if (state === RB.ReviewRequest.PENDING && this.model.get('hasDraft') && !RB.EnabledFeatures.unifiedBanner) {
          BannerClass = DraftBannerView;
        } else {
          return;
        }
        let $existingBanner = this.#$bannersContainer.children();
        console.assert(BannerClass);
        console.assert($existingBanner.length <= 1);
        if ($existingBanner.length === 0) {
          $existingBanner = undefined;
        }
        this.banner = new BannerClass({
          el: $existingBanner,
          reviewRequestEditorView: this
        });
        if ($existingBanner) {
          $existingBanner.show();
        } else {
          this.banner.$el.appendTo(this.#$bannersContainer);
        }
        this.banner.render();
      }

      /**
       * Add a review reply editor view.
       *
       * These views are constructed by the individual review views. We keep
       * track of them here so that we can save any open editors when performing
       * publish operations.
       *
       * Args:
       *     reviewReplyEditorView (RB.ReviewRequestPage.ReviewReplyEditorView):
       *          The review reply editor view.
       */
      addReviewReplyEditorView(reviewReplyEditorView) {
        this.#reviewReplyEditorViews.push(reviewReplyEditorView);
      }

      /**
       * Handle a click on the "Publish Draft" button.
       *
       * Begins publishing the review request. If there are any field editors
       * still open, they'll be saved first.
       *
       * Args:
       *     options (object):
       *         Options for the publish operation.
       */
      async publishDraft(options) {
        this.model.set({
          publishing: true
        });
        await this.saveOpenEditors();
        await this.model.publishDraft(options);
      }

      /**
       * Finish saving all open editors.
       */
      async saveOpenEditors() {
        await Promise.all(Object.values(this.#fieldViews).filter(field => field.needsSave()).map(field => field.finishSave()));
        await Promise.all(this.#reviewReplyEditorViews.filter(view => view.needsSave()).map(field => field.save()));
      }

      /**
       * Upload a dropped file as a file attachment.
       *
       * A temporary file attachment placeholder will appear while the
       * file attachment uploads. After the upload has finished, it will
       * be replaced with the thumbnail depicting the file attachment.
       *
       * Args:
       *     file (File):
       *         The file to upload.
       */
      _uploadFile(file) {
        // Create a temporary file listing.
        const fileAttachment = this.model.createFileAttachment();
        fileAttachment.set('file', file);
        fileAttachment.save();
      }

      /**
       * Set up all review request actions and listens for events.
       */
      _setupActions() {
        RB.ReviewRequestActionHook.each(hook => {
          _.each(hook.get('callbacks'), (handler, selector) => this.$(selector).click(handler));
        });
      }

      /**
       * Build a thumbnail for a FileAttachment.
       *
       * The thumbnail will be added to the page. The editor will listen
       * for events on the thumbnail to update the current edit state.
       *
       * This can be called either when dynamically adding a new file
       * attachment (through drag-and-drop or Add File), or after importing
       * from the rendered page.
       *
       * Args:
       *     fileAttachment (FileAttachment):
       *         The file attachment.
       *
       *     collection (Backbone.Collection):
       *         The collection of all file attachments.
       *
       *     options (object):
       *         Options.
       *
       * Option Args:
       *     $el (jQuery):
       *         The thumbnail element, if it already exists in the DOM.
       *
       *     noAnimation (boolean):
       *         Whether to disable animation.
       */
      buildFileAttachmentThumbnail(fileAttachment, collection) {
        let options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
        const fileAttachmentComments = this.model.get('fileAttachmentComments');
        const $thumbnail = options.$el;
        const view = new FileAttachmentThumbnailView({
          canEdit: this.model.get('editable') === true,
          comments: fileAttachmentComments[fileAttachment.id],
          el: $thumbnail,
          model: fileAttachment,
          renderThumbnail: $thumbnail === undefined,
          reviewRequest: this.model.get('reviewRequest'),
          reviewRequestEditor: this.model
        });
        view.render();
        this.#fileAttachmentThumbnailViews.push(view);
        if (!$thumbnail) {
          // This is a newly added file attachment.
          const fileAttachments = this.model.get('fileAttachments');
          const index = fileAttachments.indexOf(fileAttachment);
          this.#$attachmentsContainer.show();
          view.$el.insertBefore(this.#$attachments.children().eq(index));
          if (!options.noAnimation) {
            view.fadeIn();
          }
        }
        this.listenTo(view, 'hoverIn', $thumbnail => {
          this.#$attachments.find('.file').not($thumbnail.find('.file')[0]).addClass('faded');
        });
        this.listenTo(view, 'hoverOut', () => this.#$attachments.find('.file').removeClass('faded'));
        view.on('beginEdit', () => this.model.incr('editCount'));
        view.on('endEdit', () => this.model.decr('editCount'));
        if (!RB.EnabledFeatures.unifiedBanner) {
          view.on('commentSaved', () => RB.DraftReviewBannerView.instance.show());
        }
      }

      /**
       * Import file attachments from the rendered page.
       *
       * Each file attachment already rendered will be turned into a
       * FileAttachment, and a new thumbnail will be built for it.
       *
       * Args:
       *     thumbnailEl (Element):
       *         The existing DOM element to import.
       */
      _importFileAttachmentThumbnail(thumbnailEl) {
        const $thumbnail = $(thumbnailEl);
        const id = $thumbnail.data('file-id');
        const $caption = $thumbnail.find('.file-caption .edit');
        const reviewRequest = this.model.get('reviewRequest');
        const fileAttachment = reviewRequest.draft.createFileAttachment({
          id: id
        });
        if (!$caption.hasClass('empty-caption')) {
          fileAttachment.set('caption', $caption.text());
        }
        this.model.get('fileAttachments').add(fileAttachment, {
          $el: $thumbnail
        });
      }

      /**
       * Import screenshots from the rendered page.
       *
       * Each screenshot already rendered will be turned into a Screenshot.
       *
       * Args:
       *     thumbnailEl (Element):
       *         The existing DOM element to import.
       */
      _importScreenshotThumbnail(thumbnailEl) {
        const $thumbnail = $(thumbnailEl);
        const id = $thumbnail.data('screenshot-id');
        const reviewRequest = this.model.get('reviewRequest');
        const screenshot = reviewRequest.createScreenshot(id);
        const view = new RB.ScreenshotThumbnail({
          el: $thumbnail,
          model: screenshot
        });
        view.render();
        this.model.get('screenshots').add(screenshot);
        view.on('beginEdit', () => this.model.incr('editCount'));
        view.on('endEdit', () => this.model.decr('editCount'));
      }

      /**
       * Handle a click on "Update -> Update Diff".
       *
       * Returns:
       *     boolean:
       *     False, always.
       */
      _onUpdateDiffClicked() {
        const reviewRequest = this.model.get('reviewRequest');
        const updateDiffView = new RB.UpdateDiffView({
          model: new RB.UploadDiffModel({
            changeNumber: reviewRequest.get('commitID'),
            repository: reviewRequest.get('repository'),
            reviewRequest: reviewRequest
          })
        });
        updateDiffView.render();
        return false;
      }

      /**
       * Refresh the page.
       */
      _refreshPage() {
        RB.navigateTo(this.model.get('reviewRequest').get('reviewURL'));
      }

      /**
       * Remove a file attachment thumbnail.
       *
       * Version Added:
       *     6.0
       *
       * Args:
       *     attachmentModel (FileAttachment):
       *         The model of the file attachment to remove.
       */
      _removeThumbnail(attachmentModel) {
        const thumbnailViews = this.#fileAttachmentThumbnailViews;
        const index = thumbnailViews.findIndex(view => view.model === attachmentModel);
        thumbnailViews[index].remove();
        thumbnailViews.splice(index, 1);
      }
    }) || _class6;

    var _class$1, _dec$1, _class2;

    /**
     * Update information as received from the server.
     */
    /**
     * An update bubble showing an update to the review request or a review.
     */
    let UpdatesBubbleView = spina.spina(_class$1 = class UpdatesBubbleView extends spina.BaseView {
      static id = 'updates-bubble';
      static template = _.template(['<span id="updates-bubble-summary"><%- summary %></span>', ' by ', '<a href="<%- user.url %>" id="updates-bubble-user">', '<%- user.fullname || user.username %>', '</a>', '<span id="updates-bubble-buttons">', ' <a href="#" class="update-page"><%- updatePageText %></a>', ' | ', ' <a href="#" class="ignore"><%- ignoreText %></a>'].join(''));
      static events = {
        'click .ignore': '_onIgnoreClicked',
        'click .update-page': '_onUpdatePageClicked'
      };

      /**********************
       * Instance variables *
       **********************/

      /** Options for the view. */

      /**
       * Initialize the view.
       *
       * Args:
       *     options (UpdatesBubbleViewOptions):
       *         Options for the view.
       */
      initialize(options) {
        this.options = options;
      }

      /**
       * Render the bubble with the information provided during construction.
       *
       * The bubble starts hidden. The caller must call open() to display it.
       */
      onInitialRender() {
        this.$el.html(UpdatesBubbleView.template(_.defaults({
          ignoreText: gettext("Ignore"),
          updatePageText: gettext("Update Page")
        }, this.options.updateInfo))).hide();
      }

      /**
       * Open the bubble on the screen.
       */
      open() {
        this.$el.css('position', 'fixed').fadeIn();
      }

      /**
       * Close the update bubble.
       *
       * After closing, the bubble will be removed from the DOM.
       */
      close() {
        this.trigger('closed');
        this.$el.fadeOut(_.bind(this.remove, this));
      }

      /**
       * Handle clicks on the "Update Page" link.
       *
       * Loads the review request page.
       *
       * Args:
       *     e (JQuery.ClickEvent):
       *         The event which triggered the action.
       */
      _onUpdatePageClicked(e) {
        e.preventDefault();
        e.stopPropagation();
        this.trigger('updatePage');
      }

      /*
       * Handle clicks on the "Ignore" link.
       *
       * Ignores the update and closes the page.
       *
       * Args:
       *     e (JQuery.ClickEvent):
       *         The event which triggered the action.
       */
      _onIgnoreClicked(e) {
        e.preventDefault();
        e.stopPropagation();
        this.close();
      }
    }) || _class$1;
    /**
     * Options for the ReviewablePageView.
     */
    /**
     * A page managing reviewable content for a review request.
     *
     * This provides common functionality for any page associated with a review
     * request, such as the diff viewer, review UI, or the review request page
     * itself.
     */
    let ReviewablePageView = (_dec$1 = spina.spina({
      prototypeAttrs: ['events']
    }), _dec$1(_class2 = class ReviewablePageView extends RB.PageView {
      static events = {
        'click #action-legacy-edit-review': '_onEditReviewClicked',
        'click #action-legacy-add-general-comment': 'addGeneralComment',
        'click #action-legacy-ship-it': 'shipIt',
        'click .rb-o-mobile-menu-label': '_onMenuClicked'
      };

      /**********************
       * Instance variables *
       **********************/

      /** The review request editor. */

      /** The unified banner, if present. */
      unifiedBanner = null;

      /** The star manager. */
      #starManager;

      /** The URL to the default favicon. */
      #favIconURL = null;

      /** The URL to the favicon showing an active notification. */
      #favIconNotifyURL = null;

      /** The URL to the logo image to use for notifications. */
      #logoNotificationsURL = null;

      /** The updates bubble view. */
      _updatesBubble = null;

      /**
       * Initialize the page.
       *
       * This will construct a ReviewRequest, CommentIssueManager,
       * ReviewRequestEditor, and other required objects, based on data
       * provided during construction.
       *
       * Args:
       *     options (ReviewablePageViewOptions):
       *         Options for the view.
       */
      initialize(options) {
        super.initialize(options);
        this.options = options;
        RB.DnDUploader.create();
        this.reviewRequestEditorView = new ReviewRequestEditorView({
          el: $('#review-request'),
          model: this.model.reviewRequestEditor
        });

        /*
         * Some extensions, like Power Pack and rbstopwatch, expect a few
         * legacy attributes on the view. Set these here so these extensions
         * can access them. Note that extensions should ideally use the new
         * form, if they're able to support Review Board 3.0+.
         */
        ['reviewRequest', 'pendingReview'].forEach(attrName => {
          this[attrName] = this.model.get(attrName);
          this.listenTo(this.model, `change:${attrName}`, () => {
            this[attrName] = this.model.get(attrName);
          });
        });

        /*
         * Allow the browser to report notifications, if the user has this
         * enabled.
         */
        RB.NotificationManager.instance.setup();
        if (RB.UserSession.instance.get('authenticated')) {
          this.#starManager = new RB.StarManagerView({
            el: this.$('.star').parent(),
            model: new RB.StarManager()
          });
        }
        this.listenTo(this.model, 'reviewRequestUpdated', this._onReviewRequestUpdated);
      }

      /**
       * Render the page.
       */
      renderPage() {
        const $favicon = $('head').find('link[rel="shortcut icon"]');
        this.#favIconURL = $favicon.attr('href');
        this.#favIconNotifyURL = STATIC_URLS['rb/images/favicon_notify.ico'];
        this.#logoNotificationsURL = STATIC_URLS['rb/images/logo.png'];
        const pendingReview = this.model.get('pendingReview');
        const reviewRequest = this.model.get('reviewRequest');
        if (RB.EnabledFeatures.unifiedBanner) {
          if (RB.UserSession.instance.get('authenticated')) {
            this.unifiedBanner = new UnifiedBannerView({
              el: $('#unified-banner'),
              model: new UnifiedBanner({
                pendingReview: pendingReview,
                reviewRequest: reviewRequest,
                reviewRequestEditor: this.model.reviewRequestEditor
              }),
              reviewRequestEditorView: this.reviewRequestEditorView
            });
            this.unifiedBanner.render();
          }
        } else {
          this.draftReviewBanner = RB.DraftReviewBannerView.create({
            el: $('#review-banner'),
            model: pendingReview,
            reviewRequestEditor: this.model.reviewRequestEditor
          });
          this.listenTo(pendingReview, 'destroy published', () => this.draftReviewBanner.hideAndReload());
        }
        this.reviewRequestEditorView.render();
      }

      /**
       * Remove this view from the page.
       *
       * Returns:
       *     ReviewablePageView:
       *     This object, for chaining.
       */
      remove() {
        if (this.draftReviewBanner) {
          this.draftReviewBanner.remove();
        }
        if (this.unifiedBanner) {
          this.unifiedBanner.remove();
        }
        return super.remove();
      }

      /**
       * Return data to use for assessing cross-tab page reloads.
       *
       * This returns a filter blob that will be recognized by all other tabs
       * that have the same review request.
       *
       * Version Added:
       *     6.0
       */
      getReloadData() {
        return {
          'review-request': this.model.get('reviewRequest').id
        };
      }

      /**
       * Return the review request editor view.
       *
       * Returns:
       *     ReviewRequestEditorView:
       *     The review request editor view.
       */
      getReviewRequestEditorView() {
        return this.reviewRequestEditorView;
      }

      /**
       * Return the review request editor model.
       *
       * Returns:
       *     ReviewRequestEditor:
       *     The review request editor model.
       */
      getReviewRequestEditorModel() {
        return this.model.reviewRequestEditor;
      }

      /**
       * Catch the review updated event and send the user a visual update.
       *
       * This function will handle the review updated event and decide whether
       * to send a notification depending on browser and user settings.
       *
       * Args:
       *     info (UpdateInfo):
       *         The last update information for the request.
       */
      _onReviewRequestUpdated(info) {
        this.#updateFavIcon(this.#favIconNotifyURL);
        if (RB.NotificationManager.instance.shouldNotify()) {
          this._showDesktopNotification(info);
        }
        this._showUpdatesBubble(info);
      }

      /**
       * Create the updates bubble showing information about the last update.
       *
       * Args:
       *     info (UpdateInfo):
       *         The last update information for the request.
       */
      _showUpdatesBubble(info) {
        if (this._updatesBubble) {
          this._updatesBubble.remove();
        }
        const reviewRequest = this.model.get('reviewRequest');
        this._updatesBubble = new UpdatesBubbleView({
          updateInfo: info
        });
        this.listenTo(this._updatesBubble, 'closed', () => this.#updateFavIcon(this.#favIconURL));
        this.listenTo(this._updatesBubble, 'updatePage', () => {
          RB.navigateTo(reviewRequest.get('reviewURL'));
        });
        this._updatesBubble.render().$el.appendTo(this.$el);
        this._updatesBubble.open();
      }

      /**
       * Show the user a desktop notification for the last update.
       *
       * This function will create a notification if the user has not
       * disabled desktop notifications and the browser supports HTML5
       * notifications.
       *
       *  Args:
       *     info (UpdateInfo):
       *         The last update information for the request.
       */
      _showDesktopNotification(info) {
        const reviewRequest = this.model.get('reviewRequest');
        const name = info.user.fullname || info.user.username;
        RB.NotificationManager.instance.notify({
          body: interpolate(gettext("Review request #%(value1)s, by %(name)s"), {
            "value1": reviewRequest.id,
            "name": name
          }, true),
          iconURL: this.#logoNotificationsURL,
          onClick: () => {
            RB.navigateTo(reviewRequest.get('reviewURL'));
          },
          title: info.summary
        });
      }

      /**
       * Update the favicon for the page.
       *
       * This is used to change the favicon shown on the page based on whether
       * there's a server-side update notification for the review request.
       *
       * Args:
       *     url (string):
       *         The URL to use for the shortcut icon.
       */
      #updateFavIcon(url) {
        $('head').find('link[rel="shortcut icon"]').remove().end().append($('<link/>').attr({
          href: url,
          rel: 'shortcut icon',
          type: 'image/x-icon'
        }));
      }

      /**
       * Handle a click on the "Edit Review" button.
       *
       * Displays a review dialog.
       *
       * Args:
       *     e (JQuery.ClickEvent):
       *         The event which triggered the action.
       */
      _onEditReviewClicked(e) {
        e.preventDefault();
        e.stopPropagation();
        ReviewDialogView.create({
          review: this.model.get('pendingReview'),
          reviewRequestEditor: this.model.reviewRequestEditor
        });
        return false;
      }

      /**
       * Add a new general comment.
       *
       * Args:
       *     e (JQuery.ClickEvent, optional):
       *         The event which triggered the action.
       */
      addGeneralComment(e) {
        if (e) {
          e.preventDefault();
          e.stopPropagation();
        }
        const pendingReview = this.model.get('pendingReview');
        const comment = pendingReview.createGeneralComment(undefined, RB.UserSession.instance.get('commentsOpenAnIssue'));
        if (!RB.EnabledFeatures.unifiedBanner) {
          this.listenTo(comment, 'saved', () => RB.DraftReviewBannerView.instance.show());
        }
        RB.CommentDialogView.create({
          comment: comment,
          reviewRequestEditor: this.model.reviewRequestEditor
        });
        return false;
      }

      /**
       * Handle a click on the "Ship It" button.
       *
       * Confirms that the user wants to post the review, and then posts it
       * and reloads the page.
       *
       * Args:
       *     e (JQuery.ClickEvent, optional):
       *         The event which triggered the action, if available.
       */
      async shipIt(e) {
        if (e) {
          e.preventDefault();
          e.stopPropagation();
        }
        if (confirm(gettext("Are you sure you want to post this review?"))) {
          await this.model.markShipIt();
          const reviewRequest = this.model.get('reviewRequest');
          RB.navigateTo(reviewRequest.get('reviewURL'));
        }
        return false;
      }

      /**
       * Generic handler for menu clicks.
       *
       * This simply prevents the click from bubbling up or invoking the
       * default action. This function is used for dropdown menu titles
       * so that their links do not send a request to the server when one
       * of their dropdown actions are clicked.
       *
       * Args:
       *     e (JQuery.ClickEvent):
       *         The event which triggered the action.
       */
      _onMenuClicked(e) {
        e.preventDefault();
        e.stopPropagation();
        const $menuButton = $(e.currentTarget).find('a');
        const expanded = $menuButton.attr('aria-expanded');
        const target = $menuButton.attr('aria-controls');
        const $target = this.$(`#${target}`);
        if (expanded === 'false') {
          $menuButton.attr('aria-expanded', 'true');
          $target.addClass('-is-visible');
        } else {
          $menuButton.attr('aria-expanded', 'false');
          $target.removeClass('-is-visible');
        }
      }
    }) || _class2);

    var _dec, _class;

    /** Options for adding a toggle button to the diff view. */

    /**
     * A page view for the diff viewer.
     *
     * This provides functionality for the diff viewer page for managing the
     * loading and display of diffs, and all navigation around the diffs.
     */
    let DiffViewerPageView = (_dec = spina.spina({
      mixins: [RB.KeyBindingsMixin]
    }), _dec(_class = class DiffViewerPageView extends ReviewablePageView {
      static SCROLL_BACKWARD = -1;
      static SCROLL_FORWARD = 1;
      static ANCHOR_COMMENT = 1;
      static ANCHOR_FILE = 2;
      static ANCHOR_CHUNK = 4;

      /** Number of pixels to offset when scrolling to anchors. */
      static DIFF_SCROLLDOWN_AMOUNT = 15;
      static fileEntryTemplate = _.template(`<div class="diff-container">
 <div class="diff-box">
  <table class="sidebyside loading <% if (newFile) { %>newfile<% } %>"
         id="file_container_<%- id %>">
   <thead>
    <tr class="filename-row">
     <th>
      <span class="djblets-o-spinner"></span>
      <%- filename %>
     </th>
    </tr>
   </thead>
  </table>
 </div>
</div>`);
      static viewToggleButtonContentTemplate = _.template(`<span class="fa <%- iconClass %>"></span> <%- text %>`);

      /* Template for code line link anchor */
      static anchorTemplate = _.template('<a name="<%- anchorName %>" class="highlight-anchor"></a>');
      keyBindings = {
        'aAKP<m': '_selectPreviousFile',
        'fFJN>': '_selectNextFile',
        'sSkp,': '_selectPreviousDiff',
        'dDjn.': '_selectNextDiff',
        '[x': '_selectPreviousComment',
        ']c': '_selectNextComment',
        '\x0d': '_recenterSelected',
        'rR': '_createComment'
      };

      /**********************
       * Instance variables *
       **********************/

      #$controls = null;
      #$diffs = null;
      #$highlightedChunk = null;
      #$window;
      #chunkHighlighter = null;
      #commentsHintView = null;
      #diffFileIndexView = null;
      #diffRevisionLabelView = null;
      #diffRevisionSelectorView = null;
      #paginationView1 = null;
      #paginationView2 = null;
      #startAtAnchorName = null;
      _commitListView = null;
      _diffReviewableViews = [];
      _selectedAnchorIndex = -1;
      /**
       * Initialize the diff viewer page.
       *
       * Args:
       *     options (ReviewablePageViewOptions):
       *         Options for the view.
       *
       *  See Also:
       *      :js:class:`RB.ReviewablePageView`:
       *          For the option arguments this method takes.
       */
      initialize(options) {
        super.initialize(options);
        this.#$window = $(window);
        this._$anchors = $();

        /*
         * Listen for the construction of added DiffReviewables.
         *
         * We'll queue up the loading and construction of a view when added.
         * This will ultimately result in a RB.DiffReviewableView being
         * constructed, once the data from the server is loaded.
         */
        this.listenTo(this.model.diffReviewables, 'add', this.#onDiffReviewableAdded);

        /*
         * Listen for when we're started and finished populating the list
         * of DiffReviewables. We'll use these events to clear and start the
         * diff loading queue.
         */
        const diffQueue = $.funcQueue('diff_files');
        this.listenTo(this.model.diffReviewables, 'populating', () => {
          this._diffReviewableViews.forEach(view => view.remove());
          this._diffReviewableViews = [];
          this.#diffFileIndexView.clear();
          this.#$diffs.children('.diff-container').remove();
          this.#$highlightedChunk = null;
          diffQueue.clear();
        });
        this.listenTo(this.model.diffReviewables, 'populated', () => diffQueue.start());
        this.router = new spina.Router();
        this.router.route(/^(\d+(?:-\d+)?)\/?(\?[^#]*)?/, 'revision', (revision, queryStr) => {
          const queryArgs = Djblets.parseQueryString(queryStr || '');
          const page = queryArgs.page;
          const revisionRange = revision.split('-', 2);
          const interdiffRevision = revisionRange.length === 2 ? parseInt(revisionRange[1], 10) : null;
          let baseCommitID = null;
          let tipCommitID = null;
          if (interdiffRevision === null) {
            baseCommitID = queryArgs['base-commit-id'] || null;
            tipCommitID = queryArgs['tip-commit-id'] || null;
            if (baseCommitID !== null) {
              baseCommitID = parseInt(baseCommitID, 10);
            }
            if (tipCommitID !== null) {
              tipCommitID = parseInt(tipCommitID, 10);
            }
          }
          this.model.loadDiffRevision({
            baseCommitID: baseCommitID,
            filenamePatterns: queryArgs.filenames || null,
            interdiffRevision: interdiffRevision,
            page: page ? parseInt(page, 10) : 1,
            revision: parseInt(revisionRange[0], 10),
            tipCommitID: tipCommitID
          });
        });

        /*
         * Begin managing the URL history for the page, so that we can
         * switch revisions and handle pagination while keeping the history
         * clean and the URLs representative of the current state.
         *
         * Note that Backbone will attempt to convert the hash to part of
         * the page URL, stripping away the "#". This will result in a
         * URL pointing to an incorrect, possible non-existent diff revision.
         *
         * We work around that by saving the values for the hash and query
         * string (up above), and by later replacing the current URL with a
         * new one that, amongst other things, contains the hash present
         * when the page was loaded.
         */
        Backbone.history.start({
          hashChange: false,
          pushState: true,
          root: `${this.model.get('reviewRequest').get('reviewURL')}diff/`,
          silent: true
        });
        this._setInitialURL(document.location.search || '', RB.getLocationHash());
      }

      /**
       * Remove the view from the page.
       *
       * Returns:
       *     DiffViewerPageView:
       *     This object, for chaining.
       */
      remove() {
        this.#$window.off(`resize.${this.cid}`);
        if (this.#diffFileIndexView) {
          this.#diffFileIndexView.remove();
        }
        if (this._commitListView) {
          this._commitListView.remove();
        }
        return super.remove();
      }

      /**
       * Render the page and begins loading all diffs.
       *
       * Returns:
       *     RB.DiffViewerPageView:
       *     This instance, for chaining.
       */
      renderPage() {
        super.renderPage();
        const model = this.model;
        const session = RB.UserSession.instance;
        this.#$controls = $('#view_controls');
        console.assert(this.#$controls.length === 1);

        /* Set up the view buttons. */
        this.addViewToggleButton({
          activeDescription: gettext("All lines of the files are being shown. Toggle to collapse down to only modified sections instead."),
          activeText: gettext("Collapse changes"),
          id: 'action-diff-toggle-collapse-changes',
          inactiveDescription: gettext("Only modified sections of the files are being shown. Toggle to show all lines instead."),
          inactiveText: gettext("Expand changes"),
          isActive: !this.model.get('allChunksCollapsed'),
          onToggled: isActive => {
            RB.navigateTo(isActive ? '.?expand=1' : '.?collapse=1');
          }
        });
        if (model.get('canToggleExtraWhitespace')) {
          this.addViewToggleButton({
            activeDescription: gettext("Mismatched indentation and trailing whitespace are being shown. Toggle to hide instead."),
            activeText: gettext("Hide extra whitespace"),
            id: 'action-diff-toggle-extra-whitespace',
            inactiveDescription: gettext("Mismatched indentation and trailing whitespace are being hidden. Toggle to show instead."),
            inactiveText: gettext("Show extra whitespace"),
            isActive: session.get('diffsShowExtraWhitespace'),
            onToggled: isActive => {
              session.set('diffsShowExtraWhitespace', isActive);
            }
          });
        }
        this.addViewToggleButton({
          activeDescription: gettext("Sections of the diff containing only whitespace changes are being shown. Toggle to hide those instead."),
          activeText: gettext("Hide whitespace-only changes"),
          id: 'action-diff-toggle-whitespace-only',
          inactiveDescription: gettext("Sections of the diff containing only whitespace changes are being hidden. Toggle to show those instead."),
          inactiveText: gettext("Show whitespace-only changes"),
          isActive: true,
          onToggled: () => {
            this._diffReviewableViews.forEach(view => view.toggleWhitespaceOnlyChunks());
          }
        });

        /* Listen for changes on the commit selector. */
        if (!model.commits.isEmpty()) {
          const commitListModel = new RB.DiffCommitList({
            baseCommitID: model.revision.get('baseCommitID'),
            commits: model.commits,
            historyDiff: model.commitHistoryDiff,
            tipCommitID: model.revision.get('tipCommitID')
          });
          this.listenTo(model.revision, 'change:baseCommitID change:tipCommitID', model => commitListModel.set({
            baseCommitID: model.get('baseCommitID'),
            tipCommitID: model.get('tipCommitID')
          }));
          this.listenTo(commitListModel, 'change:baseCommitID change:tipCommitID', this.#onCommitIntervalChanged);
          this._commitListView = new RB.DiffCommitListView({
            el: $('#diff_commit_list').find('.commit-list-container'),
            model: commitListModel,
            showInterCommitDiffControls: true
          });
          this._commitListView.render();
        }
        this.#diffFileIndexView = new DiffFileIndexView({
          collection: model.files,
          el: $('#diff_index').find('.diff-index-container')
        });
        this.#diffFileIndexView.render();
        this.listenTo(this.#diffFileIndexView, 'anchorClicked', this.selectAnchorByName);
        this.#diffRevisionLabelView = new RB.DiffRevisionLabelView({
          el: $('#diff_revision_label'),
          model: model.revision
        });
        this.#diffRevisionLabelView.render();
        this.listenTo(this.#diffRevisionLabelView, 'revisionSelected', this._onRevisionSelected);

        /*
         * Determine whether we need to show the revision selector. If there's
         * only one revision, we don't need to add it.
         */
        const numDiffs = model.get('numDiffs');
        if (numDiffs > 1) {
          this.#diffRevisionSelectorView = new RB.DiffRevisionSelectorView({
            el: $('#diff_revision_selector'),
            model: model.revision,
            numDiffs: numDiffs
          });
          this.#diffRevisionSelectorView.render();
          this.listenTo(this.#diffRevisionSelectorView, 'revisionSelected', this._onRevisionSelected);
        }
        this.#commentsHintView = new RB.DiffCommentsHintView({
          el: $('#diff_comments_hint'),
          model: model.commentsHint
        });
        this.#commentsHintView.render();
        this.listenTo(this.#commentsHintView, 'revisionSelected', this._onRevisionSelected);
        this.#paginationView1 = new RB.PaginationView({
          el: $('#pagination1'),
          model: model.pagination
        });
        this.#paginationView1.render();
        this.listenTo(this.#paginationView1, 'pageSelected', _.partial(this._onPageSelected, false));
        this.#paginationView2 = new RB.PaginationView({
          el: $('#pagination2'),
          model: model.pagination
        });
        this.#paginationView2.render();
        this.listenTo(this.#paginationView2, 'pageSelected', _.partial(this._onPageSelected, true));
        this.#$diffs = $('#diffs').bindClass(RB.UserSession.instance, 'diffsShowExtraWhitespace', 'ewhl');
        this.#chunkHighlighter = new RB.ChunkHighlighterView();
        this.#chunkHighlighter.render().$el.prependTo(this.#$diffs);
        $('#diff-details').removeClass('loading');
        $('#download-diff-action').bindVisibility(model, 'canDownloadDiff');
        this.#$window.on(`resize.${this.cid}`, _.throttleLayout(this.#onWindowResize.bind(this)));

        /*
         * Begin creating any DiffReviewableViews needed for the page, and
         * start loading their contents.
         */
        if (model.diffReviewables.length > 0) {
          model.diffReviewables.each(diffReviewable => this.#onDiffReviewableAdded(diffReviewable));
          $.funcQueue('diff_files').start();
        }
      }

      /**
       * Add a toggle button for changing the view of the diff.
       *
       * Args:
       *     options (ToggleButtonOptions):
       *         The options for the button.
       */
      addViewToggleButton(options) {
        console.assert(!!options);
        const $button = $('<button>');
        const updateButton = isActive => {
          let icon;
          let text;
          let description;
          if (isActive) {
            icon = 'fa-minus';
            text = options.activeText;
            description = options.activeDescription;
          } else {
            icon = 'fa-plus';
            text = options.inactiveText;
            description = options.inactiveDescription;
          }
          console.assert(text);
          console.assert(description);
          $button.data('is-active', isActive).attr('title', description).html(DiffViewerPageView.viewToggleButtonContentTemplate({
            iconClass: icon,
            text: text
          }));
        };
        updateButton(options.isActive);
        $button.attr('id', options.id).on('click', e => {
          e.preventDefault();
          e.stopPropagation();
          const isActive = !$button.data('is-active');
          updateButton(isActive);
          options.onToggled(isActive);
        });
        this.#$controls.append($('<li>').append($button));
      }

      /**
       * Queue the loading of the corresponding diff.
       *
       * When the diff is loaded, it will be placed into the appropriate location
       * in the diff viewer. The anchors on the page will be rebuilt. This will
       * then trigger the loading of the next file.
       *
       * Args:
       *     diffReviewable (RB.DiffReviewable):
       *         The diff reviewable for loading and reviewing the diff.
       *
       *     options (object):
       *         The option arguments that control the behavior of this function.
       *
       * Option Args:
       *     showDeleted (boolean, optional):
       *         Determines whether or not we want to requeue the corresponding
       *         diff in order to show its deleted content.
       */
      queueLoadDiff(diffReviewable) {
        let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
        $.funcQueue('diff_files').add(async () => {
          const fileDiffID = diffReviewable.get('fileDiffID');
          if (!options.showDeleted && $(`#file${fileDiffID}`).length === 1) {
            /*
             * We already have this diff (probably pre-loaded), and we
             * don't want to requeue it to show its deleted content.
             */
            this.#renderFileDiff(diffReviewable);
          } else {
            /*
             * We either want to queue this diff for the first time, or we
             * want to requeue it to show its deleted content.
             */
            const prefix = options.showDeleted ? '#file' : '#file_container_';
            const html = await diffReviewable.getRenderedDiff(options);
            const $container = $(prefix + fileDiffID).parent();
            if ($container.length === 0) {
              /*
               * The revision or page may have changed. There's
               * no element to work with. Just ignore this and
               * move on to the next.
               */
              return;
            }
            $container.hide();

            /*
             * jQuery's html() and replaceWith() perform checks of
             * the HTML, looking for things like <script> tags to
             * determine how best to set the HTML, and possibly
             * manipulating the string to do some normalization of
             * for cases we don't need to worry about. While this
             * is all fine for most HTML fragments, this can be
             * slow for diffs, given their size, and is
             * unnecessary. It's much faster to just set innerHTML
             * directly.
             */
            $container[0].innerHTML = html;
            this.#renderFileDiff(diffReviewable);
          }
        });
      }

      /**
       * Set up a diff as DiffReviewableView and renders it.
       *
       * This will set up a :js:class:`RB.DiffReviewableView` for the given
       * diffReviewable. The anchors from this diff render will be stored for
       * navigation.
       *
       * Once rendered and set up, the next diff in the load queue will be
       * pulled from the server.
       *
       * Args:
       *     diffReviewable (RB.DiffReviewable):
       *         The reviewable diff to render.
       */
      #renderFileDiff(diffReviewable) {
        const elementName = 'file' + diffReviewable.get('fileDiffID');
        const $el = $(`#${elementName}`);
        if ($el.length === 0) {
          /*
           * The user changed revisions before the file finished loading, and
           * the target element no longer exists. Just return.
           */
          $.funcQueue('diff_files').next();
          return;
        }

        /* Check if we're replacing a diff or adding a new one. */
        let isReplacing = true;
        let index = this._diffReviewableViews.findIndex(view => view.model === diffReviewable);
        if (index === -1) {
          index = this._diffReviewableViews.length;
          isReplacing = false;
        }
        const diffReviewableView = new RB.DiffReviewableView({
          el: $el,
          model: diffReviewable
        });
        if (isReplacing) {
          this._diffReviewableViews.splice(index, 1, diffReviewableView);
        } else {
          this._diffReviewableViews.push(diffReviewableView);
        }
        diffReviewableView.render();
        diffReviewableView.$el.parent().show();
        this.#diffFileIndexView.addDiff(index, diffReviewableView);
        this.listenTo(diffReviewableView, 'fileClicked', () => {
          this.selectAnchorByName(diffReviewable.get('file').get('index'));
        });
        this.listenTo(diffReviewableView, 'chunkClicked', name => {
          this.selectAnchorByName(name, false);
        });
        this.listenTo(diffReviewableView, 'moveFlagClicked', line => {
          this.selectAnchor(this.$(`a[target=${line}]`));
        });

        /* We must rebuild this every time. */
        this._updateAnchors(diffReviewableView.$el);
        this.listenTo(diffReviewableView, 'chunkExpansionChanged', () => {
          /* The selection rectangle may not update -- bug #1353. */
          this.#highlightAnchor($(this._$anchors[this._selectedAnchorIndex]));
        });
        if (this.#startAtAnchorName) {
          /* See if we've loaded the anchor the user wants to start at. */
          let $anchor = $(document.getElementsByName(this.#startAtAnchorName));

          /*
           * Some anchors are added by the template (such as those at
           * comment locations), but not all are. If the anchor isn't found,
           * but the URL hash is indicating that we want to start at a
           * location within this file, add the anchor.
           * */
          const urlSplit = this.#startAtAnchorName.split(',');
          if ($anchor.length === 0 && urlSplit.length === 2 && elementName === urlSplit[0]) {
            $anchor = $(DiffViewerPageView.anchorTemplate({
              anchorName: this.#startAtAnchorName
            }));
            diffReviewableView.$el.find(`tr[line='${urlSplit[1]}']`).addClass('highlight-anchor').append($anchor);
          }
          if ($anchor.length !== 0) {
            this.selectAnchor($anchor);
            this.#startAtAnchorName = null;
          }
        }
        this.listenTo(diffReviewableView, 'showDeletedClicked', () => {
          this.queueLoadDiff(diffReviewable, {
            showDeleted: true
          });
          $.funcQueue('diff_files').start();
        });
        $.funcQueue('diff_files').next();
      }

      /**
       * Select the anchor at a specified location.
       *
       * By default, this will scroll the page to position the anchor near
       * the top of the view.
       *
       * Args:
       *     $anchor (jQuery):
       *         The anchor to select.
       *
       *     scroll (boolean, optional):
       *         Whether to scroll the page to the anchor. This defaults to
       *         ``true``.
       *
       * Returns:
       *     boolean:
       *     ``true`` if the anchor was found and selected. ``false`` if not
       *     found.
       */
      selectAnchor($anchor, scroll) {
        if (!$anchor || $anchor.length === 0 || $anchor.parent().is(':hidden')) {
          return false;
        }
        if (scroll !== false) {
          this._navigate({
            anchor: $anchor.attr('name'),
            updateURLOnly: true
          });
          const anchorOffset = $anchor.offset().top;
          let scrollAmount = 0;
          if (this.unifiedBanner) {
            /*
             * The scroll offset calculation when we're running with the
             * unified banner is somewhat complex because the height of the
             * banner isn't static. The file index gets docked into the
             * banner, and changes its height depending on what files are
             * shown in the viewport.
             *
             * In order to try to scroll to the position where the top of
             * the file is nicely visible, we end up doing this twice.
             * First we try to determine a scroll offset based on the
             * position of the anchor. We then rerun the calculation using
             * the new offset to dial in closer to the right place.
             *
             * This still may not be perfect, especially when file borders
             * are close to the boundary where they're scrolling on or off
             * the screen, but it generally seems to do pretty well.
             */
            const newOffset = this.#computeScrollHeight(anchorOffset - DiffViewerPageView.DIFF_SCROLLDOWN_AMOUNT);
            scrollAmount = this.#computeScrollHeight(anchorOffset - newOffset);
          } else if (RB.DraftReviewBannerView.instance) {
            scrollAmount = DiffViewerPageView.DIFF_SCROLLDOWN_AMOUNT + RB.DraftReviewBannerView.instance.getHeight();
          }
          this.#$window.scrollTop(anchorOffset - scrollAmount);
        }
        this.#highlightAnchor($anchor);
        for (let i = 0; i < this._$anchors.length; i++) {
          if (this._$anchors[i] === $anchor[0]) {
            this._selectedAnchorIndex = i;
            break;
          }
        }
        return true;
      }

      /**
       * Compute the ideal scroll offset based on the unified banner.
       *
       * This attempts to find the ideal scroll offset based on what the diff
       * file index is doing within the unified banner.
       *
       * Args:
       *     startingOffset (number):
       *         The target scroll offset.
       *
       * Returns:
       *     number:
       *     The number of pixels to adjust the starting offset by in order to
       *     maximize the likelihood of the anchor appearing at the top of the
       *     visible viewport.
       */
      #computeScrollHeight(startingOffset) {
        const $window = $(window);
        const bannerHeight = this.unifiedBanner.getHeight(false);
        const newDockHeight = this.#diffFileIndexView.getDockedIndexExtents(startingOffset + bannerHeight, startingOffset + $window.height() - bannerHeight).height;
        return bannerHeight + newDockHeight + 20 + DiffViewerPageView.DIFF_SCROLLDOWN_AMOUNT;
      }

      /**
       * Select an anchor by name.
       *
       * Args:
       *     name (string):
       *         The name of the anchor.
       *
       *     scroll (boolean, optional):
       *         Whether to scroll the page to the anchor. This defaults to
       *         ``true``.
       *
       * Returns:
       *     boolean:
       *     ``true`` if the anchor was found and selected. ``false`` if not
       *     found.
       */
      selectAnchorByName(name, scroll) {
        return this.selectAnchor($(document.getElementsByName(name)), scroll);
      }

      /**
       * Highlight a chunk bound to an anchor element.
       *
       * Args:
       *     $anchor (jQuery):
       *         The anchor to highlight.
       */
      #highlightAnchor($anchor) {
        this.#$highlightedChunk = $anchor.closest('tbody').add($anchor.closest('thead'));
        this.#chunkHighlighter.highlight(this.#$highlightedChunk);
      }

      /**
       * Update the list of known anchors.
       *
       * This will update the list of known anchors based on all named anchors
       * in the specified table. This is called after every part of the diff
       * that is loaded.
       *
       * If no anchor is selected, this will try to select the first one.
       *
       * Args:
       *     $table (jQuery):
       *         The table containing anchors.
       */
      _updateAnchors($table) {
        this._$anchors = this._$anchors.add($table.find('th a[name]'));

        /* Skip over the change index to the first item. */
        if (this._selectedAnchorIndex === -1 && this._$anchors.length > 0) {
          this._selectedAnchorIndex = 0;
          this.#highlightAnchor($(this._$anchors[this._selectedAnchorIndex]));
        }
      }

      /**
       * Return the next navigatable anchor.
       *
       * This will take a direction to search, starting at the currently
       * selected anchor. The next anchor matching one of the types in the
       * anchorTypes bitmask will be returned. If no anchor is found,
       * null will be returned.
       *
       * Args:
       *     dir (number):
       *         The direction to navigate in. This should be
       *         :js:data:`SCROLL_BACKWARD` or js:data:`SCROLL_FORWARD`.
       *
       *     anchorTypes (number):
       *         A bitmask of types to consider when searching for the next
       *         anchor.
       *
       * Returns:
       *     jQuery:
       *     The anchor, if found. If an anchor was not found, ``null`` is
       *     returned.
       */
      #getNextAnchor(dir, anchorTypes) {
        for (let i = this._selectedAnchorIndex + dir; i >= 0 && i < this._$anchors.length; i += dir) {
          const $anchor = $(this._$anchors[i]);
          if ($anchor.closest('tr').hasClass('dimmed')) {
            continue;
          }
          if (anchorTypes & DiffViewerPageView.ANCHOR_COMMENT && $anchor.hasClass('commentflag-anchor') || anchorTypes & DiffViewerPageView.ANCHOR_FILE && $anchor.hasClass('file-anchor') || anchorTypes & DiffViewerPageView.ANCHOR_CHUNK && $anchor.hasClass('chunk-anchor')) {
            return $anchor;
          }
        }
        return null;
      }

      /**
       * Select the previous file's header on the page.
       */
      _selectPreviousFile() {
        this.selectAnchor(this.#getNextAnchor(DiffViewerPageView.SCROLL_BACKWARD, DiffViewerPageView.ANCHOR_FILE));
      }

      /**
       * Select the next file's header on the page.
       */
      _selectNextFile() {
        this.selectAnchor(this.#getNextAnchor(DiffViewerPageView.SCROLL_FORWARD, DiffViewerPageView.ANCHOR_FILE));
      }

      /**
       * Select the previous diff chunk on the page.
       */
      _selectPreviousDiff() {
        this.selectAnchor(this.#getNextAnchor(DiffViewerPageView.SCROLL_BACKWARD, DiffViewerPageView.ANCHOR_CHUNK | DiffViewerPageView.ANCHOR_FILE));
      }

      /**
       * Select the next diff chunk on the page.
       */
      _selectNextDiff() {
        this.selectAnchor(this.#getNextAnchor(DiffViewerPageView.SCROLL_FORWARD, DiffViewerPageView.ANCHOR_CHUNK | DiffViewerPageView.ANCHOR_FILE));
      }

      /**
       * Select the previous comment on the page.
       */
      _selectPreviousComment() {
        this.selectAnchor(this.#getNextAnchor(DiffViewerPageView.SCROLL_BACKWARD, DiffViewerPageView.ANCHOR_COMMENT));
      }

      /**
       * Select the next comment on the page.
       */
      _selectNextComment() {
        this.selectAnchor(this.#getNextAnchor(DiffViewerPageView.SCROLL_FORWARD, DiffViewerPageView.ANCHOR_COMMENT));
      }

      /**
       * Re-center the currently selected area on the page.
       */
      _recenterSelected() {
        this.selectAnchor($(this._$anchors[this._selectedAnchorIndex]));
      }

      /**
       * Create a comment for a chunk of a diff
       */
      _createComment() {
        const chunkID = this.#$highlightedChunk[0].id;
        const chunkElement = document.getElementById(chunkID);
        if (chunkElement) {
          const lineElements = chunkElement.getElementsByTagName('tr');
          const beginLineNum = lineElements[0].getAttribute('line');
          const beginNode = lineElements[0].cells[2];
          const endLineNum = lineElements[lineElements.length - 1].getAttribute('line');
          const endNode = lineElements[lineElements.length - 1].cells[2];
          this._diffReviewableViews.forEach(diffReviewableView => {
            if ($.contains(diffReviewableView.el, beginNode)) {
              diffReviewableView.createComment(beginLineNum, endLineNum, beginNode, endNode);
            }
          });
        }
      }

      /**
       * Set the initial URL for the page.
       *
       * This accomplishes two things:
       *
       * 1. The user may have viewed ``diff/``, and not ``diff/<revision>/``,
       *    but we want to always show the revision in the URL. This ensures
       *    we have a URL equivalent to the one we get when clicking a revision
       *    in the slider.
       *
       * 2. We want to add back any hash and query string that may have been
       *    stripped away, so the URL doesn't appear to suddenly change from
       *    what the user expected.
       *
       * This won't invoke any routes or store any new history. The back button
       * will correctly bring the user to the previous page.
       *
       * Args:
       *     queryString (string):
       *         The query string provided in the URL.
       *
       *     anchor (string):
       *         The anchor provided in the URL.
       */
      _setInitialURL(queryString, anchor) {
        this.#startAtAnchorName = anchor || null;
        this._navigate({
          anchor: anchor,
          queryString: queryString,
          updateURLOnly: true
        });
      }

      /**
       * Navigate to a new page state by calculating and setting a URL.
       *
       * This builds a URL consisting of the revision range and any other
       * state that impacts the view of the page (page number and filtered list
       * of filename patterns), updating the current location in the browser and
       * (by default) triggering a route change.
       *
       * Args:
       *     options (object):
       *         The options for the navigation.
       *
       * Option Args:
       *     revision (number, optional):
       *         The revision (or first part of an interdiff range) to view.
       *         Defaults to the current revision.
       *
       *     interdiffRevision (number, optional):
       *         The second revision of an interdiff range to view.
       *         Defaults to the current revision for the interdiff, if any.
       *
       *     page (number, optional):
       *         A page number to specify. If not provided, and if the revision
       *         range has not changed, the existing value (or lack of one)
       *         in the URL will be used. If the revision range has changed and
       *         a value was not explicitly provided, a ``page=`` will not be
       *         added to the URL.
       *
       *     anchor (string, optional):
       *         An anchor name to navigate to. This cannot begin with ``#``.
       *
       *     queryString (string, optional):
       *         An explicit query string to use for the URL. If specified,
       *         a query string will not be computed. This must begin with ``?``.
       *
       *     updateURLOnly (boolean, optional):
       *         If ``true``, the location in the browser will be updated, but
       *         a route will not be triggered.
       *
       *     baseCommitID (string, optional):
       *         The ID of the base commit to use in the request.
       *
       *     tipCommitID (string, optional):
       *         The ID of the top commit to use in the request.
       */
      _navigate(options) {
        const curRevision = this.model.revision.get('revision');
        const curInterdiffRevision = this.model.revision.get('interdiffRevision');

        /* Start the URL off with the revision range. */
        const revision = options.revision !== undefined ? options.revision : curRevision;
        const interdiffRevision = options.interdiffRevision !== undefined ? options.interdiffRevision : curInterdiffRevision;
        let baseURL = revision;
        if (interdiffRevision) {
          baseURL += `-${interdiffRevision}`;
        }
        baseURL += '/';

        /*
         * If an explicit query string is provided, we'll just use that.
         * Otherwise, we'll generate one.
         */

        let queryData = options.queryString;
        if (queryData === undefined) {
          /*
          * We'll build as an array to maintain a specific order, which
          * helps with caching and testing.
          */
          queryData = [];

          /*
           * We want to be smart about when we include ?page=. We always
           * include it if it's explicitly specified in options. If it's
           * not, then we'll fall back to what's currently in the URL, but
           * only if the revision range is staying the same, otherwise we're
           * taking it out. This simulates the behavior we've always had.
           */
          let page = options.page;
          if (page === undefined && revision === curRevision && interdiffRevision === curInterdiffRevision) {
            /*
             * It's the same, so we can plug in the page from the
             * current URL.
             */
            page = this.model.pagination.get('currentPage');
          }
          if (page && page !== 1) {
            queryData.push({
              name: 'page',
              value: page
            });
          }
          if (options.baseCommitID) {
            queryData.push({
              name: 'base-commit-id',
              value: options.baseCommitID
            });
          }
          if (options.tipCommitID) {
            queryData.push({
              name: 'tip-commit-id',
              value: options.tipCommitID
            });
          }
          const filenamePatterns = this.model.get('filenamePatterns');
          if (filenamePatterns && filenamePatterns.length > 0) {
            queryData.push({
              name: 'filenames',
              value: filenamePatterns
            });
          }
        }
        const url = Djblets.buildURL({
          anchor: options.anchor,
          baseURL: baseURL,
          queryData: queryData
        });

        /*
         * Determine if we're performing the navigation or just updating the
         * displayed URL.
         */
        let navOptions;
        if (options.updateURLOnly) {
          navOptions = {
            replace: true,
            trigger: false
          };
        } else {
          navOptions = {
            trigger: true
          };
        }
        this.router.navigate(url, navOptions);
      }

      /**
       * Handler for when a RB.DiffReviewable is added.
       *
       * This will add a placeholder entry for the file and queue the diff
       * for loading/rendering.
       *
       * Args:
       *     diffReviewable (RB.DiffReviewable):
       *         The DiffReviewable that was added.
       */
      #onDiffReviewableAdded(diffReviewable) {
        const file = diffReviewable.get('file');
        this.#$diffs.append(DiffViewerPageView.fileEntryTemplate({
          filename: file.get('depotFilename'),
          id: file.id,
          newFile: file.get('isnew')
        }));
        this.queueLoadDiff(diffReviewable);
      }

      /**
       * Handler for when the window resizes.
       *
       * Triggers a relayout of all the diffs and the chunk highlighter.
       */
      #onWindowResize() {
        for (let i = 0; i < this._diffReviewableViews.length; i++) {
          this._diffReviewableViews[i].updateLayout();
        }
        this.#chunkHighlighter.updateLayout();
        if (this.unifiedBanner) {
          this.#diffFileIndexView.queueUpdateLayout();
        }
      }

      /**
       * Callback for when a new revision is selected.
       *
       * This supports both single revisions and interdiffs. If `base` is 0, a
       * single revision is selected. If not, the interdiff between `base` and
       * `tip` will be shown.
       *
       * This will always implicitly navigate to page 1 of any paginated diffs.
       *
       * Args:
       *     revisions (Array of number):
       *         The revision range to show.
       */
      _onRevisionSelected(revisions) {
        let base = revisions[0];
        let tip = revisions[1];
        if (base === 0) {
          /* This is a single revision, not an interdiff. */
          base = tip;
          tip = null;
        }
        this._navigate({
          interdiffRevision: tip,
          revision: base
        });
      }

      /**
       * Callback for when a new page is selected.
       *
       * Navigates to the same revision with a different page number.
       *
       * Args:
       *     scroll (boolean):
       *         Whether to scroll to the file index.
       *
       *     page (number):
       *         The page number to navigate to.
       */
      _onPageSelected(scroll, page) {
        if (scroll) {
          this.selectAnchorByName('index_header', true);
        }
        this._navigate({
          page: page
        });
      }

      /**
       * Handle the selected commit interval changing.
       *
       * This will navigate to a diff with the selected base and tip commit IDs.
       *
       * Args:
       *     model (RB.DiffCommitList):
       *          The model that changed.
       */
      #onCommitIntervalChanged(model) {
        this._navigate({
          baseCommitID: model.get('baseCommitID'),
          tipCommitID: model.get('tipCommitID')
        });
      }
    }) || _class);

    exports.AddFileActionView = AddFileActionView;
    exports.AddGeneralCommentActionView = AddGeneralCommentActionView;
    exports.ArchiveActionView = ArchiveActionView;
    exports.ArchiveMenuActionView = ArchiveMenuActionView;
    exports.CloseCompletedActionView = CloseCompletedActionView;
    exports.CloseDiscardedActionView = CloseDiscardedActionView;
    exports.CommentDialogView = CommentDialogView;
    exports.CommentEditor = CommentEditor;
    exports.CreateReviewActionView = CreateReviewActionView;
    exports.DeleteActionView = DeleteActionView;
    exports.DiffComplexityIconView = DiffComplexityIconView;
    exports.DiffFileIndexView = DiffFileIndexView;
    exports.DiffViewerPage = DiffViewerPage;
    exports.DiffViewerPageView = DiffViewerPageView;
    exports.EditReviewActionView = EditReviewActionView;
    exports.FileAttachmentThumbnail = FileAttachmentThumbnail;
    exports.FileAttachmentThumbnailView = FileAttachmentThumbnailView;
    exports.MuteActionView = MuteActionView;
    exports.ReviewDialogView = ReviewDialogView;
    exports.ReviewMenuActionView = ReviewMenuActionView;
    exports.ReviewRequestEditor = ReviewRequestEditor;
    exports.ReviewRequestEditorView = ReviewRequestEditorView;
    exports.ReviewRequestFields = reviewRequestFieldViews;
    exports.ReviewablePage = ReviewablePage;
    exports.ReviewablePageView = ReviewablePageView;
    exports.ShipItActionView = ShipItActionView;
    exports.UnifiedBanner = UnifiedBanner;
    exports.UnifiedBannerView = UnifiedBannerView;
    exports.UpdateDiffActionView = UpdateDiffActionView;

    Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });

}));
//# sourceMappingURL=index.js.map
