diff --git a/scripts/langindex.json b/scripts/langindex.json index 1afd0fd4a..ff7a935dd 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -489,6 +489,12 @@ "addon.mod_forum.couldnotadd": "forum", "addon.mod_forum.cutoffdatereached": "forum", "addon.mod_forum.discussion": "forum", + "addon.mod_forum.discussionlistsortbycreatedasc": "forum", + "addon.mod_forum.discussionlistsortbycreateddesc": "forum", + "addon.mod_forum.discussionlistsortbylastpostasc": "forum", + "addon.mod_forum.discussionlistsortbylastpostdesc": "forum", + "addon.mod_forum.discussionlistsortbyrepliesasc": "forum", + "addon.mod_forum.discussionlistsortbyrepliesdesc": "forum", "addon.mod_forum.discussionlocked": "forum", "addon.mod_forum.discussionpinned": "forum", "addon.mod_forum.discussionsubscription": "forum", @@ -1734,6 +1740,7 @@ "core.sizemb": "moodle", "core.sizetb": "local_moodlemobileapp", "core.sorry": "local_moodlemobileapp", + "core.sort": "moodle", "core.sortby": "moodle", "core.start": "grouptool", "core.strftimedate": "langconfig", diff --git a/src/addon/mod/forum/components/index/addon-mod-forum-index.html b/src/addon/mod/forum/components/index/addon-mod-forum-index.html index f0f1fa603..9d93ce8b0 100644 --- a/src/addon/mod/forum/components/index/addon-mod-forum-index.html +++ b/src/addon/mod/forum/components/index/addon-mod-forum-index.html @@ -8,6 +8,7 @@ + @@ -30,6 +31,21 @@ {{ availabilityMessage }} + +
+ +
+
+ +
+ +
+ @@ -86,14 +102,6 @@ - -
- -
-
- diff --git a/src/addon/mod/forum/components/index/index.scss b/src/addon/mod/forum/components/index/index.scss index 09429a4e4..733686a59 100644 --- a/src/addon/mod/forum/components/index/index.scss +++ b/src/addon/mod/forum/components/index/index.scss @@ -2,7 +2,15 @@ ion-app.app-root addon-mod-forum-index { .addon-forum-discussion-selected { border-top: 5px solid $core-splitview-selected; } + .addon-forum-star { color: $core-star-color; } + + button.core-button-select .core-section-selector-text { + overflow: hidden; + text-overflow: ellipsis; + line-height: 2em; + white-space: nowrap; + } } diff --git a/src/addon/mod/forum/components/index/index.ts b/src/addon/mod/forum/components/index/index.ts index 762df73f4..6b8a5994c 100644 --- a/src/addon/mod/forum/components/index/index.ts +++ b/src/addon/mod/forum/components/index/index.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Component, Optional, Injector, ViewChild } from '@angular/core'; -import { Content, NavController } from 'ionic-angular'; +import { Content, ModalController, NavController } from 'ionic-angular'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; @@ -52,6 +52,11 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom addDiscussionText = this.translate.instant('addon.mod_forum.addanewdiscussion'); availabilityMessage: string; + sortingAvailable: boolean; + sortOrders = []; + selectedSortOrder = null; + sortOrderSelectorExpanded = false; + protected syncEventName = AddonModForumSyncProvider.AUTO_SYNCED; protected page = 0; protected trackPosts = false; @@ -69,6 +74,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom constructor(injector: Injector, @Optional() protected content: Content, protected navCtrl: NavController, + protected modalCtrl: ModalController, protected groupsProvider: CoreGroupsProvider, protected userProvider: CoreUserProvider, protected forumProvider: AddonModForumProvider, @@ -79,6 +85,9 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom protected prefetchHandler: AddonModForumPrefetchHandler, protected ratingOffline: CoreRatingOfflineProvider) { super(injector); + + this.sortingAvailable = this.forumProvider.isDiscussionListSortingAvailable(); + this.sortOrders = this.forumProvider.getAvailableSortOrders(); } /** @@ -162,7 +171,9 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { this.loadMoreError = false; - return this.forumProvider.getForum(this.courseId, this.module.id).then((forum) => { + const promises = []; + + promises.push(this.forumProvider.getForum(this.courseId, this.module.id).then((forum) => { this.forum = forum; this.description = forum.intro || this.description; @@ -212,7 +223,11 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom this.canAddDiscussion = this.forum.cancreatediscussions && !cutoffDateReached; }), ]); - }).then(() => { + })); + + promises.push(this.fetchSortOrderPreference()); + + return Promise.all(promises).then(() => { return Promise.all([ this.fetchOfflineDiscussion(), this.fetchDiscussions(refresh), @@ -291,7 +306,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom this.page = 0; } - return this.forumProvider.getDiscussions(this.forum.id, this.page).then((response) => { + return this.forumProvider.getDiscussions(this.forum.id, this.selectedSortOrder.value, this.page).then((response) => { let promise; if (this.usesGroups) { promise = this.forumProvider.formatDiscussionsGroups(this.forum.cmid, response.discussions); @@ -366,6 +381,27 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom }); } + /** + * Convenience function to fetch the sort order preference. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchSortOrderPreference(): Promise { + let promise; + if (this.sortingAvailable) { + promise = this.userProvider.getUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER).then((value) => { + return value ? parseInt(value, 10) : null; + }); + } else { + // Use default. + promise = Promise.resolve(null); + } + + return promise.then((value) => { + this.selectedSortOrder = this.sortOrders.find((sortOrder) => sortOrder.value === value) || this.sortOrders[0]; + }); + } + /** * Perform the invalidate content function. * @@ -382,6 +418,10 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom promises.push(this.forumProvider.invalidateAccessInformation(this.forum.id)); } + if (this.sortingAvailable) { + promises.push(this.userProvider.invalidateUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER)); + } + return Promise.all(promises); } @@ -484,6 +524,37 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom this.selectedDiscussion = 0; } + /** + * Display the sort order selector modal. + * + * @param {MouseEvent} event Event. + */ + showSortOrderSelector(event: MouseEvent): void { + if (!this.sortingAvailable) { + return; + } + + 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; + } + /** * Component being destroyed. */ diff --git a/src/addon/mod/forum/lang/en.json b/src/addon/mod/forum/lang/en.json index 04f70e158..5b2935e89 100644 --- a/src/addon/mod/forum/lang/en.json +++ b/src/addon/mod/forum/lang/en.json @@ -10,6 +10,12 @@ "couldnotadd": "Could not add 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.", "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", diff --git a/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.html b/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.html new file mode 100644 index 000000000..a3411deea --- /dev/null +++ b/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.html @@ -0,0 +1,19 @@ + + + {{ 'core.sort' | translate }} + + + + + + + + + +

