core/templates/pages/exploration-player-page/learner-experience/tutor-card.component.ts

Summary

Maintainability
F
3 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 the Tutor Card.
 */

import {
  Component,
  Input,
  SimpleChanges,
  ViewChild,
  Renderer2,
} from '@angular/core';
import {downgradeComponent} from '@angular/upgrade/static';
import {TranslateService} from '@ngx-translate/core';
import {AppConstants} from 'app.constants';
import {BindableVoiceovers} from 'domain/exploration/recorded-voiceovers.model';
import {StateCard} from 'domain/state_card/state-card.model';
import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service';
import cloneDeep from 'lodash/cloneDeep';
import isEqual from 'lodash/isEqual';
import {Subscription} from 'rxjs';
import {AudioBarStatusService} from 'services/audio-bar-status.service';
import {AudioPlayerService} from 'services/audio-player.service';
import {AutogeneratedAudioPlayerService} from 'services/autogenerated-audio-player.service';
import {ContextService} from 'services/context.service';
import {DeviceInfoService} from 'services/contextual/device-info.service';
import {UrlService} from 'services/contextual/url.service';
import {WindowDimensionsService} from 'services/contextual/window-dimensions.service';
import {WindowRef} from 'services/contextual/window-ref.service';
import {UserService} from 'services/user.service';
import {ExplorationPlayerConstants} from '../exploration-player-page.constants';
import {AudioPreloaderService} from '../services/audio-preloader.service';
import {AudioTranslationManagerService} from '../services/audio-translation-manager.service';
import {CurrentInteractionService} from '../services/current-interaction.service';
import {ExplorationPlayerStateService} from '../services/exploration-player-state.service';
import {LearnerAnswerInfoService} from '../services/learner-answer-info.service';
import {PlayerPositionService} from '../services/player-position.service';
import {I18nLanguageCodeService} from 'services/i18n-language-code.service';
import {
  animate,
  keyframes,
  state,
  style,
  transition,
  trigger,
} from '@angular/animations';
import {CollectionSummary} from 'domain/collection/collection-summary.model';
import {LearnerExplorationSummary} from 'domain/summary/learner-exploration-summary.model';
import {EndChapterCheckMarkComponent} from './end-chapter-check-mark.component';
import {EndChapterConfettiComponent} from './end-chapter-confetti.component';
import {PlatformFeatureService} from 'services/platform-feature.service';
import {QuestionPlayerConfig} from './ratings-and-recommendations.component';

const CHECK_MARK_HIDE_DELAY_IN_MSECS = 500;
const REDUCED_MOTION_ANIMATION_DURATION_IN_MSECS = 2000;
const CONFETTI_ANIMATION_DELAY_IN_MSECS = 2000;
const STANDARD_ANIMATION_DURATION_IN_MSECS = 4000;
const MILESTONE_SPECIFIC_COMPLETED_CHAPTER_COUNTS = [1, 5, 10, 25, 50];

import './tutor-card.component.css';

