diff --git a/src/addon/mod/forum/components/components.module.ts b/src/addon/mod/forum/components/components.module.ts new file mode 100644 index 000000000..e9b656899 --- /dev/null +++ b/src/addon/mod/forum/components/components.module.ts @@ -0,0 +1,50 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { CoreCourseComponentsModule } from '@core/course/components/components.module'; +import { AddonModForumIndexComponent } from './index/index'; +import { AddonModForumPostComponent } from './post/post'; + +@NgModule({ + declarations: [ + AddonModForumIndexComponent, + AddonModForumPostComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + CoreCourseComponentsModule + ], + providers: [ + ], + exports: [ + AddonModForumIndexComponent, + AddonModForumPostComponent + ], + entryComponents: [ + AddonModForumIndexComponent + ] +}) +export class AddonModForumComponentsModule {} diff --git a/src/addon/mod/forum/components/index/index.html b/src/addon/mod/forum/components/index/index.html new file mode 100644 index 000000000..f7eb42ee1 --- /dev/null +++ b/src/addon/mod/forum/components/index/index.html @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate:{$a: moduleName} }} + + + +
+ +
+ + + + + +

{{discussion.subject}}

+

+ {{ 'core.notsent' | translate }} + {{discussion.userfullname}} +

+
+ + + {{ discussion.groupname }} + +

+
+
+ + + + + +

{{discussion.subject}}

+

+ + {{discussion.created | coreDateDayOrTime}} +

{{ 'addon.mod_forum.unreadpostsnumber' | translate:{ '$a' : discussion.numunread} }}
+ + {{discussion.userfullname}} +

+
+ + + + + + + {{ discussion.groupname }} + + + + + {{ 'addon.mod_forum.numreplies' | translate:{numreplies: discussion.numreplies} }} + + + + + {{discussion.timemodified | coreTimeAgo}} + + + +
+
+ + +
+ +
+
+ + + + +
+
+
diff --git a/src/addon/mod/forum/components/index/index.scss b/src/addon/mod/forum/components/index/index.scss new file mode 100644 index 000000000..62b648d10 --- /dev/null +++ b/src/addon/mod/forum/components/index/index.scss @@ -0,0 +1,5 @@ +addon-mod-forum-index { + .addon-forum-discussion-selected { + border-top: 5px solid $core-color-light; + } +} diff --git a/src/addon/mod/forum/components/index/index.ts b/src/addon/mod/forum/components/index/index.ts new file mode 100644 index 000000000..caae5c8d7 --- /dev/null +++ b/src/addon/mod/forum/components/index/index.ts @@ -0,0 +1,440 @@ +// (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, Injector, ViewChild } from '@angular/core'; +import { Content, NavController } from 'ionic-angular'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; +import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { CoreGroupsProvider } from '@providers/groups'; +import { AddonModForumProvider } from '../../providers/forum'; +import { AddonModForumHelperProvider } from '../../providers/helper'; +import { AddonModForumOfflineProvider } from '../../providers/offline'; +import { AddonModForumSyncProvider } from '../../providers/sync'; +import { AddonModForumPrefetchHandler } from '../../providers/prefetch-handler'; + +/** + * Component that displays a forum entry page. + */ +@Component({ + selector: 'addon-mod-forum-index', + templateUrl: 'index.html', +}) +export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityComponent { + @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; + + component = AddonModForumProvider.COMPONENT; + moduleName = 'forum'; + + descriptionNote: string; + forum: any; + canLoadMore = false; + discussions = []; + offlineDiscussions = []; + selectedDiscussion = 0; // Disucssion ID or negative timecreated if it's an offline discussion. + addDiscussionText = this.translate.instant('addon.mod_forum.addanewdiscussion'); + + protected syncEventName = AddonModForumSyncProvider.AUTO_SYNCED; + protected page = 0; + protected trackPosts = false; + protected usesGroups = false; + protected syncManualObserver: any; // It will observe the sync manual event. + protected replyObserver: any; + protected newDiscObserver: any; + protected viewDiscObserver: any; + + constructor(injector: Injector, + @Optional() protected content: Content, + protected navCtrl: NavController, + protected groupsProvider: CoreGroupsProvider, + protected userProvider: CoreUserProvider, + protected forumProvider: AddonModForumProvider, + protected forumHelper: AddonModForumHelperProvider, + protected forumOffline: AddonModForumOfflineProvider, + protected forumSync: AddonModForumSyncProvider, + protected prefetchDelegate: CoreCourseModulePrefetchDelegate, + protected prefetchHandler: AddonModForumPrefetchHandler) { + super(injector); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + super.ngOnInit(); + + // Refresh data if this forum discussion is synchronized from discussions list. + this.syncManualObserver = this.eventsProvider.on(AddonModForumSyncProvider.MANUAL_SYNCED, (data) => { + this.autoSyncEventReceived(data); + }, this.siteId); + + // Listen for discussions added. When a discussion is added, we reload the data. + this.newDiscObserver = this.eventsProvider.on(AddonModForumProvider.NEW_DISCUSSION_EVENT, this.eventReceived.bind(this)); + this.replyObserver = this.eventsProvider.on(AddonModForumProvider.REPLY_DISCUSSION_EVENT, this.eventReceived.bind(this)); + + // Select the curren opened discussion. + this.viewDiscObserver = this.eventsProvider.on(AddonModForumProvider.VIEW_DISCUSSION_EVENT, (data) => { + if (this.forum && this.forum.id == data.forumId) { + this.selectedDiscussion = this.splitviewCtrl.isOn() ? data.discussion : 0; + } + }, this.sitesProvider.getCurrentSiteId()); + + this.loadContent(false, true).then(() => { + if (!this.forum) { + return; + } + + if (this.splitviewCtrl.isOn()) { + // Load the first discussion. + if (this.offlineDiscussions.length > 0) { + this.openNewDiscussion(this.offlineDiscussions[0].timecreated); + } else if (this.discussions.length > 0) { + this.openDiscussion(this.discussions[0]); + } + } + + this.forumProvider.logView(this.forum.id).then(() => { + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + }).catch((error) => { + // Ignore errors. + }); + }); + } + + /** + * Download the component contents. + * + * @param {boolean} [refresh=false] Whether we're refreshing data. + * @param {boolean} [sync=false] If the refresh needs syncing. + * @param {boolean} [showErrors=false] Wether to show errors to the user or hide them. + * @return {Promise} Promise resolved when done. + */ + protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + return this.forumProvider.getForum(this.courseId, this.module.id).then((forum) => { + this.forum = forum; + + this.description = forum.intro || this.description; + this.descriptionNote = this.translate.instant('addon.mod_forum.numdiscussions', {numdiscussions: forum.numdiscussions}); + if (typeof forum.istracked != 'undefined') { + this.trackPosts = forum.istracked; + } + + this.dataRetrieved.emit(forum); + + switch (forum.type) { + case 'news': + case 'blog': + this.addDiscussionText = this.translate.instant('addon.mod_forum.addanewtopic'); + break; + case 'qanda': + this.addDiscussionText = this.translate.instant('addon.mod_forum.addanewquestion'); + break; + default: + this.addDiscussionText = this.translate.instant('addon.mod_forum.addanewdiscussion'); + } + + if (sync) { + // Try to synchronize the forum. + return this.syncActivity(showErrors).then((updated) => { + if (updated) { + // Sync successful, send event. + this.eventsProvider.trigger(AddonModForumSyncProvider.MANUAL_SYNCED, { + forumId: forum.id, + userId: this.sitesProvider.getCurrentSiteUserId(), + source: 'index', + }, this.sitesProvider.getCurrentSiteId()); + } + }); + } + }).then(() => { + // Check if the activity uses groups. + return this.groupsProvider.getActivityGroupMode(this.forum.cmid).then((mode) => { + this.usesGroups = (mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS); + }); + }).then(() => { + return Promise.all([ + this.fetchOfflineDiscussion(), + this.fetchDiscussions(refresh), + ]); + }).catch((message) => { + if (!refresh) { + // Get forum failed, retry without using cache since it might be a new activity. + return this.refreshContent(sync); + } + + this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.errorgetforum', true); + + this.canLoadMore = false; // Set to false to prevent infinite calls with infinite-loading. + }); + } + + /** + * Convenience function to fetch offline discussions. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchOfflineDiscussion(): Promise { + return this.forumOffline.getNewDiscussions(this.forum.id).then((offlineDiscussions) => { + this.hasOffline = !!offlineDiscussions.length; + + if (this.hasOffline) { + let promise; + if (this.usesGroups) { + promise = this.forumProvider.formatDiscussionsGroups(this.forum.cmid, offlineDiscussions); + } else { + promise = Promise.resolve(offlineDiscussions); + } + + return promise.then((offlineDiscussions) => { + // Fill user data for Offline discussions (should be already cached). + const userPromises = []; + offlineDiscussions.forEach((discussion) => { + if (discussion.parent != 0 || this.forum.type != 'single') { + // Do not show author for first post and type single. + userPromises.push(this.userProvider.getProfile(discussion.userid, this.courseId, true) + .then((user) => { + discussion.userfullname = user.fullname; + discussion.userpictureurl = user.profileimageurl; + }).catch(() => { + // Ignore errors. + })); + } + }); + + return Promise.all(userPromises).then(() => { + // Sort discussion by time (newer first). + offlineDiscussions.sort((a, b) => b.timecreated - a.timecreated); + + this.offlineDiscussions = offlineDiscussions; + }); + }); + } else { + this.offlineDiscussions = []; + } + }); + } + + /** + * Convenience function to get forum discussions. + * + * @param {boolean} refresh Whether we're refreshing data. + * @return {Promise} Promise resolved when done. + */ + protected fetchDiscussions(refresh: boolean): Promise { + if (refresh) { + this.page = 0; + } + + return this.forumProvider.getDiscussions(this.forum.id, this.page).then((response) => { + let promise; + if (this.usesGroups) { + promise = this.forumProvider.formatDiscussionsGroups(this.forum.cmid, response.discussions); + } else { + promise = Promise.resolve(response.discussions); + } + + return promise.then((discussions) => { + if (this.forum.type == 'single') { + // Hide author for first post and type single. + for (const x in discussions) { + if (discussions[x].userfullname && discussions[x].parent == 0) { + discussions[x].userfullname = false; + break; + } + } + } + + if (typeof this.forum.istracked == 'undefined' && !this.trackPosts) { + // If any discussion has unread posts, the whole forum is being tracked. + for (const y in discussions) { + if (discussions[y].numunread > 0) { + this.trackPosts = true; + break; + } + } + } + + if (this.page == 0) { + this.discussions = discussions; + } else { + this.discussions = this.discussions.concat(discussions); + } + + this.canLoadMore = response.canLoadMore; + this.page++; + + // Check if there are replies for discussions stored in offline. + return this.forumOffline.hasForumReplies(this.forum.id).then((hasOffline) => { + const offlinePromises = []; + this.hasOffline = this.hasOffline || hasOffline; + + if (hasOffline) { + // Only update new fetched discussions. + discussions.forEach((discussion) => { + // Get offline discussions. + offlinePromises.push(this.forumOffline.getDiscussionReplies(discussion.discussion).then((replies) => { + discussion.numreplies = parseInt(discussion.numreplies, 10) + replies.length; + })); + }); + } + + return Promise.all(offlinePromises); + }); + }); + }); + } + + /** + * Convenience function to load more forum discussions. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchMoreDiscussions(): Promise { + return this.fetchDiscussions(false).catch((message) => { + this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.errorgetforum', true); + + this.canLoadMore = false; // Set to false to prevent infinite calls with infinite-loading. + }); + } + + /** + * Perform the invalidate content function. + * + * @return {Promise} Resolved when done. + */ + protected invalidateContent(): Promise { + const promises = []; + + promises.push(this.forumProvider.invalidateForumData(this.courseId)); + + if (this.forum) { + promises.push(this.forumProvider.invalidateDiscussionsList(this.forum.id)); + promises.push(this.groupsProvider.invalidateActivityGroupMode(this.forum.cmid)); + } + + return Promise.all(promises); + } + + /** + * Performs the sync of the activity. + * + * @return {Promise} Promise resolved when done. + */ + protected sync(): Promise { + const promises = []; + + promises.push(this.forumSync.syncForumDiscussions(this.forum.id).then((result) => { + if (result.warnings && result.warnings.length) { + this.domUtils.showErrorModal(result.warnings[0]); + } + + return result; + })); + + promises.push(this.forumSync.syncForumReplies(this.forum.id).then((result) => { + if (result.warnings && result.warnings.length) { + this.domUtils.showErrorModal(result.warnings[0]); + } + + return result; + })); + + return Promise.all(promises).then((results) => { + return results.reduce((a, b) => ({ + updated: a.updated || b.updated, + warnings: (a.warnings || []).concat(b.warnings || []), + }), {updated: false}); + }); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param {any} result Data returned on the sync function. + * @return {boolean} Whether it succeed or not. + */ + protected hasSyncSucceed(result: any): boolean { + return result.updated; + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param {any} syncEventData Data receiven on sync observer. + * @return {boolean} True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: any): boolean { + return this.forum && syncEventData.source != 'index' && syncEventData.forumId == this.forum.id && + syncEventData.userId == this.sitesProvider.getCurrentSiteUserId(); + } + + /** + * Function called when we receive an event of new discussion or reply to discussion. + * + * @param {any} data Event data. + */ + protected eventReceived(data: any): void { + if ((this.forum && this.forum.id === data.forumId) || data.cmId === this.module.id) { + this.showLoadingAndRefresh(false); + + // Check completion since it could be configured to complete once the user adds a new discussion or replies. + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + } + } + + /** + * Opens a discussion. + * + * @param {any} discussion Discussion object. + */ + openDiscussion(discussion: any): void { + const params = { + courseId: this.courseId, + cmId: this.module.id, + forumId: this.forum.id, + discussionId: discussion.discussion, + trackPosts: this.trackPosts, + locked: discussion.locked && !discussion.canreply + }; + this.splitviewCtrl.push('AddonModForumDiscussionPage', params); + } + + /** + * Opens the new discussion form. + * + * @param {number} [timeCreated=0] Creation time of the offline discussion. + */ + openNewDiscussion(timeCreated: number = 0): void { + const params = { + courseId: this.courseId, + cmId: this.module.id, + forumId: this.forum.id, + timeCreated: timeCreated, + }; + this.splitviewCtrl.push('AddonModForumNewDiscussionPage', params); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + + this.syncManualObserver && this.syncManualObserver.off(); + this.newDiscObserver && this.newDiscObserver.off(); + this.replyObserver && this.replyObserver.off(); + this.viewDiscObserver && this.viewDiscObserver.off(); + } +} diff --git a/src/addon/mod/forum/components/post/post.html b/src/addon/mod/forum/components/post/post.html new file mode 100644 index 000000000..3e89f3252 --- /dev/null +++ b/src/addon/mod/forum/components/post/post.html @@ -0,0 +1,61 @@ + + + + +

{{post.subject}}

+

+ {{ 'core.notsent' | translate }} + + {{post.modified | coreDateDayOrTime}} +

{{ 'addon.mod_forum.unread' | translate }}
+ + {{post.userfullname}} +

