core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts

Summary

Maintainability
F
6 days
Test Coverage
// Copyright 2021 The Oppia Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
 * @fileoverview Component for translation suggestion review modal.
 */

import {
  Component,
  OnInit,
  ChangeDetectorRef,
  ViewChild,
  ElementRef,
  Input,
} from '@angular/core';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {AlertsService} from 'services/alerts.service';
import {ContextService} from 'services/context.service';
import {ContributionAndReviewService} from '../services/contribution-and-review.service';
import {ContributionOpportunitiesService} from '../services/contribution-opportunities.service';
import {LanguageUtilService} from 'domain/utilities/language-util.service';
import {SiteAnalyticsService} from 'services/site-analytics.service';
import {ThreadDataBackendApiService} from 'pages/exploration-editor-page/feedback-tab/services/thread-data-backend-api.service';
import {UserService} from 'services/user.service';
import {ValidatorsService} from 'services/validators.service';
import {ThreadMessage} from 'domain/feedback_message/ThreadMessage.model';
import {AppConstants} from 'app.constants';
import {ListSchema, UnicodeSchema} from 'services/schema-default-value.service';
import {UserContributionRightsDataBackendDict} from 'services/user-backend-api.service';
// This throws "TS2307". We need to
// suppress this error because rte-output-display is not strictly typed yet.
// @ts-ignore
import {RteOutputDisplayComponent} from 'rich_text_components/rte-output-display.component';
import {UndoSnackbarComponent} from 'components/custom-snackbar/undo-snackbar.component';
import {MatSnackBar, MatSnackBarRef} from '@angular/material/snack-bar';
import {PlatformFeatureService} from 'services/platform-feature.service';

interface HTMLSchema {
  type: string;
}

interface EditedContentDict {
  html: string;
}

interface ActiveContributionDetailsDict {
  chapter_title: string;
  story_title: string;
  topic_name: string;
}

interface SuggestionChangeDict {
  cmd: string;
  content_html: string | string[];
  content_id: string;
  data_format: string;
  language_code: string;
  state_name: string;
  translation_html: string;
}

interface ActiveSuggestionDict {
  author_name: string;
  change_cmd: SuggestionChangeDict;
  exploration_content_html: string | string[] | null;
  language_code: string;
  last_updated_msecs: number;
  status: string;
  suggestion_id: string;
  suggestion_type: string;
  target_id: string;
  target_type: string;
}

// Details are null if suggestion's corresponding opportunity is deleted.
// See issue #14234.
export interface ActiveContributionDict {
  details: ActiveContributionDetailsDict | null;
  suggestion: ActiveSuggestionDict;
}

interface PendingSuggestionDict {
  target_id: string;
  suggestion_id: string;
  action_status: string;
  reviewer_message: string;
  commit_message?: string;
}

enum ExpansionTabType {
  CONTENT,
  TRANSLATION,
}

const COMMIT_TIMEOUT_DURATION = 30000; // 30 seconds in milliseconds.

