diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 19e816b09..d49371457 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -30,6 +30,7 @@ jobs: MOODLE_BRANCH: ${{ github.event.inputs.moodle_branch || 'master' }} MOODLE_REPOSITORY: ${{ github.event.inputs.moodle_repository || 'https://github.com/moodle/moodle' }} BEHAT_TAGS: ${{ github.event.inputs.behat_tags || '~@performance' }} + steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v3 @@ -50,6 +51,7 @@ jobs: export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle cp $GITHUB_WORKSPACE/moodle-docker/config.docker-template.php $GITHUB_WORKSPACE/moodle/config.php sed -i "61i\$CFG->behat_ionic_wwwroot = 'http://moodleapp';" $GITHUB_WORKSPACE/moodle/config.php + echo "define('TEST_MOD_BIGBLUEBUTTONBN_MOCK_SERVER', 'http://bbbmockserver/hash' . sha1(\$CFG->behat_wwwroot));" >> $GITHUB_WORKSPACE/moodle/config.php $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose pull $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose up -d $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-wait-for-db @@ -58,6 +60,7 @@ jobs: docker build --build-arg build_command="npm run build:test" -t moodlehq/moodleapp:behat . docker run -d --rm --name moodleapp moodlehq/moodleapp:behat docker network connect moodle-docker_default moodleapp --alias moodleapp + docker run --detach --name bbbmockserver --network moodle-docker_default moodlehq/bigbluebutton_mock:latest - name: Init Behat run: | export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle diff --git a/scripts/langindex.json b/scripts/langindex.json index a022f6a59..009c539ca 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -458,10 +458,14 @@ "addon.mod_bigbluebuttonbn.view_message_conference_room_ready": "bigbluebuttonbn", "addon.mod_bigbluebuttonbn.view_message_moderator": "bigbluebuttonbn", "addon.mod_bigbluebuttonbn.view_message_moderators": "bigbluebuttonbn", + "addon.mod_bigbluebuttonbn.view_message_norecordings": "bigbluebuttonbn", "addon.mod_bigbluebuttonbn.view_message_session_started_at": "bigbluebuttonbn", "addon.mod_bigbluebuttonbn.view_message_viewer": "bigbluebuttonbn", "addon.mod_bigbluebuttonbn.view_message_viewers": "bigbluebuttonbn", "addon.mod_bigbluebuttonbn.view_nojoin": "bigbluebuttonbn", + "addon.mod_bigbluebuttonbn.view_recording_format_presentation": "bigbluebuttonbn", + "addon.mod_bigbluebuttonbn.view_recording_list_action_play": "bigbluebuttonbn", + "addon.mod_bigbluebuttonbn.view_section_title_recordings": "bigbluebuttonbn", "addon.mod_book.errorchapter": "book", "addon.mod_book.modulenameplural": "book", "addon.mod_book.navnexttitle": "book", diff --git a/src/addons/mod/bigbluebuttonbn/components/index/index.html b/src/addons/mod/bigbluebuttonbn/components/index/index.html index f51197f46..899bee333 100644 --- a/src/addons/mod/bigbluebuttonbn/components/index/index.html +++ b/src/addons/mod/bigbluebuttonbn/components/index/index.html @@ -83,6 +83,46 @@ + + + +

{{ 'addon.mod_bigbluebuttonbn.view_section_title_recordings' | translate }}

+
+
+ + + + +

{{ recording.type }}

+

{{ recording.name }}

+
+ + + +
+
+ + +

{{ data.label }}

+

+ +

+

{{ data.value }}

