diff --git a/src/addons/messages/pages/contacts/contacts.html b/src/addons/messages/pages/contacts/contacts.html index 628c289c7..9fd4b990f 100644 --- a/src/addons/messages/pages/contacts/contacts.html +++ b/src/addons/messages/pages/contacts/contacts.html @@ -29,7 +29,7 @@ - diff --git a/src/addons/mod/forum/components/components.module.ts b/src/addons/mod/forum/components/components.module.ts new file mode 100644 index 000000000..45603fbcc --- /dev/null +++ b/src/addons/mod/forum/components/components.module.ts @@ -0,0 +1,36 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreSharedModule } from '@/core/shared.module'; +import { CoreCourseComponentsModule } from '@features/course/components/components.module'; +import { CoreTagComponentsModule } from '@features/tag/components/components.module'; + +import { AddonModForumIndexComponent } from './index/index'; + +@NgModule({ + declarations: [ + AddonModForumIndexComponent, + ], + imports: [ + CoreSharedModule, + CoreCourseComponentsModule, + CoreTagComponentsModule, + ], + exports: [ + AddonModForumIndexComponent, + ], +}) +export class AddonModForumComponentsModule {} diff --git a/src/addons/mod/forum/components/index/index.html b/src/addons/mod/forum/components/index/index.html new file mode 100644 index 000000000..b5504a53a --- /dev/null +++ b/src/addons/mod/forum/components/index/index.html @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate:{$a: moduleName} }} + + + + + + + + {{ availabilityMessage }} + + + + + + + +
+ + {{ selectedSortOrder.label | translate }} +
+
+
+ + + +
+

+ +

+
+
+ + +
+

{{discussion.userfullname}}

+

{{ discussion.groupname }}

+

{{ 'core.notsent' | translate }}

+
+
+
+
+ + + +
+

+ + + + + +

+ + + + +
+
+ + +
+

{{discussion.userfullname}}

+

{{ discussion.groupname }}

+

{{discussion.created * 1000 | coreFormatDate: "strftimerecentfull"}}