@Component({
  selector: 'oppia-translation-suggestion-review-modal',
  templateUrl: './translation-suggestion-review-modal.component.html',
})
export class TranslationSuggestionReviewModalComponent implements OnInit {
  // These properties are initialized using Angular lifecycle hooks
  // and we need to do non-null assertion. For more information, see
  // https://github.com/oppia/oppia/wiki/Guide-on-defining-types#ts-7-1
  activeContribution!: ActiveContributionDict;
  authorName!: string;
  activeSuggestion!: ActiveSuggestionDict;
  activeSuggestionId!: string;
  contentHtml!: string | string[];
  editedContent!: EditedContentDict;
  errorMessage!: string;
  explorationContentHtml!: string | string[] | null;
  finalCommitMessage!: string;
  initialSuggestionId!: string;
  languageCode!: string;
  languageDescription!: string;
  preEditTranslationHtml!: string;
  remainingContributionIds!: string[];
  skippedContributionIds: string[] = [];
  allContributions!: Record<string, ActiveContributionDict>;
  isLastItem!: boolean;
  isFirstItem: boolean = true;
  reviewMessage!: string;
  reviewer!: string;
  status!: string;
  heading: string = 'Your Translation Contributions';
  subheading!: string;
  suggestionIdToContribution!: Record<string, ActiveContributionDict>;
  translationHtml!: string;
  userCanReviewTranslationSuggestionsInLanguages!: string[];
  username!: string;
  resolvedSuggestionIds: string[] = [];
  errorFound: boolean = false;
  contentTypeIsHtml: boolean = false;
  contentTypeIsSetOfStrings: boolean = false;
  contentTypeIsUnicode: boolean = false;
  lastSuggestionToReview: boolean = false;
  firstSuggestionToReview: boolean = true;
  resolvingSuggestion: boolean = false;
  reviewable: boolean = false;
  canEditTranslation: boolean = false;
  userIsCurriculumAdmin: boolean = false;
  isContentExpanded: boolean = false;
  isContentOverflowing: boolean = false;
  isTranslationExpanded: boolean = false;
  isTranslationOverflowing: boolean = false;
  explorationImagesString: string = '';
  suggestionImagesString: string = '';
  queuedSuggestion?: PendingSuggestionDict;
  commitTimeout?: NodeJS.Timeout;
  removedSuggestion?: ActiveContributionDict;
  hasQueuedSuggestion: boolean = false;
  currentSnackbarRef?: MatSnackBarRef<UndoSnackbarComponent>;
  isUndoFeatureEnabled: boolean = false;
  @Input() altTextIsDisplayed: boolean = false;

  @ViewChild('contentPanel')
  contentPanel!: RteOutputDisplayComponent;

  @ViewChild('translationPanel')
  translationPanel!: RteOutputDisplayComponent;

  @ViewChild('contentContainer')
  contentContainer!: ElementRef;

  @ViewChild('translationContainer')
  translationContainer!: ElementRef;

  @ViewChild('contentPanelWithAltText')
  contentPanelWithAltText!: RteOutputDisplayComponent;

  HTML_SCHEMA: HTMLSchema = {type: 'html'};
  MAX_REVIEW_MESSAGE_LENGTH = AppConstants.MAX_REVIEW_MESSAGE_LENGTH;
  SET_OF_STRINGS_SCHEMA: ListSchema = {
    type: 'list',
    items: {
      type: 'unicode',
    },
  };

  startedEditing: boolean = false;
  translationUpdated: boolean = false;
  UNICODE_SCHEMA: UnicodeSchema = {type: 'unicode'};

  constructor(
    private readonly changeDetectorRef: ChangeDetectorRef,
    public activeModal: NgbActiveModal,
    private alertsService: AlertsService,
    private contextService: ContextService,
    private contributionAndReviewService: ContributionAndReviewService,
    private contributionOpportunitiesService: ContributionOpportunitiesService,
    private languageUtilService: LanguageUtilService,
    private siteAnalyticsService: SiteAnalyticsService,
    private threadDataBackendApiService: ThreadDataBackendApiService,
    private userService: UserService,
    private validatorsService: ValidatorsService,
    private snackBar: MatSnackBar,
    private platformFeatureService: PlatformFeatureService
  ) {}