+
+
+
+
+ + + +
+
diff --git a/src/addons/mod/bigbluebuttonbn/components/index/index.scss b/src/addons/mod/bigbluebuttonbn/components/index/index.scss index c6f581f99..dd64226f6 100644 --- a/src/addons/mod/bigbluebuttonbn/components/index/index.scss +++ b/src/addons/mod/bigbluebuttonbn/components/index/index.scss @@ -1,3 +1,26 @@ -ion-item > p[slot="end"] { - font-size: var(--text-size); +:host { + --recording-details-background: var(--gray-100); + --recording-details-border: var(--gray-500); + + ion-item > p[slot="end"] { + font-size: var(--text-size); + } + + core-empty-box ::ng-deep p { + font-size: 100%; + } + + .addon-mod_bbb-recording-details { + border-top: 2px solid var(--recording-details-border); + border-bottom: 2px solid var(--recording-details-border); + + ion-item { + --background: var(--recording-details-background); + } + } +} + +:host-context(html.dark) { + --recording-details-background: var(--gray-800); + --recording-details-border: var(--gray-500); } diff --git a/src/addons/mod/bigbluebuttonbn/components/index/index.ts b/src/addons/mod/bigbluebuttonbn/components/index/index.ts index 95ad3c6c4..3b7d126cf 100644 --- a/src/addons/mod/bigbluebuttonbn/components/index/index.ts +++ b/src/addons/mod/bigbluebuttonbn/components/index/index.ts @@ -19,7 +19,10 @@ import { CoreCourseContentsPage } from '@features/course/pages/contents/contents import { IonContent } from '@ionic/angular'; import { CoreApp } from '@services/app'; import { CoreGroupInfo, CoreGroups } from '@services/groups'; +import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { AddonModBBB, AddonModBBBData, AddonModBBBMeetingInfo, AddonModBBBService } from '../../services/bigbluebuttonbn'; @@ -40,6 +43,7 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo groupInfo?: CoreGroupInfo; groupId = 0; meetingInfo?: AddonModBBBMeetingInfo; + recordings?: RecordingData[]; constructor( protected content?: IonContent, @@ -61,6 +65,10 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo return !!this.meetingInfo && (!this.meetingInfo.features || this.meetingInfo.features.showroom); } + get showRecordings(): boolean { + return !!this.meetingInfo && (!this.meetingInfo.features || this.meetingInfo.features.showrecordings); + } + /** * @inheritdoc */ @@ -79,6 +87,8 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo } await this.fetchMeetingInfo(); + + await this.fetchRecordings(); } /** @@ -113,6 +123,72 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo } } + /** + * Get recordings. + * + * @return Promise resolved when done. + */ + async fetchRecordings(): Promise { + if (!this.bbb || !this.showRecordings) { + return; + } + + const recordingsTable = await AddonModBBB.getRecordings(this.bbb.id, this.groupId, { + cmId: this.module.id, + }); + const columns = CoreUtils.arrayToObject(recordingsTable.columns, 'key'); + + this.recordings = recordingsTable.parsedData.map(recordingData => { + const playbackEl = CoreDomUtils.convertToElement(String(recordingData.playback)); + const playbackAnchor = playbackEl.querySelector('a'); + const details: RecordingDetailData[] = []; + + Object.entries(recordingData).forEach(([key, value]) => { + const columnData = columns[key]; + if (!columnData || value === '' || key === 'actionbar') { + return; + } + + if (columnData.formatter === 'customDate' && !isNaN(Number(value))) { + value = CoreTimeUtils.userDate(Number(value), 'core.strftimedaydate'); + } else if (columnData.allowHTML && typeof value === 'string') { + // If the HTML is empty, don't display it. + const valueElement = CoreDomUtils.convertToElement(value); + if (!valueElement.querySelector('img') && (valueElement.textContent ?? '').trim() === '') { + return; + } + + if (key === 'playback') { + // Remove HTML, we're only interested in the text. + value = (valueElement.textContent ?? '').trim(); + } else { + // Treat "quick edit" buttons, they aren't supported in the app. + const quickEditLink = valueElement.querySelector('.quickeditlink'); + if (quickEditLink) { + // The first span in quick edit link contains the actual HTML, use it. + value = (quickEditLink.querySelector('span')?.innerHTML ?? '').trim(); + } + } + } + + details.push({ + label: columnData.label, + value: String(value), + allowHTML: !!columnData.allowHTML, + }); + }); + + return { + type: playbackAnchor?.innerText ?? + Translate.instant('addon.mod_bigbluebuttonbn.view_recording_format_presentation'), + name: CoreTextUtils.cleanTags(String(recordingData.recording), true), + url: playbackAnchor?.href ?? '', + details, + expanded: false, + }; + }); + } + /** * @inheritdoc */ @@ -157,6 +233,7 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo if (this.bbb) { promises.push(AddonModBBB.invalidateAllGroupsMeetingInfo(this.bbb.id)); + promises.push(AddonModBBB.invalidateAllGroupsRecordings(this.bbb.id)); } await Promise.all(promises); @@ -172,6 +249,8 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo try { await this.fetchMeetingInfo(); + + await this.fetchRecordings(); } catch (error) { CoreDomUtils.showErrorModal(error); } finally { @@ -239,4 +318,46 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo } } + /** + * Toogle the visibility of a recording (expand/collapse). + * + * @param recording Recording. + */ + toggle(recording: RecordingData): void { + recording.expanded = !recording.expanded; + } + + /** + * Play a recording. + * + * @param event Click event. + * @param recording Recording. + */ + playRecording(event: MouseEvent, recording: RecordingData): void { + event.preventDefault(); + event.stopPropagation(); + + CoreSites.getCurrentSite()?.openInBrowserWithAutoLogin(recording.url); + } + } + +/** + * Recording data. + */ +type RecordingData = { + type: string; + name: string; + url: string; + expanded: boolean; + details: RecordingDetailData[]; +}; + +/** + * Recording detail data. + */ +type RecordingDetailData = { + label: string; + value: string; + allowHTML: boolean; +}; diff --git a/src/addons/mod/bigbluebuttonbn/lang.json b/src/addons/mod/bigbluebuttonbn/lang.json index 380c2a357..5a12c19d1 100644 --- a/src/addons/mod/bigbluebuttonbn/lang.json +++ b/src/addons/mod/bigbluebuttonbn/lang.json @@ -12,8 +12,12 @@ "view_message_conference_room_ready": "This room is ready. You can join the session now.", "view_message_moderator": "moderator", "view_message_moderators": "moderators", + "view_message_norecordings": "There are no recordings available.", "view_message_session_started_at": "This session started at", "view_message_viewer": "viewer", "view_message_viewers": "viewers", - "view_nojoin": "You do not have a role that is allowed to join this session." + "view_nojoin": "You do not have a role that is allowed to join this session.", + "view_recording_format_presentation": "Presentation", + "view_recording_list_action_play": "Play", + "view_section_title_recordings": "Recordings" } diff --git a/src/addons/mod/bigbluebuttonbn/services/bigbluebuttonbn.ts b/src/addons/mod/bigbluebuttonbn/services/bigbluebuttonbn.ts index 655454fc5..03b962739 100644 --- a/src/addons/mod/bigbluebuttonbn/services/bigbluebuttonbn.ts +++ b/src/addons/mod/bigbluebuttonbn/services/bigbluebuttonbn.ts @@ -19,6 +19,7 @@ import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; import { CoreCourseLogHelper } from '@features/course/services/log-helper'; import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; import { CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; import { makeSingleton, Translate } from '@singletons'; @@ -206,6 +207,71 @@ export class AddonModBBBService { return ROOT_CACHE_KEY + 'meetingInfo:' + id + ':'; } + /** + * Get meeting info for a BBB activity. + * + * @param id BBB ID. + * @param groupId Group ID, 0 means that the function will determine the user group. + * @param options Other options. + * @return Promise resolved with the list of messages. + */ + async getRecordings( + id: number, + groupId: number = 0, + options: AddonModBBBGetMeetingInfoOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModBBBGetRecordingsWSParams = { + bigbluebuttonbnid: id, + groupid: groupId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getRecordingsCacheKey(id, groupId), + component: AddonModBBBService.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const result = await site.read( + 'mod_bigbluebuttonbn_get_recordings', + params, + preSets, + ); + + if (result.warnings?.length) { + throw new CoreWSError(result.warnings[0]); + } else if (!result.tabledata) { + throw new CoreError('Cannot retrieve recordings.'); + } + + return { + ...result.tabledata, + parsedData: CoreTextUtils.parseJSON(result.tabledata?.data, []), + }; + } + + /** + * Get cache key for get recordings WS call. + * + * @param id BBB ID. + * @param groupId Group ID, 0 means that the function will determine the user group. + * @return Cache key. + */ + protected getRecordingsCacheKey(id: number, groupId: number = 0): string { + return this.getRecordingsCacheKeyPrefix(id) + groupId; + } + + /** + * Get cache key prefix for get recordings WS call. + * + * @param id BBB ID. + * @return Cache key prefix. + */ + protected getRecordingsCacheKeyPrefix(id: number): string { + return ROOT_CACHE_KEY + 'recordings:' + id + ':'; + } + /** * Report a BBB as being viewed. * @@ -271,6 +337,33 @@ export class AddonModBBBService { await site.invalidateWsCacheForKeyStartingWith(this.getMeetingInfoCacheKeyPrefix(id)); } + /** + * Invalidate recordings for a certain group. + * + * @param id BBB ID. + * @param groupId Group ID, 0 means that the function will determine the user group. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateRecordings(id: number, groupId: number = 0, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getRecordingsCacheKey(id, groupId)); + } + + /** + * Invalidate recordings for all groups. + * + * @param id BBB ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllGroupsRecordings(id: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getRecordingsCacheKeyPrefix(id)); + } + /** * Returns whether or not the BBB plugin is enabled for a certain site. * @@ -410,3 +503,48 @@ export type AddonModBBBEndMeetingWSParams = { export type AddonModBBBGetMeetingInfoOptions = CoreCourseCommonModWSOptions & { updateCache?: boolean; }; + +/** + * Params of mod_bigbluebuttonbn_get_recordings WS. + */ +export type AddonModBBBGetRecordingsWSParams = { + bigbluebuttonbnid: number; // Bigbluebuttonbn instance id. + tools?: string; // A set of enabled tools. + groupid?: number; // Group ID. +}; + +/** + * Data returned by mod_bigbluebuttonbn_get_recordings WS. + */ +export type AddonModBBBGetRecordingsWSResponse = { + status: boolean; // Whether the fetch was successful. + tabledata?: AddonModBBBRecordingsWSTableData; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Table data returned by mod_bigbluebuttonbn_get_recordings WS. + */ +export type AddonModBBBRecordingsWSTableData = { + activity: string; + ping_interval: number; // eslint-disable-line @typescript-eslint/naming-convention + locale: string; + profile_features: string[]; // eslint-disable-line @typescript-eslint/naming-convention + columns: { + key: string; + label: string; + width: string; + type?: string; // Column type. + sortable?: boolean; // Whether this column is sortable. + allowHTML?: boolean; // Whether this column contains HTML. + formatter?: string; // Formatter name. + }[]; + data: string; +}; + +/** + * Recordings table data with some calculated data. + */ +export type AddonModBBBRecordingsTableData = AddonModBBBRecordingsWSTableData & { + parsedData: Record[]; +}; diff --git a/src/addons/mod/bigbluebuttonbn/tests/behat/basic_usage.feature b/src/addons/mod/bigbluebuttonbn/tests/behat/basic_usage.feature index 5462396c2..92b32be1c 100755 --- a/src/addons/mod/bigbluebuttonbn/tests/behat/basic_usage.feature +++ b/src/addons/mod/bigbluebuttonbn/tests/behat/basic_usage.feature @@ -97,3 +97,77 @@ Feature: Test basic usage of BBB activity in app When I close all opened windows And I press "Join session" in the app Then the app should have opened a browser tab with url "blindsidenetworks.com" + + @lms_from4.1 + Scenario: Display right info based on instance type + Given the following "activities" exist: + | activity | name | course | idnumber | type | + | bigbluebuttonbn | Room & recordings | C1 | bbb1 | 0 | + | bigbluebuttonbn | Room only | C1 | bbb2 | 1 | + | bigbluebuttonbn | Recordings only | C1 | bbb3 | 2 | + And I entered the bigbluebuttonbn activity "Room & recordings" on course "Course 1" as "student1" in the app + Then I should find "This room is ready. You can join the session now." in the app + And I should be able to press "Join session" in the app + And I should find "Recordings" in the app + And I should find "There are no recordings available." in the app + + When I press the back button in the app + And I press "Room only" in the app + Then I should find "This room is ready. You can join the session now." in the app + And I should be able to press "Join session" in the app + But I should not find "Recordings" in the app + + When I press the back button in the app + And I press "Recordings only" in the app + Then I should find "Recordings" in the app + But I should not find "This room is ready. You can join the session now." in the app + And I should not be able to press "Join session" in the app + + # Test recordings requires a BBB mock server. If you're using docker, you can run the BBB mock server with this command: + # + # docker run --name bbbmockserver -p 8001:80 moodlehq/bigbluebutton_mock:latest + # + # You also need to edit the config.php of your Moodle site to add this line: + # + # define('TEST_MOD_BIGBLUEBUTTONBN_MOCK_SERVER', 'http://bbbmockserver:8001/hash' . sha1($CFG->behat_wwwroot)); + Scenario: View recordings + Given a BigBlueButton mock server is configured + And the following "activities" exist: + | activity | name | course | idnumber | type | recordings_imported | + | bigbluebuttonbn | BBB | C1 | bbb1 | 0 | 0 | + And the following "mod_bigbluebuttonbn > meeting" exists: + | activity | BBB | + And the following "mod_bigbluebuttonbn > recordings" exist: + | bigbluebuttonbn | name | description | status | + | BBB | Recording 1 | Description 1 | 3 | + | BBB | Recording 2 | Description 2 | 3 | + And I entered the bigbluebuttonbn activity "BBB" on course "Course 1" as "student1" in the app + Then I should find "Presentation" in the app + And I should find "Recording 1" in the app + And I should find "Recording 2" in the app + But I should not find "Description 1" in the app + And I should not find "Description 2" in the app + + When I press "Recording 1" in the app + Then I should find "Description 1" in the app + And I should find "Presentation" within "Playback" "ion-item" in the app + And I should find "Recording 1" within "Name" "ion-item" in the app + And I should find "Date" in the app + And I should find "3600" within "Duration" "ion-item" in the app + But I should not find "Description 2" in the app + + When I press "Recording 1" in the app + Then I should not find "Description 1" in the app + + When I press "Recording 2" in the app + Then I should find "Description 2" in the app + And I should find "Presentation" within "Playback" "ion-item" in the app + And I should find "Recording 2" within "Name" "ion-item" in the app + But I should not find "Description 1" in the app + + # Test play button, but the mock server doesn't support viewing recordings. + When I press "Play" near "Recording 1" in the app + And I press "OK" in the app + And I switch to the browser tab opened by the app + And I log in as "student1" + Then I should see "The recording URL is invalid" diff --git a/src/addons/mod/bigbluebuttonbn/tests/behat/groups.feature b/src/addons/mod/bigbluebuttonbn/tests/behat/groups.feature index 3c87f29f0..80e41fbaa 100755 --- a/src/addons/mod/bigbluebuttonbn/tests/behat/groups.feature +++ b/src/addons/mod/bigbluebuttonbn/tests/behat/groups.feature @@ -81,3 +81,29 @@ Feature: Test usage of BBB activity with groups in app And I press "Group 2" in the app Then I should find "This room is ready. You can join the session now." in the app And I should be able to press "Join session" in the app + + Scenario: View recordings + Given a BigBlueButton mock server is configured + And the following "activities" exist: + | activity | name | course | idnumber | wait | groupmode | type | recordings_imported | + | bigbluebuttonbn | Test BBB | C1 | bbb1 | 0 | 2 | 0 | 0 | + And the following "mod_bigbluebuttonbn > meeting" exists: + | activity | Test BBB | + And the following "mod_bigbluebuttonbn > meetings" exist: + | activity | group | + | Test BBB | G1 | + | Test BBB | G2 | + And the following "mod_bigbluebuttonbn > recordings" exist: + | bigbluebuttonbn | name | description | status | group | + | Test BBB | Recording 1 | Description 1 | 3 | G1 | + | Test BBB | Recording 2 | Description 2 | 3 | G2 | + And I entered the bigbluebuttonbn activity "Test BBB" on course "Course 1" as "student1" in the app + When I press "Visible groups" in the app + And I press "Group 1" in the app + Then I should find "Recording 1" in the app + But I should not find "Recording 2" in the app + + When I press "Visible groups" in the app + And I press "Group 2" in the app + Then I should find "Recording 2" in the app + But I should not find "Recording 1" in the app diff --git a/src/core/directives/collapsible-footer.ts b/src/core/directives/collapsible-footer.ts index 173d0ac44..035a845f5 100644 --- a/src/core/directives/collapsible-footer.ts +++ b/src/core/directives/collapsible-footer.ts @@ -38,6 +38,7 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { @Input() appearOnBottom = false; + protected id = '0'; protected element: HTMLElement; protected initialHeight = 48; protected finalHeight = 0; @@ -63,6 +64,8 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { * @inheritdoc */ async ngOnInit(): Promise { + this.id = String(CoreUtils.getUniqueId('CoreCollapsibleFooterDirective')); + // Only if not present or explicitly falsy it will be false. this.appearOnBottom = !CoreUtils.isFalseOrZero(this.appearOnBottom); this.slotPromise = CoreDom.slotOnContent(this.element); @@ -72,6 +75,7 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { await this.waitFormatTextsRendered(); this.content = this.element.closest('ion-content'); + this.content?.setAttribute('data-collapsible-footer-id', this.id); await this.calculateHeight(); @@ -242,14 +246,21 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { * @inheritdoc */ async ngOnDestroy(): Promise { - this.content?.style.setProperty('--padding-bottom', this.initialPaddingBottom); + if (this.content) { + // Only reset the variables and classes if the collapsible footer hasn't changed (avoid race conditions). + if (this.content.getAttribute('data-collapsible-footer-id') === this.id) { + this.content.style.setProperty('--padding-bottom', this.initialPaddingBottom); + this.content.classList.remove('has-collapsible-footer'); + } - if (this.content && this.contentScrollListener) { - this.content.removeEventListener('ionScroll', this.contentScrollListener); - } - if (this.content && this.endContentScrollListener) { - this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener); + if (this.contentScrollListener) { + this.content.removeEventListener('ionScroll', this.contentScrollListener); + } + if (this.endContentScrollListener) { + this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener); + } } + if (this.page && this.pageDidEnterListener) { this.page.removeEventListener('ionViewDidEnter', this.pageDidEnterListener); }