+
+
+
+
diff --git a/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.module.ts b/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.module.ts new file mode 100644 index 000000000..9a10ae395 --- /dev/null +++ b/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModForumSortOrderSelectorPage } from './sort-order-selector'; + +@NgModule({ + declarations: [ + AddonModForumSortOrderSelectorPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(AddonModForumSortOrderSelectorPage), + TranslateModule.forChild() + ], +}) +export class AddonModForumSortOrderSelectorPagePageModule {} diff --git a/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.ts b/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.ts new file mode 100644 index 000000000..0d83646a6 --- /dev/null +++ b/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { IonicPage, NavParams, ViewController } from 'ionic-angular'; + +/** + * Page that displays the sort selector. + */ +@IonicPage({ segment: 'addon-mod-forum-sort-order-selector' }) +@Component({ + selector: 'page-addon-mod-forum-sort-order-selector', + templateUrl: 'sort-order-selector.html', +}) +export class AddonModForumSortOrderSelectorPage { + + sortOrders = []; + selected: number; + + constructor(navParams: NavParams, private viewCtrl: ViewController) { + this.sortOrders = navParams.get('sortOrders'); + this.selected = navParams.get('selected'); + } + + /** + * Close the modal. + */ + closeModal(): void { + this.viewCtrl.dismiss(); + } + + /** + * Select a sort order. + * + * @param {any} sortOrder Selected sort order. + */ + selectSortOrder(sortOrder: any): void { + this.viewCtrl.dismiss(sortOrder); + } +} diff --git a/src/addon/mod/forum/providers/forum.ts b/src/addon/mod/forum/providers/forum.ts index 6bd80e7bd..0f6683e11 100644 --- a/src/addon/mod/forum/providers/forum.ts +++ b/src/addon/mod/forum/providers/forum.ts @@ -14,7 +14,7 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { CoreSite } from '@classes/site'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreAppProvider } from '@providers/app'; import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreGroupsProvider } from '@providers/groups'; @@ -38,6 +38,14 @@ export class AddonModForumProvider { static CHANGE_DISCUSSION_EVENT = 'addon_mod_forum_lock_discussion'; static MARK_READ_EVENT = 'addon_mod_forum_mark_read'; + static PREFERENCE_SORTORDER = 'forum_discussionlistsortorder'; + static SORTORDER_LASTPOST_DESC = 1; + static SORTORDER_LASTPOST_ASC = 2; + static SORTORDER_CREATED_DESC = 3; + static SORTORDER_CREATED_ASC = 4; + static SORTORDER_REPLIES_DESC = 5; + static SORTORDER_REPLIES_ASC = 6; + protected ROOT_CACHE_KEY = 'mmaModForum:'; constructor(private appProvider: CoreAppProvider, @@ -58,7 +66,7 @@ export class AddonModForumProvider { * @return {string} Cache key. */ protected getCanAddDiscussionCacheKey(forumId: number, groupId: number): string { - return this.getCommonCanAddDiscussionCacheKey(forumId) + ':' + groupId; + return this.getCommonCanAddDiscussionCacheKey(forumId) + groupId; } /** @@ -68,7 +76,7 @@ export class AddonModForumProvider { * @return {string} Cache key. */ protected getCommonCanAddDiscussionCacheKey(forumId: number): string { - return this.ROOT_CACHE_KEY + 'canadddiscussion:' + forumId; + return this.ROOT_CACHE_KEY + 'canadddiscussion:' + forumId + ':'; } /** @@ -105,10 +113,17 @@ export class AddonModForumProvider { * Get cache key for forum discussions list WS calls. * * @param {number} forumId Forum ID. - * @return {string} Cache key. + * @param {number} sortOrder Sort order. + * @return {string} Cache key. */ - protected getDiscussionsListCacheKey(forumId: number): string { - return this.ROOT_CACHE_KEY + 'discussions:' + forumId; + protected getDiscussionsListCacheKey(forumId: number, sortOrder: number): string { + let key = this.ROOT_CACHE_KEY + 'discussions:' + forumId; + + if (sortOrder != AddonModForumProvider.SORTORDER_LASTPOST_DESC) { + key += ':' + sortOrder; + } + + return key; } /** @@ -452,10 +467,64 @@ export class AddonModForumProvider { }); } + /** + * Return whether discussion lists can be sorted. + * + * @param {CoreSite} [site] Site. If not defined, current site. + * @return {boolean} True if discussion lists can be sorted. + */ + isDiscussionListSortingAvailable(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + return site.isVersionGreaterEqualThan('3.7'); + } + + /** + * Return the list of available sort orders. + * + * @return {{label: string, value: number}[]} List of sort orders. + */ + getAvailableSortOrders(): {label: string, value: number}[] { + 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 {number} forumId Forum ID. + * @param {number} [sortOrder] Sort order. * @param {number} [page=0] Page. * @param {boolean} [forceCache] True to always get the value from cache. false otherwise. * @param {string} [siteId] Site ID. If not defined, current site. @@ -463,23 +532,59 @@ export class AddonModForumProvider { * - discussions: List of discussions. * - canLoadMore: True if there may be more discussions to load. */ - getDiscussions(forumId: number, page: number = 0, forceCache?: boolean, siteId?: string): Promise { + getDiscussions(forumId: number, sortOrder?: number, page: number = 0, forceCache?: boolean, siteId?: string): Promise { + sortOrder = sortOrder || AddonModForumProvider.SORTORDER_LASTPOST_DESC; + return this.sitesProvider.getSite(siteId).then((site) => { - const params = { + let method = 'mod_forum_get_forum_discussions_paginated'; + const params: any = { forumid: forumId, - sortby: 'timemodified', - sortdirection: 'DESC', page: page, perpage: AddonModForumProvider.DISCUSSIONS_PER_PAGE }; - const preSets: any = { - cacheKey: this.getDiscussionsListCacheKey(forumId) + + if (site.wsAvailable('mod_forum_get_forum_discussions')) { + // Since Moodle 3.7. + method = 'mod_forum_get_forum_discussions'; + params.sortorder = sortOrder; + } else { + if (sortOrder == AddonModForumProvider.SORTORDER_LASTPOST_DESC) { + params.sortby = 'timemodified'; + params.sortdirection = 'DESC'; + } else { + // Sorting not supported with the old WS method. + return Promise.reject(null); + } + } + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getDiscussionsListCacheKey(forumId, sortOrder) }; if (forceCache) { preSets.omitExpires = true; } - return site.read('mod_forum_get_forum_discussions_paginated', params, preSets).then((response) => { + return site.read(method, params, preSets).catch((error) => { + // Try to get the data from cache stored with the old WS method. + if (!this.appProvider.isOnline() && method == 'mod_forum_get_forum_discussion' && + sortOrder == AddonModForumProvider.SORTORDER_LASTPOST_DESC) { + + const params = { + forumid: forumId, + page: page, + perpage: AddonModForumProvider.DISCUSSIONS_PER_PAGE, + sortby: 'timemodified', + sortdirection: 'DESC' + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getDiscussionsListCacheKey(forumId, sortOrder), + omitExpires: true + }; + + return site.read('mod_forum_get_forum_discussions_paginated', params, preSets); + } + + return Promise.reject(error); + }).then((response) => { if (response) { this.storeUserData(response.discussions); @@ -499,7 +604,8 @@ export class AddonModForumProvider { * If a page fails, the discussions until that page will be returned along with a flag indicating an error occurred. * * @param {number} forumId Forum ID. - * @param {boolean} forceCache True to always get the value from cache, false otherwise. + * @param {number} [sortOrder] Sort order. + * @param {boolean} [forceCache] True to always get the value from cache, false otherwise. * @param {number} [numPages] Number of pages to get. If not defined, all pages. * @param {number} [startPage] Page to start. If not defined, first page. * @param {string} [siteId] Site ID. If not defined, current site. @@ -507,8 +613,8 @@ export class AddonModForumProvider { * - discussions: List of discussions. * - error: True if an error occurred, false otherwise. */ - getDiscussionsInPages(forumId: number, forceCache?: boolean, numPages?: number, startPage?: number, siteId?: string) - : Promise { + getDiscussionsInPages(forumId: number, sortOrder?: number, forceCache?: boolean, numPages?: number, startPage?: number, + siteId?: string): Promise { if (typeof numPages == 'undefined') { numPages = -1; } @@ -525,7 +631,7 @@ export class AddonModForumProvider { const getPage = (page: number): Promise => { // Get page discussions. - return this.getDiscussions(forumId, page, forceCache, siteId).then((response) => { + return this.getDiscussions(forumId, sortOrder, page, forceCache, siteId).then((response) => { result.discussions = result.discussions.concat(response.discussions); numPages--; @@ -569,22 +675,32 @@ export class AddonModForumProvider { invalidateContent(moduleId: number, courseId: number): Promise { // Get the forum first, we need the forum ID. return this.getForum(courseId, moduleId).then((forum) => { - // We need to get the list of discussions to be able to invalidate their posts. - return this.getDiscussionsInPages(forum.id, true).then((response) => { - // Now invalidate the WS calls. - const promises = []; + const promises = []; - promises.push(this.invalidateForumData(courseId)); - promises.push(this.invalidateDiscussionsList(forum.id)); - promises.push(this.invalidateCanAddDiscussion(forum.id)); - promises.push(this.invalidateAccessInformation(forum.id)); + promises.push(this.invalidateForumData(courseId)); + promises.push(this.invalidateDiscussionsList(forum.id)); + promises.push(this.invalidateCanAddDiscussion(forum.id)); + promises.push(this.invalidateAccessInformation(forum.id)); - response.discussions.forEach((discussion) => { - promises.push(this.invalidateDiscussionPosts(discussion.discussion)); - }); + 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, sortOrder.value, true).then((response) => { + // Now invalidate the WS calls. + const promises = []; - return this.utils.allPromises(promises); + response.discussions.forEach((discussion) => { + promises.push(this.invalidateDiscussionPosts(discussion.discussion)); + }); + + return this.utils.allPromises(promises); + })); }); + + if (this.isDiscussionListSortingAvailable()) { + promises.push(this.userProvider.invalidateUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER)); + } + + return this.utils.allPromises(promises); }); } @@ -623,7 +739,9 @@ export class AddonModForumProvider { */ invalidateDiscussionsList(forumId: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { - return site.invalidateWsCacheForKey(this.getDiscussionsListCacheKey(forumId)); + return this.utils.allPromises(this.getAvailableSortOrders().map((sortOrder) => { + return site.invalidateWsCacheForKey(this.getDiscussionsListCacheKey(forumId, sortOrder.value)); + })); }); } diff --git a/src/addon/mod/forum/providers/helper.ts b/src/addon/mod/forum/providers/helper.ts index 3e5928bde..5c568c911 100644 --- a/src/addon/mod/forum/providers/helper.ts +++ b/src/addon/mod/forum/providers/helper.ts @@ -162,7 +162,7 @@ export class AddonModForumHelperProvider { siteId = siteId || this.sitesProvider.getCurrentSiteId(); const findDiscussion = (page: number): Promise => { - return this.forumProvider.getDiscussions(forumId, page, false, siteId).then((response) => { + return this.forumProvider.getDiscussions(forumId, undefined, page, false, siteId).then((response) => { if (response.discussions && response.discussions.length > 0) { const discussion = response.discussions.find((discussion) => discussion.id == discussionId); if (discussion) { diff --git a/src/addon/mod/forum/providers/prefetch-handler.ts b/src/addon/mod/forum/providers/prefetch-handler.ts index ec799c6b2..ab8461414 100644 --- a/src/addon/mod/forum/providers/prefetch-handler.ts +++ b/src/addon/mod/forum/providers/prefetch-handler.ts @@ -25,7 +25,7 @@ import { CoreGroupsProvider } from '@providers/groups'; import { CoreUserProvider } from '@core/user/providers/user'; import { AddonModForumProvider } from './forum'; import { AddonModForumSyncProvider } from './sync'; -import { CoreRatingProvider } from '@core/rating/providers/rating'; +import { CoreRatingProvider, CoreRatingInfo } from '@core/rating/providers/rating'; /** * Handler to prefetch forums. @@ -107,26 +107,36 @@ export class AddonModForumPrefetchHandler extends CoreCourseActivityPrefetchHand * @return {Promise} Promise resolved with array of posts. */ protected getPostsForPrefetch(forum: any): Promise { - // Get discussions in first 2 pages. - return this.forumProvider.getDiscussionsInPages(forum.id, false, 2).then((response) => { - if (response.error) { - return Promise.reject(null); - } + const posts = {}; + const ratingInfos: CoreRatingInfo[] = []; - const promises = []; - let posts = []; + const promises = this.forumProvider.getAvailableSortOrders().map((sortOrder) => { + // Get discussions in first 2 pages. + return this.forumProvider.getDiscussionsInPages(forum.id, sortOrder.value, false, 2).then((response) => { + if (response.error) { + return Promise.reject(null); + } - response.discussions.forEach((discussion) => { - promises.push(this.forumProvider.getDiscussionPosts(discussion.discussion).then((response) => { - posts = posts.concat(response.posts); + const promises = []; - return this.ratingProvider.prefetchRatings('module', forum.cmid, forum.scale, forum.course, - response.ratinginfo); - })); + response.discussions.forEach((discussion) => { + promises.push(this.forumProvider.getDiscussionPosts(discussion.discussion).then((response) => { + response.posts.forEach((post) => { + posts[post.id] = post; + }); + ratingInfos.push(response.ratinginfo); + })); + }); + + return Promise.all(promises); }); + }); - return Promise.all(promises).then(() => { - return posts; + return Promise.all(promises).then(() => { + const ratingInfo = this.ratingProvider.mergeRatingInfos(ratingInfos); + + return this.ratingProvider.prefetchRatings('module', forum.cmid, forum.scale, forum.course, ratingInfo).then(() => { + return this.utils.objectToArray(posts); }); }); } @@ -210,6 +220,11 @@ export class AddonModForumPrefetchHandler extends CoreCourseActivityPrefetchHand // Prefetch access information. promises.push(this.forumProvider.getAccessInformation(forum.id)); + // Prefetch sort order preference. + if (this.forumProvider.isDiscussionListSortingAvailable()) { + promises.push(this.userProvider.getUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER)); + } + return Promise.all(promises); }); } diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 7cbba2a50..373327eeb 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -489,6 +489,12 @@ "addon.mod_forum.couldnotadd": "Could not add your post due to an unknown error", "addon.mod_forum.cutoffdatereached": "The cut-off date for posting to this forum is reached so you can no longer post to it.", "addon.mod_forum.discussion": "Discussion", + "addon.mod_forum.discussionlistsortbycreatedasc": "Sort by creation date in ascending order", + "addon.mod_forum.discussionlistsortbycreateddesc": "Sort by creation date in descending order", + "addon.mod_forum.discussionlistsortbylastpostasc": "Sort by last post creation date in ascending order", + "addon.mod_forum.discussionlistsortbylastpostdesc": "Sort by last post creation date in descending order", + "addon.mod_forum.discussionlistsortbyrepliesasc": "Sort by number of replies in ascending order", + "addon.mod_forum.discussionlistsortbyrepliesdesc": "Sort by number of replies in descending order", "addon.mod_forum.discussionlocked": "This discussion has been locked so you can no longer reply to it.", "addon.mod_forum.discussionpinned": "Pinned", "addon.mod_forum.discussionsubscription": "Discussion subscription", @@ -1734,6 +1740,7 @@ "core.sizemb": "MB", "core.sizetb": "TB", "core.sorry": "Sorry...", + "core.sort": "Sort", "core.sortby": "Sort by", "core.start": "Start", "core.strftimedate": "%d %B %Y", diff --git a/src/core/rating/providers/rating.ts b/src/core/rating/providers/rating.ts index e14e4593f..1b9104459 100644 --- a/src/core/rating/providers/rating.ts +++ b/src/core/rating/providers/rating.ts @@ -326,6 +326,44 @@ export class CoreRatingProvider { }); } + /** + * Convenience function to merge two or more rating infos of the same instance. + * + * @param {CoreRatingInfo[]} ratingInfos Array of rating infos. + * @return {CoreRatingInfo} Merged rating info or null. + */ + mergeRatingInfos(ratingInfos: CoreRatingInfo[]): CoreRatingInfo { + let result: CoreRatingInfo = null; + const scales = {}; + const ratings = {}; + + ratingInfos.forEach((ratingInfo) => { + if (!ratingInfo) { + // Skip null rating infos. + return; + } + + if (!result) { + result = Object.assign({}, ratingInfo); + } + + (ratingInfo.scales || []).forEach((scale) => { + scales[scale.id] = scale; + }); + + (ratingInfo.ratings || []).forEach((rating) => { + ratings[rating.itemid] = rating; + }); + }); + + if (result) { + result.scales = this.utils.objectToArray(scales); + result.ratings = this.utils.objectToArray(ratings); + } + + return result; + } + /** * Prefetch individual ratings. * diff --git a/src/core/user/providers/offline.ts b/src/core/user/providers/offline.ts new file mode 100644 index 000000000..36ccc4c05 --- /dev/null +++ b/src/core/user/providers/offline.ts @@ -0,0 +1,114 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; + +/** + * Structure of offline user preferences. + */ +export interface CoreUserOfflinePreference { + name: string; + value: string; + onlinevalue: string; +} + +/** + * Service to handle offline user preferences. + */ +@Injectable() +export class CoreUserOfflineProvider { + + // Variables for database. + static PREFERENCES_TABLE = 'user_preferences'; + protected siteSchema: CoreSiteSchema = { + name: 'CoreUserOfflineProvider', + version: 1, + tables: [ + { + name: CoreUserOfflineProvider.PREFERENCES_TABLE, + columns: [ + { + name: 'name', + type: 'TEXT', + unique: true, + notNull: true + }, + { + name: 'value', + type: 'TEXT' + }, + { + name: 'onlinevalue', + type: 'TEXT' + }, + ] + } + ] + }; + + constructor(private sitesProvider: CoreSitesProvider) { + this.sitesProvider.registerSiteSchema(this.siteSchema); + } + + /** + * Get preferences that were changed offline. + * + * @return {Promise} Promise resolved with list of preferences. + */ + getChangedPreferences(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecordsSelect(CoreUserOfflineProvider.PREFERENCES_TABLE, 'value != onlineValue'); + }); + } + + /** + * Get an offline preference. + * + * @param {string} name Name of the preference. + * @return {Promise} Promise resolved with the preference, rejected if not found. + */ + getPreference(name: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { name }; + + return site.getDb().getRecord(CoreUserOfflineProvider.PREFERENCES_TABLE, conditions); + }); + } + + /** + * Set an offline preference. + * + * @param {string} name Name of the preference. + * @param {string} value Value of the preference. + * @param {string} onlineValue Online value of the preference. If unedfined, preserve previously stored value. + * @return {Promise} Promise resolved when done. + */ + setPreference(name: string, value: string, onlineValue?: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let promise: Promise; + if (typeof onlineValue == 'undefined') { + promise = this.getPreference(name, site.id).then((preference) => preference.onlinevalue); + } else { + promise = Promise.resolve(onlineValue); + } + + return promise.then((onlineValue) => { + const record = { name, value, onlinevalue: onlineValue }; + + return site.getDb().insertRecord(CoreUserOfflineProvider.PREFERENCES_TABLE, record); + }); + }); + } +} diff --git a/src/core/user/providers/sync-cron-handler.ts b/src/core/user/providers/sync-cron-handler.ts new file mode 100644 index 000000000..a51b2da8b --- /dev/null +++ b/src/core/user/providers/sync-cron-handler.ts @@ -0,0 +1,48 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@providers/cron'; +import { CoreUserSyncProvider } from './sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class CoreUserSyncCronHandler implements CoreCronHandler { + name = 'CoreUserSyncCronHandler'; + + constructor(private userSync: CoreUserSyncProvider) {} + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). + * @return {Promise} Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return this.userSync.syncPreferences(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return 300000; // 5 minutes. + } +} diff --git a/src/core/user/providers/sync.ts b/src/core/user/providers/sync.ts new file mode 100644 index 000000000..c63e8dc61 --- /dev/null +++ b/src/core/user/providers/sync.ts @@ -0,0 +1,100 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreAppProvider } from '@providers/app'; +import { CoreUserOfflineProvider } from './offline'; +import { CoreUserProvider } from './user'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSyncProvider } from '@providers/sync'; + +/** + * Service to sync user preferences. + */ +@Injectable() +export class CoreUserSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'core_user_autom_synced'; + + constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, + translate: TranslateService, syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, + private userOffline: CoreUserOfflineProvider, private userProvider: CoreUserProvider, + private utils: CoreUtilsProvider, timeUtils: CoreTimeUtilsProvider) { + super('CoreUserSync', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate, timeUtils); + } + + /** + * Try to synchronize user preferences in a certain site or in all sites. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncPreferences(siteId?: string): Promise { + const syncFunctionLog = 'all user preferences'; + + return this.syncOnSites(syncFunctionLog, this.syncPreferencesFunc.bind(this), [], siteId); + } + + /** + * Sync user preferences of a site. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + protected syncPreferencesFunc(siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const syncId = 'preferences'; + + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing, return the promise. + return this.getOngoingSync(syncId, siteId); + } + + const warnings = []; + + this.logger.debug(`Try to sync user preferences`); + + const syncPromise = this.userOffline.getChangedPreferences(siteId).then((preferences) => { + return this.utils.allPromises(preferences.map((preference) => { + return this.userProvider.getUserPreferenceOnline(preference.name, siteId).then((onlineValue) => { + if (preference.onlinevalue != onlineValue) { + // Prefernce was changed on web while the app was offline, do not sync. + return this.userOffline.setPreference(preference.name, onlineValue, onlineValue, siteId); + } + + return this.userProvider.setUserPreference(name, preference.value, siteId).catch((error) => { + if (this.utils.isWebServiceError(error)) { + warnings.push(this.textUtils.getErrorMessageFromError(error)); + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + }); + }); + })); + }).then(() => { + // All done, return the warnings. + return warnings; + }); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } +} diff --git a/src/core/user/providers/user.ts b/src/core/user/providers/user.ts index dd87758d8..085a73502 100644 --- a/src/core/user/providers/user.ts +++ b/src/core/user/providers/user.ts @@ -15,9 +15,11 @@ import { Injectable } from '@angular/core'; import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreLoggerProvider } from '@providers/logger'; -import { CoreSite } from '@classes/site'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreAppProvider } from '@providers/app'; +import { CoreUserOfflineProvider } from './offline'; /** * Service to provide user functionalities. @@ -59,7 +61,8 @@ export class CoreUserProvider { protected logger; constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider, - private filepoolProvider: CoreFilepoolProvider) { + private filepoolProvider: CoreFilepoolProvider, private appProvider: CoreAppProvider, + private userOffline: CoreUserOfflineProvider) { this.logger = logger.getInstance('CoreUserProvider'); this.sitesProvider.registerSiteSchema(this.siteSchema); } @@ -272,6 +275,67 @@ export class CoreUserProvider { }); } + /** + * Get a user preference (online or offline). + * + * @param {string} name Name of the preference. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {string} Preference value or null if preference not set. + */ + getUserPreference(name: string, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.userOffline.getPreference(name, siteId).catch(() => { + return null; + }).then((preference) => { + if (preference && !this.appProvider.isOnline()) { + // Offline, return stored value. + return preference.value; + } + + return this.getUserPreferenceOnline(name, siteId).then((wsValue) => { + if (preference && preference.value != preference.onlinevalue && preference.onlinevalue == wsValue) { + // Sync is pending for this preference, return stored value. + return preference.value; + } + + return this.userOffline.setPreference(name, wsValue, wsValue).then(() => { + return wsValue; + }); + }); + }); + } + + /** + * Get cache key for a user preference WS call. + * + * @param {string} name Preference name. + * @return {string} Cache key. + */ + protected getUserPreferenceCacheKey(name: string): string { + return this.ROOT_CACHE_KEY + 'preference:' + name; + } + + /** + * Get a user preference online. + * + * @param {string} name Name of the preference. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {string} Preference value or null if preference not set. + */ + getUserPreferenceOnline(name: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const data = { name }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserPreferenceCacheKey(data.name) + }; + + return site.read('core_user_get_user_preferences', data, preSets).then((result) => { + return result.preferences[0] ? result.preferences[0].value : null; + }); + }); + } + /** * Invalidates user WS calls. * @@ -298,6 +362,19 @@ export class CoreUserProvider { }); } + /** + * Invalidate user preference. + * + * @param {string} name Name of the preference. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserPreference(name: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getUserPreferenceCacheKey(name)); + }); + } + /** * Check if course participants is disabled in a certain site. * @@ -455,6 +532,45 @@ export class CoreUserProvider { return Promise.all(promises); } + /** + * Set a user preference (online or offline). + * + * @param {string} name Name of the preference. + * @param {string} value Value of the preference. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved on success. + */ + setUserPreference(name: string, value: string, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + let isOnline = this.appProvider.isOnline(); + let promise: Promise; + + if (isOnline) { + const preferences = [{type: name, value}]; + promise = this.updateUserPreferences(preferences, undefined, undefined, siteId).catch((error) => { + // Preference not saved online. + isOnline = false; + + return Promise.reject(error); + }); + } else { + promise = Promise.resolve(); + } + + return promise.finally(() => { + // Update stored online value if saved online. + const onlineValue = isOnline ? value : undefined; + + return Promise.all([ + this.userOffline.setPreference(name, value, onlineValue), + this.invalidateUserPreference(name).catch(() => { + // Ignore error. + }) + ]); + }); + } + /** * Update a preference for a user. * @@ -478,13 +594,14 @@ export class CoreUserProvider { /** * Update some preferences for a user. * - * @param {any} preferences List of preferences. + * @param {{name: string, value: string}[]} preferences List of preferences. * @param {boolean} [disableNotifications] Whether to disable all notifications. Undefined to not update this value. * @param {number} [userId] User ID. If not defined, site's current user. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved if success. */ - updateUserPreferences(preferences: any, disableNotifications: boolean, userId?: number, siteId?: string): Promise { + updateUserPreferences(preferences: {type: string, value: string}[], disableNotifications?: boolean, userId?: number, + siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { userId = userId || site.getUserId(); diff --git a/src/core/user/user.module.ts b/src/core/user/user.module.ts index d2886ffe3..845f2179e 100644 --- a/src/core/user/user.module.ts +++ b/src/core/user/user.module.ts @@ -26,6 +26,10 @@ import { CoreUserParticipantsCourseOptionHandler } from './providers/course-opti import { CoreUserParticipantsLinkHandler } from './providers/participants-link-handler'; import { CoreCourseOptionsDelegate } from '@core/course/providers/options-delegate'; import { CoreUserComponentsModule } from './components/components.module'; +import { CoreCronDelegate } from '@providers/cron'; +import { CoreUserOfflineProvider } from './providers/offline'; +import { CoreUserSyncProvider } from './providers/sync'; +import { CoreUserSyncCronHandler } from './providers/sync-cron-handler'; // List of providers (without handlers). export const CORE_USER_PROVIDERS: any[] = [ @@ -33,6 +37,8 @@ export const CORE_USER_PROVIDERS: any[] = [ CoreUserProfileFieldDelegate, CoreUserProvider, CoreUserHelperProvider, + CoreUserOfflineProvider, + CoreUserSyncProvider ]; @NgModule({ @@ -46,10 +52,13 @@ export const CORE_USER_PROVIDERS: any[] = [ CoreUserProfileFieldDelegate, CoreUserProvider, CoreUserHelperProvider, + CoreUserOfflineProvider, + CoreUserSyncProvider, CoreUserProfileMailHandler, CoreUserProfileLinkHandler, CoreUserParticipantsCourseOptionHandler, - CoreUserParticipantsLinkHandler + CoreUserParticipantsLinkHandler, + CoreUserSyncCronHandler, ] }) export class CoreUserModule { @@ -57,12 +66,14 @@ export class CoreUserModule { eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, userProvider: CoreUserProvider, contentLinksDelegate: CoreContentLinksDelegate, userLinkHandler: CoreUserProfileLinkHandler, courseOptionHandler: CoreUserParticipantsCourseOptionHandler, linkHandler: CoreUserParticipantsLinkHandler, - courseOptionsDelegate: CoreCourseOptionsDelegate) { + courseOptionsDelegate: CoreCourseOptionsDelegate, cronDelegate: CoreCronDelegate, + syncHandler: CoreUserSyncCronHandler) { userDelegate.registerHandler(userProfileMailHandler); courseOptionsDelegate.registerHandler(courseOptionHandler); contentLinksDelegate.registerHandler(userLinkHandler); contentLinksDelegate.registerHandler(linkHandler); + cronDelegate.register(syncHandler); eventsProvider.on(CoreEventsProvider.USER_DELETED, (data) => { // Search for userid in params. diff --git a/src/lang/en.json b/src/lang/en.json index 0fb6bb5bd..64042e606 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -223,6 +223,7 @@ "sizemb": "MB", "sizetb": "TB", "sorry": "Sorry...", + "sort": "Sort", "sortby": "Sort by", "start": "Start", "strftimedate": "%d %B %Y",