  ngOnInit(): void {
    this.isUndoFeatureEnabled =
      this.platformFeatureService.status.CdAllowUndoingTranslationReview.isEnabled;
    this.activeSuggestionId = this.initialSuggestionId;
    this.activeContribution =
      this.suggestionIdToContribution[this.activeSuggestionId];
    this.activeSuggestion = this.activeContribution.suggestion;
    this.authorName = this.activeSuggestion.author_name;
    this.languageDescription =
      this.languageUtilService.getAudioLanguageDescription(
        this.activeSuggestion.language_code
      );
    this.status = this.activeSuggestion.status;
    if (this.reviewable) {
      this.siteAnalyticsService.registerContributorDashboardViewSuggestionForReview(
        'Translation'
      );
      this.heading = 'Review Translation Contributions';
    }
    const suggestionIds = Object.keys(this.suggestionIdToContribution);
    const clickedSuggestionIndex = suggestionIds.indexOf(
      this.activeSuggestionId
    );
    this.skippedContributionIds = suggestionIds.slice(
      0,
      clickedSuggestionIndex
    );
    delete this.suggestionIdToContribution[this.initialSuggestionId];
    this.remainingContributionIds = suggestionIds.slice(
      clickedSuggestionIndex + 1,
      suggestionIds.length
    );
    this.remainingContributionIds.reverse();
    this.isLastItem = this.remainingContributionIds.length === 0;
    this.allContributions = this.suggestionIdToContribution;
    this.allContributions[this.activeSuggestionId] = this.activeContribution;

    this.refreshActiveContributionState();
    // The 'html' value is passed as an object as it is required for
    // schema-based-editor. Otherwise the corrrectly updated value for
    // the translation is not received from the editor when the translation
    // is edited by the reviewer.
    this.editedContent = {
      html: this.translationHtml,
    };
  }

  refreshActiveContributionState(): void {
    this.activeContribution = this.allContributions[this.activeSuggestionId];

    // Close modal instance if the suggestion's corresponding opportunity
    // is deleted. See issue #14234.
    if (this.activeContribution.details === null) {
      this.activeModal.close(this.resolvedSuggestionIds);
      return;
    }
    this.activeSuggestion = this.activeContribution.suggestion;
    this.contextService.setCustomEntityContext(
      AppConstants.IMAGE_CONTEXT.EXPLORATION_SUGGESTIONS,
      this.activeSuggestion.target_id
    );
    this.subheading =
      `${this.activeContribution.details.topic_name} / ` +
      `${this.activeContribution.details.story_title} / ` +
      `${this.activeContribution.details.chapter_title}`;

    this.isLastItem = this.remainingContributionIds.length === 0;
    this.isFirstItem = this.skippedContributionIds.length === 0;
    this.userCanReviewTranslationSuggestionsInLanguages = [];
    this.languageCode = this.activeSuggestion.change_cmd.language_code;
    this.userService.getUserInfoAsync().then(userInfo => {
      const username = userInfo.getUsername();

      if (username === null) {
        throw new Error('Cannot fetch username.');
      }
      this.username = username;
      this.userIsCurriculumAdmin = userInfo.isCurriculumAdmin();
    });
    this.userService
      .getUserContributionRightsDataAsync()
      .then(userContributionRights => {
        let userContributionRightsData =
          userContributionRights as UserContributionRightsDataBackendDict;
        this.userCanReviewTranslationSuggestionsInLanguages =
          userContributionRightsData.can_review_translation_for_language_codes;
        this.canEditTranslation =
          this.userCanReviewTranslationSuggestionsInLanguages.includes(
            this.languageCode
          ) && this.username !== this.activeSuggestion.author_name;
      });
    this.isContentExpanded = false;
    this.isTranslationExpanded = false;
    this.errorMessage = '';
    this.errorFound = false;
    this.startedEditing = false;
    this.resolvingSuggestion = false;
    this.lastSuggestionToReview =
      Object.keys(this.allContributions).length <= 1;
    this.translationHtml = this.activeSuggestion.change_cmd.translation_html;
    this.status = this.activeSuggestion.status;
    this.contentHtml = this.activeSuggestion.change_cmd.content_html;
    this.explorationContentHtml =
      this.activeSuggestion.exploration_content_html;
    this.contentTypeIsHtml =
      this.activeSuggestion.change_cmd.data_format === 'html';
    this.contentTypeIsUnicode =
      this.activeSuggestion.change_cmd.data_format === 'unicode';
    this.contentTypeIsSetOfStrings =
      this.activeSuggestion.change_cmd.data_format ===
        'set_of_normalized_string' ||
      this.activeSuggestion.change_cmd.data_format === 'set_of_unicode_string';
    this.reviewMessage = '';
    if (!this.reviewable) {
      this._getThreadMessagesAsync(this.activeSuggestionId).then(() => {
        // No review message and no exploration content means the suggestion
        // became obsolete and was auto-rejected in a batch job. See issue
        // #16022.
        if (!this.reviewMessage && !this.explorationContentHtml) {
          this.reviewMessage =
            AppConstants.OBSOLETE_TRANSLATION_SUGGESTION_REVIEW_MSG;
        }
      });
    }
    this.explorationImagesString = this.getImageInfoForSuggestion(
      this.contentHtml
    );
    this.suggestionImagesString = this.getImageInfoForSuggestion(
      this.translationHtml
    );
    setTimeout(() => {
      this.computePanelOverflowState();
    }, 0);
  }