+
+
+ + + + {{ 'addon.mod_forum.lastpost' | translate }} + {{discussion.timemodified | coreTimeAgo}} + {{discussion.created | coreTimeAgo}} + + + + + {{ 'addon.mod_forum.numreplies' | translate:{numreplies: discussion.numreplies} }} + + {{ discussion.numunread }} + + + + +
+
+ +
+
+ + + + + + +
+
diff --git a/src/addons/mod/forum/components/index/index.scss b/src/addons/mod/forum/components/index/index.scss new file mode 100644 index 000000000..1a1c03ae2 --- /dev/null +++ b/src/addons/mod/forum/components/index/index.scss @@ -0,0 +1,65 @@ +@import '../../../../../theme/globals.scss'; + +:host { + + .addon-forum-sorting-select { + display: flex; + + .core-button-select { + flex: 1; + } + + .core-button-select-text { + overflow: hidden; + text-overflow: ellipsis; + } + + } + + .addon-forum-star { + color: var(--core-color); + } + + .addon-mod-forum-discussion.item { + + ion-label { + margin-top: 4px; + + h2 { + margin-top: 8px; + margin-bottom: 8px; + font-weight: bold; + + ion-icon { + @include margin(0, 6px, 0, 0); + } + + } + + } + + core-user-avatar { + --core-avatar-size: var(--addon-forum-avatar-size); + + @include margin(0, 8px, 0, 0); + } + + .addon-mod-forum-discussion-title, + .addon-mod-forum-discussion-info { + display: flex; + align-items: center; + } + + .addon-mod-forum-discussion-title h2, + .addon-mod-forum-discussion-info .addon-mod-forum-discussion-author { + flex-grow: 1; + } + + .addon-mod-forum-discussion-more-info { + font-size: 1.4rem; + clear: both; + } + + } + +} diff --git a/src/addons/mod/forum/components/index/index.ts b/src/addons/mod/forum/components/index/index.ts new file mode 100644 index 000000000..702f1b44c --- /dev/null +++ b/src/addons/mod/forum/components/index/index.ts @@ -0,0 +1,563 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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, OnInit, OnDestroy } from '@angular/core'; +import { IonContent } from '@ionic/angular'; +import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; +import { + AddonModForum, + AddonModForumData, + AddonModForumProvider, + AddonModForumSortOrder, + AddonModForumDiscussion, +} from '@addons/mod/forum/services/forum.service'; +import { AddonModForumOffline, AddonModForumOfflineDiscussion } from '@addons/mod/forum/services/offline.service'; +import { Translate } from '@singletons'; +import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; +import { AddonModForumHelper } from '@addons/mod/forum/services/helper.service'; +import { CoreGroups, CoreGroupsProvider } from '@services/groups'; +import { CoreEvents } from '@singletons/events'; +import { AddonModForumSyncProvider } from '@addons/mod/forum/services/sync.service'; +import { CoreSites } from '@services/sites'; +import { CoreUser } from '@features/user/services/user'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreCourse } from '@features/course/services/course'; + +/** + * Component that displays a forum entry page. + */ +@Component({ + selector: 'addon-mod-forum-index', + templateUrl: 'index.html', + styleUrls: ['index.scss'], +}) +export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy { + + component = AddonModForumProvider.COMPONENT; + moduleName = 'forum'; + + descriptionNote?: string; + forum?: AddonModForumData; + canLoadMore = false; + loadMoreError = false; + discussions: AddonModForumDiscussion[] = []; + offlineDiscussions: AddonModForumOfflineDiscussion[] = []; + selectedDiscussion = 0; // Disucssion ID or negative timecreated if it's an offline discussion. + canAddDiscussion = false; + addDiscussionText!: string; + availabilityMessage: string | null = null; + + sortingAvailable!: boolean; + sortOrders: AddonModForumSortOrder[] = []; + selectedSortOrder: AddonModForumSortOrder | null = null; + sortOrderSelectorExpanded = false; + + protected syncEventName = AddonModForumSyncProvider.AUTO_SYNCED; + protected page = 0; + protected trackPosts = false; + protected usesGroups = false; + protected canPin = false; + protected syncManualObserver: any; // It will observe the sync manual event. + protected replyObserver: any; + protected newDiscObserver: any; + protected viewDiscObserver: any; + protected changeDiscObserver: any; + + hasOfflineRatings?: boolean; + protected ratingOfflineObserver: any; + protected ratingSyncObserver: any; + + constructor( + @Optional() protected content?: IonContent, + @Optional() courseContentsPage?: CoreCourseContentsPage, + ) { + super('AddonModForumIndexComponent', content, courseContentsPage); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + this.addDiscussionText = Translate.instance.instant('addon.mod_forum.addanewdiscussion'); + this.sortingAvailable = AddonModForum.instance.isDiscussionListSortingAvailable(); + this.sortOrders = AddonModForum.instance.getAvailableSortOrders(); + + await super.ngOnInit(); + await this.loadContent(false, true); + + if (!this.forum) { + return; + } + + CoreUtils.instance.ignoreErrors( + AddonModForum.instance + .logView(this.forum.id, this.forum.name) + .then(async () => { + CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); + + return; + }), + ); + } + + /** + * 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(); + this.changeDiscObserver && this.changeDiscObserver.off(); + this.ratingOfflineObserver && this.ratingOfflineObserver.off(); + this.ratingSyncObserver && this.ratingSyncObserver.off(); + } + + /** + * Download the component contents. + * + * @param refresh Whether we're refreshing data. + * @param sync If the refresh needs syncing. + * @param showErrors Wether to show errors to the user or hide them. + */ + protected async fetchContent(refresh: boolean = false, sync: boolean = false): Promise { + this.loadMoreError = false; + + const promises: Promise[] = []; + + promises.push(this.fetchForum()); + promises.push(this.fetchSortOrderPreference()); + + try { + await Promise.all(promises); + await Promise.all([ + this.fetchOfflineDiscussions(), + this.fetchDiscussions(refresh), + ]); + } catch (error) { + if (refresh) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true); + + this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. + } else { + // Get forum failed, retry without using cache since it might be a new activity. + await this.refreshContent(sync); + } + } + + this.fillContextMenu(refresh); + } + + private async fetchForum(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + if (!this.courseId || !this.module) { + return; + } + + this.loadMoreError = false; + + const promises: Promise[] = []; + + promises.push( + AddonModForum.instance + .getForum(this.courseId, this.module.id) + .then(async (forum) => { + this.forum = forum; + + this.description = forum.intro || this.description; + this.descriptionNote = Translate.instant('addon.mod_forum.numdiscussions', { + numdiscussions: forum.numdiscussions, + }); + if (typeof forum.istracked != 'undefined') { + this.trackPosts = forum.istracked; + } + + this.availabilityMessage = AddonModForumHelper.instance.getAvailabilityMessage(forum); + + this.dataRetrieved.emit(forum); + + switch (forum.type) { + case 'news': + case 'blog': + this.addDiscussionText = Translate.instant('addon.mod_forum.addanewtopic'); + break; + case 'qanda': + this.addDiscussionText = Translate.instant('addon.mod_forum.addanewquestion'); + break; + default: + this.addDiscussionText = Translate.instant('addon.mod_forum.addanewdiscussion'); + } + + if (!sync) { + return; + } + + // Try to synchronize the forum. + const updated = await this.syncActivity(showErrors); + + if (!updated) { + return; + } + + // Sync successful, send event. + CoreEvents.trigger(AddonModForumSyncProvider.MANUAL_SYNCED, { + forumId: forum.id, + userId: CoreSites.instance.getCurrentSiteUserId(), + source: 'index', + }, CoreSites.instance.getCurrentSiteId()); + + const promises: Promise[] = []; + + // Check if the activity uses groups. + promises.push( + // eslint-disable-next-line promise/no-nesting + CoreGroups.instance + .getActivityGroupMode(this.forum.cmid) + .then(async mode => { + this.usesGroups = mode === CoreGroupsProvider.SEPARATEGROUPS + || mode === CoreGroupsProvider.VISIBLEGROUPS; + + return; + }), + ); + + promises.push( + // eslint-disable-next-line promise/no-nesting + AddonModForum.instance + .getAccessInformation(this.forum.id, { cmId: this.module!.id }) + .then(async accessInfo => { + // Disallow adding discussions if cut-off date is reached and the user has not the + // capability to override it. + // Just in case the forum was fetched from WS when the cut-off date was not reached but it is now. + const cutoffDateReached = AddonModForumHelper.instance.isCutoffDateReached(this.forum!) + && !accessInfo.cancanoverridecutoff; + this.canAddDiscussion = !!this.forum?.cancreatediscussions && !cutoffDateReached; + + return; + }), + ); + + if (AddonModForum.instance.isSetPinStateAvailableForSite()) { + // Use the canAddDiscussion WS to check if the user can pin discussions. + promises.push( + // eslint-disable-next-line promise/no-nesting + AddonModForum.instance + .canAddDiscussionToAll(this.forum.id, { cmId: this.module!.id }) + .then(async response => { + this.canPin = !!response.canpindiscussions; + + return; + }) + .catch(async () => { + this.canPin = false; + + return; + }), + ); + } else { + this.canPin = false; + } + + await Promise.all(promises); + + return; + }), + ); + + promises.push(this.fetchSortOrderPreference()); + + try { + await Promise.all(promises); + await Promise.all([ + this.fetchOfflineDiscussions(), + 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); + } + + CoreDomUtils.instance.showErrorModalDefault(message, 'addon.mod_forum.errorgetforum', true); + + this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. + } + + this.fillContextMenu(refresh); + } + + /** + * Convenience function to fetch offline discussions. + * + * @return Promise resolved when done. + */ + protected async fetchOfflineDiscussions(): Promise { + const forum = this.forum!; + let offlineDiscussions = await AddonModForumOffline.instance.getNewDiscussions(forum.id); + this.hasOffline = !!offlineDiscussions.length; + + if (!this.hasOffline) { + this.offlineDiscussions = []; + + return; + } + + if (this.usesGroups) { + offlineDiscussions = await AddonModForum.instance.formatDiscussionsGroups(forum.cmid, offlineDiscussions); + } + + // Fill user data for Offline discussions (should be already cached). + const promises = offlineDiscussions.map(async (discussion: any) => { + if (discussion.parent === 0 || forum.type === 'single') { + // Do not show author for first post and type single. + return; + } + + try { + const user = await CoreUser.instance.getProfile(discussion.userid, this.courseId, true); + + discussion.userfullname = user.fullname; + discussion.userpictureurl = user.profileimageurl; + } catch (error) { + // Ignore errors. + } + }); + + await Promise.all(promises); + + // Sort discussion by time (newer first). + offlineDiscussions.sort((a, b) => b.timecreated - a.timecreated); + + this.offlineDiscussions = offlineDiscussions; + } + + /** + * Convenience function to get forum discussions. + * + * @param refresh Whether we're refreshing data. + * @return Promise resolved when done. + */ + protected async fetchDiscussions(refresh: boolean): Promise { + const forum = this.forum!; + this.loadMoreError = false; + + if (refresh) { + this.page = 0; + } + + const response = await AddonModForum.instance.getDiscussions(forum.id, { + cmId: forum.cmid, + sortOrder: this.selectedSortOrder!.value, + page: this.page, + }); + let discussions = response.discussions; + + if (this.usesGroups) { + discussions = await AddonModForum.instance.formatDiscussionsGroups(forum.cmid, discussions); + } + + // Hide author for first post and type single. + if (forum.type === 'single') { + for (const discussion of discussions) { + if (discussion.userfullname && discussion.parent === 0) { + (discussion as any).userfullname = false; + break; + } + } + } + + // If any discussion has unread posts, the whole forum is being tracked. + if (typeof forum.istracked === 'undefined' && !this.trackPosts) { + for (const discussion of discussions) { + if (discussion.numunread > 0) { + this.trackPosts = true; + break; + } + } + } + + this.discussions = this.page === 0 + ? discussions + : this.discussions.concat(discussions); + + this.canLoadMore = response.canLoadMore; + this.page++; + + // Check if there are replies for discussions stored in offline. + const hasOffline = await AddonModForumOffline.instance.hasForumReplies(forum.id); + + this.hasOffline = this.hasOffline || hasOffline; + + if (hasOffline) { + // Only update new fetched discussions. + const promises = discussions.map(async (discussion: any) => { + // Get offline discussions. + const replies = await AddonModForumOffline.instance.getDiscussionReplies(discussion.discussion); + + discussion.numreplies = Number(discussion.numreplies) + replies.length; + }); + + await Promise.all(promises); + } + } + + /** + * Convenience function to load more forum discussions. + * + * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. + * @return Promise resolved when done. + */ + fetchMoreDiscussions(infiniteComplete?: any): Promise { + return this.fetchDiscussions(false).catch((message) => { + CoreDomUtils.instance.showErrorModalDefault(message, 'addon.mod_forum.errorgetforum', true); + + this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. + }).finally(() => { + infiniteComplete && infiniteComplete(); + }); + } + + /** + * Convenience function to fetch the sort order preference. + * + * @return Promise resolved when done. + */ + protected async fetchSortOrderPreference(): Promise { + const getSortOrder = async () => { + if (!this.sortingAvailable) { + return null; + } + + const value = await CoreUtils.instance.ignoreErrors( + CoreUser.instance.getUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER), + ); + + return value ? parseInt(value, 10) : null; + }; + + const value = await getSortOrder(); + + this.selectedSortOrder = this.sortOrders.find(sortOrder => sortOrder.value === value) || this.sortOrders[0]; + } + + /** + * Perform the invalidate content function. + * + * @return Resolved when done. + */ + protected invalidateContent(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModForum.instance.invalidateForumData(this.courseId!)); + + if (this.forum) { + promises.push(AddonModForum.instance.invalidateDiscussionsList(this.forum.id)); + promises.push(CoreGroups.instance.invalidateActivityGroupMode(this.forum.cmid)); + promises.push(AddonModForum.instance.invalidateAccessInformation(this.forum.id)); + } + + if (this.sortingAvailable) { + promises.push(CoreUser.instance.invalidateUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER)); + } + + return Promise.all(promises); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param result Data returned on the sync function. + * @return Whether it succeed or not. + */ + protected hasSyncSucceed(result: any): boolean { + return result.updated; + } + + /** + * Opens a discussion. + * + * @param discussion Discussion object. + */ + openDiscussion(discussion: AddonModForumDiscussion): void { + alert(`Open discussion ${discussion.id}: Not implemented!`); + + // @todo + // const params = { + // courseId: this.courseId, + // cmId: this.module.id, + // forumId: this.forum.id, + // discussion: discussion, + // trackPosts: this.trackPosts, + // }; + // this.splitviewCtrl.push('AddonModForumDiscussionPage', params); + } + + /** + * Opens the new discussion form. + * + * @param timeCreated Creation time of the offline discussion. + */ + openNewDiscussion(timeCreated: number = 0): void { + alert(`Open new discussion at ${timeCreated} not implemented!`); + + // @todo + // const params = { + // courseId: this.courseId, + // cmId: this.module.id, + // forumId: this.forum.id, + // timeCreated: timeCreated, + // }; + // this.splitviewCtrl.push('AddonModForumNewDiscussionPage', params); + + this.selectedDiscussion = 0; + } + + /** + * Display the sort order selector modal. + * + * @param event Event. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + showSortOrderSelector(event: MouseEvent): void { + if (!this.sortingAvailable) { + return; + } + + alert('Show sort order selector not implemented'); + + // @todo + // const params = { sortOrders: this.sortOrders, selected: this.selectedSortOrder.value }; + // const modal = this.modalCtrl.create('AddonModForumSortOrderSelectorPage', params); + // modal.onDidDismiss((sortOrder) => { + // this.sortOrderSelectorExpanded = false; + + // if (sortOrder && sortOrder.value != this.selectedSortOrder.value) { + // this.selectedSortOrder = sortOrder; + // this.page = 0; + // this.userProvider.setUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER, sortOrder.value.toFixed(0)) + // .then(() => { + // this.showLoadingAndFetch(); + // }).catch((error) => { + // this.domUtils.showErrorModalDefault(error, 'Error updating preference.'); + // }); + // } + // }); + + // modal.present({ ev: event }); + // this.sortOrderSelectorExpanded = true; + } + +} diff --git a/src/addons/mod/forum/forum-lazy.module.ts b/src/addons/mod/forum/forum-lazy.module.ts new file mode 100644 index 000000000..6e549fa44 --- /dev/null +++ b/src/addons/mod/forum/forum-lazy.module.ts @@ -0,0 +1,40 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { RouterModule, Routes } from '@angular/router'; + +import { CoreSharedModule } from '@/core/shared.module'; + +import { AddonModForumComponentsModule } from './components/components.module'; +import { AddonModForumIndexPage } from './pages/index'; + +const routes: Routes = [ + { + path: ':courseId/:cmId', + component: AddonModForumIndexPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + AddonModForumComponentsModule, + ], + declarations: [ + AddonModForumIndexPage, + ], +}) +export class AddonModForumLazyModule {} diff --git a/src/addons/mod/forum/forum.module.ts b/src/addons/mod/forum/forum.module.ts new file mode 100644 index 000000000..56acdf148 --- /dev/null +++ b/src/addons/mod/forum/forum.module.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { APP_INITIALIZER, NgModule } from '@angular/core'; +import { Routes } from '@angular/router'; + +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; + +import { AddonModForumComponentsModule } from './components/components.module'; +import { AddonModForumModuleHandler, AddonModForumModuleHandlerService } from './services/handlers/module'; +import { SITE_SCHEMA } from './services/offline-db'; + +const routes: Routes = [ + { + path: AddonModForumModuleHandlerService.PAGE_NAME, + loadChildren: () => import('./forum-lazy.module').then(m => m.AddonModForumLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + AddonModForumComponentsModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + useValue: () => CoreCourseModuleDelegate.instance.registerHandler(AddonModForumModuleHandler.instance), + }, + ], +}) +export class AddonModForumModule {} diff --git a/src/addons/mod/forum/lang.json b/src/addons/mod/forum/lang.json new file mode 100644 index 000000000..e03b2dbf9 --- /dev/null +++ b/src/addons/mod/forum/lang.json @@ -0,0 +1,66 @@ +{ + "addanewdiscussion": "Add a new discussion topic", + "addanewquestion": "Add a new question", + "addanewtopic": "Add a new topic", + "addtofavourites": "Star this discussion", + "advanced": "Advanced", + "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", + "couldnotupdate": "Could not update your post due to an unknown error", + "cutoffdatereached": "The cut-off date for posting to this forum is reached so you can no longer post to it.", + "delete": "Delete", + "deletedpost": "The post has been deleted", + "deletesure": "Are you sure you want to delete this post?", + "discussion": "Discussion", + "discussionlistsortbycreatedasc": "Sort by creation date in ascending order", + "discussionlistsortbycreateddesc": "Sort by creation date in descending order", + "discussionlistsortbylastpostasc": "Sort by last post creation date in ascending order", + "discussionlistsortbylastpostdesc": "Sort by last post creation date in descending order", + "discussionlistsortbyrepliesasc": "Sort by number of replies in ascending order", + "discussionlistsortbyrepliesdesc": "Sort by number of replies in descending order", + "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.", + "errorposttoallgroups": "Could not create new discussion in all groups.", + "favouriteupdated": "Your star option has been updated.", + "forumnodiscussionsyet": "There are no discussions yet in this forum.", + "group": "Group", + "lastpost": "Last post", + "lockdiscussion": "Lock this discussion", + "lockupdated": "The lock option has been updated.", + "message": "Message", + "modeflatnewestfirst": "Display replies flat, with newest first", + "modeflatoldestfirst": "Display replies flat, with oldest first", + "modenested": "Display replies in nested form", + "modulenameplural": "Forums", + "numdiscussions": "{{numdiscussions}} discussions", + "numreplies": "{{numreplies}} replies", + "pindiscussion": "Pin this discussion", + "pinupdated": "The pin option has been updated.", + "postisprivatereply": "This is a private reply. It is not visible to other participants.", + "posttoforum": "Post to forum", + "posttomygroups": "Post a copy to all groups", + "privatereply": "Reply privately", + "re": "Re:", + "refreshdiscussions": "Refresh discussions", + "refreshposts": "Refresh posts", + "removefromfavourites": "Unstar this discussion", + "reply": "Reply", + "replyplaceholder": "Write your reply...", + "subject": "Subject", + "tagarea_forum_posts": "Forum posts", + "thisforumhasduedate": "The due date for posting to this forum is {{$a}}.", + "thisforumisdue": "The due date for posting to this forum was {{$a}}.", + "unlockdiscussion": "Unlock this discussion", + "unpindiscussion": "Unpin this discussion", + "unread": "Unread", + "unreadpostsnumber": "{{$a}} unread posts", + "yourreply": "Your reply" +} diff --git a/src/addons/mod/forum/pages/index/index.html b/src/addons/mod/forum/pages/index/index.html new file mode 100644 index 000000000..ffcd6cfa7 --- /dev/null +++ b/src/addons/mod/forum/pages/index/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/addons/mod/forum/pages/index/index.ts b/src/addons/mod/forum/pages/index/index.ts new file mode 100644 index 000000000..fb1237a53 --- /dev/null +++ b/src/addons/mod/forum/pages/index/index.ts @@ -0,0 +1,49 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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, OnInit } from '@angular/core'; + +import { AddonModForumData } from '@addons/mod/forum/services/forum.service'; +import { CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; + +@Component({ + selector: 'page-addon-mod-forum-index', + templateUrl: 'index.html', +}) +export class AddonModForumIndexPage implements OnInit { + + title!: string; + module!: CoreCourseAnyModuleData; + courseId!: number; + + /** + * @inheritdoc + */ + ngOnInit(): void { + this.module = CoreNavigator.instance.getRouteParam('module')!; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; + this.title = this.module?.name; + } + + /** + * Update some data based on the forum instance. + * + * @param forum Forum instance. + */ + updateData(forum: AddonModForumData): void { + this.title = forum.name || this.title; + } + +} diff --git a/src/addons/mod/forum/services/forum.service.ts b/src/addons/mod/forum/services/forum.service.ts new file mode 100644 index 000000000..4e81215e0 --- /dev/null +++ b/src/addons/mod/forum/services/forum.service.ts @@ -0,0 +1,2055 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreUser } from '@features/user/services/user'; +import { CoreApp } from '@services/app'; +import { CoreFilepool } from '@services/filepool'; +import { CoreGroups } from '@services/groups'; +import { CoreSitesCommonWSOptions, CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModForumOffline, AddonModForumReplyOptions } from './offline.service'; + +const ROOT_CACHE_KEY = 'mmaModForum:'; + +/** + * Service that provides some features for forums. + * + * @todo Add all content. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModForumProvider { + + static readonly COMPONENT = 'mmaModForum'; + static readonly DISCUSSIONS_PER_PAGE = 10; // Max of discussions per page. + static readonly NEW_DISCUSSION_EVENT = 'addon_mod_forum_new_discussion'; + static readonly REPLY_DISCUSSION_EVENT = 'addon_mod_forum_reply_discussion'; + static readonly VIEW_DISCUSSION_EVENT = 'addon_mod_forum_view_discussion'; + static readonly CHANGE_DISCUSSION_EVENT = 'addon_mod_forum_change_discussion_status'; + static readonly MARK_READ_EVENT = 'addon_mod_forum_mark_read'; + static readonly LEAVING_POSTS_PAGE = 'addon_mod_forum_leaving_posts_page'; + + static readonly PREFERENCE_SORTORDER = 'forum_discussionlistsortorder'; + static readonly SORTORDER_LASTPOST_DESC = 1; + static readonly SORTORDER_LASTPOST_ASC = 2; + static readonly SORTORDER_CREATED_DESC = 3; + static readonly SORTORDER_CREATED_ASC = 4; + static readonly SORTORDER_REPLIES_DESC = 5; + static readonly SORTORDER_REPLIES_ASC = 6; + + static readonly ALL_PARTICIPANTS = -1; + static readonly ALL_GROUPS = -2; + + /** + * Get cache key for can add discussion WS calls. + * + * @param forumId Forum ID. + * @param groupId Group ID. + * @return 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. + * TODO: Use getForumDataCacheKey as a prefix. + * + * @param forumId Forum ID. + * @return Cache key. + */ + protected getCommonCanAddDiscussionCacheKey(forumId: number): string { + return ROOT_CACHE_KEY + 'canadddiscussion:' + forumId + ':'; + } + + /** + * Get prefix cache key for all forum activity data WS calls. + * + * @param forumId Forum ID. + * @return Cache key. + */ + protected getForumDataPrefixCacheKey(forumId: number): string { + return ROOT_CACHE_KEY + forumId; + } + + /** + * Get cache key for discussion post data WS calls. + * + * @param forumId Forum ID. + * @param discussionId Discussion ID. + * @param postId Course ID. + * @return Cache key. + */ + protected getDiscussionPostDataCacheKey(forumId: number, discussionId: number, postId: number): string { + return this.getForumDiscussionDataCacheKey(forumId, discussionId) + ':post:' + postId; + } + + /** + * Get cache key for forum data WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getForumDiscussionDataCacheKey(forumId: number, discussionId: number): string { + return this.getForumDataPrefixCacheKey(forumId) + ':discussion:' + discussionId; + } + + /** + * Get cache key for forum data WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getForumDataCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'forum:' + courseId; + } + + /** + * Get cache key for forum access information WS calls. + * TODO: Use getForumDataCacheKey as a prefix. + * + * @param forumId Forum ID. + * @return Cache key. + */ + protected getAccessInformationCacheKey(forumId: number): string { + return ROOT_CACHE_KEY + 'accessInformation:' + forumId; + } + + /** + * Get cache key for forum discussion posts WS calls. + * TODO: Use getForumDiscussionDataCacheKey instead. + * + * @param discussionId Discussion ID. + * @return Cache key. + */ + protected getDiscussionPostsCacheKey(discussionId: number): string { + return ROOT_CACHE_KEY + 'discussion:' + discussionId; + } + + /** + * Get cache key for forum discussions list WS calls. + * + * @param forumId Forum ID. + * @param sortOrder Sort order. + * @return Cache key. + */ + protected getDiscussionsListCacheKey(forumId: number, sortOrder: number): string { + let key = ROOT_CACHE_KEY + 'discussions:' + forumId; + + if (sortOrder != AddonModForumProvider.SORTORDER_LASTPOST_DESC) { + key += ':' + sortOrder; + } + + return key; + } + + /** + * Add a new discussion. It will fail if offline or cannot connect. + * + * @param forumId Forum ID. + * @param subject New discussion's subject. + * @param message New discussion's message. + * @param options Options (subscribe, pin, ...). + * @param groupId Group this discussion belongs to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the discussion is created. + */ + async addNewDiscussionOnline( + forumId: number, + subject: string, + message: string, + options?: AddonModForumAddDiscussionWSOptionsObject, + groupId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: AddonModForumAddDiscussionWSParams = { + forumid: forumId, + subject: subject, + message: message, + + // eslint-disable-next-line max-len + options: CoreUtils.instance.objectToArrayOfObjects( + options || {}, + 'name', + 'value', + ), + }; + + if (groupId) { + params.groupid = groupId; + } + + const response = await site.write('mod_forum_add_discussion', params); + + // Other errors ocurring. + if (!response || !response.discussionid) { + return Promise.reject(CoreUtils.instance.createFakeWSError('')); + } else { + return response.discussionid; + } + } + + /** + * Check if a user can post to a certain group. + * + * @param forumId Forum ID. + * @param groupId Group ID. + * @param options Other options. + * @return Promise resolved with an object with the following properties: + * - status (boolean) + * - canpindiscussions (boolean) + * - cancreateattachment (boolean) + */ + async canAddDiscussion( + forumId: number, + groupId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const params: AddonModForumCanAddDiscussionWSParams = { + forumid: forumId, + groupid: groupId, + }; + const preSets = { + cacheKey: this.getCanAddDiscussionCacheKey(forumId, groupId), + component: AddonModForumProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const site = await CoreSites.instance.getSite(options.siteId); + const result = await site.read('mod_forum_can_add_discussion', params, preSets); + + if (!result) { + throw new Error('Invalid response calling mod_forum_can_add_discussion'); + } + + if (typeof result.canpindiscussions == 'undefined') { + // WS doesn't support it yet, default it to false to prevent students from seeing 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; + } + + /** + * Check if a user can post to all groups. + * + * @param forumId Forum ID. + * @param options Other options. + * @return Promise resolved with an object with the following properties: + * - status (boolean) + * - canpindiscussions (boolean) + * - cancreateattachment (boolean) + */ + canAddDiscussionToAll(forumId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + return this.canAddDiscussion(forumId, AddonModForumProvider.ALL_PARTICIPANTS, options); + } + + /** + * Delete a post. + * + * @param postId Post id. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + * @since 3.8 + */ + async deletePost(postId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: AddonModForumDeletePostWSParams = { + postid: postId, + }; + + await site.write('mod_forum_delete_post', params); + } + + /** + * Extract the starting post of a discussion from a list of posts. The post is removed from the array passed as a parameter. + * + * @param posts Posts to search. + * @return Starting post or undefined if not found. + */ + extractStartingPost(posts: AddonModForumPost[]): AddonModForumPost | undefined { + const index = posts.findIndex((post) => !post.parentid); + + return index >= 0 ? posts.splice(index, 1).pop() : undefined; + } + + /** + * There was a bug adding new discussions to All Participants (see MDL-57962). Check if it's fixed. + * + * @return True if fixed, false otherwise. + */ + isAllParticipantsFixed(): boolean { + return !!CoreSites.instance.getCurrentSite()?.isVersionGreaterEqualThan(['3.1.5', '3.2.2']); + } + + /** + * Returns whether or not getDiscussionPost WS available or not. + * + * @return If WS is avalaible. + * @since 3.8 + */ + isGetDiscussionPostAvailable(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('mod_forum_get_discussion_post'); + } + + /** + * Returns whether or not getDiscussionPost WS available or not. + * + * @param site Site. If not defined, current site. + * @return If WS is avalaible. + * @since 3.7 + */ + isGetDiscussionPostsAvailable(site?: CoreSite): boolean { + return site + ? site.wsAvailable('mod_forum_get_discussion_posts') + : CoreSites.instance.wsAvailableInCurrentSite('mod_forum_get_discussion_posts'); + } + + /** + * Returns whether or not deletePost WS available or not. + * + * @return If WS is avalaible. + * @since 3.8 + */ + isDeletePostAvailable(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('mod_forum_delete_post'); + } + + /** + * Returns whether or not updatePost WS available or not. + * + * @return If WS is avalaible. + * @since 3.8 + */ + isUpdatePostAvailable(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('mod_forum_update_discussion_post'); + } + + /** + * Format discussions, setting groupname if the discussion group is valid. + * + * @param cmId Forum cmid. + * @param discussions List of discussions to format. + * @return Promise resolved with the formatted discussions. + */ + formatDiscussionsGroups(cmId: number, discussions: any[]): Promise { + discussions = CoreUtils.instance.clone(discussions); + + return CoreGroups.instance.getActivityAllowedGroups(cmId).then((result) => { + const strAllParts = Translate.instant('core.allparticipants'); + const strAllGroups = Translate.instant('core.allgroups'); + + // Turn groups into an object where each group is identified by id. + const groups = {}; + result.groups.forEach((fg) => { + groups[fg.id] = fg; + }); + + // Format discussions. + discussions.forEach((disc) => { + if (disc.groupid == AddonModForumProvider.ALL_PARTICIPANTS) { + disc.groupname = strAllParts; + } else if (disc.groupid == AddonModForumProvider.ALL_GROUPS) { + // Offline discussions only. + disc.groupname = strAllGroups; + } else { + const group = groups[disc.groupid]; + if (group) { + disc.groupname = group.name; + } + } + }); + + return discussions; + }).catch(() => discussions); + } + + /** + * Get all course forums. + * + * @param courseId Course ID. + * @param options Other options. + * @return Promise resolved when the forums are retrieved. + */ + async getCourseForums(courseId: number, options: CoreSitesCommonWSOptions = {}): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModForumGetForumsByCoursesWSParams = { + courseids: [courseId], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getForumDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModForumProvider.COMPONENT, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), + }; + + return site.read('mod_forum_get_forums_by_courses', params, preSets); + } + + /** + * Get a particular discussion post. + * + * @param forumId Forum ID. + * @param discussionId Discussion ID. + * @param postId Post ID. + * @param options Other options. + * @return Promise resolved when the post is retrieved. + */ + async getDiscussionPost( + forumId: number, + discussionId: number, + postId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + const params: AddonModForumGetDiscussionPostWSParams = { + postid: postId, + }; + const preSets = { + cacheKey: this.getDiscussionPostDataCacheKey(forumId, discussionId, postId), + updateFrequency: CoreSite.FREQUENCY_USUALLY, + component: AddonModForumProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_forum_get_discussion_post', + params, + preSets, + ); + + if (!response.post) { + throw new Error('Post not found'); + } + + return response.post; + } + + /** + * Get a forum by course module ID. + * + * @param courseId Course ID. + * @param cmId Course module ID. + * @param options Other options. + * @return Promise resolved when the forum is retrieved. + */ + async getForum(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise { + const forums = await this.getCourseForums(courseId, options); + + const forum = forums.find(forum => forum.cmid == cmId); + + if (!forum) { + throw new Error('Forum not found'); + } + + return forum; + } + + /** + * Get a forum by forum ID. + * + * @param courseId Course ID. + * @param forumId Forum ID. + * @param options Other options. + * @return Promise resolved when the forum is retrieved. + */ + async getForumById(courseId: number, forumId: number, options: CoreSitesCommonWSOptions = {}): Promise { + const forums = await this.getCourseForums(courseId, options); + const forum = forums.find(forum => forum.id === forumId); + + if (!forum) { + throw new Error(`Forum with id ${forumId} not found`); + } + + return forum; + } + + /** + * Get access information for a given forum. + * + * @param forumId Forum ID. + * @param options Other options. + * @return Object with access information. + * @since 3.7 + */ + async getAccessInformation( + forumId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + if (!site.wsAvailable('mod_forum_get_forum_access_information')) { + // Access information not available for 3.6 or older sites. + return {}; + } + + const params: AddonModForumGetForumAccessInformationWSParams = { + forumid: forumId, + }; + const preSets = { + cacheKey: this.getAccessInformationCacheKey(forumId), + component: AddonModForumProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read( + 'mod_forum_get_forum_access_information', + params, + preSets, + ); + } + + /** + * Get forum discussion posts. + * + * @param discussionId Discussion ID. + * @param options Other options. + * @return Promise resolved with forum posts and rating info. + */ + async getDiscussionPosts(discussionId: number, options: CoreCourseCommonModWSOptions = {}): Promise<{ + posts: AddonModForumPost[]; + courseid?: number; + forumid?: number; + ratinginfo?: AddonModForumRatingInfo; + }> { + // Convenience function to translate legacy data to new format. + const translateLegacyPostsFormat = (posts: any[]): any[] => posts.map((post) => { + const newPost = { + id: post.id , + discussionid: post.discussion, + parentid: post.parent, + hasparent: !!post.parent, + author: { + id: post.userid, + fullname: post.userfullname, + urls: { profileimage: post.userpictureurl }, + }, + timecreated: post.created, + subject: post.subject, + message: post.message, + attachments: post.attachments, + capabilities: { + reply: !!post.canreply, + }, + + unread: !post.postread, + isprivatereply: !!post.isprivatereply, + tags: post.tags, + }; + + if (post.groupname) { + newPost.author['groups'] = [{ name: post.groupname }]; + } + + return newPost; + }); + + // For some reason, the new WS doesn't use the tags exporter so it returns a different format than other WebServices. + // Convert the new format to the exporter one so it's the same as in other WebServices. + const translateTagsFormatToLegacy = (posts: any[]): any[] => { + posts.forEach((post) => { + post.tags = post.tags.map((tag) => { + const viewUrl = (tag.urls && tag.urls.view) || ''; + const params = CoreUrlUtils.instance.extractUrlParams(viewUrl); + + return { + id: tag.tagid, + taginstanceid: tag.id, + flag: tag.flag ? 1 : 0, + isstandard: tag.isstandard, + rawname: tag.displayname, + name: tag.displayname, + tagcollid: params.tc ? Number(params.tc) : undefined, + taginstancecontextid: params.from ? Number(params.from) : undefined, + }; + }); + }); + + return posts; + }; + + const params: AddonModForumGetDiscussionPostsWSParams | AddonModForumGetForumDiscussionPostsWSParams = { + discussionid: discussionId, + }; + const preSets = { + cacheKey: this.getDiscussionPostsCacheKey(discussionId), + component: AddonModForumProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const site = await CoreSites.instance.getSite(options.siteId); + const isGetDiscussionPostsAvailable = this.isGetDiscussionPostsAvailable(site); + + const response = isGetDiscussionPostsAvailable + ? await site.read('mod_forum_get_discussion_posts', params, preSets) + : await site.read( + 'mod_forum_get_forum_discussion_posts', + params, + preSets, + ); + + if (!response) { + throw new Error('Could not get forum posts'); + } + + if (isGetDiscussionPostsAvailable) { + response.posts = translateTagsFormatToLegacy((response as AddonModForumGetDiscussionPostsWSResponse).posts); + } else { + response.posts = translateLegacyPostsFormat((response as AddonModForumGetForumDiscussionPostsWSResponse).posts); + } + + this.storeUserData(response.posts); + + return response as AddonModForumGetDiscussionPostsWSResponse; + } + + /** + * Sort forum discussion posts by an specified field. + * + * @param posts Discussion posts to be sorted in place. + * @param direction Direction of the sorting (ASC / DESC). + */ + sortDiscussionPosts(posts: AddonModForumPost[], direction: string): void { + // @todo: Check children when sorting. + posts.sort((a, b) => { + const timeCreatedA = Number(a.timecreated) || 0; + const timeCreatedB = Number(b.timecreated) || 0; + if (timeCreatedA == 0 || timeCreatedB == 0) { + // Leave 0 at the end. + return timeCreatedB - timeCreatedA; + } + + if (direction == 'ASC') { + return timeCreatedA - timeCreatedB; + } else { + return timeCreatedB - timeCreatedA; + } + }); + } + + /** + * Return whether discussion lists can be sorted. + * + * @param site Site. If not defined, current site. + * @return True if discussion lists can be sorted. + */ + isDiscussionListSortingAvailable(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.isVersionGreaterEqualThan('3.7'); + } + + /** + * Return the list of available sort orders. + * + * @return List of sort orders. + */ + getAvailableSortOrders(): AddonModForumSortOrder[] { + const sortOrders = [ + { + label: 'addon.mod_forum.discussionlistsortbylastpostdesc', + value: AddonModForumProvider.SORTORDER_LASTPOST_DESC, + }, + ]; + + if (this.isDiscussionListSortingAvailable()) { + sortOrders.push( + { + label: 'addon.mod_forum.discussionlistsortbylastpostasc', + value: AddonModForumProvider.SORTORDER_LASTPOST_ASC, + }, + { + label: 'addon.mod_forum.discussionlistsortbycreateddesc', + value: AddonModForumProvider.SORTORDER_CREATED_DESC, + }, + { + label: 'addon.mod_forum.discussionlistsortbycreatedasc', + value: AddonModForumProvider.SORTORDER_CREATED_ASC, + }, + { + label: 'addon.mod_forum.discussionlistsortbyrepliesdesc', + value: AddonModForumProvider.SORTORDER_REPLIES_DESC, + }, + { + label: 'addon.mod_forum.discussionlistsortbyrepliesasc', + value: AddonModForumProvider.SORTORDER_REPLIES_ASC, + }, + ); + } + + return sortOrders; + } + + /** + * Get forum discussions. + * + * @param forumId Forum ID. + * @param options Other options. + * @return Promise resolved with an object with: + * - discussions: List of discussions. Note that for every discussion in the list discussion.id is the main post ID but + * discussion ID is discussion.discussion. + * - canLoadMore: True if there may be more discussions to load. + */ + async getDiscussions( + forumId: number, + options: AddonModForumGetDiscussionsOptions = {}, + ): Promise<{ discussions: AddonModForumDiscussion[]; canLoadMore: boolean }> { + options.sortOrder = options.sortOrder || AddonModForumProvider.SORTORDER_LASTPOST_DESC; + options.page = options.page || 0; + + const site = await CoreSites.instance.getSite(options.siteId); + let method = 'mod_forum_get_forum_discussions_paginated'; + const params: AddonModForumGetForumDiscussionsPaginatedWSParams | AddonModForumGetForumDiscussionsWSParams = { + forumid: forumId, + page: options.page, + perpage: AddonModForumProvider.DISCUSSIONS_PER_PAGE, + }; + + if (site.wsAvailable('mod_forum_get_forum_discussions')) { + // Since Moodle 3.7. + method = 'mod_forum_get_forum_discussions'; + (params as AddonModForumGetForumDiscussionsWSParams).sortorder = options.sortOrder; + } else { + if (options.sortOrder !== AddonModForumProvider.SORTORDER_LASTPOST_DESC) { + throw new Error('Sorting not supported with the old WS method.'); + } + + (params as AddonModForumGetForumDiscussionsPaginatedWSParams).sortby = 'timemodified'; + (params as AddonModForumGetForumDiscussionsPaginatedWSParams).sortdirection = 'DESC'; + } + + const preSets = { + cacheKey: this.getDiscussionsListCacheKey(forumId, options.sortOrder), + component: AddonModForumProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + let response: AddonModForumGetForumDiscussionsPaginatedWSResponse | AddonModForumGetForumDiscussionsWSResponse; + try { + // eslint-disable-next-line max-len + response = await site.read( + method, + params, + preSets, + ); + } catch (error) { + // Try to get the data from cache stored with the old WS method. + if ( + CoreApp.instance.isOnline() || + method !== 'mod_forum_get_forum_discussions' || + options.sortOrder !== AddonModForumProvider.SORTORDER_LASTPOST_DESC + ) { + throw error; + } + + const params: AddonModForumGetForumDiscussionsPaginatedWSParams = { + forumid: forumId, + page: options.page, + perpage: AddonModForumProvider.DISCUSSIONS_PER_PAGE, + sortby: 'timemodified', + sortdirection: 'DESC', + }; + Object.assign(preSets, CoreSites.instance.getReadingStrategyPreSets(CoreSitesReadingStrategy.PreferCache)); + + response = await site.read( + 'mod_forum_get_forum_discussions_paginated', + params, + preSets, + ); + } + + if (!response) { + throw new Error('Could not get discussions'); + } + + await this.storeUserData(response.discussions); + + return { + discussions: response.discussions, + canLoadMore: response.discussions.length >= AddonModForumProvider.DISCUSSIONS_PER_PAGE, + }; + } + + /** + * 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 forumId Forum ID. + * @param cmId Forum cmid. + * @param sortOrder Sort order. + * @param forceCache True to always get the value from cache, false otherwise. + * @param numPages Number of pages to get. If not defined, all pages. + * @param startPage Page to start. If not defined, first page. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with an object with: + * - discussions: List of discussions. + * - error: True if an error occurred, false otherwise. + */ + async getDiscussionsInPages( + forumId: number, + options: AddonModForumGetDiscussionsInPagesOptions = {}, + ): Promise<{ discussions: AddonModForumDiscussion[]; error: boolean }> { + options.page = options.page || 0; + + const result = { + discussions: [] as AddonModForumDiscussion[], + error: false, + }; + let numPages = typeof options.numPages == 'undefined' ? -1 : options.numPages; + + if (!numPages) { + return Promise.resolve(result); + } + + const getPage = (page: number): Promise<{ discussions: AddonModForumDiscussion[]; error: boolean }> => + // Get page discussions. + this.getDiscussions(forumId, options).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(options.page); + } + + /** + * Invalidates can add discussion WS calls. + * + * @param forumId Forum ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCanAddDiscussion(forumId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getCommonCanAddDiscussionCacheKey(forumId)); + } + + /** + * Invalidate the prefetched content except files. + * To invalidate files, use AddonModForum#invalidateFiles. + * + * @param moduleId The module ID. + * @param courseId Course ID. + * @return Promise resolved when data is invalidated. + */ + async invalidateContent(moduleId: number, courseId: number): Promise { + // Get the forum first, we need the forum ID. + const forum = await this.getForum(courseId, moduleId); + const promises: Promise[] = []; + + promises.push(this.invalidateForumData(courseId)); + promises.push(this.invalidateDiscussionsList(forum.id)); + promises.push(this.invalidateCanAddDiscussion(forum.id)); + promises.push(this.invalidateAccessInformation(forum.id)); + + this.getAvailableSortOrders().forEach((sortOrder) => { + // We need to get the list of discussions to be able to invalidate their posts. + promises.push( + this + .getDiscussionsInPages(forum.id, { + cmId: forum.cmid, + sortOrder: sortOrder.value, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + }) + .then((response) => { + // Now invalidate the WS calls. + const promises: Promise[] = []; + + response.discussions.forEach((discussion) => { + promises.push(this.invalidateDiscussionPosts(discussion.discussion, forum.id)); + }); + + return CoreUtils.instance.allPromises(promises); + }), + ); + }); + + if (this.isDiscussionListSortingAvailable()) { + promises.push(CoreUser.instance.invalidateUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER)); + } + + return CoreUtils.instance.allPromises(promises); + } + + /** + * Invalidates access information. + * + * @param forumId Forum ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAccessInformation(forumId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAccessInformationCacheKey(forumId)); + } + + /** + * Invalidates forum discussion posts. + * + * @param discussionId Discussion ID. + * @param forumId Forum ID. If not set, we can't invalidate individual post information. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateDiscussionPosts(discussionId: number, forumId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const promises = [site.invalidateWsCacheForKey(this.getDiscussionPostsCacheKey(discussionId))]; + + if (forumId) { + promises.push(site.invalidateWsCacheForKeyStartingWith(this.getForumDiscussionDataCacheKey(forumId, discussionId))); + } + + await CoreUtils.instance.allPromises(promises); + } + + /** + * Invalidates discussion list. + * + * @param forumId Forum ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateDiscussionsList(forumId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await CoreUtils.instance.allPromises( + this.getAvailableSortOrders() + .map(sortOrder => site.invalidateWsCacheForKey(this.getDiscussionsListCacheKey(forumId, sortOrder.value))), + ); + } + + /** + * Invalidate the prefetched files. + * + * @param moduleId The module ID. + * @return Promise resolved when the files are invalidated. + */ + async invalidateFiles(moduleId: number): Promise { + const siteId = CoreSites.instance.getCurrentSiteId(); + + await CoreFilepool.instance.invalidateFilesByComponent(siteId, AddonModForumProvider.COMPONENT, moduleId); + } + + /** + * Invalidates forum data. + * + * @param courseId Course ID. + * @return Promise resolved when the data is invalidated. + */ + async invalidateForumData(courseId: number): Promise { + const site = CoreSites.instance.getCurrentSite(); + + await site?.invalidateWsCacheForKey(this.getForumDataCacheKey(courseId)); + } + + /** + * Report a forum as being viewed. + * + * @param id Module ID. + * @param name Name of the forum. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + logView(id: number, name?: string, siteId?: string): Promise { + const params = { + forumid: id, + }; + + return CoreCourseLogHelper.instance.logSingle( + 'mod_forum_view_forum', + params, + AddonModForumProvider.COMPONENT, + id, + name, + 'forum', + {}, + siteId, + ); + } + + /** + * Report a forum discussion as being viewed. + * + * @param id Discussion ID. + * @param forumId Forum ID. + * @param name Name of the forum. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + logDiscussionView(id: number, forumId: number, name?: string, siteId?: string): Promise { + const params = { + discussionid: id, + }; + + return CoreCourseLogHelper.instance.logSingle( + 'mod_forum_view_forum_discussion', + params, + AddonModForumProvider.COMPONENT, + forumId, + name, + 'forum', + params, + siteId, + ); + } + + /** + * Reply to a certain post. + * + * @param postId ID of the post being replied. + * @param discussionId ID of the discussion the user is replying to. + * @param forumId ID of the forum the user is replying to. + * @param name Forum name. + * @param courseId Course ID the forum belongs to. + * @param subject New post's subject. + * @param message New post's message. + * @param options Options (subscribe, attachments, ...). + * @param siteId Site ID. If not defined, current site. + * @param allowOffline True if it can be stored in offline, false otherwise. + * @return Promise resolved with a boolean indicating if the test was sent online or not. + */ + async replyPost( + postId: number, + discussionId: number, + forumId: number, + name: string, + courseId: number, + subject: string, + message: string, + options?: AddonModForumReplyOptions, + siteId?: string, + allowOffline?: boolean, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = async (): Promise => { + if (!forumId) { + // Not enough data to store in offline, reject. + throw new Error(Translate.instant('core.networkerrormsg')); + } + + await AddonModForumOffline.instance.replyPost( + postId, + discussionId, + forumId, + name, + courseId, + subject, + message, + options, + siteId, + ); + + return false; + }; + + if (!CoreApp.instance.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. + try { + await AddonModForumOffline.instance.deleteReply(postId, siteId); + await this.replyPostOnline(postId, subject, message, options, siteId); + + return true; + } catch (error) { + if (allowOffline && !CoreUtils.instance.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 postId ID of the post being replied. + * @param subject New post's subject. + * @param message New post's message. + * @param options Options (subscribe, attachments, ...). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the created post id. + */ + async replyPostOnline( + postId: number, + subject: string, + message: string, + options?: AddonModForumAddDiscussionPostWSOptionsObject, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: AddonModForumAddDiscussionPostWSParams = { + postid: postId, + subject: subject, + message: message, + + // eslint-disable-next-line max-len + options: CoreUtils.instance.objectToArrayOfObjects( + options || {}, + 'name', + 'value', + ), + }; + + const response = await site.write('mod_forum_add_discussion_post', params); + + if (!response || !response.postid) { + throw new Error('Post id missing from response'); + } + + return response.postid; + } + + /** + * Lock or unlock a discussion. + * + * @param forumId Forum id. + * @param discussionId DIscussion id. + * @param locked True to lock, false to unlock. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + * @since 3.7 + */ + async setLockState(forumId: number, discussionId: number, locked: boolean, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: AddonModForumSetLockStateWSParams = { + forumid: forumId, + discussionid: discussionId, + targetstate: locked ? 0 : 1, + }; + + await site.write('mod_forum_set_lock_state', params); + } + + /** + * Returns whether the set pin state WS is available. + * + * @param site Site. If not defined, current site. + * @return Whether it's available. + * @since 3.7 + */ + isSetPinStateAvailableForSite(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('mod_forum_set_pin_state'); + } + + /** + * Pin or unpin a discussion. + * + * @param discussionId Discussion id. + * @param locked True to pin, false to unpin. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + * @since 3.7 + */ + async setPinState(discussionId: number, pinned: boolean, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: AddonModForumSetPinStateWSParams = { + discussionid: discussionId, + targetstate: pinned ? 1 : 0, + }; + + await site.write('mod_forum_set_pin_state', params); + } + + /** + * Star or unstar a discussion. + * + * @param discussionId Discussion id. + * @param starred True to star, false to unstar. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + * @since 3.7 + */ + async toggleFavouriteState(discussionId: number, starred: boolean, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: AddonModForumToggleFavouriteStateWSParams = { + discussionid: discussionId, + targetstate: starred ? 1 : 0 as any, + }; + + await site.write('mod_forum_toggle_favourite_state', params); + } + + /** + * Store the users data from a discussions/posts list. + * + * @param list Array of posts or discussions. + */ + protected storeUserData(list: any[]): void { + const users = {}; + + list.forEach((entry) => { + if (entry.author) { + const authorId = Number(entry.author.id); + if (!isNaN(authorId) && !users[authorId]) { + users[authorId] = { + id: entry.author.id, + fullname: entry.author.fullname, + profileimageurl: entry.author.urls.profileimage, + }; + } + } + 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, + }; + } + }); + + CoreUser.instance.storeUsers(CoreUtils.instance.objectToArray(users)); + } + + /** + * Update a certain post. + * + * @param postId ID of the post being edited. + * @param subject New post's subject. + * @param message New post's message. + * @param options Options (subscribe, attachments, ...). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with success boolean when done. + */ + async updatePost( + postId: number, + subject: string, + message: string, + options?: AddonModForumUpdateDiscussionPostWSOptionsObject, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: AddonModForumUpdateDiscussionPostWSParams = { + postid: postId, + subject: subject, + message: message, + + // eslint-disable-next-line max-len + options: CoreUtils.instance.objectToArrayOfObjects( + options || {}, + 'name', + 'value', + ), + }; + + const response = await site.write('mod_forum_update_discussion_post', params); + + return response && response.status; + } + +} + +export class AddonModForum extends makeSingleton(AddonModForumProvider) {} + +/** + * Params of mod_forum_get_forums_by_courses WS. + */ +type AddonModForumGetForumsByCoursesWSParams = { + courseids?: number[]; // Array of Course IDs. +}; + +/** + * General forum activity data. + */ +export type AddonModForumData = { + id: number; // Forum id. + course: number; // Course id. + type: string; // The forum type. + name: string; // Forum name. + intro: string; // The forum intro. + introformat: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + introfiles?: CoreWSExternalFile[]; + duedate?: number; // Duedate for the user. + cutoffdate?: number; // Cutoffdate for the user. + assessed: number; // Aggregate type. + assesstimestart: number; // Assess start time. + assesstimefinish: number; // Assess finish time. + scale: number; // Scale. + // eslint-disable-next-line @typescript-eslint/naming-convention + grade_forum: number; // Whole forum grade. + // eslint-disable-next-line @typescript-eslint/naming-convention + grade_forum_notify: number; // Whether to send notifications to students upon grading by default. + maxbytes: number; // Maximum attachment size. + maxattachments: number; // Maximum number of attachments. + forcesubscribe: number; // Force users to subscribe. + trackingtype: number; // Subscription mode. + rsstype: number; // RSS feed for this activity. + rssarticles: number; // Number of RSS recent articles. + timemodified: number; // Time modified. + warnafter: number; // Post threshold for warning. + blockafter: number; // Post threshold for blocking. + blockperiod: number; // Time period for blocking. + completiondiscussions: number; // Student must create discussions. + completionreplies: number; // Student must post replies. + completionposts: number; // Student must post discussions or replies. + cmid: number; // Course module id. + numdiscussions?: number; // Number of discussions in the forum. + cancreatediscussions?: boolean; // If the user can create discussions. + lockdiscussionafter?: number; // After what period a discussion is locked. + istracked?: boolean; // If the user is tracking the forum. + unreadpostscount?: number; // The number of unread posts for tracked forums. +}; + +/** + * Forum discussion. + */ +export type AddonModForumDiscussion = { + id: number; // Post id. + name: string; // Discussion name. + groupid: number; // Group id. + timemodified: number; // Time modified. + usermodified: number; // The id of the user who last modified. + timestart: number; // Time discussion can start. + timeend: number; // Time discussion ends. + discussion: number; // Discussion id. + parent: number; // Parent id. + userid: number; // User who started the discussion id. + created: number; // Creation time. + modified: number; // Time modified. + mailed: number; // Mailed?. + subject: string; // The post subject. + message: string; // The post message. + messageformat: number; // Message format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + messagetrust: number; // Can we trust?. + messageinlinefiles?: CoreWSExternalFile[]; + attachment: string; // Has attachments?. + attachments?: CoreWSExternalFile[]; + totalscore: number; // The post message total score. + mailnow: number; // Mail now?. + userfullname: string; // Post author full name. + usermodifiedfullname: string; // Post modifier full name. + userpictureurl: string; // Post author picture. + usermodifiedpictureurl: string; // Post modifier picture. + numreplies: number; // The number of replies in the discussion. + numunread: number; // The number of unread discussions. + pinned: boolean; // Is the discussion pinned. + locked: boolean; // Is the discussion locked. + starred?: boolean; // Is the discussion starred. + canreply: boolean; // Can the user reply to the discussion. + canlock: boolean; // Can the user lock the discussion. + canfavourite?: boolean; // Can the user star the discussion. +}; + +/** + * Legacy forum post data. + */ +export type AddonModForumLegacyPost = { + id: number; // Post id. + discussion: number; // Discussion id. + parent: number; // Parent id. + userid: number; // User id. + created: number; // Creation time. + modified: number; // Time modified. + mailed: number; // Mailed?. + subject: string; // The post subject. + message: string; // The post message. + messageformat: number; // Message format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + messagetrust: number; // Can we trust?. + messageinlinefiles?: CoreWSExternalFile[]; + attachment: string; // Has attachments?. + attachments?: CoreWSExternalFile[]; + totalscore: number; // The post message total score. + mailnow: number; // Mail now?. + children: number[]; + canreply: boolean; // The user can reply to posts?. + postread: boolean; // The post was read. + userfullname: string; // Post author full name. + userpictureurl?: string; // Post author picture. + deleted: boolean; // This post has been removed. + isprivatereply: boolean; // The post is a private reply. + tags?: { // Tags. + id: number; // Tag id. + name: string; // Tag name. + rawname: string; // The raw, unnormalised name for the tag as entered by users. + isstandard: boolean; // Whether this tag is standard. + tagcollid: number; // Tag collection id. + taginstanceid: number; // Tag instance id. + taginstancecontextid: number; // Context the tag instance belongs to. + itemid: number; // Id of the record tagged. + ordering: number; // Tag ordering. + flag: number; // Whether the tag is flagged as inappropriate. + }[]; +}; + +/** + * Forum post data. + */ +export type AddonModForumPost = { + id: number; // Id. + subject: string; // Subject. + replysubject: string; // Replysubject. + message: string; // Message. + messageformat: number; // Message format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + author: { + id?: number; // Id. + fullname?: string; // Fullname. + isdeleted?: boolean; // Isdeleted. + groups?: { // Groups. + id: number; // Id. + name: string; // Name. + urls: { + image?: string; // Image. + }; + }[]; + urls: { + profile?: string; // The URL for the use profile page. + profileimage?: string; // The URL for the use profile image. + }; + }; + discussionid: number; // Discussionid. + hasparent: boolean; // Hasparent. + parentid?: number; // Parentid. + timecreated: number; // Timecreated. + unread?: boolean; // Unread. + isdeleted: boolean; // Isdeleted. + isprivatereply: boolean; // Isprivatereply. + haswordcount: boolean; // Haswordcount. + wordcount?: number; // Wordcount. + charcount?: number; // Charcount. + capabilities: { + view: boolean; // Whether the user can view the post. + edit: boolean; // Whether the user can edit the post. + delete: boolean; // Whether the user can delete the post. + split: boolean; // Whether the user can split the post. + reply: boolean; // Whether the user can reply to the post. + selfenrol: boolean; // Whether the user can self enrol into the course. + export: boolean; // Whether the user can export the post. + controlreadstatus: boolean; // Whether the user can control the read status of the post. + canreplyprivately: boolean; // Whether the user can post a private reply. + }; + urls?: { + view?: string; // The URL used to view the post. + viewisolated?: string; // The URL used to view the post in isolation. + viewparent?: string; // The URL used to view the parent of the post. + edit?: string; // The URL used to edit the post. + delete?: string; // The URL used to delete the post. + + // The URL used to split the discussion with the selected post being the first post in the new discussion. + split?: string; + + reply?: string; // The URL used to reply to the post. + export?: string; // The URL used to export the post. + markasread?: string; // The URL used to mark the post as read. + markasunread?: string; // The URL used to mark the post as unread. + discuss?: string; // Discuss. + }; + attachments: { // Attachments. + contextid: number; // Contextid. + component: string; // Component. + filearea: string; // Filearea. + itemid: number; // Itemid. + filepath: string; // Filepath. + filename: string; // Filename. + isdir: boolean; // Isdir. + isimage: boolean; // Isimage. + timemodified: number; // Timemodified. + timecreated: number; // Timecreated. + filesize: number; // Filesize. + author: string; // Author. + license: string; // License. + filenameshort: string; // Filenameshort. + filesizeformatted: string; // Filesizeformatted. + icon: string; // Icon. + timecreatedformatted: string; // Timecreatedformatted. + timemodifiedformatted: string; // Timemodifiedformatted. + url: string; // Url. + urls: { + export?: string; // The URL used to export the attachment. + }; + html: { + plagiarism?: string; // The HTML source for the Plagiarism Response. + }; + }[]; + tags?: { // Tags. + id: number; // The ID of the Tag. + tagid: number; // The tagid. + isstandard: boolean; // Whether this is a standard tag. + displayname: string; // The display name of the tag. + flag: boolean; // Wehther this tag is flagged. + urls: { + view: string; // The URL to view the tag. + }; + }[]; + html?: { + rating?: string; // The HTML source to rate the post. + taglist?: string; // The HTML source to view the list of tags. + authorsubheading?: string; // The HTML source to view the author details. + }; +}; + +/** + * Forum rating info. + */ +export type AddonModForumRatingInfo = { + contextid: number; // Context id. + component: string; // Context name. + ratingarea: string; // Rating area name. + canviewall?: boolean; // Whether the user can view all the individual ratings. + canviewany?: boolean; // Whether the user can view aggregate of ratings of others. + scales?: { // Different scales used information. + id: number; // Scale id. + courseid?: number; // Course id. + name?: string; // Scale name (when a real scale is used). + max: number; // Max value for the scale. + isnumeric: boolean; // Whether is a numeric scale. + items?: { // Scale items. Only returned for not numerical scales. + value: number; // Scale value/option id. + name: string; // Scale name. + }[]; + }[]; + ratings?: { // The ratings. + itemid: number; // Item id. + scaleid?: number; // Scale id. + userid?: number; // User who rated id. + aggregate?: number; // Aggregated ratings grade. + aggregatestr?: string; // Aggregated ratings as string. + aggregatelabel?: string; // The aggregation label. + count?: number; // Ratings count (used when aggregating). + rating?: number; // The rating the user gave. + canrate?: boolean; // Whether the user can rate the item. + canviewaggregate?: boolean; // Whether the user can view the aggregated grade. + }[]; +}; + +/** + * Options to pass to get discussions. + */ +export type AddonModForumGetDiscussionsOptions = CoreCourseCommonModWSOptions & { + sortOrder?: number; // Sort order. + page?: number; // Page. Defaults to 0. +}; + +/** + * Options to pass to get discussions in pages. + */ +export type AddonModForumGetDiscussionsInPagesOptions = AddonModForumGetDiscussionsOptions & { + numPages?: number; // Number of pages to get. If not defined, all pages. +}; + +/** + * Forum access information. + */ +export type AddonModForumAccessInformation = { + canaddinstance?: boolean; // Whether the user has the capability mod/forum:addinstance allowed. + canviewdiscussion?: boolean; // Whether the user has the capability mod/forum:viewdiscussion allowed. + canviewhiddentimedposts?: boolean; // Whether the user has the capability mod/forum:viewhiddentimedposts allowed. + canstartdiscussion?: boolean; // Whether the user has the capability mod/forum:startdiscussion allowed. + canreplypost?: boolean; // Whether the user has the capability mod/forum:replypost allowed. + canaddnews?: boolean; // Whether the user has the capability mod/forum:addnews allowed. + canreplynews?: boolean; // Whether the user has the capability mod/forum:replynews allowed. + canviewrating?: boolean; // Whether the user has the capability mod/forum:viewrating allowed. + canviewanyrating?: boolean; // Whether the user has the capability mod/forum:viewanyrating allowed. + canviewallratings?: boolean; // Whether the user has the capability mod/forum:viewallratings allowed. + canrate?: boolean; // Whether the user has the capability mod/forum:rate allowed. + canpostprivatereply?: boolean; // Whether the user has the capability mod/forum:postprivatereply allowed. + canreadprivatereplies?: boolean; // Whether the user has the capability mod/forum:readprivatereplies allowed. + cancreateattachment?: boolean; // Whether the user has the capability mod/forum:createattachment allowed. + candeleteownpost?: boolean; // Whether the user has the capability mod/forum:deleteownpost allowed. + candeleteanypost?: boolean; // Whether the user has the capability mod/forum:deleteanypost allowed. + cansplitdiscussions?: boolean; // Whether the user has the capability mod/forum:splitdiscussions allowed. + canmovediscussions?: boolean; // Whether the user has the capability mod/forum:movediscussions allowed. + canpindiscussions?: boolean; // Whether the user has the capability mod/forum:pindiscussions allowed. + caneditanypost?: boolean; // Whether the user has the capability mod/forum:editanypost allowed. + canviewqandawithoutposting?: boolean; // Whether the user has the capability mod/forum:viewqandawithoutposting allowed. + canviewsubscribers?: boolean; // Whether the user has the capability mod/forum:viewsubscribers allowed. + canmanagesubscriptions?: boolean; // Whether the user has the capability mod/forum:managesubscriptions allowed. + canpostwithoutthrottling?: boolean; // Whether the user has the capability mod/forum:postwithoutthrottling allowed. + canexportdiscussion?: boolean; // Whether the user has the capability mod/forum:exportdiscussion allowed. + canexportforum?: boolean; // Whether the user has the capability mod/forum:exportforum allowed. + canexportpost?: boolean; // Whether the user has the capability mod/forum:exportpost allowed. + canexportownpost?: boolean; // Whether the user has the capability mod/forum:exportownpost allowed. + canaddquestion?: boolean; // Whether the user has the capability mod/forum:addquestion allowed. + canallowforcesubscribe?: boolean; // Whether the user has the capability mod/forum:allowforcesubscribe allowed. + cancanposttomygroups?: boolean; // Whether the user has the capability mod/forum:canposttomygroups allowed. + cancanoverridediscussionlock?: boolean; // Whether the user has the capability mod/forum:canoverridediscussionlock allowed. + cancanoverridecutoff?: boolean; // Whether the user has the capability mod/forum:canoverridecutoff allowed. + cancantogglefavourite?: boolean; // Whether the user has the capability mod/forum:cantogglefavourite allowed. + cangrade?: boolean; // Whether the user has the capability mod/forum:grade allowed. +}; + +/** + * Can add discussion info. + */ +export type AddonModForumCanAddDiscussion = { + status: boolean; // True if the user can add discussions, false otherwise. + canpindiscussions?: boolean; // True if the user can pin discussions, false otherwise. + cancreateattachment?: boolean; // True if the user can add attachments, false otherwise. +}; + +/** + * Sorting order. + */ +export type AddonModForumSortOrder = { + label: string; + value: number; +}; + +/** + * Params of mod_forum_get_forum_discussions WS. + */ +export type AddonModForumGetForumDiscussionsWSParams = { + forumid: number; // Forum instance id. + sortorder?: number; // Sort by this element: numreplies, , created or timemodified. + page?: number; // Current page. + perpage?: number; // Items per page. + groupid?: number; // Group id. +}; + +/** + * Data returned by mod_forum_get_forum_discussions WS. + */ +export type AddonModForumGetForumDiscussionsWSResponse = { + discussions: AddonModForumDiscussion[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_forum_get_forum_discussions_paginated WS. + */ +export type AddonModForumGetForumDiscussionsPaginatedWSParams = { + forumid: number; // Forum instance id. + sortby?: string; // Sort by this element: id, timemodified, timestart or timeend. + sortdirection?: string; // Sort direction: ASC or DESC. + page?: number; // Current page. + perpage?: number; // Items per page. +}; + +/** + * Data returned by mod_forum_get_forum_discussions_paginated WS. + */ +export type AddonModForumGetForumDiscussionsPaginatedWSResponse = { + discussions: AddonModForumDiscussion[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data returned by mod_forum_get_forums_by_courses WS. + */ +export type AddonModForumGetForumsByCoursesWSResponse = AddonModForumData[]; + +/** + * Array options of mod_forum_add_discussion WS. + */ +export type AddonModForumAddDiscussionWSOptionsArray = { + // Option name. + name: 'discussionsubscribe' | 'discussionpinned' | 'inlineattachmentsid' | 'attachmentsid'; + + // Option value. + // This param is validated in the external function, expected values are: + // discussionsubscribe (bool) - subscribe to the discussion?, default to true + // discussionpinned (bool) - is the discussion pinned, default to false + // inlineattachmentsid (int) - the draft file area id for inline attachments + // attachmentsid (int) - the draft file area id for attachments. + value: string; +}[]; + +/** + * Object options of mod_forum_add_discussion WS. + */ +export type AddonModForumAddDiscussionWSOptionsObject = { + discussionsubscribe?: string; + discussionpinned?: string; + inlineattachmentsid?: string; + attachmentsid?: string; +}; + +/** + * Array options of mod_forum_add_discussion_post WS. + */ +export type AddonModForumAddDiscussionPostWSOptionsArray = { + // Option name. + name: 'discussionsubscribe' | 'private' | 'inlineattachmentsid' | 'attachmentsid' | 'topreferredformat'; + + // Option value. + // This param is validated in the external function, expected values are: + // discussionsubscribe (bool) - subscribe to the discussion?, default to true + // private (bool) - make this reply private to the author of the parent post, default to false. + // inlineattachmentsid (int) - the draft file area id for inline attachments + // attachmentsid (int) - the draft file area id for attachments + // topreferredformat (bool) - convert the message & messageformat to FORMAT_HTML, defaults to false. + value: string; +}[]; + +/** + * Object options of mod_forum_add_discussion_post WS. + */ +export type AddonModForumAddDiscussionPostWSOptionsObject = { + discussionsubscribe?: boolean; + private?: boolean; + inlineattachmentsid?: number; + attachmentsid?: number; + topreferredformat?: boolean; +}; + +/** + * Array options of mod_forum_update_discussion_post WS. + */ +export type AddonModForumUpdateDiscussionPostWSOptionsArray = { + // Option name. + name: 'pinned' | 'discussionsubscribe' | 'inlineattachmentsid' | 'attachmentsid'; + + // Option value. + // This param is validated in the external function, expected values are: + // pinned (bool) - (only for discussions) whether to pin this discussion or not + // discussionsubscribe (bool) - whether to subscribe to the post or not + // inlineattachmentsid (int) - the draft file area id for inline attachments in the text + // attachmentsid (int) - the draft file area id for attachments. + value: string; // The value of the option. +}[]; + +/** + * Object options of mod_forum_update_discussion_post WS. + */ +export type AddonModForumUpdateDiscussionPostWSOptionsObject = { + pinned?: boolean; + discussionsubscribe?: boolean; + inlineattachmentsid?: number; + attachmentsid?: number; +}; + +/** + * Params of mod_forum_add_discussion WS. + */ +export type AddonModForumAddDiscussionWSParams = { + forumid: number; // Forum instance ID. + subject: string; // New Discussion subject. + message: string; // New Discussion message (only html format allowed). + groupid?: number; // The group, default to 0. + options?: AddonModForumAddDiscussionWSOptionsArray; +}; + +/** + * Data returned by mod_forum_add_discussion WS. + */ +export type AddonModForumAddDiscussionWSResponse = { + discussionid: number; // New Discussion ID. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_forum_add_discussion_post WS. + */ +export type AddonModForumAddDiscussionPostWSParams = { + postid: number; // The post id we are going to reply to (can be the initial discussion post). + subject: string; // New post subject. + message: string; // New post message (html assumed if messageformat is not provided). + options?: AddonModForumAddDiscussionPostWSOptionsArray; + messageformat?: number; // Message format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). +}; + +/** + * Data returned by mod_forum_add_discussion_post WS. + */ +export type AddonModForumAddDiscussionPostWSResponse = { + postid: number; // New post id. + warnings?: CoreWSExternalWarning[]; + post: AddonModForumPost; + messages?: { // List of warnings. + type: string; // The classification to be used in the client side. + message: string; // Untranslated english message to explain the warning. + }[]; +}; + +/** + * Params of mod_forum_get_forum_access_information WS. + */ +export type AddonModForumGetForumAccessInformationWSParams = { + forumid: number; // Forum instance id. +}; + +/** + * Data returned by mod_forum_get_forum_access_information WS. + */ +export type AddonModForumGetForumAccessInformationWSResponse = { + warnings?: CoreWSExternalWarning[]; +} & AddonModForumAccessInformation; + +/** + * Params of mod_forum_can_add_discussion WS. + */ +export type AddonModForumCanAddDiscussionWSParams = { + forumid: number; // Forum instance ID. + groupid?: number; // The group to check, default to active group (Use -1 to check if the user can post in all the groups). +}; + +/** + * Data returned by mod_forum_can_add_discussion WS. + */ +export type AddonModForumCanAddDiscussionWSResponse = { + warnings?: CoreWSExternalWarning[]; +} & AddonModForumCanAddDiscussion; + +/** + * Params of mod_forum_delete_post WS. + */ +export type AddonModForumDeletePostWSParams = { + postid: number; // Post to be deleted. It can be a discussion topic post. +}; + +/** + * Data returned by mod_forum_delete_post WS. + */ +export type AddonModForumDeletePostWSResponse = CoreStatusWithWarningsWSResponse; + + +/** + * Params of mod_forum_get_discussion_post WS. + */ +export type AddonModForumGetDiscussionPostWSParams = { + postid: number; // Post to fetch. +}; + +/** + * Data returned by mod_forum_get_discussion_post WS. + */ +export type AddonModForumGetDiscussionPostWSResponse = { + post: AddonModForumPost; + warnings?: CoreWSExternalWarning[]; +}; + + +/** + * Params of mod_forum_get_discussion_posts WS. + */ +export type AddonModForumGetDiscussionPostsWSParams = { + discussionid: number; // The ID of the discussion from which to fetch posts. + sortby?: string; // Sort by this element: id, created or modified. + sortdirection?: string; // Sort direction: ASC or DESC. +}; + +/** + * Data returned by mod_forum_get_discussion_posts WS. + */ +export type AddonModForumGetDiscussionPostsWSResponse = { + posts: AddonModForumPost[]; + forumid: number; // The forum id. + courseid: number; // The forum course id. + ratinginfo?: AddonModForumRatingInfo; // Rating information. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_forum_get_forum_discussion_posts WS. + */ +export type AddonModForumGetForumDiscussionPostsWSParams = { + discussionid: number; // Discussion ID. + sortby?: string; // Sort by this element: id, created or modified. + sortdirection?: string; // Sort direction: ASC or DESC. +}; + +/** + * Data returned by mod_forum_get_forum_discussion_posts WS. + */ +export type AddonModForumGetForumDiscussionPostsWSResponse = { + posts: AddonModForumLegacyPost[]; + ratinginfo?: AddonModForumRatingInfo; // Rating information. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_forum_set_lock_state WS. + */ +export type AddonModForumSetLockStateWSParams = { + forumid: number; // Forum that the discussion is in. + discussionid: number; // The discussion to lock / unlock. + targetstate: number; // The timestamp for the lock state. +}; + +/** + * Data returned by mod_forum_set_lock_state WS. + */ +export type AddonModForumSetLockStateWSResponse = { + id: number; // The discussion we are locking. + locked: boolean; // The locked state of the discussion. + times: { + locked: number; // The locked time of the discussion. + }; +}; + +/** + * Params of mod_forum_set_pin_state WS. + */ +export type AddonModForumSetPinStateWSParams = { + discussionid: number; // The discussion to pin or unpin. + targetstate: number; // The target state. +}; + +/** + * Data returned by mod_forum_set_pin_state WS. + */ +export type AddonModForumSetPinStateWSResponse = { + id: number; // Id. + forumid: number; // Forumid. + pinned: boolean; // Pinned. + locked: boolean; // Locked. + istimelocked: boolean; // Istimelocked. + name: string; // Name. + firstpostid: number; // Firstpostid. + group?: { + name: string; // Name. + urls: { + picture?: string; // Picture. + userlist?: string; // Userlist. + }; + }; + times: { + modified: number; // Modified. + start: number; // Start. + end: number; // End. + locked: number; // Locked. + }; + userstate: { + subscribed: boolean; // Subscribed. + favourited: boolean; // Favourited. + }; + capabilities: { + subscribe: boolean; // Subscribe. + move: boolean; // Move. + pin: boolean; // Pin. + post: boolean; // Post. + manage: boolean; // Manage. + favourite: boolean; // Favourite. + }; + urls: { + view: string; // View. + viewlatest?: string; // Viewlatest. + viewfirstunread?: string; // Viewfirstunread. + markasread: string; // Markasread. + subscribe: string; // Subscribe. + pin?: string; // Pin. + }; + timed: { + istimed?: boolean; // Istimed. + visible?: boolean; // Visible. + }; +}; + +/** + * Params of mod_forum_toggle_favourite_state WS. + */ +export type AddonModForumToggleFavouriteStateWSParams = { + discussionid: number; // The discussion to subscribe or unsubscribe. + targetstate: boolean; // The target state. +}; + +/** + * Data returned by mod_forum_toggle_favourite_state WS. + */ +export type AddonModForumToggleFavouriteStateWSResponse = { + id: number; // Id. + forumid: number; // Forumid. + pinned: boolean; // Pinned. + locked: boolean; // Locked. + istimelocked: boolean; // Istimelocked. + name: string; // Name. + firstpostid: number; // Firstpostid. + group?: { + name: string; // Name. + urls: { + picture?: string; // Picture. + userlist?: string; // Userlist. + }; + }; + times: { + modified: number; // Modified. + start: number; // Start. + end: number; // End. + locked: number; // Locked. + }; + userstate: { + subscribed: boolean; // Subscribed. + favourited: boolean; // Favourited. + }; + capabilities: { + subscribe: boolean; // Subscribe. + move: boolean; // Move. + pin: boolean; // Pin. + post: boolean; // Post. + manage: boolean; // Manage. + favourite: boolean; // Favourite. + }; + urls: { + view: string; // View. + viewlatest?: string; // Viewlatest. + viewfirstunread?: string; // Viewfirstunread. + markasread: string; // Markasread. + subscribe: string; // Subscribe. + pin?: string; // Pin. + }; + timed: { + istimed?: boolean; // Istimed. + visible?: boolean; // Visible. + }; +}; + +/** + * Params of mod_forum_update_discussion_post WS. + */ +export type AddonModForumUpdateDiscussionPostWSParams = { + postid: number; // Post to be updated. It can be a discussion topic post. + subject?: string; // Updated post subject. + message?: string; // Updated post message (HTML assumed if messageformat is not provided). + messageformat?: number; // Message format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + options?: AddonModForumUpdateDiscussionPostWSOptionsArray; // Configuration options for the post. +}; + +/** + * Data returned by mod_forum_update_discussion_post WS. + */ +export type AddonModForumUpdateDiscussionPostWSResponse = CoreStatusWithWarningsWSResponse; diff --git a/src/addons/mod/forum/services/forum.ts b/src/addons/mod/forum/services/forum.ts deleted file mode 100644 index 9a30af0cb..000000000 --- a/src/addons/mod/forum/services/forum.ts +++ /dev/null @@ -1,132 +0,0 @@ -// (C) Copyright 2015 Moodle Pty Ltd. -// -// 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 { CoreSite, CoreSiteWSPreSets } from '@classes/site'; -import { CoreSitesCommonWSOptions, CoreSites } from '@services/sites'; -import { CoreWSExternalFile } from '@services/ws'; -import { makeSingleton } from '@singletons'; - -const ROOT_CACHE_KEY = 'mmaModForum:'; - -/** - * Service that provides some features for forums. - * - * @todo Add all content. - */ -@Injectable({ providedIn: 'root' }) -export class AddonModForumProvider { - - static readonly COMPONENT = 'mmaModForum'; - - /** - * Get cache key for forum data WS calls. - * - * @param courseId Course ID. - * @return Cache key. - */ - protected getForumDataCacheKey(courseId: number): string { - return ROOT_CACHE_KEY + 'forum:' + courseId; - } - - /** - * Get all course forums. - * - * @param courseId Course ID. - * @param options Other options. - * @return Promise resolved when the forums are retrieved. - */ - async getCourseForums(courseId: number, options: CoreSitesCommonWSOptions = {}): Promise { - const site = await CoreSites.instance.getSite(options.siteId); - - const params: AddonModForumGetForumsByCoursesWSParams = { - courseids: [courseId], - }; - const preSets: CoreSiteWSPreSets = { - cacheKey: this.getForumDataCacheKey(courseId), - updateFrequency: CoreSite.FREQUENCY_RARELY, - component: AddonModForumProvider.COMPONENT, - ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), - }; - - return site.read('mod_forum_get_forums_by_courses', params, preSets); - } - - /** - * Invalidates forum data. - * - * @param courseId Course ID. - * @return Promise resolved when the data is invalidated. - */ - async invalidateForumData(courseId: number): Promise { - await CoreSites.instance.getCurrentSite()?.invalidateWsCacheForKey(this.getForumDataCacheKey(courseId)); - } - -} - -export class AddonModForum extends makeSingleton(AddonModForumProvider) {} - -/** - * Params of mod_forum_get_forums_by_courses WS. - */ -type AddonModForumGetForumsByCoursesWSParams = { - courseids?: number[]; // Array of Course IDs. -}; - -/** - * General forum activity data. - */ -export type AddonModForumData = { - id: number; // Forum id. - course: number; // Course id. - type: string; // The forum type. - name: string; // Forum name. - intro: string; // The forum intro. - introformat: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). - introfiles?: CoreWSExternalFile[]; - duedate?: number; // Duedate for the user. - cutoffdate?: number; // Cutoffdate for the user. - assessed: number; // Aggregate type. - assesstimestart: number; // Assess start time. - assesstimefinish: number; // Assess finish time. - scale: number; // Scale. - // eslint-disable-next-line @typescript-eslint/naming-convention - grade_forum: number; // Whole forum grade. - // eslint-disable-next-line @typescript-eslint/naming-convention - grade_forum_notify: number; // Whether to send notifications to students upon grading by default. - maxbytes: number; // Maximum attachment size. - maxattachments: number; // Maximum number of attachments. - forcesubscribe: number; // Force users to subscribe. - trackingtype: number; // Subscription mode. - rsstype: number; // RSS feed for this activity. - rssarticles: number; // Number of RSS recent articles. - timemodified: number; // Time modified. - warnafter: number; // Post threshold for warning. - blockafter: number; // Post threshold for blocking. - blockperiod: number; // Time period for blocking. - completiondiscussions: number; // Student must create discussions. - completionreplies: number; // Student must post replies. - completionposts: number; // Student must post discussions or replies. - cmid: number; // Course module id. - numdiscussions?: number; // Number of discussions in the forum. - cancreatediscussions?: boolean; // If the user can create discussions. - lockdiscussionafter?: number; // After what period a discussion is locked. - istracked?: boolean; // If the user is tracking the forum. - unreadpostscount?: number; // The number of unread posts for tracked forums. -}; - -/** - * Data returned by mod_forum_get_forums_by_courses WS. - */ -export type AddonModForumGetForumsByCoursesWSResponse = AddonModForumData[]; diff --git a/src/addons/mod/forum/services/handlers/module.ts b/src/addons/mod/forum/services/handlers/module.ts new file mode 100644 index 000000000..3477ada82 --- /dev/null +++ b/src/addons/mod/forum/services/handlers/module.ts @@ -0,0 +1,173 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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, Type } from '@angular/core'; +import { AddonModForum, AddonModForumProvider } from '../forum.service'; +import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate'; +import { CoreConstants } from '@/core/constants'; +import { AddonModForumIndexComponent } from '../../components/index'; + +/** + * Handler to support forum modules. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModForumModuleHandlerService implements CoreCourseModuleHandler { + + static readonly PAGE_NAME = 'mod_forum'; + + name = 'AddonModForum'; + modName = 'forum'; + + supportedFeatures = { + [CoreConstants.FEATURE_GROUPS]: true, + [CoreConstants.FEATURE_GROUPINGS]: true, + [CoreConstants.FEATURE_MOD_INTRO]: true, + [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true, + [CoreConstants.FEATURE_COMPLETION_HAS_RULES]: true, + [CoreConstants.FEATURE_GRADE_HAS_GRADE]: true, + [CoreConstants.FEATURE_GRADE_OUTCOMES]: true, + [CoreConstants.FEATURE_BACKUP_MOODLE2]: true, + [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true, + [CoreConstants.FEATURE_RATE]: true, + [CoreConstants.FEATURE_PLAGIARISM]: true, + }; + + /** + * Check if the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + isEnabled(): Promise { + return Promise.resolve(true); + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param module The module object. + * @param courseId The course ID. + * @param sectionId The section ID. + * @return Data to render the module. + */ + getData(module: CoreCourseAnyModuleData, courseId: number): CoreCourseModuleHandlerData { + const data: CoreCourseModuleHandlerData = { + icon: CoreCourse.instance.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined), + title: module.name, + class: 'addon-mod_forum-handler', + showDownloadButton: true, + action(_: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions): void { + options = options || {}; + options.params = options.params || {}; + Object.assign(options.params, { module }); + + CoreNavigator.instance.navigateToSitePath( + `${AddonModForumModuleHandlerService.PAGE_NAME}/${courseId}/${module.id}`, + options, + ); + }, + }; + + if ('afterlink' in module && !!module.afterlink) { + data.extraBadgeColor = ''; + const match = />(\d+)[^<]+/.exec(module.afterlink); + data.extraBadge = match ? Translate.instance.instant('addon.mod_forum.unreadpostsnumber', { $a : match[1] }) : ''; + } else { + this.updateExtraBadge(data, courseId, module.id); + } + + const event = CoreEvents.on( + AddonModForumProvider.MARK_READ_EVENT, + (eventData: { courseId?: number; moduleId?: number; siteId?: string }) => { + if (eventData.courseId !== courseId || eventData.moduleId !== module.id) { + return; + } + + this.updateExtraBadge(data, eventData.courseId, eventData.moduleId, eventData.siteId); + }, + CoreSites.instance.getCurrentSiteId(), + ); + + data.onDestroy = () => event.off(); + + return data; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * + * @return The component to use, undefined if not found. + */ + async getMainComponent(): Promise | undefined> { + 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 Whether the refresher should be displayed. + */ + displayRefresherInSingleActivity(): boolean { + return false; + } + + /** + * Triggers an update for the extra badge text. + * + * @param data Course Module Handler data. + * @param courseId Course ID. + * @param moduleId Course module ID. + * @param siteId Site ID. If not defined, current site. + */ + async updateExtraBadge(data: CoreCourseModuleHandlerData, courseId: number, moduleId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!siteId) { + return; + } + + data.extraBadge = Translate.instance.instant('core.loading'); + data.extraBadgeColor = 'light'; + + await CoreUtils.instance.ignoreErrors(AddonModForum.instance.invalidateForumData(courseId)); + + try { + // Handle unread posts. + const forum = await AddonModForum.instance.getForum(courseId, moduleId, { siteId }); + + data.extraBadgeColor = ''; + data.extraBadge = forum.unreadpostscount + ? Translate.instance.instant( + 'addon.mod_forum.unreadpostsnumber', + { $a : forum.unreadpostscount }, + ) + : ''; + } catch (error) { + // Ignore errors. + data.extraBadgeColor = ''; + data.extraBadge = ''; + } + } + +} + +export class AddonModForumModuleHandler extends makeSingleton(AddonModForumModuleHandlerService) {} diff --git a/src/addons/mod/forum/services/helper.service.ts b/src/addons/mod/forum/services/helper.service.ts new file mode 100644 index 000000000..04dc2b272 --- /dev/null +++ b/src/addons/mod/forum/services/helper.service.ts @@ -0,0 +1,507 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreFileEntry, CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { CoreUser } from '@features/user/services/user'; +import { CoreApp } from '@services/app'; +import { CoreFile } from '@services/file'; +import { CoreSites } from '@services/sites'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModForum, AddonModForumData, AddonModForumProvider } from './forum.service'; +import { AddonModForumOffline } from './offline.service'; + +/** + * Service that provides some features for forums. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModForumHelperProvider { + + /** + * Add a new discussion. + * + * @param forumId Forum ID. + * @param name Forum name. + * @param courseId Course ID the forum belongs to. + * @param subject New discussion's subject. + * @param message New discussion's message. + * @param attachments New discussion's attachments. + * @param options Options (subscribe, pin, ...). + * @param groupIds Groups this discussion belongs to. + * @param timeCreated The time the discussion was created. Only used when editing discussion. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with ids of the created discussions or null if stored offline + */ + async addNewDiscussion( + forumId: number, + name: string, + courseId: number, + subject: string, + message: string, + attachments?: any[], + options?: any, + groupIds?: number[], + timeCreated?: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + groupIds = (groupIds && groupIds.length > 0) ? groupIds : [0]; + + let saveOffline = false; + const attachmentsIds: number[] = []; + let offlineAttachments: any; + + // Convenience function to store a message to be synchronized later. + const storeOffline = async (): Promise => { + // Multiple groups, the discussion is being posted to all groups. + const groupId = groupIds!.length > 1 ? AddonModForumProvider.ALL_GROUPS : groupIds![0]; + + if (offlineAttachments) { + options.attachmentsid = offlineAttachments; + } + + await AddonModForumOffline.instance.addNewDiscussion( + forumId, + name, + courseId, + subject, + message, + options, + groupId, + timeCreated, + siteId, + ); + }; + + // First try to upload attachments, once per group. + if (attachments && attachments.length > 0) { + const promises = groupIds.map( + () => this + .uploadOrStoreNewDiscussionFiles(forumId, timeCreated || 0, attachments, false) + .then(attach => attachmentsIds.push(attach)), + ); + + try { + await Promise.all(promises); + } catch (error) { + // Cannot upload them in online, save them in offline. + saveOffline = true; + + const attach = await this.uploadOrStoreNewDiscussionFiles(forumId, timeCreated || 0, attachments, true); + + offlineAttachments = attach; + } + } + + // If we are editing an offline discussion, discard previous first. + if (timeCreated) { + await AddonModForumOffline.instance.deleteNewDiscussion(forumId, timeCreated, siteId); + } + + if (saveOffline || !CoreApp.instance.isOnline()) { + await storeOffline(); + + return null; + } + + const errors: Error[] = []; + const discussionIds: number[] = []; + const promises = groupIds.map(async (groupId, index) => { + const groupOptions = CoreUtils.instance.clone(options); + + if (attachmentsIds[index]) { + groupOptions.attachmentsid = attachmentsIds[index]; + } + + try { + const discussionId = await AddonModForum.instance.addNewDiscussionOnline( + forumId, + subject, + message, + groupOptions, + groupId, + siteId, + ); + + discussionIds.push(discussionId); + } catch (error) { + errors.push(error); + } + }); + + await Promise.all(promises); + + if (errors.length == groupIds.length) { + // All requests have failed. + for (let i = 0; i < errors.length; i++) { + if (CoreUtils.instance.isWebServiceError(errors[i]) || (attachments && attachments.length > 0)) { + // The WebService has thrown an error or offline not supported, reject. + throw errors[i]; + } + } + + // Couldn't connect to server, store offline. + await storeOffline(); + + return null; + } + + return discussionIds; + } + + /** + * Convert offline reply to online format in order to be compatible with them. + * + * @param offlineReply Offline version of the reply. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the object converted to Online. + */ + convertOfflineReplyToOnline(offlineReply: any, siteId?: string): Promise { + const reply: any = { + id: -offlineReply.timecreated, + discussionid: offlineReply.discussionid, + parentid: offlineReply.postid, + hasparent: !!offlineReply.postid, + author: { + id: offlineReply.userid, + }, + timecreated: false, + subject: offlineReply.subject, + message: offlineReply.message, + attachments: [], + capabilities: { + reply: false, + }, + unread: false, + isprivatereply: offlineReply.options && offlineReply.options.private, + tags: null, + }; + const promises: Promise[] = []; + + // 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.parentid, siteId, offlineReply.userid) + .then(files => reply.attachments = reply.attachments.concat(files)), + ); + } + } + + // Get user data. + promises.push( + CoreUtils.instance.ignoreErrors( + CoreUser.instance + .getProfile(offlineReply.userid, offlineReply.courseid, true) + .then(user => { + reply.author.fullname = user.fullname; + reply.author.urls = { profileimage: user.profileimageurl }; + + return; + }), + ), + ); + + return Promise.all(promises).then(() => { + reply.attachment = reply.attachments.length > 0 ? 1 : 0; + + return reply; + }); + } + + /** + * Delete stored attachment files for a new discussion. + * + * @param forumId Forum ID. + * @param timecreated The time the discussion was created. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when deleted. + */ + async deleteNewDiscussionStoredFiles(forumId: number, timecreated: number, siteId?: string): Promise { + const folderPath = await AddonModForumOffline.instance.getNewDiscussionFolder(forumId, timecreated, siteId); + + // Ignore any errors, CoreFileProvider.removeDir fails if folder doesn't exist. + await CoreUtils.instance.ignoreErrors(CoreFile.instance.removeDir(folderPath)); + } + + /** + * Delete stored attachment files for a reply. + * + * @param forumId Forum ID. + * @param postId ID of the post being replied. + * @param siteId Site ID. If not defined, current site. + * @param userId User the reply belongs to. If not defined, current user in site. + * @return Promise resolved when deleted. + */ + async deleteReplyStoredFiles(forumId: number, postId: number, siteId?: string, userId?: number): Promise { + const folderPath = await AddonModForumOffline.instance.getReplyFolder(forumId, postId, siteId, userId); + + // Ignore any errors, CoreFileProvider.removeDir fails if folder doesn't exist. + await CoreUtils.instance.ignoreErrors(CoreFile.instance.removeDir(folderPath)); + } + + /** + * Returns the availability message of the given forum. + * + * @param forum Forum instance. + * @return Message or null if the forum has no cut-off or due date. + */ + getAvailabilityMessage(forum: AddonModForumData): string | null { + if (this.isCutoffDateReached(forum)) { + return Translate.instance.instant('addon.mod_forum.cutoffdatereached'); + } + + if (this.isDueDateReached(forum)) { + const dueDate = CoreTimeUtils.instance.userDate(forum.duedate * 1000); + + return Translate.instance.instant('addon.mod_forum.thisforumisdue', { $a: dueDate }); + } + + if ((forum.duedate ?? 0) > 0) { + const dueDate = CoreTimeUtils.instance.userDate(forum.duedate! * 1000); + + return Translate.instance.instant('addon.mod_forum.thisforumhasduedate', { $a: dueDate }); + } + + return null; + } + + /** + * Get a forum discussion by id. + * + * This function is inefficient because it needs to fetch all discussion pages in the worst case. + * + * @param forumId Forum ID. + * @param cmId Forum cmid + * @param discussionId Discussion ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the discussion data. + */ + getDiscussionById(forumId: number, cmId: number, discussionId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const findDiscussion = async (page: number): Promise => { + const response = await AddonModForum.instance.getDiscussions(forumId, { + cmId, + page, + siteId, + }); + + if (response.discussions && response.discussions.length > 0) { + // Note that discussion.id is the main post ID but discussion ID is discussion.discussion. + const discussion = response.discussions.find((discussion) => discussion.discussion == discussionId); + + if (discussion) { + return discussion; + } + + if (response.canLoadMore) { + return findDiscussion(page + 1); + } + } + + throw new Error('Discussion not found'); + }; + + return findDiscussion(0); + } + + /** + * Get a list of stored attachment files for a new discussion. See AddonModForumHelper#storeNewDiscussionFiles. + * + * @param forumId Forum ID. + * @param timecreated The time the discussion was created. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + async getNewDiscussionStoredFiles(forumId: number, timecreated: number, siteId?: string): Promise { + const folderPath = await AddonModForumOffline.instance.getNewDiscussionFolder(forumId, timecreated, siteId); + + return CoreFileUploader.instance.getStoredFiles(folderPath); + } + + /** + * Get a list of stored attachment files for a reply. See AddonModForumHelper#storeReplyFiles. + * + * @param forumId Forum ID. + * @param postId ID of the post being replied. + * @param siteId Site ID. If not defined, current site. + * @param userId User the reply belongs to. If not defined, current user in site. + * @return Promise resolved with the files. + */ + async getReplyStoredFiles(forumId: number, postId: number, siteId?: string, userId?: number): Promise { + const folderPath = await AddonModForumOffline.instance.getReplyFolder(forumId, postId, siteId, userId); + + return CoreFileUploader.instance.getStoredFiles(folderPath); + } + + /** + * Check if the data of a post/discussion has changed. + * + * @param post Current data. + * @param original Original ata. + * @return 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; + } + + if (post.isprivatereply != original.isprivatereply) { + return true; + } + + return CoreFileUploader.instance.areFileListDifferent(post.files, original.files); + } + + /** + * Is the cutoff date for the forum reached? + * + * @param forum Forum instance. + */ + isCutoffDateReached(forum: any): boolean { + const now = Date.now() / 1000; + + return forum.cutoffdate > 0 && forum.cutoffdate < now; + } + + /** + * Is the due date for the forum reached? + * + * @param forum Forum instance. + */ + isDueDateReached(forum: AddonModForumData): forum is AddonModForumData & { duedate: number } { + const now = Date.now() / 1000; + const duedate = forum.duedate ?? 0; + + return duedate > 0 && duedate < now; + } + + /** + * Given a list of files (either online files or local files), store the local files in a local folder + * to be submitted later. + * + * @param forumId Forum ID. + * @param timecreated The time the discussion was created. + * @param files List of files. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success, rejected otherwise. + */ + async storeNewDiscussionFiles( + forumId: number, + timecreated: number, + files: CoreFileEntry[], + siteId?: string, + ): Promise { + // Get the folder where to store the files. + const folderPath = await AddonModForumOffline.instance.getNewDiscussionFolder(forumId, timecreated, siteId); + + return CoreFileUploader.instance.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 forumId Forum ID. + * @param postId ID of the post being replied. + * @param files List of files. + * @param siteId Site ID. If not defined, current site. + * @param userId User the reply belongs to. If not defined, current user in site. + * @return Promise resolved if success, rejected otherwise. + */ + async storeReplyFiles(forumId: number, postId: number, files: any[], siteId?: string, userId?: number): Promise { + // Get the folder where to store the files. + const folderPath = await AddonModForumOffline.instance.getReplyFolder(forumId, postId, siteId, userId); + + await CoreFileUploader.instance.storeFilesToUpload(folderPath, files); + } + + /** + * Upload or store some files for a new discussion, depending if the user is offline or not. + * + * @param forumId Forum ID. + * @param timecreated The time the discussion was created. + * @param files List of files. + * @param offline True if files sould be stored for offline, false to upload them. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success. + */ + uploadOrStoreNewDiscussionFiles( + forumId: number, + timecreated: number, + files: CoreFileEntry[], + offline: true, + siteId?: string, + ): Promise; + uploadOrStoreNewDiscussionFiles( + forumId: number, + timecreated: number, + files: CoreFileEntry[], + offline: false, + siteId?: string, + ): Promise; + uploadOrStoreNewDiscussionFiles( + forumId: number, + timecreated: number, + files: CoreFileEntry[], + offline: boolean, + siteId?: string, + ): Promise { + if (offline) { + return this.storeNewDiscussionFiles(forumId, timecreated, files, siteId); + } else { + return CoreFileUploader.instance.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId); + } + } + + /** + * Upload or store some files for a reply, depending if the user is offline or not. + * + * @param forumId Forum ID. + * @param postId ID of the post being replied. + * @param files List of files. + * @param offline True if files sould be stored for offline, false to upload them. + * @param siteId Site ID. If not defined, current site. + * @param userId User the reply belongs to. If not defined, current user in site. + * @return 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 CoreFileUploader.instance.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId); + } + } + +} + +export class AddonModForumHelper extends makeSingleton(AddonModForumHelperProvider) {} diff --git a/src/addons/mod/forum/services/offline-db.ts b/src/addons/mod/forum/services/offline-db.ts new file mode 100644 index 000000000..d690c1956 --- /dev/null +++ b/src/addons/mod/forum/services/offline-db.ts @@ -0,0 +1,115 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for AddonModForum service. + */ +export const DISCUSSIONS_TABLE = 'addon_mod_forum_discussions'; +export const REPLIES_TABLE = 'addon_mod_forum_replies'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModForumOfflineProvider', + version: 1, + tables: [ + { + name: 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: 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'], + }, + ], +}; diff --git a/src/addons/mod/forum/services/offline.service.ts b/src/addons/mod/forum/services/offline.service.ts new file mode 100644 index 000000000..ba6a422ea --- /dev/null +++ b/src/addons/mod/forum/services/offline.service.ts @@ -0,0 +1,436 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreFile } from '@services/file'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { makeSingleton } from '@singletons'; +import { AddonModForumProvider } from './forum.service'; +import { DISCUSSIONS_TABLE, REPLIES_TABLE } from './offline-db'; + +/** + * Service to handle offline forum. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModForumOfflineProvider { + + /** + * Delete a forum offline discussion. + * + * @param forumId Forum ID. + * @param timeCreated The time the discussion was created. + * @param siteId Site ID. If not defined, current site. + * @param userId User the discussion belongs to. If not defined, current user in site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteNewDiscussion(forumId: number, timeCreated: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + const conditions = { + forumid: forumId, + userid: userId || site.getUserId(), + timecreated: timeCreated, + }; + + await site.getDb().deleteRecords(DISCUSSIONS_TABLE, conditions); + } + + /** + * Get a forum offline discussion. + * + * @param forumId Forum ID. + * @param timeCreated The time the discussion was created. + * @param siteId Site ID. If not defined, current site. + * @param userId User the discussion belongs to. If not defined, current user in site. + * @return Promise resolved if stored, rejected if failure. + */ + async getNewDiscussion( + forumId: number, + timeCreated: number, + siteId?: string, + userId?: number, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + const conditions = { + forumid: forumId, + userid: userId || site.getUserId(), + timecreated: timeCreated, + }; + + const record = await site.getDb().getRecord(DISCUSSIONS_TABLE, conditions); + + return this.parseRecordOptions(record); + } + + /** + * Get all offline new discussions. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with discussions. + */ + async getAllNewDiscussions(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const records = await site.getDb().getRecords(DISCUSSIONS_TABLE); + + return this.parseRecordsOptions(records); + } + + /** + * Check if there are offline new discussions to send. + * + * @param forumId Forum ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User the discussions belong to. If not defined, current user in site. + * @return Promise resolved with boolean: true if has offline answers, false otherwise. + */ + async hasNewDiscussions(forumId: number, siteId?: string, userId?: number): Promise { + try { + const discussions = await this.getNewDiscussions(forumId, siteId, userId); + + return !!discussions.length; + } catch (error) { + // No offline data found, return false. + + return false; + } + } + + /** + * Get new discussions to be synced. + * + * @param forumId Forum ID to get. + * @param siteId Site ID. If not defined, current site. + * @param userId User the discussions belong to. If not defined, current user in site. + * @return Promise resolved with the object to be synced. + */ + async getNewDiscussions(forumId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + const conditions = { + forumid: forumId, + userid: userId || site.getUserId(), + }; + + const records = await site.getDb().getRecords(DISCUSSIONS_TABLE, conditions); + + return this.parseRecordsOptions(records); + } + + /** + * Offline version for adding a new discussion to a forum. + * + * @param forumId Forum ID. + * @param name Forum name. + * @param courseId Course ID the forum belongs to. + * @param subject New discussion's subject. + * @param message New discussion's message. + * @param options Options (subscribe, pin, ...). + * @param groupId Group this discussion belongs to. + * @param timeCreated The time the discussion was created. If not defined, current time. + * @param siteId Site ID. If not defined, current site. + * @param userId User the discussion belong to. If not defined, current user in site. + * @return Promise resolved when new discussion is successfully saved. + */ + async addNewDiscussion( + forumId: number, + name: string, + courseId: number, + subject: string, + message: string, + options?: AddonModForumDiscussionOptions, + groupId?: number, + timeCreated?: number, + siteId?: string, + userId?: number, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + const data: AddonModForumOfflineDiscussionRecord = { + forumid: forumId, + name: name, + courseid: courseId, + subject: subject, + message: message, + options: JSON.stringify(options || {}), + groupid: groupId || AddonModForumProvider.ALL_PARTICIPANTS, + userid: userId || site.getUserId(), + timecreated: timeCreated || new Date().getTime(), + }; + + await site.getDb().insertRecord(DISCUSSIONS_TABLE, data); + } + + /** + * Delete forum offline replies. + * + * @param postId ID of the post being replied. + * @param siteId Site ID. If not defined, current site. + * @param userId User the reply belongs to. If not defined, current user in site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteReply(postId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + const conditions = { + postid: postId, + userid: userId || site.getUserId(), + }; + + await site.getDb().deleteRecords(REPLIES_TABLE, conditions); + } + + /** + * Get all offline replies. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with replies. + */ + async getAllReplies(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const records = await site.getDb().getRecords(REPLIES_TABLE); + + return this.parseRecordsOptions(records); + } + + /** + * Check if there is an offline reply for a forum to be synced. + * + * @param forumId ID of the forum being replied. + * @param siteId Site ID. If not defined, current site. + * @param userId User the replies belong to. If not defined, current user in site. + * @return Promise resolved with boolean: true if has offline answers, false otherwise. + */ + async hasForumReplies(forumId: number, siteId?: string, userId?: number): Promise { + try { + const replies = await this.getForumReplies(forumId, siteId, userId); + + return !!replies.length; + } catch (error) { + // No offline data found, return false. + + return false; + } + } + + /** + * Get the replies of a forum to be synced. + * + * @param forumId ID of the forum being replied. + * @param siteId Site ID. If not defined, current site. + * @param userId User the replies belong to. If not defined, current user in site. + * @return Promise resolved with replies. + */ + async getForumReplies(forumId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + const conditions = { + forumid: forumId, + userid: userId || site.getUserId(), + }; + + const records = await site.getDb().getRecords(REPLIES_TABLE, conditions); + + return this.parseRecordsOptions(records); + } + + /** + * Check if there is an offline reply to be synced. + * + * @param discussionId ID of the discussion the user is replying to. + * @param siteId Site ID. If not defined, current site. + * @param userId User the replies belong to. If not defined, current user in site. + * @return Promise resolved with boolean: true if has offline answers, false otherwise. + */ + async hasDiscussionReplies(discussionId: number, siteId?: string, userId?: number): Promise { + try { + const replies = await this.getDiscussionReplies(discussionId, siteId, userId); + + return !!replies.length; + } catch (error) { + // No offline data found, return false. + + return false; + } + } + + /** + * Get the replies of a discussion to be synced. + * + * @param discussionId ID of the discussion the user is replying to. + * @param siteId Site ID. If not defined, current site. + * @param userId User the replies belong to. If not defined, current user in site. + * @return Promise resolved with discussions. + */ + async getDiscussionReplies(discussionId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + const conditions = { + discussionid: discussionId, + userid: userId || site.getUserId(), + }; + + const records = await site.getDb().getRecords(REPLIES_TABLE, conditions); + + return this.parseRecordsOptions(records); + } + + /** + * Offline version for replying to a certain post. + * + * @param postId ID of the post being replied. + * @param discussionId ID of the discussion the user is replying to. + * @param forumId ID of the forum the user is replying to. + * @param name Forum name. + * @param courseId Course ID the forum belongs to. + * @param subject New post's subject. + * @param message New post's message. + * @param options Options (subscribe, attachments, ...). + * @param siteId Site ID. If not defined, current site. + * @param userId User the post belong to. If not defined, current user in site. + * @return Promise resolved when the post is created. + */ + async replyPost( + postId: number, + discussionId: number, + forumId: number, + name: string, + courseId: number, + subject: string, + message: string, + options?: AddonModForumReplyOptions, + siteId?: string, + userId?: number, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + const data: AddonModForumOfflineReplyRecord = { + 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(), + }; + + await site.getDb().insertRecord(REPLIES_TABLE, data); + } + + /** + * Get the path to the folder where to store files for offline attachments in a forum. + * + * @param forumId Forum ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the path. + */ + async getForumFolder(forumId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const siteFolderPath = CoreFile.instance.getSiteFolder(site.getId()); + + return CoreTextUtils.instance.concatenatePaths(siteFolderPath, 'offlineforum/' + forumId); + } + + /** + * Get the path to the folder where to store files for a new offline discussion. + * + * @param forumId Forum ID. + * @param timeCreated The time the discussion was created. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the path. + */ + async getNewDiscussionFolder(forumId: number, timeCreated: number, siteId?: string): Promise { + const folderPath = await this.getForumFolder(forumId, siteId); + + return CoreTextUtils.instance.concatenatePaths(folderPath, 'newdisc_' + timeCreated); + } + + /** + * Get the path to the folder where to store files for a new offline reply. + * + * @param forumId Forum ID. + * @param postId ID of the post being replied. + * @param siteId Site ID. If not defined, current site. + * @param userId User the replies belong to. If not defined, current user in site. + * @return Promise resolved with the path. + */ + async getReplyFolder(forumId: number, postId: number, siteId?: string, userId?: number): Promise { + const folderPath = await this.getForumFolder(forumId, siteId); + const site = await CoreSites.instance.getSite(siteId); + userId = userId || site.getUserId(); + + return CoreTextUtils.instance.concatenatePaths(folderPath, 'reply_' + postId + '_' + userId); + } + + /** + * Parse "options" column of fetched record. + * + * @param records List of records. + * @return List of records with options parsed. + */ + protected parseRecordsOptions< + R extends { options: string }, + O extends Record = Record + >(records: R[]): (Omit & { options: O })[] { + return records.map(record => this.parseRecordOptions(record)); + } + + /** + * Parse "options" column of fetched record. + * + * @param record Record. + * @return Record with options parsed. + */ + protected parseRecordOptions< + R extends { options: string }, + O extends Record = Record + >(record: R): Omit & { options: O } { + record.options = CoreTextUtils.instance.parseJSON(record.options); + + return record as unknown as Omit & { options: O }; + } + +} + +export class AddonModForumOffline extends makeSingleton(AddonModForumOfflineProvider) {} + +export type AddonModForumDiscussionOptions = Record; +export type AddonModForumReplyOptions = Record; + +export type AddonModForumOfflineDiscussion = { + forumid: number; + name: string; + courseid: number; + subject: string; + message: string; + options: AddonModForumDiscussionOptions; + groupid: number; + userid: number; + timecreated: number; +}; +export type AddonModForumOfflineReply = { + postid: number; + discussionid: number; + forumid: number; + name: string; + courseid: number; + subject: string; + message: string; + options: AddonModForumReplyOptions; + userid: number; + timecreated: number; +}; + +export type AddonModForumOfflineDiscussionRecord = Omit & { + options: string; +}; +export type AddonModForumOfflineReplyRecord = Omit & { + options: string; +}; diff --git a/src/addons/mod/forum/services/sync.service.ts b/src/addons/mod/forum/services/sync.service.ts new file mode 100644 index 000000000..58b256be5 --- /dev/null +++ b/src/addons/mod/forum/services/sync.service.ts @@ -0,0 +1,600 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; +import { CoreApp } from '@services/app'; +import { CoreGroups } from '@services/groups'; +import { CoreSites } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { CoreArray } from '@singletons/array'; +import { CoreEvents } from '@singletons/events'; +import { AddonModForum, AddonModForumProvider } from './forum.service'; +import { AddonModForumHelper } from './helper.service'; +import { AddonModForumOffline, AddonModForumOfflineDiscussion, AddonModForumOfflineReply } from './offline.service'; + +/** + * Service to sync forums. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModForumSyncProvider extends CoreSyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_mod_forum_autom_synced'; + static readonly MANUAL_SYNCED = 'addon_mod_forum_manual_synced'; + + private _componentTranslate?: string; + + constructor() { + super('AddonModForumSyncProvider'); + } + + protected get componentTranslate(): string { + if (!this._componentTranslate) { + this._componentTranslate = CoreCourse.instance.translateModuleName('forum'); + } + + return this._componentTranslate; + } + + /** + * Try to synchronize all the forums in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + async syncAllForums(siteId?: string, force?: boolean): Promise { + await this.syncOnSites('all forums', this.syncAllForumsFunc.bind(this, !!force), siteId); + } + + /** + * Sync all forums on a site. + * + * @param force Wether to force sync not depending on last execution. + * @param siteId Site ID to sync. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllForumsFunc(force: boolean, siteId: string): Promise { + const sitePromises: Promise[] = []; + + // Sync all new discussions. + const syncDiscussions = async (discussions: AddonModForumOfflineDiscussion[]) => { + // Do not sync same forum twice. + const syncedForumIds: number[] = []; + const promises = discussions.map(async discussion => { + if (CoreArray.contains(syncedForumIds, discussion.forumid)) { + return; + } + + syncedForumIds.push(discussion.forumid); + const result = force + ? await this.syncForumDiscussions(discussion.forumid, discussion.userid, siteId) + : await this.syncForumDiscussionsIfNeeded(discussion.forumid, discussion.userid, siteId); + + if (result && result.updated) { + // Sync successful, send event. + CoreEvents.trigger(AddonModForumSyncProvider.AUTO_SYNCED, { + forumId: discussion.forumid, + userId: discussion.userid, + warnings: result.warnings, + }, siteId); + } + }); + + await Promise.all(Object.values(promises)); + }; + + sitePromises.push( + AddonModForumOffline.instance + .getAllNewDiscussions(siteId) + .then(discussions => syncDiscussions(discussions)), + ); + + // Sync all discussion replies. + const syncReplies = async (replies: AddonModForumOfflineReply[]) => { + // Do not sync same discussion twice. + const syncedDiscussionIds: number[] = []; + const promises = replies.map(async reply => { + if (CoreArray.contains(syncedDiscussionIds, reply.discussionid)) { + return; + } + + const result = force + ? await this.syncDiscussionReplies(reply.discussionid, reply.userid, siteId) + : await this.syncDiscussionRepliesIfNeeded(reply.discussionid, reply.userid, siteId); + + if (result && result.updated) { + // Sync successful, send event. + CoreEvents.trigger(AddonModForumSyncProvider.AUTO_SYNCED, { + forumId: reply.forumid, + discussionId: reply.discussionid, + userId: reply.userid, + warnings: result.warnings, + }, siteId); + } + }); + + await Promise.all(promises); + }; + + sitePromises.push( + AddonModForumOffline.instance + .getAllReplies(siteId) + .then(replies => syncReplies(replies)), + ); + + // Sync ratings. + sitePromises.push(this.syncRatings(undefined, undefined, force, siteId)); + + await Promise.all(sitePromises); + } + + /** + * Sync a forum only if a certain time has passed since the last time. + * + * @param forumId Forum ID. + * @param userId User the discussion belong to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the forum is synced or if it doesn't need to be synced. + */ + async syncForumDiscussionsIfNeeded( + forumId: number, + userId: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const syncId = this.getForumSyncId(forumId, userId); + + const needed = await this.isSyncNeeded(syncId, siteId); + + if (needed) { + return this.syncForumDiscussions(forumId, userId, siteId); + } + } + + /** + * Synchronize all offline discussions of a forum. + * + * @param forumId Forum ID to be synced. + * @param userId User the discussions belong to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + async syncForumDiscussions( + forumId: number, + userId?: number, + siteId?: string, + ): Promise { + userId = userId || CoreSites.instance.getCurrentSiteUserId(); + siteId = siteId || CoreSites.instance.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 (CoreSync.instance.isBlocked(AddonModForumProvider.COMPONENT, syncId, siteId)) { + this.logger.debug('Cannot sync forum ' + forumId + ' because it is blocked.'); + + return Promise.reject(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + } + + this.logger.debug('Try to sync forum ' + forumId + ' for user ' + userId); + + const result: AddonModForumSyncResult = { + warnings: [], + updated: false, + }; + + // Sync offline logs. + const syncDiscussions = async (): Promise<{ warnings: string[]; updated: boolean }> => { + await CoreUtils.instance.ignoreErrors( + CoreCourseLogHelper.instance.syncActivity(AddonModForumProvider.COMPONENT, forumId, siteId), + ); + + // Get offline responses to be sent. + const discussions = await CoreUtils.instance.ignoreErrors( + AddonModForumOffline.instance.getNewDiscussions(forumId, siteId, userId), + [] as AddonModForumOfflineDiscussion[], + ); + + if (discussions.length !== 0 && !CoreApp.instance.isOnline()) { + throw new Error('cannot sync in offline'); + } + + const promises = discussions.map(async discussion => { + const errors: Error[] = []; + const groupIds = discussion.groupid === AddonModForumProvider.ALL_GROUPS + ? await AddonModForum.instance + .getForumById(discussion.courseid, discussion.forumid, { siteId }) + .then(forum => CoreGroups.instance.getActivityAllowedGroups(forum.cmid)) + .then(result => result.groups.map((group) => group.id)) + : [discussion.groupid]; + + await Promise.all(groupIds.map(async groupId => { + try { + // First of all upload the attachments (if any). + const itemId = await this.uploadAttachments(forumId, discussion, true, siteId, userId); + + // Now try to add the discussion. + const options = CoreUtils.instance.clone(discussion.options || {}); + options.attachmentsid = itemId; + + await AddonModForum.instance.addNewDiscussionOnline( + forumId, + discussion.subject, + discussion.message, + options, + groupId, + siteId, + ); + } catch (error) { + errors.push(error); + } + })); + + if (errors.length === groupIds.length) { + // All requests have failed, reject if errors were not returned by WS. + for (const error of errors) { + if (!CoreUtils.instance.isWebServiceError(error)) { + throw error; + } + } + } + + // All requests succeeded, some failed or all failed with a WS error. + result.updated = true; + + await this.deleteNewDiscussion(forumId, discussion.timecreated, siteId, userId); + + if (errors.length === groupIds.length) { + // All requests failed with WS error. + result.warnings.push(Translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: discussion.name, + error: CoreTextUtils.instance.getErrorMessageFromError(errors[0]), + })); + } + }); + + await Promise.all(promises); + + if (result.updated) { + // Data has been sent to server. Now invalidate the WS calls. + const promises = [ + AddonModForum.instance.invalidateDiscussionsList(forumId, siteId), + AddonModForum.instance.invalidateCanAddDiscussion(forumId, siteId), + ]; + + await CoreUtils.instance.ignoreErrors(Promise.all(promises)); + } + + // Sync finished, set sync time. + await CoreUtils.instance.ignoreErrors(this.setSyncTime(syncId, siteId)); + + return result; + }; + + return this.addOngoingSync(syncId, syncDiscussions(), siteId); + } + + /** + * Synchronize forum offline ratings. + * + * @param cmId Course module to be synced. If not defined, sync all forums. + * @param discussionId Discussion id to be synced. If not defined, sync all discussions. + * @param force Wether to force sync not depending on last execution. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async syncRatings(cmId?: number, discussionId?: number, force?: boolean, siteId?: string): Promise { + // @todo + } + + /** + * Synchronize all offline discussion replies of a forum. + * + * @param forumId Forum ID to be synced. + * @param userId User the discussions belong to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + async syncForumReplies(forumId: number, userId?: number, siteId?: string): Promise { + // Get offline forum replies to be sent. + const replies = await CoreUtils.instance.ignoreErrors( + AddonModForumOffline.instance.getForumReplies(forumId, siteId, userId), + [] as AddonModForumOfflineReply[], + ); + + if (!replies.length) { + // Nothing to sync. + return { warnings: [], updated: false }; + } else if (!CoreApp.instance.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + const promises: Record> = {}; + + // 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); + }); + + const results = await Promise.all(Object.values(promises)); + + return results.reduce((a, b) => ({ + warnings: a.warnings.concat(b.warnings), + updated: a.updated || b.updated, + }), { warnings: [], updated: false } as AddonModForumSyncResult); + } + + /** + * Sync a forum discussion replies only if a certain time has passed since the last time. + * + * @param discussionId Discussion ID to be synced. + * @param userId User the posts belong to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the forum discussion is synced or if it doesn't need to be synced. + */ + async syncDiscussionRepliesIfNeeded( + discussionId: number, + userId?: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const syncId = this.getDiscussionSyncId(discussionId, userId); + + const needed = await this.isSyncNeeded(syncId, siteId); + + if (needed) { + return this.syncDiscussionReplies(discussionId, userId, siteId); + } + } + + /** + * Synchronize all offline replies from a discussion. + * + * @param discussionId Discussion ID to be synced. + * @param userId User the posts belong to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + async syncDiscussionReplies(discussionId: number, userId?: number, siteId?: string): Promise { + userId = userId || CoreSites.instance.getCurrentSiteUserId(); + siteId = siteId || CoreSites.instance.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 (CoreSync.instance.isBlocked(AddonModForumProvider.COMPONENT, syncId, siteId)) { + this.logger.debug('Cannot sync forum discussion ' + discussionId + ' because it is blocked.'); + + throw new Error(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + } + + this.logger.debug('Try to sync forum discussion ' + discussionId + ' for user ' + userId); + + let forumId; + const result: AddonModForumSyncResult = { + warnings: [], + updated: false, + }; + + // Get offline responses to be sent. + const syncReplies = async () => { + const replies = await CoreUtils.instance.ignoreErrors( + AddonModForumOffline.instance.getDiscussionReplies(discussionId, siteId, userId), + [] as AddonModForumOfflineReply[], + ); + + if (replies.length !== 0 && !CoreApp.instance.isOnline()) { + throw new Error('Cannot sync in offline'); + } + + const promises = replies.map(async reply => { + forumId = reply.forumid; + reply.options = reply.options || {}; + + try { + // First of all upload the attachments (if any). + await this.uploadAttachments(forumId, reply, false, siteId, userId).then((itemId) => { + // Now try to send the reply. + reply.options.attachmentsid = itemId; + + return AddonModForum.instance.replyPostOnline( + reply.postid, + reply.subject, + reply.message, + reply.options, + siteId, + ); + }); + + result.updated = true; + + await this.deleteReply(forumId, reply.postid, siteId, userId); + } catch (error) { + if (!CoreUtils.instance.isWebServiceError(error)) { + throw error; + } + + // The WebService has thrown an error, this means that responses cannot be submitted. Delete them. + result.updated = true; + + await this.deleteReply(forumId, reply.postid, siteId, userId); + + // Responses deleted, add a warning. + result.warnings.push(Translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: reply.name, + error: CoreTextUtils.instance.getErrorMessageFromError(error), + })); + } + }); + + await Promise.all(promises); + + // Data has been sent to server. Now invalidate the WS calls. + const invalidationPromises: Promise[] = []; + + if (forumId) { + invalidationPromises.push(AddonModForum.instance.invalidateDiscussionsList(forumId, siteId)); + } + + invalidationPromises.push(AddonModForum.instance.invalidateDiscussionPosts(discussionId, forumId, siteId)); + + await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(invalidationPromises)); + + // Sync finished, set sync time. + await CoreUtils.instance.ignoreErrors(this.setSyncTime(syncId, siteId)); + + // All done, return the warnings. + return result; + }; + + return this.addOngoingSync(syncId, syncReplies(), siteId); + } + + /** + * Delete a new discussion. + * + * @param forumId Forum ID the discussion belongs to. + * @param timecreated The timecreated of the discussion. + * @param siteId Site ID. If not defined, current site. + * @param userId User the discussion belongs to. If not defined, current user in site. + * @return Promise resolved when deleted. + */ + protected async deleteNewDiscussion(forumId: number, timecreated: number, siteId?: string, userId?: number): Promise { + await Promise.all([ + AddonModForumOffline.instance.deleteNewDiscussion(forumId, timecreated, siteId, userId), + CoreUtils.instance.ignoreErrors( + AddonModForumHelper.instance.deleteNewDiscussionStoredFiles(forumId, timecreated, siteId), + ), + ]); + } + + /** + * Delete a new discussion. + * + * @param forumId Forum ID the discussion belongs to. + * @param postId ID of the post being replied. + * @param siteId Site ID. If not defined, current site. + * @param userId User the discussion belongs to. If not defined, current user in site. + * @return Promise resolved when deleted. + */ + protected async deleteReply(forumId: number, postId: number, siteId?: string, userId?: number): Promise { + await Promise.all([ + AddonModForumOffline.instance.deleteReply(postId, siteId, userId), + CoreUtils.instance.ignoreErrors(AddonModForumHelper.instance.deleteReplyStoredFiles(forumId, postId, siteId, userId)), + ]); + } + + /** + * Upload attachments of an offline post/discussion. + * + * @param forumId Forum ID the post belongs to. + * @param post Offline post or discussion. + * @param isDisc True if it's a new discussion, false if it's a reply. + * @param siteId Site ID. If not defined, current site. + * @param userId User the reply belongs to. If not defined, current user in site. + * @return Promise resolved with draftid if uploaded, resolved with undefined if nothing to upload. + */ + protected async uploadAttachments( + forumId: number, + post: any, + isDisc: boolean, + siteId?: string, + userId?: number, + ): Promise { + const attachments = post && post.options && post.options.attachmentsid; + + if (!attachments) { + return; + } + + // Has some attachments to sync. + let files = attachments.online || []; + + if (attachments.offline) { + // Has offline files. + try { + const atts = isDisc + ? await AddonModForumHelper.instance.getNewDiscussionStoredFiles(forumId, post.timecreated, siteId) + : await AddonModForumHelper.instance.getReplyStoredFiles(forumId, post.postid, siteId, userId); + + files = files.concat(atts); + } catch (error) { + // Folder not found, no files to add. + } + } + + await CoreFileUploader.instance.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId); + } + + /** + * Get the ID of a forum sync. + * + * @param forumId Forum ID. + * @param userId User the responses belong to.. If not defined, current user. + * @return Sync ID. + */ + getForumSyncId(forumId: number, userId?: number): string { + userId = userId || CoreSites.instance.getCurrentSiteUserId(); + + return 'forum#' + forumId + '#' + userId; + } + + /** + * Get the ID of a discussion sync. + * + * @param discussionId Discussion ID. + * @param userId User the responses belong to.. If not defined, current user. + * @return Sync ID. + */ + getDiscussionSyncId(discussionId: number, userId?: number): string { + userId = userId || CoreSites.instance.getCurrentSiteUserId(); + + return 'discussion#' + discussionId + '#' + userId; + } + +} + +/** + * Result of forum sync. + */ +export type AddonModForumSyncResult = { + updated: boolean; + warnings: string[]; +}; diff --git a/src/addons/mod/lesson/services/lesson.ts b/src/addons/mod/lesson/services/lesson.ts index d73b3985a..9baefdab0 100644 --- a/src/addons/mod/lesson/services/lesson.ts +++ b/src/addons/mod/lesson/services/lesson.ts @@ -3110,7 +3110,7 @@ export class AddonModLessonProvider { const params: AddonModLessonProcessPageWSParams = { lessonid: lessonId, pageid: pageId, - data: CoreUtils.instance.objectToArrayOfObjects(data, 'name', 'value', true), + data: CoreUtils.instance.objectToArrayOfObjects(data, 'name', 'value', true), review: !!options.review, }; diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts index 92f8e574f..687482f10 100644 --- a/src/addons/mod/mod.module.ts +++ b/src/addons/mod/mod.module.ts @@ -16,6 +16,7 @@ import { NgModule } from '@angular/core'; import { AddonModAssignModule } from './assign/assign.module'; import { AddonModBookModule } from './book/book.module'; +import { AddonModForumModule } from './forum/forum.module'; import { AddonModLessonModule } from './lesson/lesson.module'; import { AddonModPageModule } from './page/page.module'; import { AddonModQuizModule } from './quiz/quiz.module'; @@ -25,6 +26,7 @@ import { AddonModQuizModule } from './quiz/quiz.module'; imports: [ AddonModAssignModule, AddonModBookModule, + AddonModForumModule, AddonModLessonModule, AddonModPageModule, AddonModQuizModule, diff --git a/src/core/features/course/classes/main-activity-component.ts b/src/core/features/course/classes/main-activity-component.ts index 6d234b2ec..5425d7dd7 100644 --- a/src/core/features/course/classes/main-activity-component.ts +++ b/src/core/features/course/classes/main-activity-component.ts @@ -68,7 +68,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR * Component being initialized. */ async ngOnInit(): Promise { - super.ngOnInit(); + await super.ngOnInit(); this.hasOffline = false; this.syncIcon = CoreConstants.ICON_LOADING; diff --git a/src/core/features/fileuploader/services/fileuploader.ts b/src/core/features/fileuploader/services/fileuploader.ts index e9bcdcc5a..d22e2d024 100644 --- a/src/core/features/fileuploader/services/fileuploader.ts +++ b/src/core/features/fileuploader/services/fileuploader.ts @@ -779,3 +779,5 @@ export type CoreFileUploaderTypeListInfoEntry = { name?: string; extlist: string; }; + +export type CoreFileEntry = CoreWSExternalFile | FileEntry; diff --git a/src/core/features/sitehome/services/sitehome.ts b/src/core/features/sitehome/services/sitehome.ts index e6a852aae..3694b3d40 100644 --- a/src/core/features/sitehome/services/sitehome.ts +++ b/src/core/features/sitehome/services/sitehome.ts @@ -19,7 +19,7 @@ import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { makeSingleton } from '@singletons'; import { CoreCourse } from '../../course/services/course'; import { CoreCourses } from '../../courses/services/courses'; -import { AddonModForum, AddonModForumData } from '@/addons/mod/forum/services/forum'; +import { AddonModForum, AddonModForumData } from '@addons/mod/forum/services/forum.service'; /** * Items with index 1 and 3 were removed on 2.5 and not being supported in the app. diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index cbe98637f..dc5c66b65 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -219,7 +219,7 @@ export class CoreUtilsProvider { try { const response = await this.timeoutPromise(window.fetch(url, initOptions), CoreWS.instance.getRequestTimeout()); - return response.redirected; + return !!response && response.redirected; } catch (error) { if (error.timeout && controller) { // Timeout, abort the request. @@ -1072,13 +1072,16 @@ export class CoreUtilsProvider { * @param sortByValue True to sort values alphabetically, false otherwise. * @return Array of objects with the name & value of each property. */ - objectToArrayOfObjects>( - obj: Record, + objectToArrayOfObjects< + A extends Record = Record, + O extends Record = Record + >( + obj: O, keyName: string, valueName: string, sortByKey?: boolean, sortByValue?: boolean, - ): T[] { + ): A[] { // Get the entries from an object or primitive value. const getEntries = (elKey: string, value: unknown): Record[] | unknown => { if (typeof value == 'undefined' || value == null) { @@ -1114,7 +1117,7 @@ export class CoreUtilsProvider { } // "obj" will always be an object, so "entries" will always be an array. - const entries = getEntries('', obj) as T[]; + const entries = getEntries('', obj) as A[]; if (sortByKey || sortByValue) { return entries.sort((a, b) => { if (sortByKey) { @@ -1223,7 +1226,7 @@ export class CoreUtilsProvider { promiseDefer(): PromiseDefer { const deferred: Partial> = {}; deferred.promise = new Promise((resolve, reject): void => { - deferred.resolve = resolve; + deferred.resolve = resolve as (value?: T | undefined) => void; deferred.reject = reject; }); @@ -1371,7 +1374,7 @@ export class CoreUtilsProvider { * @param time Number of milliseconds of the timeout. * @return Promise with the timeout. */ - timeoutPromise(promise: Promise, time: number): Promise { + timeoutPromise(promise: Promise, time: number): Promise { return new Promise((resolve, reject): void => { let timedOut = false; const resolveBeforeTimeout = () => { diff --git a/src/core/singletons/index.ts b/src/core/singletons/index.ts index 057183385..616ff6296 100644 --- a/src/core/singletons/index.ts +++ b/src/core/singletons/index.ts @@ -158,4 +158,10 @@ export class NavController extends makeSingleton(NavControllerService) {} export class Router extends makeSingleton(RouterService) {} // Convert external libraries injectables. -export class Translate extends makeSingleton(TranslateService) {} +export class Translate extends makeSingleton(TranslateService) { + + static instant(key: string | Array, interpolateParams?: Record): string | any { + return this.instance.instant(key, interpolateParams); + } + +} diff --git a/src/theme/theme.light.scss b/src/theme/theme.light.scss index 8d623e909..9548066aa 100644 --- a/src/theme/theme.light.scss +++ b/src/theme/theme.light.scss @@ -183,6 +183,8 @@ --addon-messages-discussion-badge: var(--custom-messages-discussion-badge, var(--core-color)); --addon-messages-discussion-badge-text: var(--custom-messages-discussion-badge-text, var(--white)); + --addon-forum-avatar-size: var(--custom-forum-avatar-size, 28px); + --drop-shadow: var(--custom-drop-shadow, 0, 0, 0, 0.2); --core-menu-box-shadow-end: var(--custom-menu-box-shadow-end, -4px 0px 16px rgba(0, 0, 0, 0.18));