+
+ + +
+ + + + +
+
+ + + + + + + + + {{ 'addon.mod_forum.subject' | translate }} + + + + {{ 'addon.mod_forum.message' | translate }} + + + + + + + + + + + + + + + + + + + + diff --git a/src/addon/mod/forum/components/post/post.ts b/src/addon/mod/forum/components/post/post.ts new file mode 100644 index 000000000..528b10974 --- /dev/null +++ b/src/addon/mod/forum/components/post/post.ts @@ -0,0 +1,313 @@ +// (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, Input, Output, Optional, EventEmitter, OnInit, OnDestroy } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { NavController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { AddonModForumProvider } from '../../providers/forum'; +import { AddonModForumHelperProvider } from '../../providers/helper'; +import { AddonModForumOfflineProvider } from '../../providers/offline'; +import { AddonModForumSyncProvider } from '../../providers/sync'; + +/** + * Components that shows a discussion post, its attachments and the action buttons allowed (reply, etc.). + */ +@Component({ + selector: 'addon-mod-forum-post', + templateUrl: 'post.html', +}) +export class AddonModForumPostComponent implements OnInit, OnDestroy { + + @Input() post: any; // Post. + @Input() courseId: number; // Post's course ID. + @Input() discussionId: number; // Post's' discussion ID. + @Input() component: string; // Component this post belong to. + @Input() componentId: number; // Component ID. + @Input() replyData: any; // Object with the new post data. Usually shared between posts. + @Input() originalData: any; // Object with the original post data. Usually shared between posts. + @Input() trackPosts: boolean; // True if post is being tracked. + @Input() forum: any; // The forum the post belongs to. Required for attachments and offline posts. + @Input() defaultSubject: string; // Default subject to set to new posts. + @Output() onPostChange: EventEmitter; // Event emitted when a reply is posted or modified. + + messageControl = new FormControl(); + + uniqueId: string; + + protected syncId: string; + + constructor( + private navCtrl: NavController, + private uploaderProvider: CoreFileUploaderProvider, + private syncProvider: CoreSyncProvider, + private domUtils: CoreDomUtilsProvider, + private textUtils: CoreTextUtilsProvider, + private translate: TranslateService, + private forumProvider: AddonModForumProvider, + private forumHelper: AddonModForumHelperProvider, + private forumOffline: AddonModForumOfflineProvider, + private forumSync: AddonModForumSyncProvider, + @Optional() private svComponent: CoreSplitViewComponent) { + this.onPostChange = new EventEmitter(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.uniqueId = this.post.id ? 'reply' + this.post.id : 'edit' + this.post.parent; + } + + /** + * Opens the profile of a user. + * + * @param {number} userId + */ + openUserProfile(userId: number): void { + // Decide which navCtrl to use. If this page is inside a split view, use the split view's master nav. + const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + navCtrl.push('CoreUserProfilePage', {userId, courseId: this.courseId}); + } + + /** + * Set data to new post, clearing temporary files and updating original data. + * + * @param {number} [replyingTo] Id of post beeing replied. + * @param {boolean} [isEditing] True it's an offline reply beeing edited, false otherwise. + * @param {string} [subject] Subject of the reply. + * @param {string} [message] Message of the reply. + * @param {any[]} [files] Reply attachments. + */ + protected setReplyData(replyingTo?: number, isEditing?: boolean, subject?: string, message?: string, files?: any[]): void { + // Delete the local files from the tmp folder if any. + this.uploaderProvider.clearTmpFiles(this.replyData.files); + + this.replyData.replyingTo = replyingTo || 0; + this.replyData.isEditing = !!isEditing; + this.replyData.subject = subject || this.defaultSubject || ''; + this.replyData.message = message || null; + this.replyData.files = files || []; + + // Update rich text editor. + this.messageControl.setValue(this.replyData.message); + + // Update original data. + this.originalData.subject = this.replyData.subject; + this.originalData.message = this.replyData.message; + this.originalData.files = this.replyData.files.slice(); + } + + /** + * Set this post as being replied to. + */ + showReply(): void { + if (this.replyData.isEditing) { + // User is editing a post, data needs to be resetted. Ask confirm if there is unsaved data. + this.confirmDiscard().then(() => { + this.setReplyData(this.post.id); + }).catch(() => { + // Cancelled. + }); + } else if (!this.replyData.replyingTo) { + // User isn't replying, it's a brand new reply. Initialize the data. + this.setReplyData(this.post.id); + } else { + // The post being replied has changed but the data will be kept. + this.replyData.replyingTo = this.post.id; + this.messageControl.setValue(this.replyData.message); + } + } + + /** + * Set this post as being edited to. + */ + editReply(): void { + // Ask confirm if there is unsaved data. + this.confirmDiscard().then(() => { + this.syncId = this.forumSync.getDiscussionSyncId(this.discussionId); + this.syncProvider.blockOperation(AddonModForumProvider.COMPONENT, this.syncId); + + this.setReplyData(this.post.parent, true, this.post.subject, this.post.message, this.post.attachments); + }).catch(() => { + // Cancelled. + }); + } + + /** + * Message changed. + * + * @param {string} text The new text. + */ + onMessageChange(text: string): void { + this.replyData.message = text; + } + + /** + * Reply to this post. + */ + reply(): void { + if (!this.replyData.subject) { + this.domUtils.showErrorModal('addon.mod_forum.erroremptysubject', true); + + return; + } + + if (!this.replyData.message) { + this.domUtils.showErrorModal('addon.mod_forum.erroremptymessage', true); + + return; + } + + let saveOffline = false; + let message = this.replyData.message; + const subject = this.replyData.subject; + const replyingTo = this.replyData.replyingTo; + const files = this.replyData.files || []; + const options: any = {}; + const modal = this.domUtils.showModalLoading('core.sending', true); + + // Check if rich text editor is enabled or not. + this.domUtils.isRichTextEditorEnabled().then((enabled) => { + if (!enabled) { + // Rich text editor not enabled, add some HTML to the message if needed. + message = this.textUtils.formatHtmlLines(message); + } + + // Upload attachments first if any. + if (files.length) { + return this.forumHelper.uploadOrStoreReplyFiles(this.forum.id, replyingTo, files, false).catch((error) => { + // Cannot upload them in online, save them in offline. + if (!this.forum.id) { + // Cannot store them in offline without the forum ID. Reject. + return Promise.reject(error); + } + + saveOffline = true; + + return this.forumHelper.uploadOrStoreReplyFiles(this.forum.id, replyingTo, files, true); + }); + } + }).then((attach) => { + if (attach) { + options.attachmentsid = attach; + } + + if (saveOffline) { + // Save post in offline. + return this.forumOffline.replyPost(replyingTo, this.discussionId, this.forum.id, this.forum.name, + this.courseId, subject, message, options).then(() => { + // Return false since it wasn't sent to server. + return false; + }); + } else { + // Try to send it to server. + // Don't allow offline if there are attachments since they were uploaded fine. + return this.forumProvider.replyPost(replyingTo, this.discussionId, this.forum.id, this.forum.name, + this.courseId, subject, message, options, undefined, !files.length); + } + }).then((sent) => { + if (sent && this.forum.id) { + // Data sent to server, delete stored files (if any). + this.forumHelper.deleteReplyStoredFiles(this.forum.id, replyingTo); + } + + // Reset data. + this.setReplyData(); + + this.onPostChange.emit(); + + if (this.syncId) { + this.syncProvider.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId); + } + }).catch((message) => { + this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.couldnotadd', true); + }).finally(() => { + modal.dismiss(); + }); + } + + /** + * Cancel reply. + */ + cancel(): void { + this.confirmDiscard().then(() => { + // Reset data. + this.setReplyData(); + + if (this.syncId) { + this.syncProvider.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId); + } + }).catch(() => { + // Cancelled. + }); + } + + /** + * Discard offline reply. + */ + discard(): void { + this.domUtils.showConfirm(this.translate.instant('core.areyousure')).then(() => { + const promises = []; + + promises.push(this.forumOffline.deleteReply(this.post.parent)); + if (this.forum.id) { + promises.push(this.forumHelper.deleteReplyStoredFiles(this.forum.id, this.post.parent).catch(() => { + // Ignore errors, maybe there are no files. + })); + } + + return Promise.all(promises).finally(() => { + // Reset data. + this.setReplyData(); + + this.onPostChange.emit(); + + if (this.syncId) { + this.syncProvider.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId); + } + }); + }).catch(() => { + // Cancelled. + }); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + if (this.syncId) { + this.syncProvider.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId); + } + } + + /** + * Confirm discard changes if any. + * + * @return {Promise} Promise resolved if the user confirms or data was not changed and rejected otherwise. + */ + protected confirmDiscard(): Promise { + if (this.forumHelper.hasPostDataChanged(this.replyData, this.originalData)) { + // Show confirmation if some data has been modified. + return this.domUtils.showConfirm(this.translate.instant('core.confirmloss')); + } else { + return Promise.resolve(); + } + } +} diff --git a/src/addon/mod/forum/forum.module.ts b/src/addon/mod/forum/forum.module.ts new file mode 100644 index 000000000..8c4717f54 --- /dev/null +++ b/src/addon/mod/forum/forum.module.ts @@ -0,0 +1,60 @@ +// (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 { NgModule } from '@angular/core'; +import { CoreCronDelegate } from '@providers/cron'; +import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; +import { AddonModForumProvider } from './providers/forum'; +import { AddonModForumOfflineProvider } from './providers/offline'; +import { AddonModForumHelperProvider } from './providers/helper'; +import { AddonModForumSyncProvider } from './providers/sync'; +import { AddonModForumModuleHandler } from './providers/module-handler'; +import { AddonModForumPrefetchHandler } from './providers/prefetch-handler'; +import { AddonModForumSyncCronHandler } from './providers/sync-cron-handler'; +import { AddonModForumIndexLinkHandler } from './providers/index-link-handler'; +import { AddonModForumDiscussionLinkHandler } from './providers/discussion-link-handler'; +import { AddonModForumComponentsModule } from './components/components.module'; + +@NgModule({ + declarations: [ + ], + imports: [ + AddonModForumComponentsModule, + ], + providers: [ + AddonModForumProvider, + AddonModForumOfflineProvider, + AddonModForumHelperProvider, + AddonModForumSyncProvider, + AddonModForumModuleHandler, + AddonModForumPrefetchHandler, + AddonModForumSyncCronHandler, + AddonModForumIndexLinkHandler, + AddonModForumDiscussionLinkHandler, + ] +}) +export class AddonModForumModule { + constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModForumModuleHandler, + prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModForumPrefetchHandler, + cronDelegate: CoreCronDelegate, syncHandler: AddonModForumSyncCronHandler, linksDelegate: CoreContentLinksDelegate, + indexHandler: AddonModForumIndexLinkHandler, discussionHandler: AddonModForumDiscussionLinkHandler) { + moduleDelegate.registerHandler(moduleHandler); + prefetchDelegate.registerHandler(prefetchHandler); + cronDelegate.register(syncHandler); + linksDelegate.registerHandler(indexHandler); + linksDelegate.registerHandler(discussionHandler); + } +} diff --git a/src/addon/mod/forum/lang/en.json b/src/addon/mod/forum/lang/en.json new file mode 100644 index 000000000..5a461dcfd --- /dev/null +++ b/src/addon/mod/forum/lang/en.json @@ -0,0 +1,34 @@ +{ + "addanewdiscussion": "Add a new discussion topic", + "addanewquestion": "Add a new question", + "addanewtopic": "Add a new topic", + "cannotadddiscussion": "Adding discussions to this forum requires group membership.", + "cannotadddiscussionall": "You do not have permission to add a new discussion topic for all participants.", + "cannotcreatediscussion": "Could not create new discussion", + "couldnotadd": "Could not add your post due to an unknown error", + "discussion": "Discussion", + "discussionlocked": "This discussion has been locked so you can no longer reply to it.", + "discussionpinned": "Pinned", + "discussionsubscription": "Discussion subscription", + "edit": "Edit", + "erroremptymessage": "Post message cannot be empty", + "erroremptysubject": "Post subject cannot be empty.", + "errorgetforum": "Error getting forum data.", + "errorgetgroups": "Error getting group settings.", + "forumnodiscussionsyet": "There are no discussions yet in this forum.", + "group": "Group", + "message": "Message", + "modeflatnewestfirst": "Display replies flat, with newest first", + "modeflatoldestfirst": "Display replies flat, with oldest first", + "modenested": "Display replies in nested form", + "numdiscussions": "{{numdiscussions}} discussions", + "numreplies": "{{numreplies}} replies", + "posttoforum": "Post to forum", + "re": "Re:", + "refreshdiscussions": "Refresh discussions", + "refreshposts": "Refresh posts", + "reply": "Reply", + "subject": "Subject", + "unread": "Unread", + "unreadpostsnumber": "{{$a}} unread posts" +} diff --git a/src/addon/mod/forum/pages/discussion/discussion.html b/src/addon/mod/forum/pages/discussion/discussion.html new file mode 100644 index 000000000..08b5a387e --- /dev/null +++ b/src/addon/mod/forum/pages/discussion/discussion.html @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate:{$a: discussionStr} }} + + + + {{ 'addon.mod_forum.discussionlocked' | translate }} + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+
+
diff --git a/src/addon/mod/forum/pages/discussion/discussion.module.ts b/src/addon/mod/forum/pages/discussion/discussion.module.ts new file mode 100644 index 000000000..9877a3397 --- /dev/null +++ b/src/addon/mod/forum/pages/discussion/discussion.module.ts @@ -0,0 +1,35 @@ +// (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 { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModForumComponentsModule } from '../../components/components.module'; +import { AddonModForumDiscussionPage } from './discussion'; + +@NgModule({ + declarations: [ + AddonModForumDiscussionPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + AddonModForumComponentsModule, + IonicPageModule.forChild(AddonModForumDiscussionPage), + TranslateModule.forChild() + ], +}) +export class AddonModForumDiscussionPageModule {} diff --git a/src/addon/mod/forum/pages/discussion/discussion.ts b/src/addon/mod/forum/pages/discussion/discussion.ts new file mode 100644 index 000000000..ae1dab47b --- /dev/null +++ b/src/addon/mod/forum/pages/discussion/discussion.ts @@ -0,0 +1,412 @@ +// (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(); + } +} diff --git a/src/addon/mod/forum/pages/index/index.html b/src/addon/mod/forum/pages/index/index.html new file mode 100644 index 000000000..6153680ea --- /dev/null +++ b/src/addon/mod/forum/pages/index/index.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/addon/mod/forum/pages/index/index.module.ts b/src/addon/mod/forum/pages/index/index.module.ts new file mode 100644 index 000000000..c677d8d2e --- /dev/null +++ b/src/addon/mod/forum/pages/index/index.module.ts @@ -0,0 +1,33 @@ +// (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 { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModForumComponentsModule } from '../../components/components.module'; +import { AddonModForumIndexPage } from './index'; + +@NgModule({ + declarations: [ + AddonModForumIndexPage, + ], + imports: [ + CoreDirectivesModule, + AddonModForumComponentsModule, + IonicPageModule.forChild(AddonModForumIndexPage), + TranslateModule.forChild() + ], +}) +export class AddonModForumIndexPageModule {} diff --git a/src/addon/mod/forum/pages/index/index.ts b/src/addon/mod/forum/pages/index/index.ts new file mode 100644 index 000000000..102e0b88f --- /dev/null +++ b/src/addon/mod/forum/pages/index/index.ts @@ -0,0 +1,48 @@ +// (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, ViewChild } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { AddonModForumIndexComponent } from '../../components/index/index'; + +/** + * Page that displays a forum. + */ +@IonicPage({ segment: 'addon-mod-forum-index' }) +@Component({ + selector: 'page-addon-mod-forum-index', + templateUrl: 'index.html', +}) +export class AddonModForumIndexPage { + @ViewChild(AddonModForumIndexComponent) forumComponent: AddonModForumIndexComponent; + + title: string; + module: any; + courseId: number; + + constructor(navParams: NavParams) { + this.module = navParams.get('module') || {}; + this.courseId = navParams.get('courseId'); + this.title = this.module.name; + } + + /** + * Update some data based on the forum instance. + * + * @param {any} forum Forum instance. + */ + updateData(forum: any): void { + this.title = forum.name || this.title; + } +} diff --git a/src/addon/mod/forum/pages/new-discussion/new-discussion.html b/src/addon/mod/forum/pages/new-discussion/new-discussion.html new file mode 100644 index 000000000..096edd01d --- /dev/null +++ b/src/addon/mod/forum/pages/new-discussion/new-discussion.html @@ -0,0 +1,53 @@ + + + {{ 'addon.mod_forum.addanewdiscussion' | translate }} + + + + + + + + + + + + + + {{ 'addon.mod_forum.subject' | translate }} + + + + {{ 'addon.mod_forum.message' | translate }} + + + + + {{ 'addon.mod_forum.group' | translate }} + + {{ group.name }} + + + + {{ 'addon.mod_forum.discussionsubscription' | translate }} + + + + {{ 'addon.mod_forum.discussionpinned' | translate }} + + + + + + + + + + + + + + + + diff --git a/src/addon/mod/forum/pages/new-discussion/new-discussion.module.ts b/src/addon/mod/forum/pages/new-discussion/new-discussion.module.ts new file mode 100644 index 000000000..b94e61e1b --- /dev/null +++ b/src/addon/mod/forum/pages/new-discussion/new-discussion.module.ts @@ -0,0 +1,33 @@ +// (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 { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModForumNewDiscussionPage } from './new-discussion'; + +@NgModule({ + declarations: [ + AddonModForumNewDiscussionPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(AddonModForumNewDiscussionPage), + TranslateModule.forChild() + ], +}) +export class AddonModForumNewDiscussionPageModule {} diff --git a/src/addon/mod/forum/pages/new-discussion/new-discussion.ts b/src/addon/mod/forum/pages/new-discussion/new-discussion.ts new file mode 100644 index 000000000..2ab73fc2b --- /dev/null +++ b/src/addon/mod/forum/pages/new-discussion/new-discussion.ts @@ -0,0 +1,535 @@ +// (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, OnDestroy, Optional, ViewChild } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { IonicPage, NavController, NavParams } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreRichTextEditorComponent } from '@components/rich-text-editor/rich-text-editor.ts'; +import { AddonModForumProvider } from '../../providers/forum'; +import { AddonModForumOfflineProvider } from '../../providers/offline'; +import { AddonModForumHelperProvider } from '../../providers/helper'; +import { AddonModForumSyncProvider } from '../../providers/sync'; + +/** + * Page that displays the new discussion form. + */ +@IonicPage({ segment: 'addon-mod-forum-new-discussion' }) +@Component({ + selector: 'page-addon-mod-forum-new-discussion', + templateUrl: 'new-discussion.html', +}) +export class AddonModForumNewDiscussionPage implements OnDestroy { + + @ViewChild(CoreRichTextEditorComponent) messageEditor: CoreRichTextEditorComponent; + + component = AddonModForumProvider.COMPONENT; + messageControl = new FormControl(); + groupsLoaded = false; + showGroups = false; + hasOffline = false; + canCreateAttachments = true; // Assume we can by default. + canPin = false; + forum: any; + showForm = false; + groups = []; + newDiscussion = { + subject: '', + message: null, // Null means empty or just white space. + groupId: 0, + subscribe: true, + pin: false, + files: [] + }; + + protected courseId: number; + protected cmId: number; + protected forumId: number; + protected timeCreated: number; + protected syncId: string; + protected syncObserver: any; + protected isDestroyed = false; + protected originalData: any; + + constructor(navParams: NavParams, + private navCtrl: NavController, + private translate: TranslateService, + private domUtils: CoreDomUtilsProvider, + private eventsProvider: CoreEventsProvider, + private groupsProvider: CoreGroupsProvider, + private sitesProvider: CoreSitesProvider, + private syncProvider: CoreSyncProvider, + private uploaderProvider: CoreFileUploaderProvider, + private textUtils: CoreTextUtilsProvider, + private utils: CoreUtilsProvider, + private forumProvider: AddonModForumProvider, + private forumOffline: AddonModForumOfflineProvider, + private forumSync: AddonModForumSyncProvider, + private forumHelper: AddonModForumHelperProvider, + @Optional() private svComponent: CoreSplitViewComponent) { + this.courseId = navParams.get('courseId'); + this.cmId = navParams.get('cmId'); + this.forumId = navParams.get('forumId'); + this.timeCreated = navParams.get('timeCreated'); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.fetchDiscussionData().finally(() => { + this.groupsLoaded = 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 && data.userId == this.sitesProvider.getCurrentSiteUserId()) { + this.domUtils.showAlertTranslated('core.notice', 'core.contenteditingsynced'); + this.returnToDiscussions(); + } + }, 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.timeCreated, + }, this.sitesProvider.getCurrentSiteId()); + } + + /** + * Fetch if forum uses groups and the groups it uses. + * + * @param {boolean} [refresh] Whether we're refreshing data. + * @return {Promise} Promise resolved when done. + */ + protected fetchDiscussionData(refresh?: boolean): Promise { + return this.groupsProvider.getActivityGroupMode(this.cmId).then((mode) => { + const promises = []; + + if (mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS) { + promises.push(this.groupsProvider.getActivityAllowedGroups(this.cmId).then((forumGroups) => { + let promise; + if (mode === CoreGroupsProvider.VISIBLEGROUPS) { + // We need to check which of the returned groups the user can post to. + promise = this.validateVisibleGroups(forumGroups); + } else { + // WS already filters groups, no need to do it ourselves. Add "All participants" if needed. + promise = this.addAllParticipantsOption(forumGroups, true); + } + + return promise.then((forumGroups) => { + if (forumGroups.length > 0) { + this.groups = forumGroups; + // Do not override group id. + this.newDiscussion.groupId = this.newDiscussion.groupId || forumGroups[0].id; + this.showGroups = true; + } else { + const message = mode === CoreGroupsProvider.SEPARATEGROUPS ? + 'addon.mod_forum.cannotadddiscussionall' : 'addon.mod_forum.cannotadddiscussion'; + + return Promise.reject(this.translate.instant(message)); + } + }); + })); + } else { + this.showGroups = false; + + // Use the canAddDiscussion WS to check if the user can add attachments and pin discussions. + promises.push(this.forumProvider.canAddDiscussionToAll(this.forumId).then((response) => { + this.canPin = !!response.canpindiscussions; + this.canCreateAttachments = !!response.cancreateattachment; + }).catch(() => { + // Ignore errors, use default values. + })); + } + + // Get forum. + promises.push(this.forumProvider.getForum(this.courseId, this.cmId).then((forum) => { + this.forum = forum; + })); + + // If editing a discussion, get offline data. + if (this.timeCreated && !refresh) { + this.syncId = this.forumSync.getForumSyncId(this.forumId); + promises.push(this.forumSync.waitForSync(this.syncId).then(() => { + // Do not block if the scope is already destroyed. + if (!this.isDestroyed) { + this.syncProvider.blockOperation(AddonModForumProvider.COMPONENT, this.syncId); + } + + return this.forumOffline.getNewDiscussion(this.forumId, this.timeCreated).then((discussion) => { + this.hasOffline = true; + discussion.options = discussion.options || {}; + this.newDiscussion.groupId = discussion.groupid ? discussion.groupid : this.newDiscussion.groupId; + this.newDiscussion.subject = discussion.subject; + this.newDiscussion.message = discussion.message; + this.newDiscussion.subscribe = discussion.options.discussionsubscribe; + this.newDiscussion.pin = discussion.options.discussionpinned; + this.messageControl.setValue(discussion.message); + + // Treat offline attachments if any. + if (discussion.options.attachmentsid && discussion.options.attachmentsid.offline) { + return this.forumHelper.getNewDiscussionStoredFiles(this.forumId, this.timeCreated).then((files) => { + this.newDiscussion.files = files; + }); + } + }); + })); + } + + return Promise.all(promises); + }).then(() => { + if (!this.originalData) { + // Initialize original data. + this.originalData = { + subject: this.newDiscussion.subject, + message: this.newDiscussion.message, + files: this.newDiscussion.files.slice(), + }; + } + this.showForm = true; + }).catch((message) => { + this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.errorgetgroups', true); + this.showForm = false; + }); + } + + /** + * Validate which of the groups returned by getActivityAllowedGroups in visible groups should be shown to post to. + * + * @param {any[]} forumGroups Forum groups. + * @return {Promise} Promise resolved when done. + */ + protected validateVisibleGroups(forumGroups: any[]): Promise { + // We first check if the user can post to all the groups. + return this.forumProvider.canAddDiscussionToAll(this.forumId).catch(() => { + // The call failed, let's assume he can't. + return { + status: false, + canpindiscussions: false, + cancreateattachment: true + }; + }).then((response) => { + this.canPin = !!response.canpindiscussions; + this.canCreateAttachments = !!response.cancreateattachment; + + if (response.status) { + // The user can post to all groups, add the "All participants" option and return them all. + return this.addAllParticipantsOption(forumGroups, false); + } else { + // The user can't post to all groups, let's check which groups he can post to. + const promises = []; + const filtered = []; + + forumGroups.forEach((group) => { + promises.push(this.forumProvider.canAddDiscussion(this.forumId, group.id).catch(() => { + /* The call failed, let's return true so the group is shown. If the user can't post to + it an error will be shown when he tries to add the discussion. */ + return { + status: true + }; + }).then((response) => { + if (response.status) { + filtered.push(group); + } + })); + }); + + return Promise.all(promises).then(() => { + return filtered; + }); + } + }); + } + + /** + * Filter forum groups, returning only those that are inside user groups. + * + * @param {any[]} forumGroups Forum groups. + * @param {any[]} userGroups User groups. + * @return {any[]} Filtered groups. + */ + protected filterGroups(forumGroups: any[], userGroups: any[]): any[] { + const filtered = []; + const userGroupsIds = userGroups.map((g) => g.id); + + forumGroups.forEach((fg) => { + if (userGroupsIds.indexOf(fg.id) > -1) { + filtered.push(fg); + } + }); + + return filtered; + } + + /** + * Add the "All participants" option to a list of groups if the user can add a discussion to all participants. + * + * @param {any[]} groups Groups. + * @param {boolean} check True to check if the user can add a discussion to all participants. + * @return {Promise} Promise resolved with the list of groups. + */ + protected addAllParticipantsOption(groups: any[], check: boolean): Promise { + if (!this.forumProvider.isAllParticipantsFixed()) { + // All participants has a bug, don't add it. + return Promise.resolve(groups); + } + + let promise; + + if (check) { + // We need to check if the user can add a discussion to all participants. + promise = this.forumProvider.canAddDiscussionToAll(this.forumId).then((response) => { + this.canPin = !!response.canpindiscussions; + this.canCreateAttachments = !!response.cancreateattachment; + + return response.status; + }).catch(() => { + // The call failed, let's assume he can't. + return false; + }); + } else { + // No need to check, assume the user can. + promise = Promise.resolve(true); + } + + return promise.then((canAdd) => { + if (canAdd) { + groups.unshift({ + courseid: this.courseId, + id: -1, + name: this.translate.instant('core.allparticipants') + }); + } + + return groups; + }); + } + + /** + * Pull to refresh. + * + * @param {any} refresher Refresher. + */ + refreshGroups(refresher: any): void { + const promises = [ + this.groupsProvider.invalidateActivityGroupMode(this.cmId), + this.groupsProvider.invalidateActivityAllowedGroups(this.cmId), + this.forumProvider.invalidateCanAddDiscussion(this.forumId), + ]; + + Promise.all(promises).finally(() => { + this.fetchDiscussionData(true).finally(() => { + refresher.complete(); + }); + }); + } + + /** + * Convenience function to update or return to discussions depending on device. + * + * @param {number} [discussionId] Id of the new discussion. + */ + protected returnToDiscussions(discussionId?: number): void { + const data: any = { + forumId: this.forumId, + cmId: this.cmId, + discussionId: discussionId, + }; + this.eventsProvider.trigger(AddonModForumProvider.NEW_DISCUSSION_EVENT, data, this.sitesProvider.getCurrentSiteId()); + + // Delete the local files from the tmp folder. + this.uploaderProvider.clearTmpFiles(this.newDiscussion.files); + + if (this.svComponent && this.svComponent.isOn()) { + // Empty form. + this.hasOffline = false; + this.newDiscussion.subject = ''; + this.newDiscussion.message = null; + this.newDiscussion.files = []; + this.messageEditor.clearText(); + this.originalData = this.utils.clone(this.newDiscussion); + + // Trigger view event, to highlight the current opened discussion in the split view. + this.eventsProvider.trigger(AddonModForumProvider.VIEW_DISCUSSION_EVENT, { + forumId: this.forumId, + discussion: 0, + }, this.sitesProvider.getCurrentSiteId()); + } else { + this.originalData = null; // Avoid asking for confirmation. + this.navCtrl.pop(); + } + } + + /** + * Message changed. + * + * @param {string} text The new text. + */ + onMessageChange(text: string): void { + this.newDiscussion.message = text; + } + + /** + * Add a new discussion. + */ + add(): void { + const forumName = this.forum.name; + const subject = this.newDiscussion.subject; + let message = this.newDiscussion.message; + const pin = this.newDiscussion.pin; + const groupId = this.newDiscussion.groupId; + const attachments = this.newDiscussion.files; + const discTimecreated = this.timeCreated || Date.now(); + const options: any = { + discussionsubscribe: !!this.newDiscussion.subscribe + }; + let saveOffline = false; + + if (!subject) { + this.domUtils.showErrorModal('addon.mod_forum.erroremptysubject', true); + + return; + } + if (!message) { + this.domUtils.showErrorModal('addon.mod_forum.erroremptymessage', true); + + return; + } + + const modal = this.domUtils.showModalLoading('core.sending', true); + + // Check if rich text editor is enabled or not. + this.domUtils.isRichTextEditorEnabled().then((enabled) => { + if (!enabled) { + // Rich text editor not enabled, add some HTML to the message if needed. + message = this.textUtils.formatHtmlLines(message); + } + + // Upload attachments first if any. + if (attachments.length) { + return this.forumHelper.uploadOrStoreNewDiscussionFiles(this.forumId, discTimecreated, attachments, false) + .catch(() => { + // Cannot upload them in online, save them in offline. + saveOffline = true; + + return this.forumHelper.uploadOrStoreNewDiscussionFiles(this.forumId, discTimecreated, attachments, true); + }); + } + }).then((attach) => { + if (attach) { + options.attachmentsid = attach; + } + if (pin) { + options.discussionpinned = true; + } + + if (saveOffline) { + // Save discussion in offline. + return this.forumOffline.addNewDiscussion(this.forumId, forumName, this.courseId, subject, + message, options, groupId, discTimecreated).then(() => { + // Don't return anything. + }); + } else { + // Try to send it to server. + // Don't allow offline if there are attachments since they were uploaded fine. + return this.forumProvider.addNewDiscussion(this.forumId, forumName, this.courseId, subject, message, options, + groupId, undefined, discTimecreated, !attachments.length); + } + }).then((discussionId) => { + if (discussionId) { + // Data sent to server, delete stored files (if any). + this.forumHelper.deleteNewDiscussionStoredFiles(this.forumId, discTimecreated); + } + + this.returnToDiscussions(discussionId); + }).catch((message) => { + this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.cannotcreatediscussion', true); + }).finally(() => { + modal.dismiss(); + }); + } + + /** + * Discard an offline saved discussion. + */ + discard(): void { + this.domUtils.showConfirm(this.translate.instant('core.areyousure')).then(() => { + const promises = []; + + promises.push(this.forumOffline.deleteNewDiscussion(this.forumId, this.timeCreated)); + promises.push(this.forumHelper.deleteNewDiscussionStoredFiles(this.forumId, this.timeCreated).catch(() => { + // Ignore errors, maybe there are no files. + })); + + return Promise.all(promises).then(() => { + this.returnToDiscussions(); + }); + }).catch(() => { + // Cancelled. + }); + } + + /** + * 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.newDiscussion, 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.newDiscussion.files); + }); + } + + /** + * Runs when the page is about to leave and no longer be the active page. + */ + ionViewWillLeave(): void { + this.syncObserver && this.syncObserver.off(); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + if (this.syncId) { + this.syncProvider.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId); + } + this.isDestroyed = true; + } +} diff --git a/src/addon/mod/forum/providers/discussion-link-handler.ts b/src/addon/mod/forum/providers/discussion-link-handler.ts new file mode 100644 index 000000000..f4962baf0 --- /dev/null +++ b/src/addon/mod/forum/providers/discussion-link-handler.ts @@ -0,0 +1,69 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; + +/** + * Handler to treat links to forum review. + */ +@Injectable() +export class AddonModForumDiscussionLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonModForumDiscussionLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModForum'; + pattern = /\/mod\/forum\/discuss\.php.*([\&\?]d=\d+)/; + + constructor(protected domUtils: CoreDomUtilsProvider, protected linkHelper: CoreContentLinksHelperProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + return [{ + action: (siteId, navCtrl?): void => { + const pageParams = { + courseId: courseId || parseInt(params.courseid, 10) || parseInt(params.cid, 10), + discussionId: parseInt(params.d, 10), + }; + this.linkHelper.goInSite(navCtrl, 'AddonModForumDiscussionPage', pageParams, siteId); + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + return true; + } +} diff --git a/src/addon/mod/forum/providers/forum.ts b/src/addon/mod/forum/providers/forum.ts new file mode 100644 index 000000000..37f36b139 --- /dev/null +++ b/src/addon/mod/forum/providers/forum.ts @@ -0,0 +1,734 @@ +// (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 { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreFilepoolProvider } from '@providers/filepool'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { AddonModForumOfflineProvider } from './offline'; + +/** + * Service that provides some features for forums. + */ +@Injectable() +export class AddonModForumProvider { + static COMPONENT = 'mmaModForum'; + static DISCUSSIONS_PER_PAGE = 10; // Max of discussions per page. + static NEW_DISCUSSION_EVENT = 'addon_mod_forum_new_discussion'; + static REPLY_DISCUSSION_EVENT = 'addon_mod_forum_reply_discussion'; + static VIEW_DISCUSSION_EVENT = 'addon_mod_forum_view_discussion'; + + protected ROOT_CACHE_KEY = 'mmaModForum:'; + + constructor(private appProvider: CoreAppProvider, + private sitesProvider: CoreSitesProvider, + private groupsProvider: CoreGroupsProvider, + private filepoolProvider: CoreFilepoolProvider, + private userProvider: CoreUserProvider, + private translate: TranslateService, + private utils: CoreUtilsProvider, + private forumOffline: AddonModForumOfflineProvider) {} + + /** + * Get cache key for can add discussion WS calls. + * + * @param {number} forumId Forum ID. + * @param {number} groupId Group ID. + * @return {string} Cache key. + */ + protected getCanAddDiscussionCacheKey(forumId: number, groupId: number): string { + return this.getCommonCanAddDiscussionCacheKey(forumId) + ':' + groupId; + } + + /** + * Get common part of cache key for can add discussion WS calls. + * + * @param {number} forumId Forum ID. + * @return {string} Cache key. + */ + protected getCommonCanAddDiscussionCacheKey(forumId: number): string { + return this.ROOT_CACHE_KEY + 'canadddiscussion:' + forumId; + } + + /** + * Get cache key for forum data WS calls. + * + * @param {number} courseId Course ID. + * @return {string} Cache key. + */ + protected getForumDataCacheKey(courseId: number): string { + return this.ROOT_CACHE_KEY + 'forum:' + courseId; + } + + /** + * Get cache key for forum discussion posts WS calls. + * + * @param {number} discussionId Discussion ID. + * @return {string} Cache key. + */ + protected getDiscussionPostsCacheKey(discussionId: number): string { + return this.ROOT_CACHE_KEY + 'discussion:' + discussionId; + } + + /** + * Get cache key for forum discussions list WS calls. + * + * @param {number} forumId Forum ID. + * @return {string} Cache key. + */ + protected getDiscussionsListCacheKey(forumId: number): string { + return this.ROOT_CACHE_KEY + 'discussions:' + forumId; + } + + /** + * Add a new discussion. + * + * @param {number} forumId Forum ID. + * @param {string} name Forum name. + * @param {number} courseId Course ID the forum belongs to. + * @param {string} subject New discussion's subject. + * @param {string} message New discussion's message. + * @param {any} [options] Options (subscribe, pin, ...). + * @param {string} [groupId] Group this discussion belongs to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [timeCreated] The time the discussion was created. Only used when editing discussion. + * @param {boolean} allowOffline True if it can be stored in offline, false otherwise. + * @return {Promise} Promise resolved with discussion ID if sent online, resolved with false if stored offline. + */ + addNewDiscussion(forumId: number, name: string, courseId: number, subject: string, message: string, options?: any, + groupId?: number, siteId?: string, timeCreated?: number, allowOffline?: boolean): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = (): Promise => { + return this.forumOffline.addNewDiscussion(forumId, name, courseId, subject, message, options, + groupId, timeCreated, siteId).then(() => { + return false; + }); + }; + + // If we are editing an offline discussion, discard previous first. + let discardPromise; + if (timeCreated) { + discardPromise = this.forumOffline.deleteNewDiscussion(forumId, timeCreated, siteId); + } else { + discardPromise = Promise.resolve(); + } + + return discardPromise.then(() => { + if (!this.appProvider.isOnline() && allowOffline) { + // App is offline, store the action. + return storeOffline(); + } + + return this.addNewDiscussionOnline(forumId, subject, message, options, groupId, siteId).then((id) => { + // Success, return the discussion ID. + return id; + }).catch((error) => { + if (!allowOffline || this.utils.isWebServiceError(error)) { + // The WebService has thrown an error or offline not supported, reject. + return Promise.reject(error); + } + + // Couldn't connect to server, store in offline. + return storeOffline(); + }); + }); + } + + /** + * Add a new discussion. It will fail if offline or cannot connect. + * + * @param {number} forumId Forum ID. + * @param {string} subject New discussion's subject. + * @param {string} message New discussion's message. + * @param {any} [options] Options (subscribe, pin, ...). + * @param {string} [groupId] Group this discussion belongs to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the discussion is created. + */ + addNewDiscussionOnline(forumId: number, subject: string, message: string, options?: any, groupId?: number, siteId?: string) + : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + forumid: forumId, + subject: subject, + message: message, + options: this.utils.objectToArrayOfObjects(options, 'name', 'value') + }; + + if (groupId) { + params.groupid = groupId; + } + + return site.write('mod_forum_add_discussion', params).then((response) => { + // Other errors ocurring. + if (!response || !response.discussionid) { + return this.utils.createFakeWSError(''); + } else { + return response.discussionid; + } + }); + }); + } + + /** + * Check if a user can post to a certain group. + * + * @param {number} forumId Forum ID. + * @param {number} groupId Group ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with an object with the following properties: + * - status (boolean) + * - canpindiscussions (boolean) + * - cancreateattachment (boolean) + */ + canAddDiscussion(forumId: number, groupId: number, siteId?: string): Promise { + const params = { + forumid: forumId, + groupid: groupId + }; + const preSets = { + cacheKey: this.getCanAddDiscussionCacheKey(forumId, groupId) + }; + + return this.sitesProvider.getSite(siteId).then((site) => { + return site.read('mod_forum_can_add_discussion', params, preSets).then((result) => { + if (result) { + if (typeof result.canpindiscussions == 'undefined') { + // WS doesn't support it yet, default it to false to prevent students from seing the option. + result.canpindiscussions = false; + } + if (typeof result.cancreateattachment == 'undefined') { + // WS doesn't support it yet, default it to true since usually the users will be able to create them. + result.cancreateattachment = true; + } + + return result; + } + + return Promise.reject(null); + }); + }); + } + + /** + * Check if a user can post to all groups. + * + * @param {number} forumId Forum ID. + * @return {Promise} Promise resolved with an object with the following properties: + * - status (boolean) + * - canpindiscussions (boolean) + * - cancreateattachment (boolean) + */ + canAddDiscussionToAll(forumId: number): Promise { + return this.canAddDiscussion(forumId, -1); + } + + /** + * Extract the starting post of a discussion from a list of posts. The post is removed from the array passed as a parameter. + * + * @param {any[]} posts Posts to search. + * @return {any} Starting post or undefined if not found. + */ + extractStartingPost(posts: any[]): any { + // Check the last post first, since they'll usually be ordered by create time. + for (let i = posts.length - 1; i >= 0; i--) { + if (posts[i].parent == 0) { + return posts.splice(i, 1).pop(); // Remove it from the array. + } + } + + return undefined; + } + + /** + * There was a bug adding new discussions to All Participants (see MDL-57962). Check if it's fixed. + * + * @return {boolean} True if fixed, false otherwise. + */ + isAllParticipantsFixed(): boolean { + return this.sitesProvider.getCurrentSite().isVersionGreaterEqualThan(['3.1.5', '3.2.2']); + } + + /** + * Format discussions, setting groupname if the discussion group is valid. + * + * @param {number} cmId Forum cmid. + * @param {any[]} discussions List of discussions to format. + * @return {Promise} Promise resolved with the formatted discussions. + */ + formatDiscussionsGroups(cmId: number, discussions: any[]): Promise { + discussions = this.utils.clone(discussions); + + return this.groupsProvider.getActivityAllowedGroups(cmId).then((forumGroups) => { + const strAllParts = this.translate.instant('core.allparticipants'); + + // Turn groups into an object where each group is identified by id. + const groups = {}; + forumGroups.forEach((fg) => { + groups[fg.id] = fg; + }); + + // Format discussions. + discussions.forEach((disc) => { + if (disc.groupid === -1) { + disc.groupname = strAllParts; + } else { + const group = groups[disc.groupid]; + if (group) { + disc.groupname = group.name; + } + } + }); + + return discussions; + }).catch(() => { + return discussions; + }); + } + + /** + * Get all course forums. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the forums are retrieved. + */ + getCourseForums(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + courseids: [courseId] + }; + const preSets = { + cacheKey: this.getForumDataCacheKey(courseId) + }; + + return site.read('mod_forum_get_forums_by_courses', params, preSets); + }); + } + + /** + * Get a forum by course module ID. + * + * @param {number} courseId Course ID. + * @param {number} cmId Course module ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the forum is retrieved. + */ + getForum(courseId: number, cmId: number, siteId?: string): Promise { + return this.getCourseForums(courseId, siteId).then((forums) => { + const forum = forums.find((forum) => forum.cmid == cmId); + if (forum) { + return forum; + } + + return Promise.reject(null); + }); + } + + /** + * Get a forum by forum ID. + * + * @param {number} courseId Course ID. + * @param {number} forumId Forum ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the forum is retrieved. + */ + getForumById(courseId: number, forumId: number, siteId?: string): Promise { + return this.getCourseForums(courseId, siteId).then((forums) => { + const forum = forums.find((forum) => forum.id == forumId); + if (forum) { + return forum; + } + + return Promise.reject(null); + }); + } + + /** + * Get forum discussion posts. + * + * @param {number} discussionId Discussion ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with forum posts. + */ + getDiscussionPosts(discussionId: number, siteId?: string): Promise { + const params = { + discussionid: discussionId + }; + const preSets = { + cacheKey: this.getDiscussionPostsCacheKey(discussionId) + }; + + return this.sitesProvider.getSite(siteId).then((site) => { + return site.read('mod_forum_get_forum_discussion_posts', params, preSets).then((response) => { + if (response) { + this.storeUserData(response.posts); + + return response.posts; + } else { + return Promise.reject(null); + } + }); + }); + } + + /** + * Sort forum discussion posts by an specified field. + * + * @param {any[]} posts Discussion posts to be sorted in place. + * @param {string} direction Direction of the sorting (ASC / DESC). + */ + sortDiscussionPosts(posts: any[], direction: string): void { + // @todo: Check children when sorting. + posts.sort((a, b) => { + a = parseInt(a.created, 10); + b = parseInt(b.created, 10); + if (direction == 'ASC') { + return a - b; + } else { + return b - a; + } + }); + } + + /** + * Get forum discussions. + * + * @param {number} forumId Forum ID. + * @param {number} [page=0] Page. + * @param {boolean} [forceCache] True to always get the value from cache. false otherwise. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with an object with: + * - discussions: List of discussions. + * - canLoadMore: True if there may be more discussions to load. + */ + getDiscussions(forumId: number, page: number = 0, forceCache?: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + forumid: forumId, + sortby: 'timemodified', + sortdirection: 'DESC', + page: page, + perpage: AddonModForumProvider.DISCUSSIONS_PER_PAGE + }; + const preSets: any = { + cacheKey: this.getDiscussionsListCacheKey(forumId) + }; + if (forceCache) { + preSets.omitExpires = true; + } + + return site.read('mod_forum_get_forum_discussions_paginated', params, preSets).then((response) => { + if (response) { + this.storeUserData(response.discussions); + + return Promise.resolve({ + discussions: response.discussions, + canLoadMore: response.discussions.length >= AddonModForumProvider.DISCUSSIONS_PER_PAGE, + }); + } else { + return Promise.reject(null); + } + }); + }); + } + + /** + * Get forum discussions in several pages. + * If a page fails, the discussions until that page will be returned along with a flag indicating an error occurred. + * + * @param {number} forumId Forum ID. + * @param {boolean} forceCache True to always get the value from cache, false otherwise. + * @param {number} [numPages] Number of pages to get. If not defined, all pages. + * @param {number} [startPage] Page to start. If not defined, first page. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with an object with: + * - discussions: List of discussions. + * - error: True if an error occurred, false otherwise. + */ + getDiscussionsInPages(forumId: number, forceCache?: boolean, numPages?: number, startPage?: number, siteId?: string) + : Promise { + if (typeof numPages == 'undefined') { + numPages = -1; + } + startPage = startPage || 0; + + const result = { + discussions: [], + error: false + }; + + if (!numPages) { + return Promise.resolve(result); + } + + const getPage = (page: number): Promise => { + // Get page discussions. + return this.getDiscussions(forumId, page, forceCache, siteId).then((response) => { + result.discussions = result.discussions.concat(response.discussions); + numPages--; + + if (response.canLoadMore && numPages !== 0) { + return getPage(page + 1); // Get next page. + } else { + return result; + } + }).catch(() => { + // Error getting a page. + result.error = true; + + return result; + }); + }; + + return getPage(startPage); + } + + /** + * Invalidates can add discussion WS calls. + * + * @param {number} forumId Forum ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCanAddDiscussion(forumId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getCommonCanAddDiscussionCacheKey(forumId)); + }); + } + + /** + * Invalidate the prefetched content except files. + * To invalidate files, use AddonModForum#invalidateFiles. + * + * @param {number} moduleId The module ID. + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved when data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): Promise { + // Get the forum first, we need the forum ID. + return this.getForum(courseId, moduleId).then((forum) => { + // We need to get the list of discussions to be able to invalidate their posts. + return this.getDiscussionsInPages(forum.id, true).then((response) => { + // Now invalidate the WS calls. + const promises = []; + + promises.push(this.invalidateForumData(courseId)); + promises.push(this.invalidateDiscussionsList(forum.id)); + promises.push(this.invalidateCanAddDiscussion(forum.id)); + + response.discussions.forEach((discussion) => { + promises.push(this.invalidateDiscussionPosts(discussion.discussion)); + }); + + return this.utils.allPromises(promises); + }); + }); + } + + /** + * Invalidates forum discussion posts. + * + * @param {number} discussionId Discussion ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateDiscussionPosts(discussionId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getDiscussionPostsCacheKey(discussionId)); + }); + } + + /** + * Invalidates discussion list. + * + * @param {number} forumId Forum ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateDiscussionsList(forumId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getDiscussionsListCacheKey(forumId)); + }); + } + + /** + * Invalidate the prefetched files. + * + * @param {number} moduleId The module ID. + * @return {Promise} Promise resolved when the files are invalidated. + */ + invalidateFiles(moduleId: number): Promise { + const siteId = this.sitesProvider.getCurrentSiteId(); + + return this.filepoolProvider.invalidateFilesByComponent(siteId, AddonModForumProvider.COMPONENT, moduleId); + } + + /** + * Invalidates forum data. + * + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateForumData(courseId: number): Promise { + return this.sitesProvider.getCurrentSite().invalidateWsCacheForKey(this.getForumDataCacheKey(courseId)); + } + + /** + * Report a forum as being viewed. + * + * @param {number} id Module ID. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logView(id: number): Promise { + const params = { + forumid: id + }; + + return this.sitesProvider.getCurrentSite().write('mod_forum_view_forum', params); + } + + /** + * Report a forum discussion as being viewed. + * + * @param {number} id Discussion ID. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logDiscussionView(id: number): Promise { + const params = { + discussionid: id + }; + + return this.sitesProvider.getCurrentSite().write('mod_forum_view_forum_discussion', params); + } + + /** + * Reply to a certain post. + * + * @param {number} postId ID of the post being replied. + * @param {number} discussionId ID of the discussion the user is replying to. + * @param {number} forumId ID of the forum the user is replying to. + * @param {string} name Forum name. + * @param {number} courseId Course ID the forum belongs to. + * @param {string} subject New post's subject. + * @param {string} message New post's message. + * @param {any} [options] Options (subscribe, attachments, ...). + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {boolean} [allowOffline] True if it can be stored in offline, false otherwise. + * @return {Promise} Promise resolved with post ID if sent online, resolved with false if stored offline. + */ + replyPost(postId: number, discussionId: number, forumId: number, name: string, courseId: number, subject: string, + message: string, options?: any, siteId?: string, allowOffline?: boolean): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = (): Promise => { + if (!forumId) { + // Not enough data to store in offline, reject. + return Promise.reject(this.translate.instant('core.networkerrormsg')); + } + + return this.forumOffline.replyPost(postId, discussionId, forumId, name, courseId, subject, message, options, siteId) + .then(() => { + return false; + }); + }; + + if (!this.appProvider.isOnline() && allowOffline) { + // App is offline, store the action. + return storeOffline(); + } + + // If there's already a reply to be sent to the server, discard it first. + return this.forumOffline.deleteReply(postId, siteId).then(() => { + + return this.replyPostOnline(postId, subject, message, options, siteId).then(() => { + return true; + }).catch((error) => { + if (allowOffline && !this.utils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } else { + // The WebService has thrown an error or offline not supported, reject. + return Promise.reject(error); + } + }); + }); + } + + /** + * Reply to a certain post. It will fail if offline or cannot connect. + * + * @param {number} postId ID of the post being replied. + * @param {string} subject New post's subject. + * @param {string} message New post's message. + * @param {any} [options] Options (subscribe, attachments, ...). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the created post id. + */ + replyPostOnline(postId: number, subject: string, message: string, options?: any, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + postid: postId, + subject: subject, + message: message, + options: this.utils.objectToArrayOfObjects(options, 'name', 'value') + }; + + return site.write('mod_forum_add_discussion_post', params).then((response) => { + if (!response || !response.postid) { + return this.utils.createFakeWSError(''); + } else { + return response.postid; + } + }); + }); + } + + /** + * Store the users data from a discussions/posts list. + * + * @param {any[]} list Array of posts or discussions. + */ + protected storeUserData(list: any[]): void { + const users = {}; + + list.forEach((entry) => { + const userId = parseInt(entry.userid); + if (!isNaN(userId) && !users[userId]) { + users[userId] = { + id: userId, + fullname: entry.userfullname, + profileimageurl: entry.userpictureurl + }; + } + const userModified = parseInt(entry.usermodified); + if (!isNaN(userModified) && !users[userModified]) { + users[userModified] = { + id: userModified, + fullname: entry.usermodifiedfullname, + profileimageurl: entry.usermodifiedpictureurl + }; + } + }); + + this.userProvider.storeUsers(this.utils.objectToArray(users)); + } +} diff --git a/src/addon/mod/forum/providers/helper.ts b/src/addon/mod/forum/providers/helper.ts new file mode 100644 index 000000000..e039cdf7b --- /dev/null +++ b/src/addon/mod/forum/providers/helper.ts @@ -0,0 +1,243 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreFileProvider } from '@providers/file'; +import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { AddonModForumProvider } from './forum'; +import { AddonModForumOfflineProvider } from './offline'; + +/** + * Service that provides some features for forums. + */ +@Injectable() +export class AddonModForumHelperProvider { + constructor(private fileProvider: CoreFileProvider, + private uploaderProvider: CoreFileUploaderProvider, + private userProvider: CoreUserProvider, + private forumOffline: AddonModForumOfflineProvider) {} + + /** + * Convert offline reply to online format in order to be compatible with them. + * + * @param {any} offlineReply Offline version of the reply. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the object converted to Online. + */ + convertOfflineReplyToOnline(offlineReply: any, siteId?: string): Promise { + const reply: any = { + attachments: [], + canreply: false, + children: [], + created: offlineReply.timecreated, + discussion: offlineReply.discussionid, + id: false, + mailed: 0, + mailnow: 0, + message: offlineReply.message, + messageformat: 1, + messagetrust: 0, + modified: false, + parent: offlineReply.postid, + postread: false, + subject: offlineReply.subject, + totalscore: 0, + userid: offlineReply.userid + }, + promises = []; + + // Treat attachments if any. + if (offlineReply.options && offlineReply.options.attachmentsid) { + reply.attachments = offlineReply.options.attachmentsid.online || []; + + if (offlineReply.options.attachmentsid.offline) { + promises.push(this.getReplyStoredFiles(offlineReply.forumid, reply.parent, siteId, reply.userid) + .then((files) => { + reply.attachments = reply.attachments.concat(files); + })); + } + } + + // Get user data. + promises.push(this.userProvider.getProfile(offlineReply.userid, offlineReply.courseid, true).then((user) => { + reply.userfullname = user.fullname; + reply.userpictureurl = user.profileimageurl; + }).catch(() => { + // Ignore errors. + })); + + return Promise.all(promises).then(() => { + reply.attachment = reply.attachments.length > 0 ? 1 : 0; + + return reply; + }); + } + + /** + * Delete stored attachment files for a new discussion. + * + * @param {number} forumId Forum ID. + * @param {number} timecreated The time the discussion was created. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when deleted. + */ + deleteNewDiscussionStoredFiles(forumId: number, timecreated: number, siteId?: string): Promise { + return this.forumOffline.getNewDiscussionFolder(forumId, timecreated, siteId).then((folderPath) => { + return this.fileProvider.removeDir(folderPath).catch(() => { + // Ignore any errors, CoreFileProvider.removeDir fails if folder doesn't exists. + }); + }); + } + + /** + * Delete stored attachment files for a reply. + * + * @param {number} forumId Forum ID. + * @param {number} postId ID of the post being replied. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the reply belongs to. If not defined, current user in site. + * @return {Promise} Promise resolved when deleted. + */ + deleteReplyStoredFiles(forumId: number, postId: number, siteId?: string, userId?: number): Promise { + return this.forumOffline.getReplyFolder(forumId, postId, siteId, userId).then((folderPath) => { + return this.fileProvider.removeDir(folderPath).catch(() => { + // Ignore any errors, CoreFileProvider.removeDir fails if folder doesn't exists. + }); + }); + } + + /** + * Get a list of stored attachment files for a new discussion. See AddonModForumHelper#storeNewDiscussionFiles. + * + * @param {number} forumId Forum ID. + * @param {number} timecreated The time the discussion was created. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the files. + */ + getNewDiscussionStoredFiles(forumId: number, timecreated: number, siteId?: string): Promise { + return this.forumOffline.getNewDiscussionFolder(forumId, timecreated, siteId).then((folderPath) => { + return this.uploaderProvider.getStoredFiles(folderPath); + }); + } + + /** + * Get a list of stored attachment files for a reply. See AddonModForumHelper#storeReplyFiles. + * + * @param {number} forumId Forum ID. + * @param {number} postId ID of the post being replied. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the reply belongs to. If not defined, current user in site. + * @return {Promise} Promise resolved with the files. + */ + getReplyStoredFiles(forumId: number, postId: number, siteId?: string, userId?: number): Promise { + return this.forumOffline.getReplyFolder(forumId, postId, siteId, userId).then((folderPath) => { + return this.uploaderProvider.getStoredFiles(folderPath); + }); + } + + /** + * Check if the data of a post/discussion has changed. + * + * @param {any} post Current data. + * @param {any} [original] Original ata. + * @return {boolean} True if data has changed, false otherwise. + */ + hasPostDataChanged(post: any, original?: any): boolean { + if (!original || original.subject == null) { + // There is no original data, assume it hasn't changed. + return false; + } + + if (post.subject != original.subject || post.message != original.message) { + return true; + } + + return this.uploaderProvider.areFileListDifferent(post.files, original.files); + } + + /** + * Given a list of files (either online files or local files), store the local files in a local folder + * to be submitted later. + * + * @param {number} forumId Forum ID. + * @param {number} timecreated The time the discussion was created. + * @param {any[]} files List of files. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success, rejected otherwise. + */ + storeNewDiscussionFiles(forumId: number, timecreated: number, files: any[], siteId?: string): Promise { + // Get the folder where to store the files. + return this.forumOffline.getNewDiscussionFolder(forumId, timecreated, siteId).then((folderPath) => { + return this.uploaderProvider.storeFilesToUpload(folderPath, files); + }); + } + + /** + * Given a list of files (either online files or local files), store the local files in a local folder + * to be submitted later. + * + * @param {number} forumId Forum ID. + * @param {number} postId ID of the post being replied. + * @param {any[]} files List of files. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the reply belongs to. If not defined, current user in site. + * @return {Promise} Promise resolved if success, rejected otherwise. + */ + storeReplyFiles(forumId: number, postId: number, files: any[], siteId?: string, userId?: number): Promise { + // Get the folder where to store the files. + return this.forumOffline.getReplyFolder(forumId, postId, siteId, userId).then((folderPath) => { + return this.uploaderProvider.storeFilesToUpload(folderPath, files); + }); + } + + /** + * Upload or store some files for a new discussion, depending if the user is offline or not. + * + * @param {number} forumId Forum ID. + * @param {number} timecreated The time the discussion was created. + * @param {any[]} files List of files. + * @param {boolean} offline True if files sould be stored for offline, false to upload them. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success. + */ + uploadOrStoreNewDiscussionFiles(forumId: number, timecreated: number, files: any[], offline: boolean, siteId?: string) + : Promise { + if (offline) { + return this.storeNewDiscussionFiles(forumId, timecreated, files, siteId); + } else { + return this.uploaderProvider.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId); + } + } + + /** + * Upload or store some files for a reply, depending if the user is offline or not. + * + * @param {number} forumId Forum ID. + * @param {number} postId ID of the post being replied. + * @param {any[]} files List of files. + * @param {boolean} offline True if files sould be stored for offline, false to upload them. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the reply belongs to. If not defined, current user in site. + * @return {Promise} Promise resolved if success. + */ + uploadOrStoreReplyFiles(forumId: number, postId: number, files: any[], offline: boolean, siteId?: string, userId?: number) + : Promise { + if (offline) { + return this.storeReplyFiles(forumId, postId, files, siteId, userId); + } else { + return this.uploaderProvider.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId); + } + } +} diff --git a/src/addon/mod/forum/providers/index-link-handler.ts b/src/addon/mod/forum/providers/index-link-handler.ts new file mode 100644 index 000000000..4beb1226f --- /dev/null +++ b/src/addon/mod/forum/providers/index-link-handler.ts @@ -0,0 +1,44 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreContentLinksModuleIndexHandler } from '@core/contentlinks/classes/module-index-handler'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { AddonModForumProvider } from './forum'; + +/** + * Handler to treat links to forum index. + */ +@Injectable() +export class AddonModForumIndexLinkHandler extends CoreContentLinksModuleIndexHandler { + name = 'AddonModForumIndexLinkHandler'; + + constructor(courseHelper: CoreCourseHelperProvider, protected forumProvider: AddonModForumProvider) { + super(courseHelper, 'AddonModForum', 'forum'); + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + return true; + } +} diff --git a/src/addon/mod/forum/providers/module-handler.ts b/src/addon/mod/forum/providers/module-handler.ts new file mode 100644 index 000000000..be911f52d --- /dev/null +++ b/src/addon/mod/forum/providers/module-handler.ts @@ -0,0 +1,81 @@ +// (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 { Injectable } from '@angular/core'; +import { NavController, NavOptions } from 'ionic-angular'; +import { AddonModForumIndexComponent } from '../components/index/index'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate'; +import { CoreCourseProvider } from '@core/course/providers/course'; + +/** + * Handler to support forum modules. + */ +@Injectable() +export class AddonModForumModuleHandler implements CoreCourseModuleHandler { + name = 'AddonModForum'; + modName = 'forum'; + + constructor(private courseProvider: CoreCourseProvider) { } + + /** + * Check if the handler is enabled on a site level. + * + * @return {boolean} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean { + return true; + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param {any} module The module object. + * @param {number} courseId The course ID. + * @param {number} sectionId The section ID. + * @return {CoreCourseModuleHandlerData} Data to render the module. + */ + getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData { + return { + icon: this.courseProvider.getModuleIconSrc('forum'), + title: module.name, + class: 'addon-mod_forum-handler', + showDownloadButton: true, + action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { + navCtrl.push('AddonModForumIndexPage', {module: module, courseId: courseId}, options); + } + }; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * + * @param {any} course The course object. + * @param {any} module The module object. + * @return {any} The component to use, undefined if not found. + */ + getMainComponent(course: any, module: any): any { + return AddonModForumIndexComponent; + } + + /** + * Whether to display the course refresher in single activity course format. If it returns false, a refresher must be + * included in the template that calls the doRefresh method of the component. Defaults to true. + * + * @return {boolean} Whether the refresher should be displayed. + */ + displayRefresherInSingleActivity(): boolean { + return false; + } +} diff --git a/src/addon/mod/forum/providers/offline.ts b/src/addon/mod/forum/providers/offline.ts new file mode 100644 index 000000000..ac3a44261 --- /dev/null +++ b/src/addon/mod/forum/providers/offline.ts @@ -0,0 +1,454 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreFileProvider } from '@providers/file'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; + +/** + * Service to handle offline forum. + */ +@Injectable() +export class AddonModForumOfflineProvider { + + // Variables for database. + protected DISCUSSIONS_TABLE = 'addon_mod_forum_discussions'; + protected REPLIES_TABLE = 'addon_mod_forum_replies'; + + protected tablesSchema = [ + { + name: this.DISCUSSIONS_TABLE, + columns: [ + { + name: 'forumid', + type: 'INTEGER', + }, + { + name: 'name', + type: 'TEXT', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'subject', + type: 'TEXT', + }, + { + name: 'message', + type: 'TEXT', + }, + { + name: 'options', + type: 'TEXT', + }, + { + name: 'groupid', + type: 'INTEGER', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'timecreated', + type: 'INTEGER', + } + ], + primaryKeys: ['forumid', 'userid', 'timecreated'] + }, + { + name: this.REPLIES_TABLE, + columns: [ + { + name: 'postid', + type: 'INTEGER', + }, + { + name: 'discussionid', + type: 'INTEGER', + }, + { + name: 'forumid', + type: 'INTEGER', + }, + { + name: 'name', + type: 'TEXT', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'subject', + type: 'TEXT', + }, + { + name: 'message', + type: 'TEXT', + }, + { + name: 'options', + type: 'TEXT', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'timecreated', + type: 'INTEGER', + } + ], + primaryKeys: ['postid', 'userid'] + } + ]; + + constructor(private fileProvider: CoreFileProvider, + private sitesProvider: CoreSitesProvider, + private textUtils: CoreTextUtilsProvider) { + this.sitesProvider.createTablesFromSchema(this.tablesSchema); + } + + /** + * Delete a forum offline discussion. + * + * @param {number} forumId Forum ID. + * @param {number} timeCreated The time the discussion was created. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the discussion belongs to. If not defined, current user in site. + * @return {Promise} Promise resolved if stored, rejected if failure. + */ + deleteNewDiscussion(forumId: number, timeCreated: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { + forumid: forumId, + userid: userId || site.getUserId(), + timecreated: timeCreated, + }; + + return site.getDb().deleteRecords(this.DISCUSSIONS_TABLE, conditions); + }); + } + + /** + * Get a forum offline discussion. + * + * @param {number} forumId Forum ID. + * @param {number} timeCreated The time the discussion was created. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the discussion belongs to. If not defined, current user in site. + * @return {Promise} Promise resolved if stored, rejected if failure. + */ + getNewDiscussion(forumId: number, timeCreated: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { + forumid: forumId, + userid: userId || site.getUserId(), + timecreated: timeCreated, + }; + + return site.getDb().getRecord(this.DISCUSSIONS_TABLE, conditions).then((record) => { + record.options = this.textUtils.parseJSON(record.options); + + return record; + }); + }); + } + + /** + * Get all offline new discussions. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with discussions. + */ + getAllNewDiscussions(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(this.DISCUSSIONS_TABLE).then(this.parseRecordOptions.bind(this)); + }); + } + + /** + * Check if there are offline new discussions to send. + * + * @param {number} forumId Forum ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the discussions belong to. If not defined, current user in site. + * @return {Promise} Promise resolved with boolean: true if has offline answers, false otherwise. + */ + hasNewDiscussions(forumId: number, siteId?: string, userId?: number): Promise { + return this.getNewDiscussions(forumId, siteId, userId).then((discussions) => { + return !!discussions.length; + }).catch(() => { + // No offline data found, return false. + return false; + }); + } + + /** + * Get new discussions to be synced. + * + * @param {number} forumId Forum ID to get. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the discussions belong to. If not defined, current user in site. + * @return {Promise} Promise resolved with the object to be synced. + */ + getNewDiscussions(forumId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { + forumid: forumId, + userid: userId || site.getUserId(), + }; + + return site.getDb().getRecords(this.DISCUSSIONS_TABLE, conditions).then(this.parseRecordOptions.bind(this)); + }); + } + + /** + * Offline version for adding a new discussion to a forum. + * + * @param {number} forumId Forum ID. + * @param {string} name Forum name. + * @param {number} courseId Course ID the forum belongs to. + * @param {string} subject New discussion's subject. + * @param {string} message New discussion's message. + * @param {any} [options] Options (subscribe, pin, ...). + * @param {string} [groupId] Group this discussion belongs to. + * @param {number} [timeCreated] The time the discussion was created. If not defined, current time. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the discussion belong to. If not defined, current user in site. + * @return {Promise} Promise resolved when new discussion is successfully saved. + */ + addNewDiscussion(forumId: number, name: string, courseId: number, subject: string, message: string, options?: any, + groupId?: number, timeCreated?: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const data = { + forumid: forumId, + name: name, + courseid: courseId, + subject: subject, + message: message, + options: JSON.stringify(options || {}), + groupid: groupId || -1, + userid: userId || site.getUserId(), + timecreated: timeCreated || new Date().getTime() + }; + + return site.getDb().insertRecord(this.DISCUSSIONS_TABLE, data); + }); + } + + /** + * Delete forum offline replies. + * + * @param {number} postId ID of the post being replied. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the reply belongs to. If not defined, current user in site. + * @return {Promise} Promise resolved if stored, rejected if failure. + */ + deleteReply(postId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { + postid: postId, + userid: userId || site.getUserId(), + }; + + return site.getDb().deleteRecords(this.REPLIES_TABLE, conditions); + }); + } + + /** + * Get all offline replies. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with replies. + */ + getAllReplies(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(this.REPLIES_TABLE).then(this.parseRecordOptions.bind(this)); + }); + } + + /** + * Check if there is an offline reply for a forum to be synced. + * + * @param {number} forumId ID of the forum being replied. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the replies belong to. If not defined, current user in site. + * @return {Promise} Promise resolved with boolean: true if has offline answers, false otherwise. + */ + hasForumReplies(forumId: number, siteId?: string, userId?: number): Promise { + return this.getForumReplies(forumId, siteId, userId).then((replies) => { + return !!replies.length; + }).catch(() => { + // No offline data found, return false. + return false; + }); + } + + /** + * Get the replies of a forum to be synced. + * + * @param {number} forumId ID of the forum being replied. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the replies belong to. If not defined, current user in site. + * @return {Promise} Promise resolved with replies. + */ + getForumReplies(forumId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { + forumid: forumId, + userid: userId || site.getUserId(), + }; + + return site.getDb().getRecords(this.REPLIES_TABLE, conditions).then(this.parseRecordOptions.bind(this)); + }); + } + + /** + * Check if there is an offline reply to be synced. + * + * @param {number} discussionId ID of the discussion the user is replying to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the replies belong to. If not defined, current user in site. + * @return {Promise} Promise resolved with boolean: true if has offline answers, false otherwise. + */ + hasDiscussionReplies(discussionId: number, siteId?: string, userId?: number): Promise { + return this.getDiscussionReplies(discussionId, siteId, userId).then((replies) => { + return !!replies.length; + }).catch(() => { + // No offline data found, return false. + return false; + }); + } + + /** + * Get the replies of a discussion to be synced. + * + * @param {number} discussionId ID of the discussion the user is replying to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the replies belong to. If not defined, current user in site. + * @return {Promise} Promise resolved with discussions. + */ + getDiscussionReplies(discussionId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { + discussionid: discussionId, + userid: userId || site.getUserId(), + }; + + return site.getDb().getRecords(this.REPLIES_TABLE, conditions).then(this.parseRecordOptions.bind(this)); + }); + } + + /** + * Offline version for replying to a certain post. + * + * @param {number} postId ID of the post being replied. + * @param {number} discussionId ID of the discussion the user is replying to. + * @param {number} forumId ID of the forum the user is replying to. + * @param {string} name Forum name. + * @param {number} courseId Course ID the forum belongs to. + * @param {string} subject New post's subject. + * @param {string} message New post's message. + * @param {any} [options] Options (subscribe, attachments, ...). + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the post belong to. If not defined, current user in site. + * @return {Promise} Promise resolved when the post is created. + */ + replyPost(postId: number, discussionId: number, forumId: number, name: string, courseId: number, subject: string, + message: string, options?: any, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const data = { + postid: postId, + discussionid: discussionId, + forumid: forumId, + name: name, + courseid: courseId, + subject: subject, + message: message, + options: JSON.stringify(options || {}), + userid: userId || site.getUserId(), + timecreated: new Date().getTime() + }; + + return site.getDb().insertRecord(this.REPLIES_TABLE, data); + }); + } + + /** + * Get the path to the folder where to store files for offline attachments in a forum. + * + * @param {number} forumId Forum ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the path. + */ + getForumFolder(forumId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const siteFolderPath = this.fileProvider.getSiteFolder(site.getId()); + + return this.textUtils.concatenatePaths(siteFolderPath, 'offlineforum/' + forumId); + }); + } + + /** + * Get the path to the folder where to store files for a new offline discussion. + * + * @param {number} forumId Forum ID. + * @param {number} timeCreated The time the discussion was created. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the path. + */ + getNewDiscussionFolder(forumId: number, timeCreated: number, siteId?: string): Promise { + return this.getForumFolder(forumId, siteId).then((folderPath) => { + return this.textUtils.concatenatePaths(folderPath, 'newdisc_' + timeCreated); + }); + } + + /** + * Get the path to the folder where to store files for a new offline reply. + * + * @param {number} forumId Forum ID. + * @param {number} postId ID of the post being replied. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the replies belong to. If not defined, current user in site. + * @return {Promise} Promise resolved with the path. + */ + getReplyFolder(forumId: number, postId: number, siteId?: string, userId?: number): Promise { + return this.getForumFolder(forumId, siteId).then((folderPath) => { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return this.textUtils.concatenatePaths(folderPath, 'reply_' + postId + '_' + userId); + }); + }); + } + + /** + * Parse "options" column of fetched records. + * + * @param {any[]} records List of records. + * @return {any[]} List of records with options parsed. + */ + protected parseRecordOptions(records: any[]): any[] { + records.forEach((record) => { + record.options = this.textUtils.parseJSON(record.options); + }); + + return records; + } +} diff --git a/src/addon/mod/forum/providers/prefetch-handler.ts b/src/addon/mod/forum/providers/prefetch-handler.ts new file mode 100644 index 000000000..89da20694 --- /dev/null +++ b/src/addon/mod/forum/providers/prefetch-handler.ts @@ -0,0 +1,240 @@ +// (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 { Injectable, Injector } from '@angular/core'; +import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { AddonModForumProvider } from './forum'; + +/** + * Handler to prefetch forums. + */ +@Injectable() +export class AddonModForumPrefetchHandler extends CoreCourseModulePrefetchHandlerBase { + name = 'AddonModForum'; + modName = 'forum'; + component = AddonModForumProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^discussions$/; + + constructor(injector: Injector, + private groupsProvider: CoreGroupsProvider, + private userProvider: CoreUserProvider, + private forumProvider: AddonModForumProvider) { + super(injector); + } + + /** + * Download the module. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch. + * @return {Promise} Promise resolved when all content is downloaded. + */ + download(module: any, courseId: number, dirPath?: string): Promise { + // Same implementation for download or prefetch. + return this.prefetch(module, courseId, false, dirPath); + } + + /** + * Get list of files. If not defined, we'll assume they're in module.contents. + * + * @param {any} module Module. + * @param {Number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise} Promise resolved with the list of files. + */ + getFiles(module: any, courseId: number, single?: boolean): Promise { + return this.forumProvider.getForum(courseId, module.id).then((forum) => { + const files = this.getIntroFilesFromInstance(module, forum); + + // Get posts. + return this.getPostsForPrefetch(forum.id).then((posts) => { + // Add posts attachments and embedded files. + return files.concat(this.getPostsFiles(posts)); + }); + }).catch(() => { + // Forum not found, return empty list. + return []; + }); + } + + /** + * Given a list of forum posts, return a list with all the files (attachments and embedded files). + * + * @param {any[]} posts Forum posts. + * @return {any[]} Files. + */ + protected getPostsFiles(posts: any[]): any[] { + let files = []; + + posts.forEach((post) => { + if (post.attachments && post.attachments.length) { + files = files.concat(post.attachments); + } + if (post.message) { + files = files.concat(this.domUtils.extractDownloadableFilesFromHtmlAsFakeFileObjects(post.message)); + } + }); + + return files; + } + + /** + * Get the posts to be prefetched. + * + * @param {number} forumId Forum ID + * @return {Promise} Promise resolved with array of posts. + */ + protected getPostsForPrefetch(forumId: number): Promise { + // Get discussions in first 2 pages. + return this.forumProvider.getDiscussionsInPages(forumId, false, 2).then((response) => { + if (response.error) { + return Promise.reject(null); + } + + const promises = []; + let posts = []; + + response.discussions.forEach((discussion) => { + promises.push(this.forumProvider.getDiscussionPosts(discussion.discussion).then((ps) => { + posts = posts.concat(ps); + })); + }); + + return Promise.all(promises).then(() => { + return posts; + }); + }); + } + + /** + * Invalidate the prefetched content. + * + * @param {number} moduleId The module ID. + * @param {number} courseId The course ID the module belongs to. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return this.forumProvider.invalidateContent(moduleId, courseId); + } + + /** + * Prefetch a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch. + * @return {Promise} Promise resolved when done. + */ + prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string): Promise { + return this.prefetchPackage(module, courseId, single, this.prefetchForum.bind(this)); + } + + /** + * Prefetch a forum. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} single True if we're downloading a single module, false if we're downloading a whole section. + * @param {string} siteId Site ID. + * @return {Promise} Promise resolved when done. + */ + protected prefetchForum(module: any, courseId: number, single: boolean, siteId: string): Promise { + // Get the forum data. + return this.forumProvider.getForum(courseId, module.id).then((forum) => { + // Prefetch the posts. + return this.getPostsForPrefetch(forum.id).then((posts) => { + const promises = []; + + // Prefetch user profiles. + const userIds = posts.map((post) => post.userid).filter((userId) => !!userId); + promises.push(this.userProvider.prefetchProfiles(userIds).catch(() => { + // Ignore failures. + })); + + // Prefetch intro files, attachments and embedded files. + const files = this.getIntroFilesFromInstance(module, forum).concat(this.getPostsFiles(posts)); + promises.push(this.filepoolProvider.addFilesToQueue(siteId, files, this.component, module.id)); + + // Prefetch groups data. + promises.push(this.prefetchGroupsInfo(forum, courseId, forum.cancreatediscussions)); + + return Promise.all(promises); + }); + }); + } + + /** + * Prefetch groups info for a forum. + * + * @param {any} module The module object returned by WS. + * @param {number} courseI Course ID the module belongs to. + * @param {boolean} canCreateDiscussions Whether the user can create discussions in the forum. + * @return {Promise} Promise resolved when group data has been prefetched. + */ + protected prefetchGroupsInfo(forum: any, courseId: number, canCreateDiscussions: boolean): any { + // Check group mode. + return this.groupsProvider.getActivityGroupMode(forum.cmid).then((mode) => { + if (mode !== CoreGroupsProvider.SEPARATEGROUPS && mode !== CoreGroupsProvider.VISIBLEGROUPS) { + // Activity doesn't use groups. Prefetch canAddDiscussionToAll to determine if user can pin/attach. + return this.forumProvider.canAddDiscussionToAll(forum.id).catch(() => { + // Ignore errors. + }); + } + + // Activity uses groups, prefetch allowed groups. + return this.groupsProvider.getActivityAllowedGroups(forum.cmid).then((groups) => { + if (mode === CoreGroupsProvider.SEPARATEGROUPS) { + // Groups are already filtered by WS. Prefetch canAddDiscussionToAll to determine if user can pin/attach. + return this.forumProvider.canAddDiscussionToAll(forum.id).catch(() => { + // Ignore errors. + }); + } + + if (canCreateDiscussions) { + // Prefetch data to check the visible groups when creating discussions. + return this.forumProvider.canAddDiscussionToAll(forum.id).catch(() => { + // The call failed, let's assume he can't. + return { + status: false + }; + }).then((response) => { + if (response.status) { + // User can post to all groups, nothing else to prefetch. + return; + } + + // The user can't post to all groups, let's check which groups he can post to. + const groupPromises = []; + groups.forEach((group) => { + groupPromises.push(this.forumProvider.canAddDiscussion(forum.id, group.id).catch(() => { + // Ignore errors. + })); + }); + + return Promise.all(groupPromises); + }); + } + }); + }).catch((error) => { + // Ignore errors if cannot create discussions. + if (canCreateDiscussions) { + return Promise.reject(error); + } + }); + } +} diff --git a/src/addon/mod/forum/providers/sync-cron-handler.ts b/src/addon/mod/forum/providers/sync-cron-handler.ts new file mode 100644 index 000000000..3ce2fcbc5 --- /dev/null +++ b/src/addon/mod/forum/providers/sync-cron-handler.ts @@ -0,0 +1,47 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@providers/cron'; +import { AddonModForumSyncProvider } from './sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class AddonModForumSyncCronHandler implements CoreCronHandler { + name = 'AddonModForumSyncCronHandler'; + + constructor(private forumSync: AddonModForumSyncProvider) {} + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @return {Promise} Promise resolved when done, rejected if failure. + */ + execute(siteId?: string): Promise { + return this.forumSync.syncAllForums(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonModForumSyncProvider.SYNC_TIME; + } +} diff --git a/src/addon/mod/forum/providers/sync.ts b/src/addon/mod/forum/providers/sync.ts new file mode 100644 index 000000000..0a201a5f1 --- /dev/null +++ b/src/addon/mod/forum/providers/sync.ts @@ -0,0 +1,547 @@ +// (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 { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; +import { CoreAppProvider } from '@providers/app'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { AddonModForumProvider } from './forum'; +import { AddonModForumHelperProvider } from './helper'; +import { AddonModForumOfflineProvider } from './offline'; + +/** + * Service to sync forums. + */ +@Injectable() +export class AddonModForumSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'addon_mod_forum_autom_synced'; + static MANUAL_SYNCED = 'addon_mod_forum_manual_synced'; + static SYNC_TIME = 600000; + + protected componentTranslate: string; + + constructor(translate: TranslateService, + appProvider: CoreAppProvider, + courseProvider: CoreCourseProvider, + private eventsProvider: CoreEventsProvider, + loggerProvider: CoreLoggerProvider, + sitesProvider: CoreSitesProvider, + syncProvider: CoreSyncProvider, + textUtils: CoreTextUtilsProvider, + private uploaderProvider: CoreFileUploaderProvider, + private utils: CoreUtilsProvider, + private forumProvider: AddonModForumProvider, + private forumHelper: AddonModForumHelperProvider, + private forumOffline: AddonModForumOfflineProvider) { + + super('AddonModForumSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); + + this.componentTranslate = courseProvider.translateModuleName('forum'); + } + + /** + * Try to synchronize all the forums in a certain site or in all sites. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllForums(siteId?: string): Promise { + return this.syncOnSites('all forums', this.syncAllForumsFunc.bind(this), [], siteId); + } + + /** + * Sync all forums on a site. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + protected syncAllForumsFunc(siteId?: string): Promise { + const sitePromises = []; + + // Sync all new discussions. + sitePromises.push(this.forumOffline.getAllNewDiscussions(siteId).then((discussions) => { + const promises = {}; + + // Do not sync same forum twice. + discussions.forEach((discussion) => { + if (typeof promises[discussion.forumid] != 'undefined') { + return; + } + + promises[discussion.forumid] = this.syncForumDiscussionsIfNeeded(discussion.forumid, discussion.userid, siteId) + .then((result) => { + if (result && result.updated) { + // Sync successful, send event. + this.eventsProvider.trigger(AddonModForumSyncProvider.AUTO_SYNCED, { + forumId: discussion.forumid, + userId: discussion.userid, + warnings: result.warnings + }, siteId); + } + }); + }); + + return Promise.all(this.utils.objectToArray(promises)); + })); + + // Sync all discussion replies. + sitePromises.push(this.forumOffline.getAllReplies(siteId).then((replies) => { + const promises = {}; + + // Do not sync same discussion twice. + replies.forEach((reply) => { + if (typeof promises[reply.discussionid] != 'undefined') { + return; + } + + promises[reply.discussionid] = this.syncDiscussionRepliesIfNeeded(reply.discussionid, reply.userid, siteId) + .then((result) => { + if (result && result.updated) { + // Sync successful, send event. + this.eventsProvider.trigger(AddonModForumSyncProvider.AUTO_SYNCED, { + forumId: reply.forumid, + discussionId: reply.discussionid, + userId: reply.userid, + warnings: result.warnings + }, siteId); + } + }); + }); + + return Promise.all(this.utils.objectToArray(promises)); + })); + + return Promise.all(sitePromises); + } + + /** + * Sync a forum only if a certain time has passed since the last time. + * + * @param {number} forumId Forum ID. + * @param {number} userId User the discussion belong to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the forum is synced or if it doesn't need to be synced. + */ + syncForumDiscussionsIfNeeded(forumId: number, userId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const syncId = this.getForumSyncId(forumId, userId); + + return this.isSyncNeeded(syncId, siteId).then((needed) => { + if (needed) { + return this.syncForumDiscussions(forumId, userId, siteId); + } + }); + } + + /** + * Synchronize all offline discussions of a forum. + * + * @param {number} forumId Forum ID to be synced. + * @param {number} [userId] User the discussions belong to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + syncForumDiscussions(forumId: number, userId?: number, siteId?: string): Promise { + userId = userId || this.sitesProvider.getCurrentSiteUserId(); + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const syncId = this.getForumSyncId(forumId, userId); + + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for this discussion, return the promise. + return this.getOngoingSync(syncId, siteId); + } + + // Verify that forum isn't blocked. + if (this.syncProvider.isBlocked(AddonModForumProvider.COMPONENT, syncId, siteId)) { + this.logger.debug('Cannot sync forum ' + forumId + ' because it is blocked.'); + + return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); + } + + this.logger.debug('Try to sync forum ' + forumId + ' for user ' + userId); + + const result = { + warnings: [], + updated: false + }; + + // Get offline responses to be sent. + const syncPromise = this.forumOffline.getNewDiscussions(forumId, siteId, userId).catch(() => { + // No offline data found, return empty object. + return []; + }).then((discussions) => { + if (!discussions.length) { + // Nothing to sync. + return; + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + const promises = []; + + discussions.forEach((data) => { + data.options = data.options || {}; + + // First of all upload the attachments (if any). + const promise = this.uploadAttachments(forumId, data, true, siteId, userId).then((itemId) => { + // Now try to add the discussion. + data.options.attachmentsid = itemId; + + return this.forumProvider.addNewDiscussionOnline(forumId, data.subject, data.message, + data.options, data.groupid, siteId); + }); + + promises.push(promise.then(() => { + result.updated = true; + + return this.deleteNewDiscussion(forumId, data.timecreated, siteId, userId); + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. Delete them. + result.updated = true; + + return this.deleteNewDiscussion(forumId, data.timecreated, siteId, userId).then(() => { + // Responses deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: data.name, + error: error.error + })); + }); + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + })); + }); + + return Promise.all(promises); + }).then(() => { + if (result.updated) { + // Data has been sent to server. Now invalidate the WS calls. + const promises = [ + this.forumProvider.invalidateDiscussionsList(forumId, siteId), + this.forumProvider.invalidateCanAddDiscussion(forumId, siteId), + ]; + + return Promise.all(promises).catch(() => { + // Ignore errors. + }); + } + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(syncId, siteId).catch(() => { + // Ignore errors. + }); + }).then(() => { + // All done, return the warnings. + return result; + }); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } + + /** + * Synchronize all offline discussion replies of a forum. + * + * @param {number} forumId Forum ID to be synced. + * @param {number} [userId] User the discussions belong to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + syncForumReplies(forumId: number, userId?: number, siteId?: string): Promise { + // Get offline forum replies to be sent. + return this.forumOffline.getForumReplies(forumId, siteId, userId).catch(() => { + // No offline data found, return empty list. + return []; + }).then((replies) => { + if (!replies.length) { + // Nothing to sync. + return { warnings: [], updated: false }; + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + const promises = {}; + + // Do not sync same discussion twice. + replies.forEach((reply) => { + if (typeof promises[reply.discussionid] != 'undefined') { + return; + } + promises[reply.discussionid] = this.syncDiscussionReplies(reply.discussionid, userId, siteId); + }); + + return Promise.all(this.utils.objectToArray(promises)).then((results) => { + return results.reduce((a, b) => ({ + warnings: a.warnings.concat(b.warnings), + updated: a.updated || b.updated, + }), { warnings: [], updated: false }); + }); + }); + } + + /** + * Sync a forum discussion replies only if a certain time has passed since the last time. + * + * @param {number} discussionId Discussion ID to be synced. + * @param {number} [userId] User the posts belong to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the forum discussion is synced or if it doesn't need to be synced. + */ + syncDiscussionRepliesIfNeeded(discussionId: number, userId?: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const syncId = this.getDiscussionSyncId(discussionId, userId); + + return this.isSyncNeeded(syncId, siteId).then((needed) => { + if (needed) { + return this.syncDiscussionReplies(discussionId, userId, siteId); + } + }); + } + + /** + * Synchronize all offline replies from a discussion. + * + * @param {number} discussionId Discussion ID to be synced. + * @param {number} [userId] User the posts belong to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + syncDiscussionReplies(discussionId: number, userId?: number, siteId?: string): Promise { + userId = userId || this.sitesProvider.getCurrentSiteUserId(); + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const syncId = this.getDiscussionSyncId(discussionId, userId); + + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for this discussion, return the promise. + return this.getOngoingSync(syncId, siteId); + } + + // Verify that forum isn't blocked. + if (this.syncProvider.isBlocked(AddonModForumProvider.COMPONENT, syncId, siteId)) { + this.logger.debug('Cannot sync forum discussion ' + discussionId + ' because it is blocked.'); + + return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); + } + + this.logger.debug('Try to sync forum discussion ' + discussionId + ' for user ' + userId); + + let forumId; + const result = { + warnings: [], + updated: false + }; + + // Get offline responses to be sent. + const syncPromise = this.forumOffline.getDiscussionReplies(discussionId, siteId, userId).catch(() => { + // No offline data found, return empty object. + return []; + }).then((replies) => { + if (!replies.length) { + // Nothing to sync. + return; + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + const promises = []; + + replies.forEach((data) => { + forumId = data.forumid; + data.options = data.options || {}; + + // First of all upload the attachments (if any). + const promise = this.uploadAttachments(forumId, data, false, siteId, userId).then((itemId) => { + // Now try to send the reply. + data.options.attachmentsid = itemId; + + return this.forumProvider.replyPostOnline(data.postid, data.subject, data.message, data.options, siteId); + }); + + promises.push(promise.then(() => { + result.updated = true; + + return this.deleteReply(forumId, data.postid, siteId, userId); + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. Delete them. + result.updated = true; + + return this.deleteReply(forumId, data.postid, siteId, userId).then(() => { + // Responses deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: data.name, + error: error.error + })); + }); + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + })); + }); + + return Promise.all(promises); + }).then(() => { + // Data has been sent to server. Now invalidate the WS calls. + const promises = []; + if (forumId) { + promises.push(this.forumProvider.invalidateDiscussionsList(forumId, siteId)); + } + promises.push(this.forumProvider.invalidateDiscussionPosts(discussionId, siteId)); + + return this.utils.allPromises(promises).catch(() => { + // Ignore errors. + }); + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(syncId, siteId).catch(() => { + // Ignore errors. + }); + }).then(() => { + // All done, return the warnings. + return result; + }); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } + + /** + * Delete a new discussion. + * + * @param {number} forumId Forum ID the discussion belongs to. + * @param {number} timecreated The timecreated of the discussion. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the discussion belongs to. If not defined, current user in site. + * @return {Promise} Promise resolved when deleted. + */ + protected deleteNewDiscussion(forumId: number, timecreated: number, siteId?: string, userId?: number): Promise { + const promises = []; + + promises.push(this.forumOffline.deleteNewDiscussion(forumId, timecreated, siteId, userId)); + promises.push(this.forumHelper.deleteNewDiscussionStoredFiles(forumId, timecreated, siteId).catch(() => { + // Ignore errors, maybe there are no files. + })); + + return Promise.all(promises); + } + + /** + * Delete a new discussion. + * + * @param {number} forumId Forum ID the discussion belongs to. + * @param {number} postId ID of the post being replied. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the discussion belongs to. If not defined, current user in site. + * @return {Promise} Promise resolved when deleted. + */ + protected deleteReply(forumId: number, postId: number, siteId?: string, userId?: number): Promise { + const promises = []; + + promises.push(this.forumOffline.deleteReply(postId, siteId, userId)); + promises.push(this.forumHelper.deleteReplyStoredFiles(forumId, postId, siteId, userId).catch(() => { + // Ignore errors, maybe there are no files. + })); + + return Promise.all(promises); + } + + /** + * Upload attachments of an offline post/discussion. + * + * @param {number} forumId Forum ID the post belongs to. + * @param {any} post Offline post or discussion. + * @param {boolean} isDisc True if it's a new discussion, false if it's a reply. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the reply belongs to. If not defined, current user in site. + * @return {Promise} Promise resolved with draftid if uploaded, resolved with undefined if nothing to upload. + */ + protected uploadAttachments(forumId: number, post: any, isDisc: boolean, siteId?: string, userId?: number): Promise { + const attachments = post && post.options && post.options.attachmentsid; + + if (attachments) { + // Has some attachments to sync. + let files = attachments.online || []; + let promise; + + if (attachments.offline) { + // Has offline files. + if (isDisc) { + promise = this.forumHelper.getNewDiscussionStoredFiles(forumId, post.timecreated, siteId); + } else { + promise = this.forumHelper.getReplyStoredFiles(forumId, post.postid, siteId, userId); + } + + promise.then((atts) => { + files = files.concat(atts); + }).catch(() => { + // Folder not found, no files to add. + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + return this.uploaderProvider.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId); + }); + } + + // No attachments, resolve. + return Promise.resolve(); + } + + /** + * Get the ID of a forum sync. + * + * @param {number} forumId Forum ID. + * @param {number} [userId] User the responses belong to.. If not defined, current user. + * @return {string} Sync ID. + */ + getForumSyncId(forumId: number, userId?: number): string { + userId = userId || this.sitesProvider.getCurrentSiteUserId(); + + return 'forum#' + forumId + '#' + userId; + } + + /** + * Get the ID of a discussion sync. + * + * @param {number} discussionId Discussion ID. + * @param {number} [userId] User the responses belong to.. If not defined, current user. + * @return {string} Sync ID. + */ + getDiscussionSyncId(discussionId: number, userId?: number): string { + userId = userId || this.sitesProvider.getCurrentSiteUserId(); + + return 'discussion#' + discussionId + '#' + userId; + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 3f85cb6bf..cec2dc5da 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -84,6 +84,7 @@ import { AddonModLabelModule } from '@addon/mod/label/label.module'; import { AddonModResourceModule } from '@addon/mod/resource/resource.module'; import { AddonModFeedbackModule } from '@addon/mod/feedback/feedback.module'; import { AddonModFolderModule } from '@addon/mod/folder/folder.module'; +import { AddonModForumModule } from '@addon/mod/forum/forum.module'; import { AddonModPageModule } from '@addon/mod/page/page.module'; import { AddonModQuizModule } from '@addon/mod/quiz/quiz.module'; import { AddonModScormModule } from '@addon/mod/scorm/scorm.module'; @@ -184,6 +185,7 @@ export const CORE_PROVIDERS: any[] = [ AddonModResourceModule, AddonModFeedbackModule, AddonModFolderModule, + AddonModForumModule, AddonModPageModule, AddonModQuizModule, AddonModScormModule, diff --git a/src/core/course/classes/main-activity-component.ts b/src/core/course/classes/main-activity-component.ts index f1fe1bf30..d0d8489f0 100644 --- a/src/core/course/classes/main-activity-component.ts +++ b/src/core/course/classes/main-activity-component.ts @@ -84,25 +84,6 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR } } - /** - * 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.loaded) { - return this.refreshContent(true, showErrors).finally(() => { - refresher && refresher.complete(); - done && done(); - }); - } - - return Promise.resolve(); - } - /** * Compares sync event data with current data to check if refresh content is needed. * diff --git a/src/core/course/classes/main-resource-component.ts b/src/core/course/classes/main-resource-component.ts index 2023b1175..5d753824f 100644 --- a/src/core/course/classes/main-resource-component.ts +++ b/src/core/course/classes/main-resource-component.ts @@ -17,7 +17,8 @@ import { TranslateService } from '@ngx-translate/core'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreCourseHelperProvider } from '@core/course/providers/helper'; -import { CoreCourseModuleMainComponent } from '@core/course/providers/module-delegate'; +import { CoreCourseModuleMainComponent, CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; +import { CoreCourseSectionPage } from '@core/course/pages/section/section.ts'; /** * Template class to easily create CoreCourseModuleMainComponent of resources (or activities without syncing). @@ -50,12 +51,16 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, protected courseHelper: CoreCourseHelperProvider; protected translate: TranslateService; protected domUtils: CoreDomUtilsProvider; + protected moduleDelegate: CoreCourseModuleDelegate; + protected courseSectionPage: CoreCourseSectionPage; constructor(injector: Injector) { this.textUtils = injector.get(CoreTextUtilsProvider); this.courseHelper = injector.get(CoreCourseHelperProvider); this.translate = injector.get(TranslateService); this.domUtils = injector.get(CoreDomUtilsProvider); + this.moduleDelegate = injector.get(CoreCourseModuleDelegate); + this.courseSectionPage = injector.get(CoreCourseSectionPage, null); this.dataRetrieved = new EventEmitter(); } @@ -73,15 +78,27 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, /** * Refresh the data. * - * @param {any} [refresher] Refresher. - * @param {Function} [done] Function to call when done. + * @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): Promise { + doRefresh(refresher?: any, done?: () => void, showErrors: boolean = false): Promise { if (this.loaded) { - return this.refreshContent().finally(() => { - refresher && refresher.complete(); - done && done(); + /* If it's a single activity course and the refresher is displayed within the component, + call doRefresh on the section page to refresh the course data. */ + let promise; + if (this.courseSectionPage && !this.moduleDelegate.displayRefresherInSingleActivity(this.module.modname)) { + promise = this.courseSectionPage.doRefresh(); + } else { + promise = Promise.resolve(); + } + + return promise.finally(() => { + return this.refreshContent(true, showErrors).finally(() => { + refresher && refresher.complete(); + done && done(); + }); }); } @@ -91,9 +108,11 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, /** * Perform the refresh content function. * + * @param {boolean} [sync=false] If the refresh needs syncing. + * @param {boolean} [showErrors=false] Wether to show errors to the user or hide them. * @return {Promise} Resolved when done. */ - protected refreshContent(): Promise { + protected refreshContent(sync: boolean = false, showErrors: boolean = false): Promise { this.refreshIcon = 'spinner'; return this.invalidateContent().catch(() => { diff --git a/src/core/course/formats/singleactivity/providers/handler.ts b/src/core/course/formats/singleactivity/providers/handler.ts index 67090c9ee..977ada030 100644 --- a/src/core/course/formats/singleactivity/providers/handler.ts +++ b/src/core/course/formats/singleactivity/providers/handler.ts @@ -14,6 +14,7 @@ import { Injectable, Injector } from '@angular/core'; import { CoreCourseFormatHandler } from '../../../providers/format-delegate'; +import { CoreCourseModuleDelegate } from '../../../providers/module-delegate'; import { CoreCourseFormatSingleActivityComponent } from '../components/singleactivity'; /** @@ -24,7 +25,7 @@ export class CoreCourseFormatSingleActivityHandler implements CoreCourseFormatHa name = 'CoreCourseFormatSingleActivity'; format = 'singleactivity'; - constructor() { + constructor(private moduleDelegate: CoreCourseModuleDelegate) { // Nothing to do. } @@ -83,6 +84,22 @@ export class CoreCourseFormatSingleActivityHandler implements CoreCourseFormatHa return false; } + /** + * Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format, + * and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true. + * + * @param {any} course The course to check. + * @param {any[]} sections List of course sections. + * @return {boolean} Whether the refresher should be displayed. + */ + displayRefresher(course: any, sections: any[]): boolean { + if (sections && sections[0] && sections[0].modules && sections[0].modules[0]) { + return this.moduleDelegate.displayRefresherInSingleActivity(sections[0].modules[0].modname); + } else { + return true; + } + } + /** * Return the Component to use to display the course format instead of using the default one. * Use it if you want to display a format completely different from the default one. diff --git a/src/core/course/pages/section/section.html b/src/core/course/pages/section/section.html index cb9f66456..16a95b37b 100644 --- a/src/core/course/pages/section/section.html +++ b/src/core/course/pages/section/section.html @@ -17,7 +17,7 @@ - + diff --git a/src/core/course/pages/section/section.ts b/src/core/course/pages/section/section.ts index 3c5f55f39..2d31e9a50 100644 --- a/src/core/course/pages/section/section.ts +++ b/src/core/course/pages/section/section.ts @@ -54,6 +54,7 @@ export class CoreCourseSectionPage implements OnDestroy { }; moduleId: number; displayEnableDownload: boolean; + displayRefresher: boolean; protected module: any; protected completionObserver; @@ -188,6 +189,9 @@ export class CoreCourseSectionPage implements OnDestroy { // Get the title again now that we have sections. this.title = this.courseFormatDelegate.getCourseTitle(this.course, this.sections); + + // Get whether to show the refresher now that we have sections. + this.displayRefresher = this.courseFormatDelegate.displayRefresher(this.course, this.sections); }); })); @@ -212,13 +216,23 @@ export class CoreCourseSectionPage implements OnDestroy { /** * Refresh the data. * - * @param {any} refresher Refresher. + * @param {any} [refresher] Refresher. + * @return {Promise} Promise resolved when done. */ - doRefresh(refresher: any): void { - this.invalidateData().finally(() => { - this.loadData(true).finally(() => { - this.formatComponent.doRefresh(refresher).finally(() => { - refresher.complete(); + doRefresh(refresher?: any): Promise { + return this.invalidateData().finally(() => { + return this.loadData(true).finally(() => { + /* Do not call doRefresh on the format component if the refresher is defined in the format component + to prevent an inifinite loop. */ + let promise; + if (this.displayRefresher) { + promise = this.formatComponent.doRefresh(refresher); + } else { + promise = Promise.resolve(); + } + + return promise.finally(() => { + refresher && refresher.complete(); }); }); }); diff --git a/src/core/course/providers/default-format.ts b/src/core/course/providers/default-format.ts index 7c9ad4648..10919e155 100644 --- a/src/core/course/providers/default-format.ts +++ b/src/core/course/providers/default-format.ts @@ -77,6 +77,18 @@ export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler { return true; } + /** + * Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format, + * and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true. + * + * @param {any} course The course to check. + * @param {any[]} sections List of course sections. + * @return {boolean} Whether the refresher should be displayed. + */ + displayRefresher?(course: any, sections: any[]): boolean { + return true; + } + /** * Given a list of sections, get the "current" section that should be displayed first. * diff --git a/src/core/course/providers/default-module.ts b/src/core/course/providers/default-module.ts index 65b52a9df..04596a46e 100644 --- a/src/core/course/providers/default-module.ts +++ b/src/core/course/providers/default-module.ts @@ -89,4 +89,14 @@ export class CoreCourseModuleDefaultHandler implements CoreCourseModuleHandler { // We can't inject CoreCourseUnsupportedModuleComponent here due to circular dependencies. // Don't return anything, by default it will use CoreCourseUnsupportedModuleComponent. } + + /** + * Whether to display the course refresher in single activity course format. If it returns false, a refresher must be + * included in the template that calls the doRefresh method of the component. Defaults to true. + * + * @return {boolean} Whether the refresher should be displayed. + */ + displayRefresherInSingleActivity(): boolean { + return true; + } } diff --git a/src/core/course/providers/format-delegate.ts b/src/core/course/providers/format-delegate.ts index e79841ace..218a9bfdf 100644 --- a/src/core/course/providers/format-delegate.ts +++ b/src/core/course/providers/format-delegate.ts @@ -65,6 +65,16 @@ export interface CoreCourseFormatHandler extends CoreDelegateHandler { */ displaySectionSelector?(course: any): boolean; + /** + * Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format, + * and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true. + * + * @param {any} course The course to check. + * @param {any[]} sections List of course sections. + * @type {boolean} Whether the refresher should be displayed. + */ + displayRefresher?(course: any, sections: any[]): boolean; + /** * Given a list of sections, get the "current" section that should be displayed first. Defaults to first section. * @@ -183,6 +193,18 @@ export class CoreCourseFormatDelegate extends CoreDelegate { return this.executeFunctionOnEnabled(course.format, 'displayEnableDownload', [course]); } + /** + * Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format, + * and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true. + * + * @param {any} course The course to check. + * @param {any[]} sections List of course sections. + * @return {boolean} Whether the refresher should be displayed. + */ + displayRefresher(course: any, sections: any[]): boolean { + return this.executeFunctionOnEnabled(course.format, 'displayRefresher', [course, sections]); + } + /** * Whether the default section selector should be displayed. Defaults to true. * diff --git a/src/core/course/providers/module-delegate.ts b/src/core/course/providers/module-delegate.ts index 530fd0ec1..b4981020e 100644 --- a/src/core/course/providers/module-delegate.ts +++ b/src/core/course/providers/module-delegate.ts @@ -53,6 +53,14 @@ export interface CoreCourseModuleHandler extends CoreDelegateHandler { * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. */ getMainComponent(injector: Injector, course: any, module: any): any | Promise; + + /** + * Whether to display the course refresher in single activity course format. If it returns false, a refresher must be + * included in the template that calls the doRefresh method of the component. Defaults to true. + * + * @return {boolean} Whether the refresher should be displayed. + */ + displayRefresherInSingleActivity?(): boolean; } /** @@ -247,4 +255,15 @@ export class CoreCourseModuleDelegate extends CoreDelegate { return false; } + + /** + * Whether to display the course refresher in single activity course format. If it returns false, a refresher must be + * included in the template that calls the doRefresh method of the component. Defaults to true. + * + * @param {any} modname The name of the module type. + * @return {boolean} Whether the refresher should be displayed. + */ + displayRefresherInSingleActivity(modname: string): boolean { + return this.executeFunctionOnEnabled(modname, 'displayRefresherInSingleActivity'); + } } diff --git a/src/providers/sync.ts b/src/providers/sync.ts index 9137e997f..510fa7be5 100644 --- a/src/providers/sync.ts +++ b/src/providers/sync.ts @@ -65,11 +65,11 @@ export class CoreSyncProvider { * Block a component and ID so it cannot be synchronized. * * @param {string} component Component name. - * @param {number} id Unique ID per component. + * @param {string | number} id Unique ID per component. * @param {string} [operation] Operation name. If not defined, a default text is used. * @param {string} [siteId] Site ID. If not defined, current site. */ - blockOperation(component: string, id: number, operation?: string, siteId?: string): void { + blockOperation(component: string, id: string | number, operation?: string, siteId?: string): void { siteId = siteId || this.sitesProvider.getCurrentSiteId(); const uniqueId = this.getUniqueSyncBlockId(component, id); @@ -104,10 +104,10 @@ export class CoreSyncProvider { * Clear all blocks for a certain component. * * @param {string} component Component name. - * @param {number} id Unique ID per component. + * @param {string | number} id Unique ID per component. * @param {string} [siteId] Site ID. If not defined, current site. */ - clearBlocks(component: string, id: number, siteId?: string): void { + clearBlocks(component: string, id: string | number, siteId?: string): void { siteId = siteId || this.sitesProvider.getCurrentSiteId(); const uniqueId = this.getUniqueSyncBlockId(component, id); @@ -150,10 +150,10 @@ export class CoreSyncProvider { * Convenience function to create unique identifiers for a component and id. * * @param {string} component Component name. - * @param {number} id Unique ID per component. + * @param {string | number} id Unique ID per component. * @return {string} Unique sync id. */ - protected getUniqueSyncBlockId(component: string, id: number): string { + protected getUniqueSyncBlockId(component: string, id: string | number): string { return component + '#' + id; } @@ -162,11 +162,11 @@ export class CoreSyncProvider { * One block can have different operations. Here we check how many operations are being blocking the object. * * @param {string} component Component name. - * @param {number} id Unique ID per component. + * @param {string | number} id Unique ID per component. * @param {string} [siteId] Site ID. If not defined, current site. * @return {boolean} Whether it's blocked. */ - isBlocked(component: string, id: number, siteId?: string): boolean { + isBlocked(component: string, id: string | number, siteId?: string): boolean { siteId = siteId || this.sitesProvider.getCurrentSiteId(); if (!this.blockedItems[siteId]) { @@ -185,11 +185,11 @@ export class CoreSyncProvider { * Unblock an operation on a component and ID. * * @param {string} component Component name. - * @param {number} id Unique ID per component. + * @param {string | number} id Unique ID per component. * @param {string} [operation] Operation name. If not defined, a default text is used. * @param {string} [siteId] Site ID. If not defined, current site. */ - unblockOperation(component: string, id: number, operation?: string, siteId?: string): void { + unblockOperation(component: string, id: string | number, operation?: string, siteId?: string): void { operation = operation || '-'; siteId = siteId || this.sitesProvider.getCurrentSiteId();