  computePanelOverflowState(): void {
    setTimeout(() => {
      this.isContentOverflowing =
        this.contentPanel.elementRef.nativeElement.offsetHeight >
        this.contentContainer.nativeElement.offsetHeight;
      this.isTranslationOverflowing =
        this.translationPanel.elementRef.nativeElement.offsetHeight >
        this.translationContainer.nativeElement.offsetHeight;
    }, 0);
  }

  ngAfterViewInit(): void {
    this.computePanelOverflowState();
  }

  toggleExpansionState(tab: ExpansionTabType): void {
    if (tab === ExpansionTabType.CONTENT) {
      this.isContentExpanded = !this.isContentExpanded;
    } else if (tab === ExpansionTabType.TRANSLATION) {
      this.isTranslationExpanded = !this.isTranslationExpanded;
    }
  }

  updateSuggestion(): void {
    const updatedTranslation = this.editedContent.html;
    const suggestionId = this.activeSuggestion.suggestion_id;
    this.preEditTranslationHtml = this.translationHtml;
    this.translationHtml = updatedTranslation;
    this.contributionAndReviewService.updateTranslationSuggestionAsync(
      suggestionId,
      updatedTranslation,
      () => {
        this.translationUpdated = true;
        this.startedEditing = false;
        this.contributionOpportunitiesService.reloadOpportunitiesEventEmitter.emit();
      },
      this.showTranslationSuggestionUpdateError
    );
    this.suggestionImagesString = this.getImageInfoForSuggestion(
      this.translationHtml
    );
  }

  // The length of the commit message should not exceed 375 characters,
  // since this is the maximum allowed commit message size.
  generateCommitMessage(): string {
    const contentId = this.activeSuggestion.change_cmd.content_id;
    const stateName = this.activeSuggestion.change_cmd.state_name;
    const contentType = contentId.split('_')[0];
    const commitMessage = `${contentType} section of "${stateName}" card`;

    return commitMessage;
  }

  async _getThreadMessagesAsync(threadId: string): Promise<void> {
    const response =
      await this.threadDataBackendApiService.fetchMessagesAsync(threadId);
    const threadMessageBackendDicts = response.messages;
    let threadMessages = threadMessageBackendDicts.map(m =>
      ThreadMessage.createFromBackendDict(m)
    );
    // This is to prevent a console error when a contribution
    // doesn't have a review message. When a contribution has
    // a review message the second element of the threadMessages
    // array contains the actual review message.
    if (threadMessages[1] !== undefined) {
      this.reviewer = threadMessages[1].authorUsername;
      this.reviewMessage = threadMessages[1].text;
    }
  }

  goToNextItem(): void {
    const lastContributionId = this.remainingContributionIds.pop();
    // If the current item is the last item, do not navigate.
    if (lastContributionId === undefined) {
      return;
    }
    // Don't add resolved contributions to the skippedContributionIds beacuse
    // we don't want to show resolved suggestions when navigating back.
    if (!this.resolvedSuggestionIds.includes(this.activeSuggestionId)) {
      this.skippedContributionIds.push(this.activeSuggestionId);
    }

    this.activeSuggestionId = lastContributionId;

    this.refreshActiveContributionState();
  }

  goToPreviousItem(): void {
    const lastContributionId = this.skippedContributionIds.pop();
    // If the current item is the first item, do not navigate.
    if (lastContributionId === undefined) {
      return;
    }
    // Don't add resolved contributions to the remainingContributionIds beacuse
    // we don't want to show resolved suggestions when navigating forward.
    if (!this.resolvedSuggestionIds.includes(this.activeSuggestionId)) {
      this.remainingContributionIds.push(this.activeSuggestionId);
    }

    this.activeSuggestionId = lastContributionId;

    this.refreshActiveContributionState();
  }

