// (C) Copyright 2015 Martin Dougiamas // // 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. import { Component, Optional, OnDestroy, ViewChild } from '@angular/core'; import { IonicPage, NavParams, Content } from 'ionic-angular'; import { Network } from '@ionic-native/network'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '@providers/app'; import { CoreEventsProvider } from '@providers/events'; import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { AddonModForumProvider } from '../../providers/forum'; import { AddonModForumOfflineProvider } from '../../providers/offline'; import { AddonModForumHelperProvider } from '../../providers/helper'; import { AddonModForumSyncProvider } from '../../providers/sync'; type SortType = 'flat-newest' | 'flat-oldest' | 'nested'; /** * Page that displays a forum discussion. */ @IonicPage({ segment: 'addon-mod-forum-discussion' }) @Component({ selector: 'page-addon-mod-forum-discussion', templateUrl: 'discussion.html', }) export class AddonModForumDiscussionPage implements OnDestroy { @ViewChild(Content) content: Content; courseId: number; discussionId: number; forum: any; discussion: any; posts: any[]; discussionLoaded = false; defaultSubject: string; isOnline: boolean; isSplitViewOn: boolean; locked: boolean; postHasOffline: boolean; sort: SortType = 'flat-oldest'; trackPosts: boolean; replyData = { replyingTo: 0, isEditing: false, subject: '', message: null, // Null means empty or just white space. files: [], }; originalData = { subject: null, // Null means original data is not set. message: null, // Null means empty or just white space. files: [], }; refreshIcon = 'spinner'; syncIcon = 'spinner'; protected cmId: number; protected forumId: number; protected onlineObserver: any; protected syncObserver: any; protected syncManualObserver: any; constructor(navParams: NavParams, network: Network, private appProvider: CoreAppProvider, private eventsProvider: CoreEventsProvider, private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, private utils: CoreUtilsProvider, private translate: TranslateService, private uploaderProvider: CoreFileUploaderProvider, private forumProvider: AddonModForumProvider, private forumOffline: AddonModForumOfflineProvider, private forumHelper: AddonModForumHelperProvider, private forumSync: AddonModForumSyncProvider, @Optional() private svComponent: CoreSplitViewComponent) { this.courseId = navParams.get('courseId'); this.cmId = navParams.get('cmId'); this.forumId = navParams.get('forumId'); this.discussionId = navParams.get('discussionId'); this.trackPosts = navParams.get('trackPosts'); this.locked = navParams.get('locked'); this.isOnline = this.appProvider.isOnline(); this.onlineObserver = network.onchange().subscribe((online) => { this.isOnline = this.appProvider.isOnline(); }); this.isSplitViewOn = this.svComponent && this.svComponent.isOn(); } /** * View loaded. */ ionViewDidLoad(): void { this.fetchPosts(true, false, true); } /** * User entered the page that contains the component. */ ionViewDidEnter(): void { // Refresh data if this discussion is synchronized automatically. this.syncObserver = this.eventsProvider.on(AddonModForumSyncProvider.AUTO_SYNCED, (data) => { if (data.forumId == this.forumId && this.discussionId == data.discussionId && data.userId == this.sitesProvider.getCurrentSiteUserId()) { // Refresh the data. this.discussionLoaded = false; this.refreshPosts(); } }, this.sitesProvider.getCurrentSiteId()); // Refresh data if this forum discussion is synchronized from discussions list. this.syncManualObserver = this.eventsProvider.on(AddonModForumSyncProvider.MANUAL_SYNCED, (data) => { if (data.source != 'discussion' && data.forumId == this.forumId && data.userId == this.sitesProvider.getCurrentSiteUserId()) { // Refresh the data. this.discussionLoaded = false; this.refreshPosts(); } }, this.sitesProvider.getCurrentSiteId()); // Trigger view event, to highlight the current opened discussion in the split view. this.eventsProvider.trigger(AddonModForumProvider.VIEW_DISCUSSION_EVENT, { forumId: this.forumId, discussion: this.discussionId, }, this.sitesProvider.getCurrentSiteId()); } /** * Check if we can leave the page or not. * * @return {boolean|Promise} Resolved if we can leave it, rejected if not. */ ionViewCanLeave(): boolean | Promise { let promise: any; if (this.forumHelper.hasPostDataChanged(this.replyData, this.originalData)) { // Show confirmation if some data has been modified. promise = this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit')); } else { promise = Promise.resolve(); } return promise.then(() => { // Delete the local files from the tmp folder. this.uploaderProvider.clearTmpFiles(this.replyData.files); }); } /** * Convenience function to get the forum. * * @return {Promise} Promise resolved with the forum. */ protected fetchForum(): Promise { if (this.courseId && this.cmId) { return this.forumProvider.getForum(this.courseId, this.cmId); } else if (this.courseId && this.forumId) { return this.forumProvider.getForumById(this.courseId, this.forumId); } else { // Cannot get the forum. return Promise.reject(null); } } /** * Convenience function to get forum discussions. * * @param {boolean} [sync] Whether to try to synchronize the discussion. * @param {boolean} [showErrors] Whether to show errors in a modal. * @param {boolean} [forceMarkAsRead] Whether to mark all posts as read. * @return {Promise} Promise resolved when done. */ protected fetchPosts(sync?: boolean, showErrors?: boolean, forceMarkAsRead?: boolean): Promise { let syncPromise; if (sync) { // Try to synchronize the forum. syncPromise = this.syncDiscussion(showErrors).catch(() => { // Ignore errors. }); } else { syncPromise = Promise.resolve(); } let onlinePosts = []; const offlineReplies = []; let hasUnreadPosts = false; return syncPromise.then(() => { return this.forumProvider.getDiscussionPosts(this.discussionId).then((posts) => { onlinePosts = posts; }).then(() => { // Check if there are responses stored in offline. return this.forumOffline.getDiscussionReplies(this.discussionId).then((replies) => { this.postHasOffline = !!replies.length; const convertPromises = []; // Index posts to allow quick access. Also check unread field. const posts = {}; onlinePosts.forEach((post) => { posts[post.id] = post; hasUnreadPosts = hasUnreadPosts || !post.postread; }); replies.forEach((offlineReply) => { // If we don't have forumId and courseId, get it from the post. if (!this.forumId) { this.forumId = offlineReply.forumid; } if (!this.courseId) { this.courseId = offlineReply.courseid; } convertPromises.push(this.forumHelper.convertOfflineReplyToOnline(offlineReply).then((reply) => { offlineReplies.push(reply); // Disable reply of the parent. Reply in offline to the same post is not allowed, edit instead. posts[reply.parent].canreply = false; })); }); return Promise.all(convertPromises).then(() => { // Convert back to array. onlinePosts = this.utils.objectToArray(posts); }); }); }); }).then(() => { const posts = offlineReplies.concat(onlinePosts); this.discussion = this.forumProvider.extractStartingPost(posts); // If sort type is nested, normal sorting is disabled and nested posts will be displayed. if (this.sort == 'nested') { // Sort first by creation date to make format tree work. this.forumProvider.sortDiscussionPosts(posts, 'ASC'); this.posts = this.utils.formatTree(posts, 'parent', 'id', this.discussion.id); } else { // Set default reply subject. const direction = this.sort == 'flat-newest' ? 'DESC' : 'ASC'; this.forumProvider.sortDiscussionPosts(posts, direction); this.posts = posts; } this.defaultSubject = this.translate.instant('addon.mod_forum.re') + ' ' + this.discussion.subject; this.replyData.subject = this.defaultSubject; // Now try to get the forum. return this.fetchForum().then((forum) => { if (this.discussion.userfullname && this.discussion.parent == 0 && forum.type == 'single') { // Hide author for first post and type single. this.discussion.userfullname = null; } // "forum.istracked" is more reliable than "trackPosts". if (typeof forum.istracked != 'undefined') { this.trackPosts = forum.istracked; } this.forumId = forum.id; this.cmId = forum.cmid; this.forum = forum; }).catch(() => { // Ignore errors. this.forum = {}; }); }).catch((message) => { this.domUtils.showErrorModal(message); }).finally(() => { this.discussionLoaded = true; this.refreshIcon = 'refresh'; this.syncIcon = 'sync'; if (forceMarkAsRead || (hasUnreadPosts && this.trackPosts)) { // // Add log in Moodle and mark unread posts as readed. this.forumProvider.logDiscussionView(this.discussionId).catch(() => { // Ignore errors. }); } }); } /** * Tries to synchronize the posts discussion. * * @param {boolean} showErrors Whether to show errors in a modal. * @return {Promise} Promise resolved when done. */ protected syncDiscussion(showErrors: boolean): Promise { return this.forumSync.syncDiscussionReplies(this.discussionId).then((result) => { if (result.warnings && result.warnings.length) { this.domUtils.showErrorModal(result.warnings[0]); } if (result && result.updated) { // Sync successful, send event. this.eventsProvider.trigger(AddonModForumSyncProvider.MANUAL_SYNCED, { forumId: this.forumId, userId: this.sitesProvider.getCurrentSiteUserId(), source: 'discussion' }, this.sitesProvider.getCurrentSiteId()); } return result.updated; }).catch((error) => { if (showErrors) { this.domUtils.showErrorModalDefault(error, 'core.errorsync', true); } return Promise.reject(null); }); } /** * Refresh the data. * * @param {any} [refresher] Refresher. * @param {Function} [done] Function to call when done. * @param {boolean} [showErrors=false] If show errors to the user of hide them. * @return {Promise} Promise resolved when done. */ doRefresh(refresher?: any, done?: () => void, showErrors: boolean = false): Promise { if (this.discussionLoaded) { return this.refreshPosts(true, showErrors).finally(() => { refresher && refresher.complete(); done && done(); }); } return Promise.resolve(); } /** * Refresh posts. * * @param {boolean} [sync] Whether to try to synchronize the discussion. * @param {boolean} [showErrors] Whether to show errors in a modal. * @return {Promise} Promise resolved when done. */ refreshPosts(sync?: boolean, showErrors?: boolean): Promise { this.content && this.content.scrollToTop(); this.refreshIcon = 'spinner'; this.syncIcon = 'spinner'; return this.forumProvider.invalidateDiscussionPosts(this.discussionId).catch(() => { // Ignore errors. }).then(() => { return this.fetchPosts(sync, showErrors); }); } /** * Function to change posts sorting * * @param {SortType} type Sort type. * @return {Promise} Promised resolved when done. */ changeSort(type: SortType): Promise { this.discussionLoaded = false; this.sort = type; this.content && this.content.scrollToTop(); return this.fetchPosts(); } /** * New post added. */ postListChanged(): void { // Trigger an event to notify a new reply. const data = { forumId: this.forumId, discussionId: this.discussionId, cmId: this.cmId }; this.eventsProvider.trigger(AddonModForumProvider.REPLY_DISCUSSION_EVENT, data, this.sitesProvider.getCurrentSiteId()); this.discussionLoaded = false; this.refreshPosts().finally(() => { this.discussionLoaded = true; }); } /** * Runs when the page is about to leave and no longer be the active page. */ ionViewWillLeave(): void { this.syncObserver && this.syncObserver.off(); this.syncManualObserver && this.syncManualObserver.off(); } /** * Page destroyed. */ ngOnDestroy(): void { this.onlineObserver && this.onlineObserver.unsubscribe(); } }