@Component({
  selector: 'oppia-tutor-card',
  templateUrl: './tutor-card.component.html',
  styleUrls: ['./tutor-card.component.css'],
  animations: [
    trigger('expandInOut', [
      state(
        'in',
        style({
          overflow: 'visible',
          height: '*',
        })
      ),
      state(
        'out',
        style({
          overflow: 'hidden',
          height: '0px',
          display: 'none',
        })
      ),
      transition('in => out', animate('500ms ease-in-out')),
      transition('out => in', [
        style({display: 'block'}),
        animate('500ms ease-in-out'),
      ]),
    ]),
    trigger('fadeInOut', [
      transition('void => *', []),
      transition('* <=> *', [
        style({opacity: 0}),
        animate(
          '1s ease',
          keyframes([style({opacity: 0}), style({opacity: 1})])
        ),
      ]),
    ]),
  ],
})
export class TutorCardComponent {
  // 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
  @ViewChild('checkMark') checkMarkComponent!: EndChapterCheckMarkComponent;
  @ViewChild('confetti') confettiComponent!: EndChapterConfettiComponent;
  @Input() displayedCard!: StateCard;
  @Input() displayedCardWasCompletedInPrevSession!: boolean;
  @Input() startCardChangeAnimation!: boolean;
  @Input() avatarImageIsShown!: boolean;
  @Input() shouldHideInteraction!: boolean;
  @Input() userIsLoggedIn!: boolean;
  @Input() explorationIsInPreviewMode!: boolean;
  @Input() questionPlayerConfig!: QuestionPlayerConfig;
  @Input() collectionSummary!: CollectionSummary;
  @Input() isRefresherExploration!: boolean;
  @Input() recommendedExplorationSummaries!: LearnerExplorationSummary[];
  @Input() parentExplorationIds!: string[];
  @Input() inStoryMode!: boolean;
  @Input() nextLessonLink!: string;
  @Input() completedChaptersCount!: number;
  @Input() milestoneMessageIsToBeDisplayed!: boolean;
  @Input() feedbackIsEnabled!: boolean;
  @Input() learnerCanOnlyAttemptQuestionOnce!: boolean;
  @Input() inputOutputHistoryIsShown!: boolean;
  @Input() checkpointCelebrationModalIsEnabled!: boolean;
  private _editorPreviewMode!: boolean;
  lastAnswer!: {answerDetails: string} | string | null;
  conceptCardIsBeingShown!: boolean;
  interactionIsActive!: boolean;
  waitingForOppiaFeedback: boolean = false;
  interactionInstructions!: string | null;
  contentAudioTranslations!: BindableVoiceovers;
  isIframed!: boolean;
  getCanAskLearnerForAnswerInfo!: () => boolean;
  OPPIA_AVATAR_IMAGE_URL!: string;
  profilePicturePngDataUrl!: string;
  profilePictureWebpDataUrl!: string;
  directiveSubscriptions = new Subscription();
  arePreviousResponsesShown: boolean = false;
  nextMilestoneChapterCount: number | null = null;
  checkMarkHidden: boolean = true;
  animationHasPlayedOnce: boolean = false;
  checkMarkSkipped: boolean = false;
  confettiAnimationTimeout!: NodeJS.Timeout;
  skipClickListener: Function | null = null;
  username!: string | null;

  constructor(
    private audioBarStatusService: AudioBarStatusService,
    private audioPlayerService: AudioPlayerService,
    private audioPreloaderService: AudioPreloaderService,
    private audioTranslationManagerService: AudioTranslationManagerService,
    private autogeneratedAudioPlayerService: AutogeneratedAudioPlayerService,
    private contextService: ContextService,
    private currentInteractionService: CurrentInteractionService,
    private deviceInfoService: DeviceInfoService,
    private explorationPlayerStateService: ExplorationPlayerStateService,
    private i18nLanguageCodeService: I18nLanguageCodeService,
    private learnerAnswerInfoService: LearnerAnswerInfoService,
    private playerPositionService: PlayerPositionService,
    private urlInterpolationService: UrlInterpolationService,
    private urlService: UrlService,
    private userService: UserService,
    private windowDimensionsService: WindowDimensionsService,
    private windowRef: WindowRef,
    public platformFeatureService: PlatformFeatureService,
    private renderer: Renderer2,
    private translateService: TranslateService
  ) {}

  async getUserInfoAsync(): Promise<void> {
    const userInfo = await this.userService.getUserInfoAsync();
    this.username = userInfo.getUsername();
    if (!this._editorPreviewMode) {
      if (this.username !== null) {
        [this.profilePicturePngDataUrl, this.profilePictureWebpDataUrl] =
          this.userService.getProfileImageDataUrl(this.username);
      } else {
        this.profilePictureWebpDataUrl =
          this.urlInterpolationService.getStaticImageUrl(
            AppConstants.DEFAULT_PROFILE_IMAGE_WEBP_PATH
          );
        this.profilePicturePngDataUrl =
          this.urlInterpolationService.getStaticImageUrl(
            AppConstants.DEFAULT_PROFILE_IMAGE_PNG_PATH
          );
      }
    } else {
      this.profilePictureWebpDataUrl =
        this.urlInterpolationService.getStaticImageUrl(
          AppConstants.DEFAULT_PROFILE_IMAGE_WEBP_PATH
        );
      this.profilePicturePngDataUrl =
        this.urlInterpolationService.getStaticImageUrl(
          AppConstants.DEFAULT_PROFILE_IMAGE_PNG_PATH
        );
    }
  }

  ngOnInit(): void {
    this._editorPreviewMode = this.contextService.isInExplorationEditorPage();
    this.getUserInfoAsync();
    this.isIframed = this.urlService.isIframed();
    this.getCanAskLearnerForAnswerInfo =
      this.learnerAnswerInfoService.getCanAskLearnerForAnswerInfo;
    this.OPPIA_AVATAR_IMAGE_URL =
      this.urlInterpolationService.getStaticImageUrl(
        '/avatar/oppia_avatar_100px.svg'
      );

    this.directiveSubscriptions.add(
      this.explorationPlayerStateService.onOppiaFeedbackAvailable.subscribe(
        () => {
          this.waitingForOppiaFeedback = false;

          // Auto scroll to the new feedback on mobile device.
          if (this.deviceInfoService.isMobileDevice()) {
            let latestFeedbackIndex =
              this.displayedCard.getInputResponsePairs().length - 1;

            this.windowRef.nativeWindow.location.hash =
              this.getInputResponsePairId(latestFeedbackIndex);
          }
        }
      )
    );
  }