  resolveSuggestionAndUpdateModal(): void {
    if (this.isUndoFeatureEnabled) {
      if (this.queuedSuggestion) {
        this.resolvedSuggestionIds.push(this.queuedSuggestion.suggestion_id);

        // Resolved contributions don't need to be displayed in the modal.
        this.removedSuggestion =
          this.allContributions[this.queuedSuggestion?.suggestion_id];
        delete this.allContributions[this.queuedSuggestion?.suggestion_id];

        // If the reviewed item was the last item, close the modal.
        if (this.lastSuggestionToReview || this.isLastItem) {
          this.commitQueuedSuggestion();
          this.activeModal.close(this.resolvedSuggestionIds);
          return;
        }
      }
      this.goToNextItem();
    } else {
      this.resolvedSuggestionIds.push(this.activeSuggestionId);

      // Resolved contributions don't need to be displayed in the modal.
      delete this.allContributions[this.activeSuggestionId];

      // If the reviewed item was the last item, close the modal.
      if (this.lastSuggestionToReview || this.isLastItem) {
        this.activeModal.close(this.resolvedSuggestionIds);
        return;
      }
      this.goToNextItem();
    }
  }

  acceptAndReviewNext(): void {
    if (this.isUndoFeatureEnabled) {
      this.finalCommitMessage = this.generateCommitMessage();
      const reviewMessageForSubmitter =
        this.reviewMessage +
        (this.translationUpdated
          ? (this.reviewMessage.length > 0 ? ': ' : '') +
            '(Note: This suggestion was submitted with reviewer edits.)'
          : '');
      this.resolvingSuggestion = true;
      this.siteAnalyticsService.registerContributorDashboardAcceptSuggestion(
        'Translation'
      );
      this.queuedSuggestion = {
        target_id: this.activeSuggestion.target_id,
        suggestion_id: this.activeSuggestionId,
        action_status: AppConstants.ACTION_ACCEPT_SUGGESTION,
        reviewer_message: reviewMessageForSubmitter,
        commit_message: this.finalCommitMessage,
      };
      this.hasQueuedSuggestion = true;
      this.resolveSuggestionAndUpdateModal();
      this.startCommitTimeout();
      this.showSnackbar();
    } else {
      this.finalCommitMessage = this.generateCommitMessage();
      const reviewMessageForSubmitter =
        this.reviewMessage +
        (this.translationUpdated
          ? (this.reviewMessage.length > 0 ? ': ' : '') +
            '(Note: This suggestion was submitted with reviewer edits.)'
          : '');
      this.resolvingSuggestion = true;
      this.siteAnalyticsService.registerContributorDashboardAcceptSuggestion(
        'Translation'
      );

      this.contributionAndReviewService.reviewExplorationSuggestion(
        this.activeSuggestion.target_id,
        this.activeSuggestionId,
        AppConstants.ACTION_ACCEPT_SUGGESTION,
        reviewMessageForSubmitter,
        this.finalCommitMessage,
        () => {
          this.alertsService.clearMessages();
          this.alertsService.addSuccessMessage('Suggestion accepted.');
          this.resolveSuggestionAndUpdateModal();
        },
        errorMessage => {
          this.alertsService.clearWarnings();
          this.alertsService.addWarning(`Invalid Suggestion: ${errorMessage}`);
        }
      );
    }
  }

  rejectAndReviewNext(reviewMessage: string): void {
    if (this.isUndoFeatureEnabled) {
      if (
        this.validatorsService.isValidReviewMessage(
          reviewMessage,
          /* ShowWarnings= */ true
        )
      ) {
        this.resolvingSuggestion = true;
        this.siteAnalyticsService.registerContributorDashboardRejectSuggestion(
          'Translation'
        );
        this.queuedSuggestion = {
          target_id: this.activeSuggestion.target_id,
          suggestion_id: this.activeSuggestionId,
          action_status: AppConstants.ACTION_REJECT_SUGGESTION,
          reviewer_message: reviewMessage || this.reviewMessage,
        };
        this.hasQueuedSuggestion = true;
        this.resolveSuggestionAndUpdateModal();
        this.startCommitTimeout();
        this.showSnackbar();
      }
    } else {
      if (
        this.validatorsService.isValidReviewMessage(
          reviewMessage,
          /* ShowWarnings= */ true
        )
      ) {
        this.resolvingSuggestion = true;
        this.siteAnalyticsService.registerContributorDashboardRejectSuggestion(
          'Translation'
        );

        // In case of rejection, the suggestion is not applied, so there is no
        // commit message. Because there is no commit to make.
        this.contributionAndReviewService.reviewExplorationSuggestion(
          this.activeSuggestion.target_id,
          this.activeSuggestionId,
          AppConstants.ACTION_REJECT_SUGGESTION,
          reviewMessage || this.reviewMessage,
          null,
          () => {
            this.alertsService.clearMessages();
            this.alertsService.addSuccessMessage('Suggestion rejected.');
            this.resolveSuggestionAndUpdateModal();
          },
          errorMessage => {
            this.alertsService.clearWarnings();
            this.alertsService.addWarning(
              `Invalid Suggestion: ${errorMessage}`
            );
          }
        );
      }
    }
  }

  revertSuggestionResolution(): void {
    // Remove the suggestion ID from resolvedSuggestionIds.
    if (this.queuedSuggestion && this.removedSuggestion) {
      const index = this.resolvedSuggestionIds.indexOf(
        this.queuedSuggestion?.suggestion_id
      );
      if (index > -1) {
        this.resolvedSuggestionIds.splice(index, 1);
      }

      // Add the removed suggestion back to allContributions.
      this.allContributions[this.queuedSuggestion?.suggestion_id] =
        this.removedSuggestion;
    }
  }

  startCommitTimeout(): void {
    clearTimeout(this.commitTimeout); // Clear existing timeout.

    // Start a new timeout for commit after timeframe.
    this.commitTimeout = setTimeout(() => {
      this.commitQueuedSuggestion();
    }, COMMIT_TIMEOUT_DURATION);
  }

  commitQueuedSuggestion(): void {
    if (!this.queuedSuggestion) {
      return;
    }
    this.contributionAndReviewService.reviewExplorationSuggestion(
      this.queuedSuggestion.target_id,
      this.queuedSuggestion.suggestion_id,
      this.queuedSuggestion.action_status,
      this.queuedSuggestion.reviewer_message,
      this.queuedSuggestion.action_status === 'accept' &&
        this.queuedSuggestion.commit_message
        ? this.queuedSuggestion.commit_message
        : null,
      // Only include commit_message for accepted suggestions.
      () => {
        this.alertsService.clearMessages();
        this.alertsService.addSuccessMessage(
          `Suggestion ${
            this.queuedSuggestion?.action_status === 'accept'
              ? 'accepted'
              : 'rejected'
          }.`
        );
        this.clearQueuedSuggestion();
      },
      errorMessage => {
        this.alertsService.clearWarnings();
        this.alertsService.addWarning(`Invalid Suggestion: ${errorMessage}`);
        this.revertSuggestionResolution();
      }
    );
  }

  clearQueuedSuggestion(): void {
    this.queuedSuggestion = undefined;
    this.hasQueuedSuggestion = false;
  }

  undoReviewAction(): void {
    clearTimeout(this.commitTimeout); // Clear the commit timeout.
    if (this.queuedSuggestion) {
      const indexToRemove = this.resolvedSuggestionIds.indexOf(
        this.queuedSuggestion.suggestion_id
      );
      if (indexToRemove !== -1) {
        this.resolvedSuggestionIds.splice(indexToRemove, 1);
        if (this.removedSuggestion) {
          this.allContributions[this.queuedSuggestion.suggestion_id] =
            this.removedSuggestion;
        }
      }
    }
    this.clearQueuedSuggestion();
  }