  ngOnDestroy(): void {
    this.directiveSubscriptions.unsubscribe();
  }

  ngOnChanges(changes: SimpleChanges): void {
    if (
      changes.displayedCard &&
      !isEqual(
        changes.displayedCard.previousValue,
        changes.displayedCard.currentValue
      )
    ) {
      this.updateDisplayedCard();
    }
    if (
      this.platformFeatureService.status.EndChapterCelebration.isEnabled &&
      this.isOnTerminalCard() &&
      !this.animationHasPlayedOnce &&
      this.inStoryMode
    ) {
      this.triggerCelebratoryAnimation();
    }
  }

  triggerCelebratoryAnimation(): void {
    this.checkMarkHidden = false;
    this.checkMarkComponent.animateCheckMark();
    this.skipClickListener = this.renderer.listen('document', 'click', () => {
      clearTimeout(this.confettiAnimationTimeout);
      this.checkMarkSkipped = true;
      setTimeout(() => {
        this.checkMarkHidden = true;
      }, CHECK_MARK_HIDE_DELAY_IN_MSECS);
    });
    this.animationHasPlayedOnce = true;
    let mediaQuery = this.windowRef.nativeWindow.matchMedia(
      '(prefers-reduced-motion)'
    );
    if (mediaQuery.matches) {
      setTimeout(() => {
        this.checkMarkSkipped = true;
        setTimeout(() => {
          this.checkMarkHidden = true;
          if (this.skipClickListener) {
            this.skipClickListener();
          }
          this.skipClickListener = null;
        }, CHECK_MARK_HIDE_DELAY_IN_MSECS);
      }, REDUCED_MOTION_ANIMATION_DURATION_IN_MSECS);
    } else {
      this.confettiAnimationTimeout = setTimeout(() => {
        this.confettiComponent.animateConfetti();
      }, CONFETTI_ANIMATION_DELAY_IN_MSECS);
      setTimeout(() => {
        this.checkMarkHidden = true;
        if (this.skipClickListener) {
          this.skipClickListener();
        }
        this.skipClickListener = null;
      }, STANDARD_ANIMATION_DURATION_IN_MSECS);
    }
  }

  generateMilestoneMessage(): string {
    if (
      !this.inStoryMode ||
      !this.milestoneMessageIsToBeDisplayed ||
      !this.completedChaptersCount ||
      !MILESTONE_SPECIFIC_COMPLETED_CHAPTER_COUNTS.includes(
        this.completedChaptersCount
      )
    ) {
      return '';
    }
    let chapterCountMessageIndex =
      MILESTONE_SPECIFIC_COMPLETED_CHAPTER_COUNTS.indexOf(
        this.completedChaptersCount
      ) + 1;
    let milestoneMessageTranslationKey =
      'I18N_END_CHAPTER_MILESTONE_MESSAGE_' + chapterCountMessageIndex;
    return this.translateService.instant(milestoneMessageTranslationKey);
  }

  setNextMilestoneAndCheckIfProgressBarIsShown(): boolean {
    if (
      !this.inStoryMode ||
      this.isCompletedChaptersCountGreaterThanLastMilestone() ||
      this.isMilestoneReachedAndMilestoneMessageToBeDisplayed()
    ) {
      this.nextMilestoneChapterCount = null;
      return false;
    }

    if (
      !this.milestoneMessageIsToBeDisplayed &&
      MILESTONE_SPECIFIC_COMPLETED_CHAPTER_COUNTS.includes(
        this.completedChaptersCount
      )
    ) {
      let chapterCountIndex =
        MILESTONE_SPECIFIC_COMPLETED_CHAPTER_COUNTS.indexOf(
          this.completedChaptersCount
        );
      this.nextMilestoneChapterCount =
        MILESTONE_SPECIFIC_COMPLETED_CHAPTER_COUNTS[chapterCountIndex + 1];
      return true;
    }

    for (let milestoneCount of MILESTONE_SPECIFIC_COMPLETED_CHAPTER_COUNTS) {
      if (milestoneCount > this.completedChaptersCount) {
        this.nextMilestoneChapterCount = milestoneCount;
        return true;
      }
    }

    return false;
  }