  showSnackbar(): void {
    this.currentSnackbarRef =
      this.snackBar.openFromComponent<UndoSnackbarComponent>(
        UndoSnackbarComponent,
        {
          duration: COMMIT_TIMEOUT_DURATION,
          verticalPosition: 'bottom',
          horizontalPosition: 'right',
        }
      );
    this.currentSnackbarRef.instance.message = 'Suggestion queued';

    this.currentSnackbarRef.onAction().subscribe(() => {
      this.undoReviewAction();
    });

    this.currentSnackbarRef.afterDismissed().subscribe(() => {
      if (this.hasQueuedSuggestion) {
        this.commitQueuedSuggestion();
      }
    });
  }

  // Returns whether the active suggestion's exploration_content_html
  // differs from the content_html of the suggestion's change object.
  hasExplorationContentChanged(): boolean {
    return !this.isHtmlContentEqual(
      this.contentHtml,
      this.explorationContentHtml
    );
  }

  isHtmlContentEqual(
    first: string | string[] | null,
    second: string | string[] | null
  ): boolean {
    if (Array.isArray(first) && Array.isArray(second)) {
      // Check equality of all array elements.
      return (
        first.length === second.length &&
        first.every(
          (val, index) =>
            this.stripWhitespace(val) === this.stripWhitespace(second[index])
        )
      );
    }
    if (angular.isString(first) && angular.isString(second)) {
      return this.stripWhitespace(first) === this.stripWhitespace(second);
    }
    return false;
  }

  // Strips whitespace (spaces, tabs, line breaks) and '&nbsp;'.
  stripWhitespace(htmlString: string): string {
    return htmlString.replace(/&nbsp;|\s+/g, '');
  }

  editSuggestion(): void {
    this.startedEditing = true;
    this.editedContent.html = this.translationHtml;
  }

  cancelEdit(): void {
    this.errorMessage = '';
    this.startedEditing = false;
    this.errorFound = false;
    this.editedContent.html = this.translationHtml;
  }

  cancel(): void {
    this.commitQueuedSuggestion();
    this.activeModal.close(this.resolvedSuggestionIds);
  }

  showTranslationSuggestionUpdateError(error: Error): void {
    this.errorMessage = 'Invalid Suggestion: ' + error.message;
    this.errorFound = true;
    this.startedEditing = true;
    this.translationHtml = this.preEditTranslationHtml;
  }

  isDeprecatedTranslationSuggestionCommand(): boolean {
    return this.activeSuggestion.change_cmd.cmd === 'add_translation';
  }

  doesTranslationContainTags(): boolean {
    return /<.*>/g.test(this.translationHtml);
  }

  getHtmlSchema(): HTMLSchema {
    return this.HTML_SCHEMA;
  }

  getUnicodeSchema(): UnicodeSchema {
    return this.UNICODE_SCHEMA;
  }

  getSetOfStringsSchema(): ListSchema {
    return this.SET_OF_STRINGS_SCHEMA;
  }

  updateHtml(value: string): void {
    if (value !== this.editedContent.html) {
      this.editedContent.html = value;
      this.changeDetectorRef.detectChanges();
    }
  }

  /**
   * Retrieves image information from the given content and
   * returns it as a string.
   * If the content is in the form of a string (not an array),
   * it parses the content using a DOMParser and extracts the HTML
   * for all 'oppia-noninteractive-image' elements. The extracted HTML
   * is returned as a string.
   * @param content The content containing image information (
   * either a string or an array of strings).
   * @returns A string representation of the extracted image information.
   */
  getImageInfoForSuggestion(content: string | string[]): string {
    let htmlString = '';

    // Images are present in form of strings not as Array of strings.
    if (!Array.isArray(content)) {
      this.altTextIsDisplayed = true;
      const doc = new DOMParser().parseFromString(content, 'text/html');
      const imgElements = doc.querySelectorAll('oppia-noninteractive-image');
      htmlString = Array.from(imgElements)
        .map(img => img.outerHTML)
        .join('');
    }

    return htmlString;
  }
}