  isMilestoneReachedAndMilestoneMessageToBeDisplayed(): boolean {
    return (
      this.milestoneMessageIsToBeDisplayed &&
      MILESTONE_SPECIFIC_COMPLETED_CHAPTER_COUNTS.includes(
        this.completedChaptersCount
      )
    );
  }

  isCompletedChaptersCountGreaterThanLastMilestone(): boolean {
    return this.completedChaptersCount > 50;
  }

  getStaticImageUrl(imagePath: string): string {
    return this.urlInterpolationService.getStaticImageUrl(imagePath);
  }

  isAudioBarExpandedOnMobileDevice(): boolean {
    return (
      this.deviceInfoService.isMobileDevice() &&
      this.audioBarStatusService.isAudioBarExpanded()
    );
  }

  isLanguageRTL(): boolean {
    return this.i18nLanguageCodeService.isCurrentLanguageRTL();
  }

  updateDisplayedCard(): void {
    this.arePreviousResponsesShown = false;
    this.lastAnswer = null;
    this.conceptCardIsBeingShown = Boolean(
      !this.displayedCard.getInteraction()
    );
    this.interactionIsActive = !this.displayedCard.isCompleted();
    this.directiveSubscriptions.add(
      this.playerPositionService.onNewCardAvailable.subscribe(
        () => (this.interactionIsActive = false)
      )
    );
    this.currentInteractionService.registerPresubmitHook(() => {
      this.waitingForOppiaFeedback = true;
    });

    if (!this.interactionIsActive) {
      this.lastAnswer = this.displayedCard.getLastAnswer();
    }

    if (!this.conceptCardIsBeingShown) {
      this.interactionInstructions =
        this.displayedCard.getInteractionInstructions();
      this.contentAudioTranslations = this.displayedCard.getVoiceovers();
      this.audioTranslationManagerService.clearSecondaryAudioTranslations();
      this.audioTranslationManagerService.setContentAudioTranslations(
        cloneDeep(this.contentAudioTranslations),
        this.displayedCard.getContentHtml(),
        AppConstants.COMPONENT_NAME_CONTENT
      );
      this.audioPlayerService.clear();
      this.audioPreloaderService.clearMostRecentlyRequestedAudioFilename();
      this.autogeneratedAudioPlayerService.cancel();
    }
  }

  isInteractionInline(): boolean {
    if (this.conceptCardIsBeingShown) {
      return true;
    }
    return this.displayedCard.isInteractionInline();
  }

  // This function returns null if audio is not available.
  getContentAudioHighlightClass(): string | null {
    if (
      this.audioTranslationManagerService.getCurrentComponentName() ===
        AppConstants.COMPONENT_NAME_CONTENT &&
      (this.audioPlayerService.isPlaying() ||
        this.autogeneratedAudioPlayerService.isPlaying())
    ) {
      return ExplorationPlayerConstants.AUDIO_HIGHLIGHT_CSS_CLASS;
    }
    return null;
  }

  getContentFocusLabel(index: number): string {
    return ExplorationPlayerConstants.CONTENT_FOCUS_LABEL_PREFIX + index;
  }

  toggleShowPreviousResponses(): void {
    this.arePreviousResponsesShown = !this.arePreviousResponsesShown;
  }

  isWindowNarrow(): boolean {
    return this.windowDimensionsService.isWindowNarrow();
  }

  canWindowShowTwoCards(): boolean {
    return (
      this.windowDimensionsService.getWidth() >
      ExplorationPlayerConstants.TWO_CARD_THRESHOLD_PX
    );
  }

  showAudioBar(): boolean {
    return (
      !this.isIframed && !this.explorationPlayerStateService.isInQuestionMode()
    );
  }

  isContentAudioTranslationAvailable(): boolean {
    if (this.conceptCardIsBeingShown) {
      return false;
    }
    return this.displayedCard.isContentAudioTranslationAvailable();
  }

  isCurrentCardAtEndOfTranscript(): boolean {
    return !this.displayedCard.isCompleted();
  }

  isOnTerminalCard(): boolean {
    return this.displayedCard.isTerminal();
  }

  getInputResponsePairId(index: number): string {
    return 'input-response-pair-' + index;
  }
}

angular.module('oppia').directive(
  'oppiaTutorCard',
  downgradeComponent({
    component: TutorCardComponent,
  }) as angular.IDirectiveFactory
);