diff --git a/src/addons/calendar/pages/edit-event/edit-event.page.ts b/src/addons/calendar/pages/edit-event/edit-event.page.ts index 71ee3feb1..e78fe1adb 100644 --- a/src/addons/calendar/pages/edit-event/edit-event.page.ts +++ b/src/addons/calendar/pages/edit-event/edit-event.page.ts @@ -570,7 +570,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy { } } - if (this.svComponent?.isOn()) { + if (this.svComponent?.outletActivated) { // Empty form. this.hasOffline = false; this.form.reset(this.originalData); diff --git a/src/addons/calendar/pages/event/event.page.ts b/src/addons/calendar/pages/event/event.page.ts index eced37428..5a1597ca1 100644 --- a/src/addons/calendar/pages/event/event.page.ts +++ b/src/addons/calendar/pages/event/event.page.ts @@ -96,7 +96,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { this.notificationsEnabled = CoreLocalNotifications.instance.isAvailable(); this.siteHomeId = CoreSites.instance.getCurrentSiteHomeId(); this.currentSiteId = CoreSites.instance.getCurrentSiteId(); - this.isSplitViewOn = this.svComponent?.isOn(); + this.isSplitViewOn = this.svComponent?.outletActivated; // Check if site supports editing and deleting. No need to check allowed types, event.canedit already does it. this.canEdit = AddonCalendar.instance.canEditEventsInSite(); diff --git a/src/addons/calendar/pages/list/list.page.ts b/src/addons/calendar/pages/list/list.page.ts index 9eb4f0936..b45b49595 100644 --- a/src/addons/calendar/pages/list/list.page.ts +++ b/src/addons/calendar/pages/list/list.page.ts @@ -121,9 +121,9 @@ export class AddonCalendarListPage implements OnInit, OnDestroy { this.refreshEvents(true, false).finally(() => { // In tablet mode try to open the event (only if it's an online event). - if (this.splitviewCtrl?.isOn() && data.eventId > 0) { + if (this.splitviewCtrl?.outletActivated && data.eventId > 0) { this.gotoEvent(data.eventId); - } else if (this.splitviewCtrl?.isOn()) { + } else if (this.splitviewCtrl?.outletActivated) { // Discussion added, clear details page. this.emptySplitView(); } @@ -133,7 +133,7 @@ export class AddonCalendarListPage implements OnInit, OnDestroy { // Listen for new event discarded event. When it does, reload the data. this.discardedObserver = CoreEvents.on(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, () => { - if (this.splitviewCtrl?.isOn()) { + if (this.splitviewCtrl?.outletActivated) { // Discussion added, clear details page. this.emptySplitView(); } @@ -155,7 +155,8 @@ export class AddonCalendarListPage implements OnInit, OnDestroy { this.eventsLoaded = false; this.refreshEvents(); - if (this.splitviewCtrl?.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) { + if (this.splitviewCtrl?.outletActivated && + this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) { // Current selected event was deleted. Clear details. this.emptySplitView(); } @@ -168,7 +169,8 @@ export class AddonCalendarListPage implements OnInit, OnDestroy { this.refreshEvents(); } - if (this.splitviewCtrl?.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) { + if (this.splitviewCtrl?.outletActivated && + this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) { // Current selected event was deleted. Clear details. this.emptySplitView(); } @@ -185,7 +187,7 @@ export class AddonCalendarListPage implements OnInit, OnDestroy { this.hasOffline = true; } else { // Event deleted, clear the details if needed and refresh the view. - if (this.splitviewCtrl?.isOn()) { + if (this.splitviewCtrl?.outletActivated) { this.emptySplitView(); } @@ -255,7 +257,7 @@ export class AddonCalendarListPage implements OnInit, OnDestroy { await this.fetchData(false, true, false); - if (!this.eventId && this.splitviewCtrl?.isOn() && this.events.length > 0) { + if (!this.eventId && this.splitviewCtrl?.outletActivated && this.events.length > 0) { // Take first online event and load it. If no online event, load the first offline. if (this.onlineEvents[0]) { this.gotoEvent(this.onlineEvents[0].id); 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..6e3dcd101 --- /dev/null +++ b/src/addons/mod/forum/components/components.module.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 { NgModule } from '@angular/core'; + +import { CoreCourseComponentsModule } from '@features/course/components/components.module'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreTagComponentsModule } from '@features/tag/components/components.module'; + +import { AddonModForumDiscussionOptionsMenuComponent } from './discussion-options-menu/discussion-options-menu'; +import { AddonModForumEditPostComponent } from './edit-post/edit-post'; +import { AddonModForumIndexComponent } from './index/index'; +import { AddonModForumPostComponent } from './post/post'; +import { AddonModForumPostOptionsMenuComponent } from './post-options-menu/post-options-menu'; +import { AddonModForumSortOrderSelectorComponent } from './sort-order-selector/sort-order-selector'; + +@NgModule({ + declarations: [ + AddonModForumDiscussionOptionsMenuComponent, + AddonModForumEditPostComponent, + AddonModForumIndexComponent, + AddonModForumPostComponent, + AddonModForumPostOptionsMenuComponent, + AddonModForumSortOrderSelectorComponent, + ], + imports: [ + CoreSharedModule, + CoreCourseComponentsModule, + CoreTagComponentsModule, + CoreEditorComponentsModule, + ], + exports: [ + AddonModForumIndexComponent, + AddonModForumPostComponent, + ], +}) +export class AddonModForumComponentsModule {} diff --git a/src/addons/mod/forum/components/discussion-options-menu/discussion-options-menu.html b/src/addons/mod/forum/components/discussion-options-menu/discussion-options-menu.html new file mode 100644 index 000000000..5332499ea --- /dev/null +++ b/src/addons/mod/forum/components/discussion-options-menu/discussion-options-menu.html @@ -0,0 +1,36 @@ + + + +

{{ 'addon.mod_forum.lockdiscussion' | translate }}

+
+
+ + + +

{{ 'addon.mod_forum.unlockdiscussion' | translate }}

+
+
+ + + +

{{ 'addon.mod_forum.pindiscussion' | translate }}

+
+
+ + + +

{{ 'addon.mod_forum.unpindiscussion' | translate }}

+
+
+ + + +

{{ 'addon.mod_forum.addtofavourites' | translate }}

+
+
+ + + +

{{ 'addon.mod_forum.removefromfavourites' | translate }}

+
+
diff --git a/src/addons/mod/forum/components/discussion-options-menu/discussion-options-menu.ts b/src/addons/mod/forum/components/discussion-options-menu/discussion-options-menu.ts new file mode 100644 index 000000000..10969ab5d --- /dev/null +++ b/src/addons/mod/forum/components/discussion-options-menu/discussion-options-menu.ts @@ -0,0 +1,143 @@ +// (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, Input, OnInit } from '@angular/core'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { PopoverController } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonModForum, AddonModForumDiscussion, AddonModForumProvider } from '../../services/forum'; + +/** + * This component is meant to display a popover with the discussion options. + */ +@Component({ + selector: 'addon-forum-discussion-options-menu', + templateUrl: 'discussion-options-menu.html', +}) +export class AddonModForumDiscussionOptionsMenuComponent implements OnInit { + + @Input() discussion!: AddonModForumDiscussion; // The discussion. + @Input() forumId!: number; // The forum Id. + @Input() cmId!: number; // The component module Id. + + canPin = false; + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + if (!AddonModForum.instance.isSetPinStateAvailableForSite()) { + this.canPin = false; + + return; + } + + // Use the canAddDiscussion WS to check if the user can pin discussions. + try { + const response = await AddonModForum.instance.canAddDiscussionToAll(this.forumId, { cmId: this.cmId }); + + this.canPin = !!response.canpindiscussions; + } catch (error) { + this.canPin = false; + } + } + + /** + * Lock or unlock the discussion. + * + * @param locked True to lock the discussion, false to unlock. + */ + async setLockState(locked: boolean): Promise { + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + const response = await AddonModForum.instance.setLockState(this.forumId, this.discussion.discussion, locked); + const data = { + forumId: this.forumId, + discussionId: this.discussion.discussion, + cmId: this.cmId, + locked: response.locked, + }; + + CoreEvents.trigger(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data, CoreSites.instance.getCurrentSiteId()); + PopoverController.instance.dismiss({ action: 'lock', value: locked }); + CoreDomUtils.instance.showToast('addon.mod_forum.lockupdated', true); + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + PopoverController.instance.dismiss(); + } finally { + modal.dismiss(); + } + } + + /** + * Pin or unpin the discussion. + * + * @param pinned True to pin the discussion, false to unpin it. + */ + async setPinState(pinned: boolean): Promise { + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + await AddonModForum.instance.setPinState(this.discussion.discussion, pinned); + + const data = { + forumId: this.forumId, + discussionId: this.discussion.discussion, + cmId: this.cmId, + pinned: pinned, + }; + + CoreEvents.trigger(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data, CoreSites.instance.getCurrentSiteId()); + PopoverController.instance.dismiss({ action: 'pin', value: pinned }); + CoreDomUtils.instance.showToast('addon.mod_forum.pinupdated', true); + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + PopoverController.instance.dismiss(); + } finally { + modal.dismiss(); + } + } + + /** + * Star or unstar the discussion. + * + * @param starred True to star the discussion, false to unstar it. + */ + async toggleFavouriteState(starred: boolean): Promise { + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + await AddonModForum.instance.toggleFavouriteState(this.discussion.discussion, starred); + + const data = { + forumId: this.forumId, + discussionId: this.discussion.discussion, + cmId: this.cmId, + starred: starred, + }; + + CoreEvents.trigger(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data, CoreSites.instance.getCurrentSiteId()); + PopoverController.instance.dismiss({ action: 'star', value: starred }); + CoreDomUtils.instance.showToast('addon.mod_forum.favouriteupdated', true); + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + PopoverController.instance.dismiss(); + } finally { + modal.dismiss(); + } + } + +} diff --git a/src/addons/mod/forum/components/edit-post/edit-post.html b/src/addons/mod/forum/components/edit-post/edit-post.html new file mode 100644 index 000000000..73f363b9a --- /dev/null +++ b/src/addons/mod/forum/components/edit-post/edit-post.html @@ -0,0 +1,55 @@ + + + + + + {{ 'addon.mod_forum.yourreply' | translate }} + + + + + + + + +
+ + {{ 'addon.mod_forum.subject' | translate }} + + + + + {{ 'addon.mod_forum.message' | translate }} + + + + + + + {{ 'addon.mod_forum.advanced' | translate }} + + + + + + + + + + {{ 'core.savechanges' | translate }} + + + + {{ 'core.cancel' | translate }} + + + +
+
diff --git a/src/addons/mod/forum/components/edit-post/edit-post.ts b/src/addons/mod/forum/components/edit-post/edit-post.ts new file mode 100644 index 000000000..a0e340629 --- /dev/null +++ b/src/addons/mod/forum/components/edit-post/edit-post.ts @@ -0,0 +1,148 @@ +// (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, ViewChild, ElementRef, Input, OnInit } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { CoreFileEntry, CoreFileUploader } from '@features/fileuploader/services/fileuploader'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { ModalController, Translate } from '@singletons'; +import { AddonModForumData, AddonModForumPost, AddonModForumReply } from '@addons/mod/forum/services/forum'; +import { AddonModForumHelper } from '@addons/mod/forum/services/helper'; + +/** + * Page that displays a form to edit discussion post. + */ +@Component({ + selector: 'addon-mod-forum-edit-post', + templateUrl: 'edit-post.html', +}) +export class AddonModForumEditPostComponent implements OnInit { + + @ViewChild('editFormEl') formElement!: ElementRef; + + @Input() component!: string; // Component this post belong to. + @Input() componentId!: number; // Component ID. + @Input() forum!: AddonModForumData; // The forum the post belongs to. Required for attachments and offline posts. + @Input() post!: AddonModForumPost; + + messageControl = new FormControl(); + advanced = false; // Display all form fields. + replyData!: AddonModForumReply; + originalData!: Omit; // Object with the original post data. Usually shared between posts. + + protected forceLeave = false; // To allow leaving the page without checking for changes. + + ngOnInit(): void { + // @todo Override android back button to show confirmation before dismiss. + + this.replyData = { + id: this.post.id, + subject: this.post.subject, + message: this.post.message, + files: this.post.attachments || [], + }; + + // Delete the local files from the tmp folder if any. + CoreFileUploader.instance.clearTmpFiles(this.replyData.files as CoreFileEntry[]); + + // Update rich text editor. + this.messageControl.setValue(this.replyData.message); + + // Update original data. + this.originalData = { + subject: this.replyData.subject, + message: this.replyData.message, + files: this.replyData.files.slice(), + }; + + // Show advanced fields if any of them has not the default value. + this.advanced = this.replyData.files.length > 0; + } + + /** + * Message changed. + * + * @param text The new text. + */ + onMessageChange(text: string): void { + this.replyData.message = text; + } + + /** + * Close modal. + * + * @param data Data to return to the page. + */ + async closeModal(data?: AddonModForumReply): Promise { + const confirmDismiss = await this.confirmDismiss(); + + if (!confirmDismiss) { + return; + } + + if (data) { + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId()); + } else { + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId()); + } + + ModalController.instance.dismiss(data); + } + + /** + * Reply to this post. + * + * @param e Click event. + */ + reply(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + + // Close the modal, sending the input data. + this.forceLeave = true; + this.closeModal(this.replyData); + } + + /** + * Show or hide advanced form fields. + */ + toggleAdvanced(): void { + this.advanced = !this.advanced; + } + + /** + * Check if we can leave the page or not. + * + * @return Resolved if we can leave it, rejected if not. + */ + private async confirmDismiss(): Promise { + if (this.forceLeave || !AddonModForumHelper.instance.hasPostDataChanged(this.replyData, this.originalData)) { + return true; + } + + try { + // Show confirmation if some data has been modified. + await CoreDomUtils.instance.showConfirm(Translate.instant('core.confirmcanceledit')); + + // Delete the local files from the tmp folder. + CoreFileUploader.instance.clearTmpFiles(this.replyData.files as CoreFileEntry[]); + + return true; + } catch (error) { + return false; + } + } + +} 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..cf63909d5 --- /dev/null +++ b/src/addons/mod/forum/components/index/index.html @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate:{$a: moduleName} }} + + + + + + + + {{ availabilityMessage }} + + + + + + + +
+ + {{ selectedSortOrder.label | translate }} +
+
+
+ + + +
+

+ + + + +

+ + + + +
+
+ + +
+

{{discussion.userfullname}}

+

{{ discussion.groupname }}

+

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

+

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

+
+
+ + + + {{ '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..51951a796 --- /dev/null +++ b/src/addons/mod/forum/components/index/index.scss @@ -0,0 +1,65 @@ +@import "~theme/globals"; + +: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..9e215fdec --- /dev/null +++ b/src/addons/mod/forum/components/index/index.ts @@ -0,0 +1,816 @@ +// (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, ViewChild, AfterViewInit } from '@angular/core'; +import { ActivatedRoute, ActivatedRouteSnapshot, Params } from '@angular/router'; +import { IonContent } from '@ionic/angular'; +import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; +import { + AddonModForum, + AddonModForumData, + AddonModForumProvider, + AddonModForumSortOrder, + AddonModForumDiscussion, + AddonModForumNewDiscussionData, + AddonModForumReplyDiscussionData, +} from '@addons/mod/forum/services/forum'; +import { AddonModForumOffline, AddonModForumOfflineDiscussion } from '@addons/mod/forum/services/offline'; +import { ModalController, PopoverController, Translate } from '@singletons'; +import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; +import { AddonModForumHelper } from '@addons/mod/forum/services/helper'; +import { CoreGroups, CoreGroupsProvider } from '@services/groups'; +import { CoreEvents, CoreEventObserver } from '@singletons/events'; +import { + AddonModForumAutoSyncData, + AddonModForumManualSyncData, + AddonModForumSyncProvider, + AddonModForumSyncResult, +} from '@addons/mod/forum/services/sync'; +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'; +import { CorePageItemsListManager } from '@classes/page-items-list-manager'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { AddonModForumDiscussionOptionsMenuComponent } from '../discussion-options-menu/discussion-options-menu'; +import { AddonModForumSortOrderSelectorComponent } from '../sort-order-selector/sort-order-selector'; +import { CoreScreen } from '@services/screen'; +import { CoreArray } from '@singletons/array'; +import { AddonModForumPrefetchHandler } from '../../services/handlers/prefetch'; +import { AddonModForumModuleHandlerService } from '../../services/handlers/module'; + +/** + * 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, AfterViewInit, OnDestroy { + + @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; + + component = AddonModForumProvider.COMPONENT; + moduleName = 'forum'; + descriptionNote?: string; + forum?: AddonModForumData; + fetchMoreDiscussionsFailed = false; + discussions: AddonModForumDiscussionsManager; + canAddDiscussion = false; + addDiscussionText!: string; + availabilityMessage: string | null = null; + sortingAvailable!: boolean; + sortOrders: AddonModForumSortOrder[] = []; + selectedSortOrder: AddonModForumSortOrder | null = null; + sortOrderSelectorExpanded = false; + canPin = false; + + protected syncEventName = AddonModForumSyncProvider.AUTO_SYNCED; + protected page = 0; + trackPosts = false; + protected usesGroups = false; + protected syncManualObserver?: CoreEventObserver; // It will observe the sync manual event. + protected replyObserver?: CoreEventObserver; + protected newDiscObserver?: CoreEventObserver; + protected viewDiscObserver?: CoreEventObserver; + protected changeDiscObserver?: CoreEventObserver; + + hasOfflineRatings?: boolean; + protected ratingOfflineObserver: any; + protected ratingSyncObserver: any; + + constructor( + route: ActivatedRoute, + @Optional() protected content?: IonContent, + @Optional() courseContentsPage?: CoreCourseContentsPage, + ) { + super('AddonModForumIndexComponent', content, courseContentsPage); + + this.discussions = new AddonModForumDiscussionsManager( + route.component, + this, + courseContentsPage ? `${AddonModForumModuleHandlerService.PAGE_NAME}/` : '', + ); + } + + /** + * 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(); + + // Refresh data if this forum discussion is synchronized from discussions list. + this.syncManualObserver = CoreEvents.on(AddonModForumSyncProvider.MANUAL_SYNCED, (data) => { + this.autoSyncEventReceived(data); + }, this.siteId); + + // Listen for discussions added. When a discussion is added, we reload the data. + this.newDiscObserver = CoreEvents.on( + AddonModForumProvider.NEW_DISCUSSION_EVENT, + this.eventReceived.bind(this, true), + ); + this.replyObserver = CoreEvents.on( + AddonModForumProvider.REPLY_DISCUSSION_EVENT, + this.eventReceived.bind(this, false), + ); + this.changeDiscObserver = CoreEvents.on(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data => { + if ((this.forum && this.forum.id === data.forumId) || data.cmId === this.module!.id) { + AddonModForum.instance.invalidateDiscussionsList(this.forum!.id).finally(() => { + if (data.discussionId) { + // Discussion changed, search it in the list of discussions. + const discussion = this.discussions.items.find( + (disc) => this.discussions.isOnlineDiscussion(disc) && data.discussionId == disc.discussion, + ) as AddonModForumDiscussion; + + if (discussion) { + if (typeof data.locked != 'undefined') { + discussion.locked = data.locked; + } + if (typeof data.pinned != 'undefined') { + discussion.pinned = data.pinned; + } + if (typeof data.starred != 'undefined') { + discussion.starred = data.starred; + } + + this.showLoadingAndRefresh(false); + } + } + + if (typeof data.deleted != 'undefined' && data.deleted) { + if (data.post?.parentid == 0 && CoreScreen.instance.isTablet && !this.discussions.empty) { + // Discussion deleted, clear details page. + this.discussions.select(this.discussions[0]); + } + + this.showLoadingAndRefresh(false); + } + }); + } + }); + + // @todo Listen for offline ratings saved and synced. + } + + async ngAfterViewInit(): Promise { + 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; + }), + ); + + this.discussions.start(this.splitView); + } + + /** + * 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, showErrors: boolean = false): Promise { + this.fetchMoreDiscussionsFailed = false; + + const promises: Promise[] = []; + + promises.push(this.fetchForum(sync, showErrors)); + promises.push(this.fetchSortOrderPreference()); + + try { + await Promise.all(promises); + await Promise.all([ + this.fetchOfflineDiscussions(), + this.fetchDiscussions(refresh), + // @todo fetch hasOfflineRatings. + ]); + } catch (error) { + if (refresh) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true); + + this.fetchMoreDiscussionsFailed = 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(sync: boolean = false, showErrors: boolean = false): Promise { + if (!this.courseId || !this.module) { + return; + } + + const forum = await AddonModForum.instance.getForum(this.courseId, this.module.id); + + this.forum = forum; + this.description = forum.intro || this.description; + this.availabilityMessage = AddonModForumHelper.instance.getAvailabilityMessage(forum); + this.descriptionNote = Translate.instant('addon.mod_forum.numdiscussions', { + numdiscussions: forum.numdiscussions, + }); + + if (typeof forum.istracked != 'undefined') { + this.trackPosts = forum.istracked; + } + + this.dataRetrieved.emit(forum); + + switch (forum.type) { + case 'news': + case 'blog': + this.addDiscussionText = 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) { + // Try to synchronize the forum. + const updated = await this.syncActivity(showErrors); + + if (updated) { + // 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( + CoreGroups.instance + .getActivityGroupMode(this.forum.cmid) + .then(async mode => { + this.usesGroups = mode === CoreGroupsProvider.SEPARATEGROUPS + || mode === CoreGroupsProvider.VISIBLEGROUPS; + + return; + }), + ); + + promises.push( + 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( + 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); + } + + /** + * 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.discussions.setOfflineDiscussions([]); + + 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.discussions.setOfflineDiscussions(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.fetchMoreDiscussionsFailed = 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.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; + } + } + } + + if (this.page === 0) { + this.discussions.setOnlineDiscussions(discussions, response.canLoadMore); + } else { + this.discussions.setItems(this.discussions.items.concat(discussions), 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) => { + // 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. + */ + async fetchMoreDiscussions(complete: () => void): Promise { + try { + await this.fetchDiscussions(false); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true); + + this.fetchMoreDiscussionsFailed = true; + } finally { + complete(); + } + } + + /** + * 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 async 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)); + } + + await Promise.all(promises); + } + + /** + * Performs the sync of the activity. + * + * @return Promise resolved when done. + */ + protected sync(): Promise { + return AddonModForumPrefetchHandler.instance.sync(this.module!, this.courseId!); + } + + /** + * 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: AddonModForumSyncResult): boolean { + return result.updated; + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param syncEventData Data receiven on sync observer. + * @return True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: AddonModForumAutoSyncData | AddonModForumManualSyncData): boolean { + return !!this.forum + && (!('source' in syncEventData) || syncEventData.source != 'index') + && syncEventData.forumId == this.forum.id + && syncEventData.userId == CoreSites.instance.getCurrentSiteUserId(); + } + + /** + * Function called when we receive an event of new discussion or reply to discussion. + * + * @param isNewDiscussion Whether it's a new discussion event. + * @param data Event data. + */ + protected eventReceived( + isNewDiscussion: boolean, + data: AddonModForumNewDiscussionData | AddonModForumReplyDiscussionData, + ): void { + if ((this.forum && this.forum.id === data.forumId) || data.cmId === this.module?.id) { + this.showLoadingAndRefresh(false).finally(() => { + // If it's a new discussion in tablet mode, try to open it. + if (isNewDiscussion && CoreScreen.instance.isTablet) { + const newDiscussionData = data as AddonModForumNewDiscussionData; + const discussion = this.discussions.items.find(disc => { + if (this.discussions.isOfflineDiscussion(disc)) { + return disc.timecreated === newDiscussionData.discTimecreated; + } + + if (this.discussions.isOnlineDiscussion(disc)) { + return CoreArray.contains(newDiscussionData.discussionIds ?? [], disc.discussion); + } + + return false; + }); + + if (discussion || !this.discussions.empty) { + this.discussions.select(discussion ?? this.discussions.items[0]); + } + } + }); + + // Check completion since it could be configured to complete once the user adds a new discussion or replies. + CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); + } + } + + /** + * Opens the new discussion form. + * + * @param timeCreated Creation time of the offline discussion. + */ + openNewDiscussion(): void { + this.discussions.select({ newDiscussion: true }); + } + + /** + * Display the sort order selector modal. + */ + async showSortOrderSelector(): Promise { + if (!this.sortingAvailable) { + return; + } + + const modal = await ModalController.instance.create({ + component: AddonModForumSortOrderSelectorComponent, + componentProps: { + sortOrders: this.sortOrders, + selected: this.selectedSortOrder!.value, + }, + }); + + modal.present(); + + this.sortOrderSelectorExpanded = true; + + const result = await modal.onDidDismiss(); + + this.sortOrderSelectorExpanded = false; + + if (result.data && result.data.value != this.selectedSortOrder?.value) { + this.selectedSortOrder = result.data; + this.page = 0; + + try { + await CoreUser.instance.setUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER, result.data.value.toFixed(0)); + await this.showLoadingAndFetch(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error updating preference.'); + } + } + } + + /** + * Show the context menu. + * + * @param event Click Event. + * @param discussion Discussion. + */ + async showOptionsMenu(event: Event, discussion: AddonModForumDiscussion): Promise { + const popover = await PopoverController.instance.create({ + component: AddonModForumDiscussionOptionsMenuComponent, + componentProps: { + discussion, + forumId: this.forum!.id, + cmId: this.module!.id, + }, + event, + }); + + popover.present(); + + const result = await popover.onDidDismiss<{ action?: string; value: boolean }>(); + + if (result.data && result.data.action) { + switch (result.data.action) { + case 'lock': + discussion.locked = result.data.value; + break; + case 'pin': + discussion.pinned = result.data.value; + break; + case 'star': + discussion.starred = result.data.value; + break; + default: + break; + } + } + } + +} + +/** + * Type to select the new discussion form. + */ +type NewDiscussionForm = { newDiscussion: true }; + +/** + * Type of items that can be held by the discussions manager. + */ +type DiscussionItem = AddonModForumDiscussion | AddonModForumOfflineDiscussion | NewDiscussionForm; + +/** + * Discussions manager. + */ +class AddonModForumDiscussionsManager extends CorePageItemsListManager { + + onlineLoaded = false; + + private discussionsPathPrefix: string; + private component: AddonModForumIndexComponent; + + constructor(pageComponent: unknown, component: AddonModForumIndexComponent, discussionsPathPrefix: string) { + super(pageComponent); + + this.component = component; + this.discussionsPathPrefix = discussionsPathPrefix; + } + + get onlineDiscussions(): AddonModForumDiscussion[] { + return this.items.filter(discussion => this.isOnlineDiscussion(discussion)) as AddonModForumDiscussion[]; + } + + /** + * @inheritdoc + */ + getItemQueryParams(discussion: DiscussionItem): Params { + return { + courseId: this.component.courseId, + cmId: this.component.module!.id, + forumId: this.component.forum!.id, + ...(this.isOnlineDiscussion(discussion) ? { discussion, trackPosts: this.component.trackPosts } : {}), + }; + } + + /** + * Type guard to infer NewDiscussionForm objects. + * + * @param discussion Item to check. + * @return Whether the item is a new discussion form. + */ + isNewDiscussionForm(discussion: DiscussionItem): discussion is NewDiscussionForm { + return 'newDiscussion' in discussion; + } + + /** + * Type guard to infer AddonModForumDiscussion objects. + * + * @param discussion Item to check. + * @return Whether the item is an online discussion. + */ + isOfflineDiscussion(discussion: DiscussionItem): discussion is AddonModForumOfflineDiscussion { + return !this.isNewDiscussionForm(discussion) + && !this.isOnlineDiscussion(discussion); + } + + /** + * Type guard to infer AddonModForumDiscussion objects. + * + * @param discussion Item to check. + * @return Whether the item is an online discussion. + */ + isOnlineDiscussion(discussion: DiscussionItem): discussion is AddonModForumDiscussion { + return 'id' in discussion; + } + + /** + * Update online discussion items. + * + * @param onlineDiscussions Online discussions + */ + setOnlineDiscussions(onlineDiscussions: AddonModForumDiscussion[], hasMoreItems: boolean = false): void { + const otherDiscussions = this.items.filter(discussion => !this.isOnlineDiscussion(discussion)); + + this.setItems(otherDiscussions.concat(onlineDiscussions), hasMoreItems); + } + + /** + * Update offline discussion items. + * + * @param offlineDiscussions Offline discussions + */ + setOfflineDiscussions(offlineDiscussions: AddonModForumOfflineDiscussion[]): void { + const otherDiscussions = this.items.filter(discussion => !this.isOfflineDiscussion(discussion)); + + this.setItems((offlineDiscussions as DiscussionItem[]).concat(otherDiscussions), this.hasMoreItems); + } + + /** + * @inheritdoc + */ + setItems(discussions: DiscussionItem[], hasMoreItems: boolean = false): void { + super.setItems(discussions, hasMoreItems); + + this.onlineLoaded = this.onlineLoaded || discussions.some(discussion => this.isOnlineDiscussion(discussion)); + } + + /** + * @inheritdoc + */ + protected getItemPath(discussion: DiscussionItem): string { + const getRelativePath = () => { + if (this.isOnlineDiscussion(discussion)) { + return discussion.discussion; + } + + if (this.isOfflineDiscussion(discussion)) { + return `new/${discussion.timecreated}`; + } + + return 'new/0'; + }; + + return this.discussionsPathPrefix + getRelativePath(); + } + + /** + * @inheritdoc + */ + protected getSelectedItemPath(route: ActivatedRouteSnapshot): string | null { + if (route.params.discussionId) { + return this.discussionsPathPrefix + route.params.discussionId; + } + + if (route.params.timeCreated) { + return this.discussionsPathPrefix + `new/${route.params.timeCreated}`; + } + + return null; + } + +} diff --git a/src/addons/mod/forum/components/post-options-menu/post-options-menu.html b/src/addons/mod/forum/components/post-options-menu/post-options-menu.html new file mode 100644 index 000000000..cbf595c92 --- /dev/null +++ b/src/addons/mod/forum/components/post-options-menu/post-options-menu.html @@ -0,0 +1,26 @@ + + + + +

{{ 'addon.mod_forum.edit' | translate }}

+
+
+ + + +

{{ 'addon.mod_forum.delete' | translate }}

+

{{ 'core.discard' | translate }}

+
+
+ + +

{{ 'core.numwords' | translate: {'$a': wordCount} }}

+
+
+ + + +

{{ 'core.openinbrowser' | translate }}

+
+
+
diff --git a/src/addons/mod/forum/components/post-options-menu/post-options-menu.scss b/src/addons/mod/forum/components/post-options-menu/post-options-menu.scss new file mode 100644 index 000000000..4ef3e574f --- /dev/null +++ b/src/addons/mod/forum/components/post-options-menu/post-options-menu.scss @@ -0,0 +1,11 @@ +:host { + core-loading:not(.core-loading-loaded) > .core-loading-container { + position: relative !important; + padding-top: 10px !important; + padding-bottom: 10px !important; + overflow: hidden; + } + core-loading > .core-loading-container .core-loading-message { + display: none; + } +} diff --git a/src/addons/mod/forum/components/post-options-menu/post-options-menu.ts b/src/addons/mod/forum/components/post-options-menu/post-options-menu.ts new file mode 100644 index 000000000..74443bcf8 --- /dev/null +++ b/src/addons/mod/forum/components/post-options-menu/post-options-menu.ts @@ -0,0 +1,132 @@ +// (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, Input, OnDestroy, OnInit } from '@angular/core'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreApp } from '@services/app'; +import { AddonModForum, AddonModForumPost } from '@addons/mod/forum/services/forum'; +import { Network, NgZone, PopoverController } from '@singletons'; +import { Subscription } from 'rxjs'; +import { CoreDomUtils } from '@services/utils/dom'; + +/** + * This component is meant to display a popover with the post options. + */ +@Component({ + selector: 'addon-forum-post-options-menu', + templateUrl: 'post-options-menu.html', + styleUrls: ['./post-options-menu.scss'], +}) +export class AddonModForumPostOptionsMenuComponent implements OnInit, OnDestroy { + + @Input() post!: AddonModForumPost; // The post. + @Input() cmId!: number; + @Input() forumId!: number; // The forum Id. + + wordCount?: number | null; // Number of words when available. + canEdit = false; + canDelete = false; + loaded = false; + url?: string; + isOnline!: boolean; + offlinePost!: boolean; + + protected onlineObserver?: Subscription; + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + this.isOnline = CoreApp.instance.isOnline(); + + this.onlineObserver = Network.instance.onChange().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(() => { + this.isOnline = CoreApp.instance.isOnline(); + }); + }); + + if (this.post.id > 0) { + const site = CoreSites.instance.getCurrentSite()!; + this.url = site.createSiteUrl('/mod/forum/discuss.php', { d: this.post.discussionid.toString() }, 'p' + this.post.id); + this.offlinePost = false; + } else { + // Offline post, you can edit or discard the post. + this.loaded = true; + this.offlinePost = true; + + return; + } + + if (typeof this.post.capabilities.delete == 'undefined') { + if (this.forumId) { + try { + this.post = + await AddonModForum.instance.getDiscussionPost(this.forumId, this.post.discussionid, this.post.id, { + cmId: this.cmId, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + }); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting discussion post.'); + } + } else { + this.loaded = true; + + return; + } + } + + this.canDelete = !!this.post.capabilities.delete && AddonModForum.instance.isDeletePostAvailable(); + this.canEdit = !!this.post.capabilities.edit && AddonModForum.instance.isUpdatePostAvailable(); + this.wordCount = (this.post.haswordcount && this.post.wordcount) || null; + this.loaded = true; + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.onlineObserver?.unsubscribe(); + } + + /** + * Close the popover. + */ + dismiss(): void { + PopoverController.instance.dismiss(); + } + + /** + * Delete a post. + */ + deletePost(): void { + if (!this.offlinePost) { + PopoverController.instance.dismiss({ action: 'delete' }); + } else { + PopoverController.instance.dismiss({ action: 'deleteoffline' }); + } + } + + /** + * Edit a post. + */ + editPost(): void { + if (!this.offlinePost) { + PopoverController.instance.dismiss({ action: 'edit' }); + } else { + PopoverController.instance.dismiss({ action: 'editoffline' }); + } + } + +} diff --git a/src/addons/mod/forum/components/post/post.html b/src/addons/mod/forum/components/post/post.html new file mode 100644 index 000000000..6f48c3db6 --- /dev/null +++ b/src/addons/mod/forum/components/post/post.html @@ -0,0 +1,153 @@ +
+ + + +
+

+ + + + + + +

+ + + + + + + + +
+ +
+
+
+ +
+ {{ 'addon.mod_forum.postisprivatereply' | translate }} +
+ + +
+ + +
+
+
+ +
{{ 'core.tag.tags' | translate }}:
+ + + +
+ + + + + + + + {{ 'addon.mod_forum.reply' | translate }} + + + +
+ +
+ + {{ 'addon.mod_forum.subject' | translate }} + + + + + {{ 'addon.mod_forum.message' | translate }} + + + + + {{ 'addon.mod_forum.privatereply' | translate }} + + + + + + + + + + {{ 'addon.mod_forum.advanced' | translate }} + + + + + + + + + + + + {{ 'addon.mod_forum.posttoforum' | translate }} + + + + {{ 'core.cancel' | translate }} + + + +
+
diff --git a/src/addons/mod/forum/components/post/post.scss b/src/addons/mod/forum/components/post/post.scss new file mode 100644 index 000000000..b9d4db52a --- /dev/null +++ b/src/addons/mod/forum/components/post/post.scss @@ -0,0 +1,72 @@ +@import "~theme/globals"; + +:host .addon-mod_forum-post { + background-color: var(--white); + border-bottom: 1px solid var(--addon-forum-border-color); + + .addon-forum-star { + color: var(--core-color); + } + + ion-card-header .item { + + &.highlight::part(native) { + background-color: var(--addon-forum-highlight-color); + } + + 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-post-title, + .addon-mod-forum-post-info { + display: flex; + align-items: center; + } + + .addon-mod-forum-post-info { + margin-top: 8px; + } + + .addon-mod-forum-post-title + .addon-mod-forum-post-info { + margin-top: 0px; + } + + .addon-mod-forum-post-title h2, + .addon-mod-forum-post-info .addon-mod-forum-post-author { + flex-grow: 1; + } + + } + + ion-card-content { + padding-top: 14px; + } + + .item .item-inner { + border-bottom: 0; + } + + .addon-mod-forum-post-more-info div { + font-size: 1rem; + } + +} diff --git a/src/addons/mod/forum/components/post/post.ts b/src/addons/mod/forum/components/post/post.ts new file mode 100644 index 000000000..9fd345a13 --- /dev/null +++ b/src/addons/mod/forum/components/post/post.ts @@ -0,0 +1,601 @@ +// (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, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Optional, + Output, + SimpleChange, + ViewChild, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { + AddonModForum, + AddonModForumAccessInformation, + AddonModForumData, + AddonModForumDiscussion, + AddonModForumPost, + AddonModForumProvider, + AddonModForumReply, + AddonModForumUpdateDiscussionPostWSOptionsObject, + AddonModForumWSPostAttachment, +} from '../../services/forum'; +import { CoreTag } from '@features/tag/services/tag'; +import { ModalController, PopoverController, Translate } from '@singletons'; +import { CoreFileEntry, CoreFileUploader } from '@features/fileuploader/services/fileuploader'; +import { IonContent } from '@ionic/angular'; +import { AddonModForumSync } from '../../services/sync'; +import { CoreSync } from '@services/sync'; +import { CoreTextUtils } from '@services/utils/text'; +import { AddonModForumHelper } from '../../services/helper'; +import { AddonModForumOffline, AddonModForumReplyOptions } from '../../services/offline'; +import { CoreUtils } from '@services/utils/utils'; +import { AddonModForumPostOptionsMenuComponent } from '../post-options-menu/post-options-menu'; +import { AddonModForumEditPostComponent } from '../edit-post/edit-post'; + +/** + * Components that shows a discussion post, its attachments and the action buttons allowed (reply, etc.). + */ +@Component({ + selector: 'addon-mod-forum-post', + templateUrl: 'post.html', + styleUrls: ['post.scss'], +}) +export class AddonModForumPostComponent implements OnInit, OnDestroy, OnChanges { + + @Input() post!: AddonModForumPost; // Post. + @Input() courseId!: number; // Post's course ID. + @Input() discussionId!: number; // Post's' discussion ID. + @Input() discussion?: AddonModForumDiscussion; // Post's' discussion, only for starting posts. + @Input() component!: string; // Component this post belong to. + @Input() componentId!: number; // Component ID. + @Input() replyData: any; // Object with the new post data. Usually shared between posts. + @Input() originalData: any; // Object with the original post data. Usually shared between posts. + @Input() trackPosts!: boolean; // True if post is being tracked. + @Input() forum!: AddonModForumData; // The forum the post belongs to. Required for attachments and offline posts. + @Input() accessInfo!: AddonModForumAccessInformation; // Forum access information. + @Input() parentSubject?: string; // Subject of parent post. + @Input() ratingInfo?: any; // TODO CoreRatingInfo; // Rating info item. + @Input() leavingPage?: boolean; // Whether the page that contains this post is being left and will be destroyed. + @Input() highlight = false; + @Output() onPostChange: EventEmitter = new EventEmitter(); // Event emitted when a reply is posted or modified. + + @ViewChild('replyFormEl') formElement!: ElementRef; + + messageControl = new FormControl(); + + uniqueId!: string; + defaultReplySubject!: string; + advanced = false; // Display all form fields. + tagsEnabled!: boolean; + displaySubject = true; + optionsMenuEnabled = false; + + protected syncId!: string; + + constructor( + protected elementRef: ElementRef, + @Optional() protected content?: IonContent, + ) {} + + get showForm(): boolean { + return this.post.id > 0 + ? !this.replyData.isEditing && this.replyData.replyingTo === this.post.id + : this.replyData.isEditing && this.replyData.replyingTo === this.post.parentid; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.tagsEnabled = CoreTag.instance.areTagsAvailableInSite(); + this.uniqueId = this.post.id > 0 ? 'reply' + this.post.id : 'edit' + this.post.parentid; + + const reTranslated = Translate.instant('addon.mod_forum.re'); + this.displaySubject = !this.parentSubject || + (this.post.subject != this.parentSubject && this.post.subject != `Re: ${this.parentSubject}` && + this.post.subject != `${reTranslated} ${this.parentSubject}`); + this.defaultReplySubject = this.post.replysubject || ((this.post.subject.startsWith('Re: ') || + this.post.subject.startsWith(reTranslated)) ? this.post.subject : `${reTranslated} ${this.post.subject}`); + + this.optionsMenuEnabled = this.post.id < 0 || (AddonModForum.instance.isGetDiscussionPostAvailable() && + (AddonModForum.instance.isDeletePostAvailable() || AddonModForum.instance.isUpdatePostAvailable())); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}): void { + if (changes.leavingPage && this.leavingPage) { + // Download all courses is enabled now, initialize it. + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId()); + } + } + + /** + * Deletes an online post. + */ + async deletePost(): Promise { + try { + await CoreDomUtils.instance.showDeleteConfirm('addon.mod_forum.deletesure'); + + const modal = await CoreDomUtils.instance.showModalLoading('core.deleting', true); + + try { + const response = await AddonModForum.instance.deletePost(this.post.id); + + const data = { + forumId: this.forum.id, + discussionId: this.discussionId, + cmId: this.forum.cmid, + deleted: response.status, + post: this.post, + }; + + CoreEvents.trigger( + AddonModForumProvider.CHANGE_DISCUSSION_EVENT, + data, + CoreSites.instance.getCurrentSiteId(), + ); + + CoreDomUtils.instance.showToast('addon.mod_forum.deletedpost', true); + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + } finally { + modal.dismiss(); + } + } catch (error) { + // Do nothing. + } + } + + /** + * Set data to new reply post, clearing temporary files and updating original data. + * + * @param replyingTo Id of post beeing replied. + * @param isEditing True it's an offline reply beeing edited, false otherwise. + * @param subject Subject of the reply. + * @param message Message of the reply. + * @param isPrivate True if it's private reply. + * @param files Reply attachments. + */ + protected setReplyFormData( + replyingTo?: number, + isEditing?: boolean, + subject?: string, + message?: string, + files?: (CoreFileEntry | AddonModForumWSPostAttachment)[], + isPrivate?: boolean, + ): void { + // Delete the local files from the tmp folder if any. + CoreFileUploader.instance.clearTmpFiles(this.replyData.files); + + this.replyData.replyingTo = replyingTo || 0; + this.replyData.isEditing = !!isEditing; + this.replyData.subject = subject || this.defaultReplySubject || ''; + this.replyData.message = message || null; + this.replyData.files = files || []; + this.replyData.isprivatereply = !!isPrivate; + + // Update rich text editor. + this.messageControl.setValue(this.replyData.message); + + // Update original data. + this.originalData.subject = this.replyData.subject; + this.originalData.message = this.replyData.message; + this.originalData.files = this.replyData.files.slice(); + this.originalData.isprivatereply = this.replyData.isprivatereply; + + // Show advanced fields if any of them has not the default value. + this.advanced = this.replyData.files.length > 0; + } + + /** + * Show the context menu. + * + * @param event Click Event. + */ + async showOptionsMenu(event: Event): Promise { + const popover = await PopoverController.instance.create({ + component: AddonModForumPostOptionsMenuComponent, + componentProps: { + post: this.post, + forumId: this.forum.id, + cmId: this.forum.cmid, + }, + event, + }); + + popover.present(); + + const result = await popover.onDidDismiss<{ action?: string }>(); + + if (result.data && result.data.action) { + switch (result.data.action) { + case 'edit': + this.editPost(); + break; + case 'editoffline': + this.editOfflineReply(); + break; + case 'delete': + this.deletePost(); + break; + case 'deleteoffline': + this.discardOfflineReply(); + break; + } + } + } + + /** + * Shows a form modal to edit an online post. + */ + async editPost(): Promise { + const modal = await ModalController.instance.create({ + component: AddonModForumEditPostComponent, + componentProps: { + post: this.post, + component: this.component, + componentId: this.componentId, + forum: this.forum, + }, + backdropDismiss: false, + }); + + modal.present(); + + const result = await modal.onDidDismiss(); + const data = result.data; + + if (!data) { + return; + } + + // Add some HTML to the message if needed. + const message = CoreTextUtils.instance.formatHtmlLines(data.message); + const files = data.files; + const options: AddonModForumUpdateDiscussionPostWSOptionsObject = {}; + + const sendingModal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + // Upload attachments first if any. + if (files.length) { + const attachment = await AddonModForumHelper.instance.uploadOrStoreReplyFiles( + this.forum.id, + this.post.id, + files, + false, + ); + + options.attachmentsid = attachment; + } + + // Try to send it to server. + const sent = await AddonModForum.instance.updatePost(this.post.id, data.subject, message, options); + + if (sent && this.forum.id) { + // Data sent to server, delete stored files (if any). + AddonModForumHelper.instance.deleteReplyStoredFiles(this.forum.id, this.post.id); + + this.onPostChange.emit(); + this.post.subject = data.subject; + this.post.message = message; + this.post.attachments = data.files; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_forum.couldnotupdate', true); + } finally { + sendingModal.dismiss(); + } + } + + /** + * Set this post as being replied to. + */ + async showReplyForm(): Promise { + if (this.replyData.isEditing) { + // User is editing a post, data needs to be resetted. Ask confirm if there is unsaved data. + try { + await this.confirmDiscard(); + this.setReplyFormData(this.post.id); + + if (this.content) { + setTimeout(() => { + CoreDomUtils.instance.scrollToElementBySelector( + this.elementRef.nativeElement, + this.content, + '#addon-forum-reply-edit-form-' + this.uniqueId, + ); + }); + } + } catch (error) { + // Cancelled. + } + + return; + } + + if (!this.replyData.replyingTo) { + // User isn't replying, it's a brand new reply. Initialize the data. + this.setReplyFormData(this.post.id); + } else { + // The post being replied has changed but the data will be kept. + this.replyData.replyingTo = this.post.id; + + if (this.replyData.subject == this.originalData.subject) { + // Update subject only if it hadn't been modified + this.replyData.subject = this.defaultReplySubject; + this.originalData.subject = this.defaultReplySubject; + } + + this.messageControl.setValue(this.replyData.message); + } + + if (this.content) { + setTimeout(() => { + CoreDomUtils.instance.scrollToElementBySelector( + this.elementRef.nativeElement, + this.content, + '#addon-forum-reply-edit-form-' + this.uniqueId, + ); + }); + } + + } + + /** + * Set this post as being edited to. + */ + async editOfflineReply(): Promise { + // Ask confirm if there is unsaved data. + try { + await this.confirmDiscard(); + + this.syncId = AddonModForumSync.instance.getDiscussionSyncId(this.discussionId); + CoreSync.instance.blockOperation(AddonModForumProvider.COMPONENT, this.syncId); + + this.setReplyFormData( + this.post.parentid, + true, + this.post.subject, + this.post.message, + this.post.attachments, + this.post.isprivatereply, + ); + } catch (error) { + // Cancelled. + } + } + + /** + * Message changed. + * + * @param text The new text. + */ + onMessageChange(text: string): void { + this.replyData.message = text; + } + + /** + * Reply to this post. + */ + async reply(): Promise { + if (!this.replyData.subject) { + CoreDomUtils.instance.showErrorModal('addon.mod_forum.erroremptysubject', true); + + return; + } + + if (!this.replyData.message) { + CoreDomUtils.instance.showErrorModal('addon.mod_forum.erroremptymessage', true); + + return; + } + + let saveOffline = false; + let message = this.replyData.message; + const subject = this.replyData.subject; + const replyingTo = this.replyData.replyingTo; + const files = this.replyData.files || []; + const options: AddonModForumReplyOptions = {}; + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + // Add some HTML to the message if needed. + message = CoreTextUtils.instance.formatHtmlLines(message); + + // Set private option if checked. + if (this.replyData.isprivatereply) { + options.private = true; + } + + // Upload attachments first if any. + let attachments; + + if (files.length) { + try { + attachments = await AddonModForumHelper.instance.uploadOrStoreReplyFiles(this.forum.id, replyingTo, files, false); + } catch (error) { + + // Cannot upload them in online, save them in offline. + if (!this.forum.id) { + // Cannot store them in offline without the forum ID. Reject. + return Promise.reject(error); + } + + saveOffline = true; + attachments = await AddonModForumHelper.instance.uploadOrStoreReplyFiles(this.forum.id, replyingTo, files, true); + } + } + + try { + if (attachments) { + options.attachmentsid = attachments; + } + + let sent; + if (saveOffline) { + // Save post in offline. + await AddonModForumOffline.instance.replyPost( + replyingTo, + this.discussionId, + this.forum.id, + this.forum.name, + this.courseId, + subject, + message, + options, + ); + + // Set sent to false since it wasn't sent to server. + sent = false; + } else { + // Try to send it to server. + // Don't allow offline if there are attachments since they were uploaded fine. + sent = await AddonModForum.instance.replyPost( + replyingTo, + this.discussionId, + this.forum.id, + this.forum.name, + this.courseId, + subject, + message, + options, + undefined, + !files.length, + ); + } + + if (sent && this.forum.id) { + // Data sent to server, delete stored files (if any). + AddonModForumHelper.instance.deleteReplyStoredFiles(this.forum.id, replyingTo); + } + + // Reset data. + this.setReplyFormData(); + + this.onPostChange.emit(); + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, sent, CoreSites.instance.getCurrentSiteId()); + + if (this.syncId) { + CoreSync.instance.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId); + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_forum.couldnotadd', true); + } finally { + modal.dismiss(); + } + } + + /** + * Cancel reply. + */ + async cancel(): Promise { + try { + await this.confirmDiscard(); + + // Reset data. + this.setReplyFormData(); + + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId()); + + if (this.syncId) { + CoreSync.instance.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId); + } + } catch (error) { + // Cancelled. + } + } + + /** + * Discard offline reply. + */ + async discardOfflineReply(): Promise { + try { + await CoreDomUtils.instance.showDeleteConfirm(); + + const promises: Promise[] = []; + + promises.push(AddonModForumOffline.instance.deleteReply(this.post.parentid!)); + + if (this.forum.id) { + promises.push(AddonModForumHelper.instance.deleteReplyStoredFiles(this.forum.id, this.post.parentid!).catch(() => { + // Ignore errors, maybe there are no files. + })); + } + + await CoreUtils.instance.ignoreErrors(Promise.all(promises)); + + // Reset data. + this.setReplyFormData(); + + this.onPostChange.emit(); + + if (this.syncId) { + CoreSync.instance.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId); + } + } catch (error) { + // Cancelled. + } + } + + /** + * Function called when rating is updated online. + */ + ratingUpdated(): void { + AddonModForum.instance.invalidateDiscussionPosts(this.discussionId, this.forum.id); + } + + /** + * Show or hide advanced form fields. + */ + toggleAdvanced(): void { + this.advanced = !this.advanced; + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + if (this.syncId) { + CoreSync.instance.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId); + } + } + + /** + * Confirm discard changes if any. + * + * @return Promise resolved if the user confirms or data was not changed and rejected otherwise. + */ + protected confirmDiscard(): Promise { + if (AddonModForumHelper.instance.hasPostDataChanged(this.replyData, this.originalData)) { + // Show confirmation if some data has been modified. + return CoreDomUtils.instance.showConfirm(Translate.instant('core.confirmloss')); + } else { + return Promise.resolve(); + } + } + +} diff --git a/src/addons/mod/forum/components/sort-order-selector/sort-order-selector.html b/src/addons/mod/forum/components/sort-order-selector/sort-order-selector.html new file mode 100644 index 000000000..5ac48825b --- /dev/null +++ b/src/addons/mod/forum/components/sort-order-selector/sort-order-selector.html @@ -0,0 +1,26 @@ + + + + + + {{ 'core.sort' | translate }} + + + + + + + + + + + + +

{{ sortOrder.label | translate }}

+
+
+
+
+
diff --git a/src/addons/mod/forum/components/sort-order-selector/sort-order-selector.ts b/src/addons/mod/forum/components/sort-order-selector/sort-order-selector.ts new file mode 100644 index 000000000..6240be65b --- /dev/null +++ b/src/addons/mod/forum/components/sort-order-selector/sort-order-selector.ts @@ -0,0 +1,47 @@ +// (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, Input } from '@angular/core'; +import { AddonModForumSortOrder } from '@addons/mod/forum/services/forum'; +import { ModalController } from '@singletons'; + +/** + * Page that displays the sort selector. + */ +@Component({ + selector: 'page-addon-mod-forum-sort-order-selector', + templateUrl: 'sort-order-selector.html', +}) +export class AddonModForumSortOrderSelectorComponent { + + @Input() sortOrders!: AddonModForumSortOrder[]; + @Input() selected!: number; + + /** + * Close the modal. + */ + closeModal(): void { + ModalController.instance.dismiss(); + } + + /** + * Select a sort order. + * + * @param sortOrder Selected sort order. + */ + selectSortOrder(sortOrder: AddonModForumSortOrder): void { + ModalController.instance.dismiss(sortOrder); + } + +} 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..a4b888420 --- /dev/null +++ b/src/addons/mod/forum/forum-lazy.module.ts @@ -0,0 +1,74 @@ +// (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 { conditionalRoutes } from '@/app/app-routing.module'; +import { CoreScreen } from '@services/screen'; +import { CoreSharedModule } from '@/core/shared.module'; + +import { AddonModForumComponentsModule } from './components/components.module'; +import { AddonModForumIndexPage } from './pages/index'; + +const mobileRoutes: Routes = [ + { + path: ':courseId/:cmId', + component: AddonModForumIndexPage, + }, + { + path: ':courseId/:cmId/new/:timeCreated', + loadChildren: () => import('./pages/new-discussion/new-discussion.module').then(m => m.AddonForumNewDiscussionPageModule), + }, + { + path: ':courseId/:cmId/:discussionId', + loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule), + }, +]; + +const tabletRoutes: Routes = [ + { + path: ':courseId/:cmId', + component: AddonModForumIndexPage, + children: [ + { + path: 'new/:timeCreated', + loadChildren: () => import('./pages/new-discussion/new-discussion.module') + .then(m => m.AddonForumNewDiscussionPageModule), + }, + { + path: ':discussionId', + loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule), + + }, + ], + }, +]; + +const routes: Routes = [ + ...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile), + ...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet), +]; + +@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..a3c602250 --- /dev/null +++ b/src/addons/mod/forum/forum.module.ts @@ -0,0 +1,111 @@ +// (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 { conditionalRoutes } from '@/app/app-routing.module'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { CoreCourseContentsRoutingModule } from '@features/course/pages/contents/contents-routing.module'; +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreScreen } from '@services/screen'; + +import { AddonModForumComponentsModule } from './components/components.module'; +import { AddonModForumModuleHandler, AddonModForumModuleHandlerService } from './services/handlers/module'; +import { SITE_SCHEMA } from './services/database/offline'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { AddonModForumPrefetchHandler } from './services/handlers/prefetch'; +import { CoreCronDelegate } from '@services/cron'; +import { AddonModForumSyncCronHandler } from './services/handlers/sync-cron'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; +import { AddonModForumDiscussionLinkHandler } from './services/handlers/discussion-link'; +import { AddonModForumIndexLinkHandler } from './services/handlers/index-link'; +import { AddonModForumListLinkHandler } from './services/handlers/list-link'; +import { AddonModForumPostLinkHandler } from './services/handlers/post-link'; +import { CoreTagAreaDelegate } from '@features/tag/services/tag-area-delegate'; +import { AddonModForumTagAreaHandler } from './services/handlers/tag-area'; +import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate'; +import { AddonModForumPushClickHandler } from './services/handlers/push-click'; + +const mainMenuRoutes: Routes = [ + { + path: `${AddonModForumModuleHandlerService.PAGE_NAME}/discussion/:discussionId`, + loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule), + }, + { + path: AddonModForumModuleHandlerService.PAGE_NAME, + loadChildren: () => import('./forum-lazy.module').then(m => m.AddonModForumLazyModule), + }, + ...conditionalRoutes( + [ + { + path: `course/index/contents/${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`, + loadChildren: () => import('./pages/new-discussion/new-discussion.module') + .then(m => m.AddonForumNewDiscussionPageModule), + }, + { + path: `course/index/contents/${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`, + loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule), + }, + ], + () => CoreScreen.instance.isMobile, + ), +]; + +const courseContentsRoutes: Routes = conditionalRoutes( + [ + { + path: `${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`, + loadChildren: () => import('./pages/new-discussion/new-discussion.module') + .then(m => m.AddonForumNewDiscussionPageModule), + }, + { + path: `${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`, + loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule), + }, + ], + () => CoreScreen.instance.isTablet, +); + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(mainMenuRoutes), + CoreCourseContentsRoutingModule.forChild({ children: courseContentsRoutes }), + AddonModForumComponentsModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + useValue: () => { + CoreCourseModuleDelegate.instance.registerHandler(AddonModForumModuleHandler.instance); + CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModForumPrefetchHandler.instance); + CoreCronDelegate.instance.register(AddonModForumSyncCronHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonModForumDiscussionLinkHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonModForumIndexLinkHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonModForumListLinkHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonModForumPostLinkHandler.instance); + CoreTagAreaDelegate.instance.registerHandler(AddonModForumTagAreaHandler.instance); + CorePushNotificationsDelegate.instance.registerClickHandler(AddonModForumPushClickHandler.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/discussion/discussion.html b/src/addons/mod/forum/pages/discussion/discussion.html new file mode 100644 index 000000000..e3e987118 --- /dev/null +++ b/src/addons/mod/forum/pages/discussion/discussion.html @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate:{$a: discussionStr} }} + + + + + + + + {{ availabilityMessage }} + + + + + + + {{ 'addon.mod_forum.discussionlocked' | translate }} + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+
+
diff --git a/src/addons/mod/forum/pages/discussion/discussion.module.ts b/src/addons/mod/forum/pages/discussion/discussion.module.ts new file mode 100644 index 000000000..dbfb5f2a1 --- /dev/null +++ b/src/addons/mod/forum/pages/discussion/discussion.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 { AddonModForumComponentsModule } from '@addons/mod/forum/components/components.module'; +import { CanLeaveGuard } from '@guards/can-leave'; +import { CoreSharedModule } from '@/core/shared.module'; + +import { AddonModForumDiscussionPage } from './discussion.page'; + +const routes: Routes = [{ + path: '', + component: AddonModForumDiscussionPage, + canDeactivate: [CanLeaveGuard], +}]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + AddonModForumComponentsModule, + ], + declarations: [ + AddonModForumDiscussionPage, + ], +}) +export class AddonForumDiscussionPageModule {} diff --git a/src/addons/mod/forum/pages/discussion/discussion.page.ts b/src/addons/mod/forum/pages/discussion/discussion.page.ts new file mode 100644 index 000000000..3f04001d3 --- /dev/null +++ b/src/addons/mod/forum/pages/discussion/discussion.page.ts @@ -0,0 +1,776 @@ +// (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, OnDestroy, ViewChild, OnInit, AfterViewInit, ElementRef, Optional } from '@angular/core'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; +import { CoreUser } from '@features/user/services/user'; +import { CanLeave } from '@guards/can-leave'; +import { IonContent } from '@ionic/angular'; +import { CoreApp } from '@services/app'; +import { CoreNavigator } from '@services/navigator'; +import { CoreScreen } from '@services/screen'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { Network, NgZone, Translate } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { Subscription } from 'rxjs'; +import { + AddonModForum, + AddonModForumAccessInformation, + AddonModForumData, + AddonModForumDiscussion, + AddonModForumPost, + AddonModForumProvider, + AddonModForumRatingInfo, +} from '../../services/forum'; +import { AddonModForumHelper } from '../../services/helper'; +import { AddonModForumOffline } from '../../services/offline'; +import { AddonModForumSync, AddonModForumSyncProvider } from '../../services/sync'; + +type SortType = 'flat-newest' | 'flat-oldest' | 'nested'; + +type Post = AddonModForumPost & { children?: Post[] }; + +/** + * Page that displays a forum discussion. + */ +@Component({ + selector: 'page-addon-mod-forum-discussion', + templateUrl: 'discussion.html', + styleUrls: ['discussion.scss'], +}) +export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDestroy, CanLeave { + + @ViewChild(IonContent) content!: IonContent; + + courseId!: number; + discussionId!: number; + forum: Partial = {}; + accessInfo: AddonModForumAccessInformation = {}; + discussion!: AddonModForumDiscussion; + startingPost?: Post; + posts!: Post[]; + discussionLoaded = false; + postSubjects!: { [id: string]: string }; + isOnline!: boolean; + postHasOffline!: boolean; + sort: SortType = 'nested'; + trackPosts!: boolean; + replyData = { + replyingTo: 0, + isEditing: false, + subject: '', + message: null, // Null means empty or just white space. + files: [], + isprivatereply: false, + }; + + originalData = { + subject: null, // Null means original data is not set. + message: null, // Null means empty or just white space. + files: [], + isprivatereply: false, + }; + + refreshIcon = 'spinner'; + syncIcon = 'spinner'; + discussionStr = ''; + component = AddonModForumProvider.COMPONENT; + cmId!: number; + canPin = false; + availabilityMessage: string | null = null; + leavingPage = false; + + protected forumId!: number; + protected postId!: number; + protected parent!: number; + protected onlineObserver?: Subscription; + protected syncObserver?: CoreEventObserver; + protected syncManualObserver?: CoreEventObserver; + + ratingInfo?: AddonModForumRatingInfo; + hasOfflineRatings!: boolean; + protected ratingOfflineObserver?: CoreEventObserver; + protected ratingSyncObserver?: CoreEventObserver; + protected changeDiscObserver?: CoreEventObserver; + + constructor( + @Optional() protected splitView: CoreSplitViewComponent, + protected elementRef: ElementRef, + ) {} + + get isMobile(): boolean { + return CoreScreen.instance.isMobile; + } + + ngOnInit(): void { + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; + this.cmId = CoreNavigator.instance.getRouteNumberParam('cmId')!; + this.forumId = CoreNavigator.instance.getRouteNumberParam('forumId')!; + this.discussion = CoreNavigator.instance.getRouteParam('discussion')!; + this.discussionId = this.discussion + ? this.discussion.discussion + : CoreNavigator.instance.getRouteNumberParam('discussionId')!; + this.trackPosts = CoreNavigator.instance.getRouteBooleanParam('trackPosts')!; + this.postId = CoreNavigator.instance.getRouteNumberParam('postId')!; + this.parent = CoreNavigator.instance.getRouteNumberParam('parent')!; + + this.isOnline = CoreApp.instance.isOnline(); + this.onlineObserver = Network.instance.onChange().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(() => { + this.isOnline = CoreApp.instance.isOnline(); + }); + }); + + this.discussionStr = Translate.instant('addon.mod_forum.discussion'); + } + + /** + * View loaded. + */ + async ngAfterViewInit(): Promise { + if (this.parent) { + this.sort = 'nested'; // Force nested order. + } else { + this.sort = await this.getUserSort(); + } + + await this.fetchPosts(true, false, true); + + const scrollTo = this.postId || this.parent; + if (scrollTo) { + // Scroll to the post. + setTimeout(() => { + CoreDomUtils.instance.scrollToElementBySelector( + this.elementRef.nativeElement, + this.content, + '#addon-mod_forum-post-' + scrollTo, + ); + }); + } + } + + /** + * User entered the page that contains the component. + */ + async ionViewDidEnter(): Promise { + if (this.syncObserver) { + // Already setup. + return; + } + + // The discussion object was not passed as parameter. + if (!this.discussion) { + await this.loadDiscussion(this.forumId, this.cmId, this.discussionId); + } + + // Refresh data if this discussion is synchronized automatically. + this.syncObserver = CoreEvents.on(AddonModForumSyncProvider.AUTO_SYNCED, data => { + if (data.forumId == this.forumId && this.discussionId == data.discussionId + && data.userId == CoreSites.instance.getCurrentSiteUserId()) { + // Refresh the data. + this.discussionLoaded = false; + this.refreshPosts(); + } + }, CoreSites.instance.getCurrentSiteId()); + + // Refresh data if this forum discussion is synchronized from discussions list. + this.syncManualObserver = CoreEvents.on(AddonModForumSyncProvider.MANUAL_SYNCED, data => { + if (data.source != 'discussion' && data.forumId == this.forumId && + data.userId == CoreSites.instance.getCurrentSiteUserId()) { + // Refresh the data. + this.discussionLoaded = false; + this.refreshPosts(); + } + }, CoreSites.instance.getCurrentSiteId()); + + // Invalidate discussion list if it was not read. + if (this.discussion.numunread > 0) { + AddonModForum.instance.invalidateDiscussionsList(this.forumId); + } + + // @todo Listen for offline ratings saved and synced. + + this.changeDiscObserver = CoreEvents.on(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data => { + if ((this.forumId && this.forumId === data.forumId) || data.cmId === this.cmId) { + AddonModForum.instance.invalidateDiscussionsList(this.forumId).finally(() => { + if (typeof data.locked != 'undefined') { + this.discussion.locked = data.locked; + } + if (typeof data.pinned != 'undefined') { + this.discussion.pinned = data.pinned; + } + if (typeof data.starred != 'undefined') { + this.discussion.starred = data.starred; + } + + if (typeof data.deleted != 'undefined' && data.deleted) { + if (!data.post?.parentid) { + if (this.splitView?.outletActivated) { + CoreNavigator.instance.navigate('../'); + } else { + CoreNavigator.instance.back(); + } + } else { + this.discussionLoaded = false; + this.refreshPosts(); + } + } + }); + } + }); + } + + /** + * Check if we can leave the page or not. + * + * @return Resolved if we can leave it, rejected if not. + */ + async canLeave(): Promise { + if (AddonModForumHelper.instance.hasPostDataChanged(this.replyData, this.originalData)) { + // Show confirmation if some data has been modified. + await CoreDomUtils.instance.showConfirm(Translate.instant('core.confirmcanceledit')); + } + + // Delete the local files from the tmp folder. + CoreFileUploader.instance.clearTmpFiles(this.replyData.files); + + this.leavingPage = true; + + return true; + } + + /** + * Runs when the page is about to leave and no longer be the active page. + */ + ionViewWillLeave(): void { + this.syncObserver && this.syncObserver.off(); + this.syncManualObserver && this.syncManualObserver.off(); + this.ratingOfflineObserver && this.ratingOfflineObserver.off(); + this.ratingSyncObserver && this.ratingSyncObserver.off(); + this.changeDiscObserver && this.changeDiscObserver.off(); + delete this.syncObserver; + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.onlineObserver && this.onlineObserver.unsubscribe(); + } + + /** + * Get sort type configured by the current user. + * + * @return Promise resolved with the sort type. + */ + protected async getUserSort(): Promise { + try { + const value = await CoreSites.instance.getCurrentSite()!.getLocalSiteConfig('AddonModForumDiscussionSort'); + + return value; + } catch (error) { + try { + const value = await CoreUser.instance.getUserPreference('forum_displaymode'); + + switch (Number(value)) { + case 1: + return 'flat-oldest'; + case -1: + return 'flat-newest'; + case 3: + return 'nested'; + case 2: // Threaded not implemented. + default: + // Not set, use default sort. + // @TODO add fallback to $CFG->forum_displaymode. + } + } catch (error) { + // Ignore errors. + } + } + + return 'flat-oldest'; + } + + /** + * Convenience function to get the forum. + * + * @return Promise resolved with the forum. + */ + protected fetchForum(): Promise { + if (this.courseId && this.cmId) { + return AddonModForum.instance.getForum(this.courseId, this.cmId); + } + + if (this.courseId && this.forumId) { + return AddonModForum.instance.getForumById(this.courseId, this.forumId); + } + + throw new Error('Cannot get the forum'); + } + + /** + * Convenience function to get the posts. + * + * @param sync Whether to try to synchronize the discussion. + * @param showErrors Whether to show errors in a modal. + * @param forceMarkAsRead Whether to mark all posts as read. + * @return Promise resolved when done. + */ + protected async fetchPosts(sync?: boolean, showErrors?: boolean, forceMarkAsRead?: boolean): Promise { + let onlinePosts: AddonModForumPost[] = []; + const offlineReplies: AddonModForumPost[] = []; + let hasUnreadPosts = false; + + try { + if (sync) { + // Try to synchronize the forum. + await CoreUtils.instance.ignoreErrors(this.syncDiscussion(!!showErrors)); + } + + const response = await AddonModForum.instance.getDiscussionPosts(this.discussionId, { cmId: this.cmId }); + const replies = await AddonModForumOffline.instance.getDiscussionReplies(this.discussionId); + const ratingInfo = response.ratinginfo; + onlinePosts = response.posts; + this.courseId = response.courseid || this.courseId; + this.forumId = response.forumid || this.forumId; + + // Check if there are responses stored in offline. + this.postHasOffline = !!replies.length; + const convertPromises: Promise[] = []; + + // Index posts to allow quick access. Also check unread field. + const onlinePostsMap: Record = {}; + onlinePosts.forEach((post) => { + onlinePostsMap[post.id] = post; + hasUnreadPosts = hasUnreadPosts || !!post.unread; + }); + + replies.forEach((offlineReply) => { + // If we don't have forumId and courseId, get it from the post. + if (!this.forumId) { + this.forumId = offlineReply.forumid; + } + if (!this.courseId) { + this.courseId = offlineReply.courseid; + } + + convertPromises.push( + AddonModForumHelper.instance + .convertOfflineReplyToOnline(offlineReply) + .then(async reply => { + offlineReplies.push(reply); + + // Disable reply of the parent. Reply in offline to the same post is not allowed, edit instead. + posts[reply.parentid!].capabilities.reply = false; + + return; + }), + ); + }); + + await Promise.all(convertPromises); + + // Convert back to array. + onlinePosts = CoreUtils.instance.objectToArray(onlinePostsMap); + + let posts = offlineReplies.concat(onlinePosts); + + this.startingPost = AddonModForum.instance.extractStartingPost(posts); + + // If sort type is nested, normal sorting is disabled and nested posts will be displayed. + if (this.sort == 'nested') { + // Sort first by creation date to make format tree work. + AddonModForum.instance.sortDiscussionPosts(posts, 'ASC'); + + const rootId = this.startingPost ? this.startingPost.id : (this.discussion ? this.discussion.id : 0); + posts = CoreUtils.instance.formatTree(posts, 'parentid', 'id', rootId); + } else { + // Set default reply subject. + const direction = this.sort == 'flat-newest' ? 'DESC' : 'ASC'; + AddonModForum.instance.sortDiscussionPosts(posts, direction); + } + + try { + // Now try to get the forum. + const forum = await this.fetchForum(); + // "forum.istracked" is more reliable than "trackPosts". + if (typeof forum.istracked != 'undefined') { + this.trackPosts = forum.istracked; + } + + this.forumId = forum.id; + this.cmId = forum.cmid; + this.courseId = forum.course; + this.forum = forum; + this.availabilityMessage = AddonModForumHelper.instance.getAvailabilityMessage(forum); + + const promises: Promise[] = []; + + promises.push( + AddonModForum.instance + .getAccessInformation(this.forumId, { cmId: this.cmId }) + .then(async accessInfo => { + this.accessInfo = accessInfo; + + // Disallow replying if cut-off date is reached and the user has not the capability to override it. + // Just in case the posts were fetched from WS when the cut-off date was not reached but it is now. + if (AddonModForumHelper.instance.isCutoffDateReached(forum) && !accessInfo.cancanoverridecutoff) { + posts.forEach((post) => { + post.capabilities.reply = false; + }); + } + + return; + }), + ); + + // The discussion object was not passed as parameter and there is no starting post. + if (!this.discussion) { + promises.push(this.loadDiscussion(this.forumId, this.cmId, this.discussionId)); + } + + await Promise.all(promises); + } catch (error) { + // Ignore errors. + } + + if (!this.discussion && !this.startingPost) { + // The discussion object was not passed as parameter and there is no starting post. Should not happen. + throw new Error('Invalid forum discussion.'); + } + + if (this.startingPost && this.startingPost.author && this.forum.type == 'single') { + // Hide author and groups for first post and type single. + delete this.startingPost.author.fullname; + delete this.startingPost.author.groups; + } + + this.posts = posts; + this.ratingInfo = ratingInfo; + this.postSubjects = this.getAllPosts().reduce( + (postSubjects, post) => { + postSubjects[post.id] = post.subject; + + return postSubjects; + }, + this.startingPost + ? { [this.startingPost.id]: this.startingPost.subject } + : {}, + ); + + if (AddonModForum.instance.isSetPinStateAvailableForSite()) { + // Use the canAddDiscussion WS to check if the user can pin discussions. + try { + const response = await AddonModForum.instance.canAddDiscussionToAll(this.forumId, { cmId: this.cmId }); + + this.canPin = !!response.canpindiscussions; + } catch (error) { + this.canPin = false; + } + } else { + this.canPin = false; + } + + // @todo fetch hasOfflineRatings. + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + } finally { + this.discussionLoaded = true; + this.refreshIcon = 'refresh'; + this.syncIcon = 'sync'; + + if (forceMarkAsRead || (hasUnreadPosts && this.trackPosts)) { + // // Add log in Moodle and mark unread posts as readed. + AddonModForum.instance.logDiscussionView(this.discussionId, this.forumId || -1, this.forum.name).catch(() => { + // Ignore errors. + }).finally(() => { + // Trigger mark read posts. + CoreEvents.trigger(AddonModForumProvider.MARK_READ_EVENT, { + courseId: this.courseId, + moduleId: this.cmId, + }, CoreSites.instance.getCurrentSiteId()); + }); + } + } + } + + /** + * Convenience function to load discussion. + * + * @param forumId Forum ID. + * @param cmId Forum cmid. + * @param discussionId Discussion ID. + * @return Promise resolved when done. + */ + protected async loadDiscussion(forumId: number, cmId: number, discussionId: number): Promise { + // Fetch the discussion if not passed as parameter. + if (this.discussion || !forumId) { + return; + } + + try { + const discussion = await AddonModForumHelper.instance.getDiscussionById(forumId, cmId, discussionId); + + this.discussion = discussion; + this.discussionId = this.discussion.discussion; + } catch (error) { + // Ignore errors. + } + } + + /** + * Tries to synchronize the posts discussion. + * + * @param showErrors Whether to show errors in a modal. + * @return Promise resolved when done. + */ + protected async syncDiscussion(showErrors: boolean): Promise { + const promises: Promise[] = []; + + promises.push( + AddonModForumSync.instance + .syncDiscussionReplies(this.discussionId) + .then((result) => { + if (result.warnings && result.warnings.length) { + CoreDomUtils.instance.showErrorModal(result.warnings[0]); + } + + if (result && result.updated) { + // Sync successful, send event. + CoreEvents.trigger(AddonModForumSyncProvider.MANUAL_SYNCED, { + forumId: this.forumId, + userId: CoreSites.instance.getCurrentSiteUserId(), + source: 'discussion', + }, CoreSites.instance.getCurrentSiteId()); + } + + return; + }), + ); + + promises.push( + AddonModForumSync.instance + .syncRatings(this.cmId, this.discussionId) + .then((result) => { + if (result.warnings && result.warnings.length) { + CoreDomUtils.instance.showErrorModal(result.warnings[0]); + } + + return; + }), + ); + + try { + await Promise.all(promises); + } catch (error) { + if (showErrors) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errorsync', true); + } + + throw new Error('Failed syncing discussion'); + } + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @param done Function to call when done. + * @param showErrors If show errors to the user of hide them. + * @return Promise resolved when done. + */ + async doRefresh(refresher?: any, done?: () => void, showErrors: boolean = false): Promise { + if (this.discussionLoaded) { + await this.refreshPosts(true, showErrors).finally(() => { + refresher && refresher.complete(); + done && done(); + }); + } + } + + /** + * Refresh posts. + * + * @param sync Whether to try to synchronize the discussion. + * @param showErrors Whether to show errors in a modal. + * @return Promise resolved when done. + */ + refreshPosts(sync?: boolean, showErrors?: boolean): Promise { + this.content.scrollToTop(); + this.refreshIcon = 'spinner'; + this.syncIcon = 'spinner'; + + const promises = [ + AddonModForum.instance.invalidateForumData(this.courseId), + AddonModForum.instance.invalidateDiscussionPosts(this.discussionId, this.forumId), + AddonModForum.instance.invalidateAccessInformation(this.forumId), + AddonModForum.instance.invalidateCanAddDiscussion(this.forumId), + ]; + + return CoreUtils.instance.allPromises(promises).catch(() => { + // Ignore errors. + }).then(() => this.fetchPosts(sync, showErrors)); + } + + /** + * Function to change posts sorting + * + * @param type Sort type. + * @return Promised resolved when done. + */ + changeSort(type: SortType): Promise { + this.discussionLoaded = false; + this.sort = type; + CoreSites.instance.getCurrentSite()!.setLocalSiteConfig('AddonModForumDiscussionSort', this.sort); + this.content.scrollToTop(); + + return this.fetchPosts(); + } + + /** + * Lock or unlock the discussion. + * + * @param locked True to lock the discussion, false to unlock. + */ + async setLockState(locked: boolean): Promise { + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + const response = await AddonModForum.instance.setLockState(this.forumId, this.discussionId, locked); + this.discussion.locked = response.locked; + + const data = { + forumId: this.forumId, + discussionId: this.discussionId, + cmId: this.cmId, + locked: this.discussion.locked, + }; + CoreEvents.trigger(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data, CoreSites.instance.getCurrentSiteId()); + + CoreDomUtils.instance.showToast('addon.mod_forum.lockupdated', true); + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + } finally { + modal.dismiss(); + } + } + + /** + * Pin or unpin the discussion. + * + * @param pinned True to pin the discussion, false to unpin it. + */ + async setPinState(pinned: boolean): Promise { + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + await AddonModForum.instance.setPinState(this.discussionId, pinned); + + this.discussion.pinned = pinned; + + const data = { + forumId: this.forumId, + discussionId: this.discussionId, + cmId: this.cmId, + pinned: this.discussion.pinned, + }; + CoreEvents.trigger(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data, CoreSites.instance.getCurrentSiteId()); + + CoreDomUtils.instance.showToast('addon.mod_forum.pinupdated', true); + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + } finally { + modal.dismiss(); + } + } + + /** + * Star or unstar the discussion. + * + * @param starred True to star the discussion, false to unstar it. + */ + async toggleFavouriteState(starred: boolean): Promise { + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + await AddonModForum.instance.toggleFavouriteState(this.discussionId, starred); + + this.discussion.starred = starred; + + const data = { + forumId: this.forumId, + discussionId: this.discussionId, + cmId: this.cmId, + starred: this.discussion.starred, + }; + CoreEvents.trigger(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data, CoreSites.instance.getCurrentSiteId()); + + CoreDomUtils.instance.showToast('addon.mod_forum.favouriteupdated', true); + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + } finally { + modal.dismiss(); + } + } + + /** + * New post added. + */ + postListChanged(): void { + // Trigger an event to notify a new reply. + const data = { + forumId: this.forumId, + discussionId: this.discussionId, + cmId: this.cmId, + }; + CoreEvents.trigger(AddonModForumProvider.REPLY_DISCUSSION_EVENT, data, CoreSites.instance.getCurrentSiteId()); + + this.discussionLoaded = false; + this.refreshPosts().finally(() => { + this.discussionLoaded = true; + }); + } + + /** + * Get all the posts contained in the discussion. + * + * @return Array containing all the posts of the discussion. + */ + protected getAllPosts(): Post[] { + return this.posts.map(this.flattenPostHierarchy.bind(this)); + } + + /** + * Flatten a post's hierarchy into an array. + * + * @param parent Parent post. + * @return Array containing all the posts within the hierarchy (including the parent). + */ + protected flattenPostHierarchy(parent: Post): Post[] { + const posts = [parent]; + const children = parent.children || []; + + for (const child of children) { + posts.push(...this.flattenPostHierarchy(child)); + } + + return posts; + } + +} diff --git a/src/addons/mod/forum/pages/discussion/discussion.scss b/src/addons/mod/forum/pages/discussion/discussion.scss new file mode 100644 index 000000000..b68bd597b --- /dev/null +++ b/src/addons/mod/forum/pages/discussion/discussion.scss @@ -0,0 +1,7 @@ +:host { + + .addon-forum-reply-button .label { + margin: 0; + } + +} 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..d4c0737e8 --- /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'; +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/pages/new-discussion/new-discussion.html b/src/addons/mod/forum/pages/new-discussion/new-discussion.html new file mode 100644 index 000000000..def9148e6 --- /dev/null +++ b/src/addons/mod/forum/pages/new-discussion/new-discussion.html @@ -0,0 +1,84 @@ + + + + + + {{ 'addon.mod_forum.addanewdiscussion' | translate }} + + + + + + + + + + + +
+ + {{ 'addon.mod_forum.subject' | translate }} + + + + + {{ 'addon.mod_forum.message' | translate }} + + + + + + + + + {{ 'addon.mod_forum.advanced' | translate }} + + + + {{ 'addon.mod_forum.posttomygroups' | translate }} + + + + {{ 'addon.mod_forum.group' | translate }} + + {{ group.name }} + + + + {{ 'addon.mod_forum.discussionsubscription' | translate }} + + + + {{ 'addon.mod_forum.discussionpinned' | translate }} + + + + + + + + + + + {{ 'addon.mod_forum.posttoforum' | translate }} + + + + {{ 'core.discard' | translate }} + + + + +
+
+
diff --git a/src/addons/mod/forum/pages/new-discussion/new-discussion.module.ts b/src/addons/mod/forum/pages/new-discussion/new-discussion.module.ts new file mode 100644 index 000000000..7cbde9b15 --- /dev/null +++ b/src/addons/mod/forum/pages/new-discussion/new-discussion.module.ts @@ -0,0 +1,42 @@ +// (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 { AddonModForumComponentsModule } from '@addons/mod/forum/components/components.module'; +import { CanLeaveGuard } from '@guards/can-leave'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; +import { CoreSharedModule } from '@/core/shared.module'; + +import { AddonModForumNewDiscussionPage } from './new-discussion.page'; + +const routes: Routes = [{ + path: '', + component: AddonModForumNewDiscussionPage, + canDeactivate: [CanLeaveGuard], +}]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + AddonModForumComponentsModule, + CoreEditorComponentsModule, + ], + declarations: [ + AddonModForumNewDiscussionPage, + ], +}) +export class AddonForumNewDiscussionPageModule {} diff --git a/src/addons/mod/forum/pages/new-discussion/new-discussion.page.ts b/src/addons/mod/forum/pages/new-discussion/new-discussion.page.ts new file mode 100644 index 000000000..e813bb990 --- /dev/null +++ b/src/addons/mod/forum/pages/new-discussion/new-discussion.page.ts @@ -0,0 +1,612 @@ +// (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, OnDestroy, ViewChild, ElementRef, OnInit, Optional } from '@angular/core'; +import { FileEntry } from '@ionic-native/file/ngx'; +import { FormControl } from '@angular/forms'; +import { CoreEvents, CoreEventObserver } from '@singletons/events'; +import { CoreGroup, CoreGroups, CoreGroupsProvider } from '@services/groups'; +import { CoreNavigator } from '@services/navigator'; +import { + AddonModForum, + AddonModForumAccessInformation, + AddonModForumCanAddDiscussion, + AddonModForumData, + AddonModForumProvider, +} from '@addons/mod/forum/services/forum'; +import { CoreEditorRichTextEditorComponent } from '@features/editor/components/rich-text-editor/rich-text-editor'; +import { AddonModForumSync, AddonModForumSyncProvider } from '@addons/mod/forum/services/sync'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { Translate } from '@singletons'; +import { CoreSync } from '@services/sync'; +import { AddonModForumDiscussionOptions, AddonModForumOffline } from '@addons/mod/forum/services/offline'; +import { CoreUtils } from '@services/utils/utils'; +import { AddonModForumHelper } from '@addons/mod/forum/services/helper'; +import { IonRefresher } from '@ionic/angular'; +import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; +import { CoreTextUtils } from '@services/utils/text'; +import { CanLeave } from '@guards/can-leave'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; + +type NewDiscussionData = { + subject: string; + message: string | null; // Null means empty or just white space. + postToAllGroups: boolean; + groupId: number; + subscribe: boolean; + pin: boolean; + files: FileEntry[]; +}; + +/** + * Page that displays the new discussion form. + */ +@Component({ + selector: 'page-addon-mod-forum-new-discussion', + templateUrl: 'new-discussion.html', +}) +export class AddonModForumNewDiscussionPage implements OnInit, OnDestroy, CanLeave { + + @ViewChild('newDiscFormEl') formElement!: ElementRef; + @ViewChild(CoreEditorRichTextEditorComponent) messageEditor!: CoreEditorRichTextEditorComponent; + + component = AddonModForumProvider.COMPONENT; + messageControl = new FormControl(); + groupsLoaded = false; + showGroups = false; + hasOffline = false; + canCreateAttachments = true; // Assume we can by default. + canPin = false; + forum!: AddonModForumData; + showForm = false; + groups: CoreGroup[] = []; + groupIds: number[] = []; + newDiscussion: NewDiscussionData = { + subject: '', + message: null, + postToAllGroups: false, + groupId: 0, + subscribe: true, + pin: false, + files: [], + }; + + advanced = false; // Display all form fields. + accessInfo: AddonModForumAccessInformation = {}; + + protected courseId!: number; + protected cmId!: number; + protected forumId!: number; + protected timeCreated!: number; + protected syncId!: string; + protected syncObserver?: CoreEventObserver; + protected isDestroyed = false; + protected originalData?: Partial; + protected forceLeave = false; + + constructor(@Optional() protected splitView: CoreSplitViewComponent) {} + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; + this.cmId = CoreNavigator.instance.getRouteNumberParam('cmId')!; + this.forumId = CoreNavigator.instance.getRouteNumberParam('forumId')!; + this.timeCreated = CoreNavigator.instance.getRouteNumberParam('timeCreated')!; + + this.fetchDiscussionData().finally(() => { + this.groupsLoaded = true; + }); + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + if (this.syncObserver) { + // Already setup. + return; + } + + // Refresh data if this discussion is synchronized automatically. + this.syncObserver = CoreEvents.on(AddonModForumSyncProvider.AUTO_SYNCED, data => { + if (data.forumId == this.forumId && data.userId == CoreSites.instance.getCurrentSiteUserId()) { + CoreDomUtils.instance.showAlertTranslated('core.notice', 'core.contenteditingsynced'); + this.returnToDiscussions(); + } + }, CoreSites.instance.getCurrentSiteId()); + } + + /** + * Fetch if forum uses groups and the groups it uses. + * + * @param refresh Whether we're refreshing data. + * @return Promise resolved when done. + */ + protected async fetchDiscussionData(refresh?: boolean): Promise { + try { + const mode = await CoreGroups.instance.getActivityGroupMode(this.cmId); + const promises: Promise[] = []; + + if (mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS) { + promises.push( + CoreGroups.instance + .getActivityAllowedGroups(this.cmId) + .then((result) => { + let promise; + if (mode === CoreGroupsProvider.VISIBLEGROUPS) { + // We need to check which of the returned groups the user can post to. + promise = this.validateVisibleGroups(result.groups); + } else { + // WS already filters groups, no need to do it ourselves. Add "All participants" if needed. + promise = this.addAllParticipantsOption(result.groups, true); + } + + // eslint-disable-next-line promise/no-nesting + return promise.then((forumGroups) => { + if (forumGroups.length > 0) { + this.groups = forumGroups; + this.groupIds = forumGroups.map((group) => group.id).filter((id) => id > 0); + // Do not override group id. + this.newDiscussion.groupId = this.newDiscussion.groupId || forumGroups[0].id; + this.showGroups = true; + if (this.groupIds.length <= 1) { + this.newDiscussion.postToAllGroups = false; + } + + return; + } else { + const message = mode === CoreGroupsProvider.SEPARATEGROUPS ? + 'addon.mod_forum.cannotadddiscussionall' : 'addon.mod_forum.cannotadddiscussion'; + + throw new Error(Translate.instant(message)); + } + }); + }), + ); + } else { + this.showGroups = false; + this.newDiscussion.postToAllGroups = false; + + // Use the canAddDiscussion WS to check if the user can add attachments and pin discussions. + promises.push( + CoreUtils.instance.ignoreErrors( + AddonModForum.instance + .canAddDiscussionToAll(this.forumId, { cmId: this.cmId }) + .then((response) => { + this.canPin = !!response.canpindiscussions; + this.canCreateAttachments = !!response.cancreateattachment; + + return; + }), + ), + ); + } + + // Get forum. + promises.push(AddonModForum.instance.getForum(this.courseId, this.cmId).then((forum) => this.forum = forum)); + + // Get access information. + promises.push( + AddonModForum.instance + .getAccessInformation(this.forumId, { cmId: this.cmId }) + .then((accessInfo) => this.accessInfo = accessInfo), + ); + + await Promise.all(promises); + + // If editing a discussion, get offline data. + if (this.timeCreated && !refresh) { + this.syncId = AddonModForumSync.instance.getForumSyncId(this.forumId); + + await AddonModForumSync.instance.waitForSync(this.syncId).then(() => { + // Do not block if the scope is already destroyed. + if (!this.isDestroyed) { + CoreSync.instance.blockOperation(AddonModForumProvider.COMPONENT, this.syncId); + } + + // eslint-disable-next-line promise/no-nesting + return AddonModForumOffline.instance + .getNewDiscussion(this.forumId, this.timeCreated) + .then(async (discussion) => { + this.hasOffline = true; + discussion.options = discussion.options || {}; + if (discussion.groupid == AddonModForumProvider.ALL_GROUPS) { + this.newDiscussion.groupId = this.groups[0].id; + this.newDiscussion.postToAllGroups = true; + } else { + this.newDiscussion.groupId = discussion.groupid; + this.newDiscussion.postToAllGroups = false; + } + this.newDiscussion.subject = discussion.subject; + this.newDiscussion.message = discussion.message; + this.newDiscussion.subscribe = !!discussion.options.discussionsubscribe; + this.newDiscussion.pin = !!discussion.options.discussionpinned; + this.messageControl.setValue(discussion.message); + + // Treat offline attachments if any. + if (typeof discussion.options.attachmentsid === 'object' && discussion.options.attachmentsid.offline) { + const files = await AddonModForumHelper.instance.getNewDiscussionStoredFiles( + this.forumId, + this.timeCreated, + ); + + this.newDiscussion.files = files; + } + + // Show advanced fields by default if any of them has not the default value. + if ( + !this.newDiscussion.subscribe || + this.newDiscussion.pin || + this.newDiscussion.files.length || + this.groups.length > 0 && this.newDiscussion.groupId != this.groups[0].id || + this.newDiscussion.postToAllGroups + ) { + this.advanced = true; + } + + return; + }); + }); + } + + if (!this.originalData) { + // Initialize original data. + this.originalData = { + subject: this.newDiscussion.subject, + message: this.newDiscussion.message, + files: this.newDiscussion.files.slice(), + }; + } + + this.showForm = true; + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_forum.errorgetgroups', true); + + this.showForm = false; + } + } + + /** + * Validate which of the groups returned by getActivityAllowedGroups in visible groups should be shown to post to. + * + * @param forumGroups Forum groups. + * @return Promise resolved with the list of groups. + */ + protected async validateVisibleGroups(forumGroups: CoreGroup[]): Promise { + let response: AddonModForumCanAddDiscussion; + + // We first check if the user can post to all the groups. + try { + response = await AddonModForum.instance.canAddDiscussionToAll(this.forumId, { cmId: this.cmId }); + } catch (error) { + // The call failed, let's assume he can't. + response = { + status: false, + canpindiscussions: false, + cancreateattachment: true, + }; + } + + this.canPin = !!response.canpindiscussions; + this.canCreateAttachments = !!response.cancreateattachment; + + // The user can post to all groups, add the "All participants" option and return them all. + if (response.status) { + return this.addAllParticipantsOption(forumGroups, false); + } + + // The user can't post to all groups, let's check which groups he can post to. + const promises: Promise[] = []; + const filtered: CoreGroup[] = []; + + forumGroups.forEach((group) => { + promises.push( + AddonModForum.instance + .canAddDiscussion(this.forumId, group.id, { cmId: this.cmId }) + + // The call failed, let's return true so the group is shown. + // If the user can't post to it an error will be shown when he tries to add the discussion. + .catch(() =>({ status: true })) + + .then((response) => { + if (response.status) { + filtered.push(group); + } + + return; + }), + ); + }); + + await Promise.all(promises); + + return filtered; + } + + /** + * Filter forum groups, returning only those that are inside user groups. + * + * @param forumGroups Forum groups. + * @param userGroups User groups. + * @return Filtered groups. + */ + protected filterGroups(forumGroups: CoreGroup[], userGroups: CoreGroup[]): CoreGroup[] { + const userGroupsIds = userGroups.map(group => group.id); + + return forumGroups.filter(forumGroup => userGroupsIds.indexOf(forumGroup.id) > -1); + } + + /** + * Add the "All participants" option to a list of groups if the user can add a discussion to all participants. + * + * @param groups Groups. + * @param check True to check if the user can add a discussion to all participants. + * @return Promise resolved with the list of groups. + */ + protected addAllParticipantsOption(groups: CoreGroup[], check: boolean): Promise { + if (!AddonModForum.instance.isAllParticipantsFixed()) { + // All participants has a bug, don't add it. + return Promise.resolve(groups); + } + + let promise; + + if (check) { + // We need to check if the user can add a discussion to all participants. + promise = AddonModForum.instance.canAddDiscussionToAll(this.forumId, { cmId: this.cmId }).then((response) => { + this.canPin = !!response.canpindiscussions; + this.canCreateAttachments = !!response.cancreateattachment; + + return response.status; + }).catch(() => + // The call failed, let's assume he can't. + false); + } else { + // No need to check, assume the user can. + promise = Promise.resolve(true); + } + + return promise.then((canAdd) => { + if (canAdd) { + groups.unshift({ + courseid: this.courseId, + id: AddonModForumProvider.ALL_PARTICIPANTS, + name: Translate.instant('core.allparticipants'), + }); + } + + return groups; + }); + } + + /** + * Pull to refresh. + * + * @param refresher Refresher. + */ + refreshGroups(refresher?: IonRefresher): void { + const promises = [ + CoreGroups.instance.invalidateActivityGroupMode(this.cmId), + CoreGroups.instance.invalidateActivityAllowedGroups(this.cmId), + AddonModForum.instance.invalidateCanAddDiscussion(this.forumId), + ]; + + Promise.all(promises).finally(() => { + this.fetchDiscussionData(true).finally(() => { + refresher?.complete(); + }); + }); + } + + /** + * Convenience function to update or return to discussions depending on device. + * + * @param discussionIds Ids of the new discussions. + * @param discTimecreated The time created of the discussion (if offline). + */ + protected returnToDiscussions(discussionIds?: number[] | null, discTimecreated?: number): void { + this.forceLeave = true; + + // Delete the local files from the tmp folder. + CoreFileUploader.instance.clearTmpFiles(this.newDiscussion.files); + + CoreEvents.trigger( + AddonModForumProvider.NEW_DISCUSSION_EVENT, + { + forumId: this.forumId, + cmId: this.cmId, + discussionIds: discussionIds, + discTimecreated: discTimecreated, + }, + CoreSites.instance.getCurrentSiteId(), + ); + + if (this.splitView?.outletActivated) { + // Empty form. + this.hasOffline = false; + this.newDiscussion.subject = ''; + this.newDiscussion.message = null; + this.newDiscussion.files = []; + this.newDiscussion.postToAllGroups = false; + this.messageEditor.clearText(); + this.originalData = CoreUtils.instance.clone(this.newDiscussion); + } else { + CoreNavigator.instance.back(); + } + } + + /** + * Message changed. + * + * @param text The new text. + */ + onMessageChange(text: string): void { + this.newDiscussion.message = text; + } + + /** + * Add a new discussion. + */ + async add(): Promise { + const forumName = this.forum.name; + const subject = this.newDiscussion.subject; + let message = this.newDiscussion.message || ''; + const pin = this.newDiscussion.pin; + const attachments = this.newDiscussion.files; + const discTimecreated = this.timeCreated || Date.now(); + const options: AddonModForumDiscussionOptions = { + discussionsubscribe: !!this.newDiscussion.subscribe, + }; + + if (!subject) { + CoreDomUtils.instance.showErrorModal('addon.mod_forum.erroremptysubject', true); + + return; + } + if (!message) { + CoreDomUtils.instance.showErrorModal('addon.mod_forum.erroremptymessage', true); + + return; + } + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + // Add some HTML to the message if needed. + message = CoreTextUtils.instance.formatHtmlLines(message); + + if (pin) { + options.discussionpinned = true; + } + + const groupIds = this.newDiscussion.postToAllGroups ? this.groupIds : [this.newDiscussion.groupId]; + + try { + const discussionIds = await AddonModForumHelper.instance.addNewDiscussion( + this.forumId, + forumName, + this.courseId, + subject, + message, + attachments, + options, + groupIds, + discTimecreated, + ); + + if (discussionIds) { + // Data sent to server, delete stored files (if any). + AddonModForumHelper.instance.deleteNewDiscussionStoredFiles(this.forumId, discTimecreated); + + CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'forum' }); + } + + if (discussionIds && discussionIds.length < groupIds.length) { + // Some discussions could not be created. + CoreDomUtils.instance.showErrorModalDefault(null, 'addon.mod_forum.errorposttoallgroups', true); + } + + CoreDomUtils.instance.triggerFormSubmittedEvent( + this.formElement, + !!discussionIds, + CoreSites.instance.getCurrentSiteId(), + ); + + this.returnToDiscussions(discussionIds, discTimecreated); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_forum.cannotcreatediscussion', true); + } finally { + modal.dismiss(); + } + } + + /** + * Discard an offline saved discussion. + */ + async discard(): Promise { + try { + await CoreDomUtils.instance.showConfirm(Translate.instant('core.areyousure')); + + const promises: Promise[] = []; + + promises.push(AddonModForumOffline.instance.deleteNewDiscussion(this.forumId, this.timeCreated)); + promises.push( + CoreUtils.instance.ignoreErrors( + AddonModForumHelper.instance.deleteNewDiscussionStoredFiles(this.forumId, this.timeCreated), + ), + ); + + await Promise.all(promises); + + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId()); + + this.returnToDiscussions(); + } catch (error) { + // Cancelled. + } + } + + /** + * Show or hide advanced form fields. + */ + toggleAdvanced(): void { + this.advanced = !this.advanced; + } + + /** + * Check if we can leave the page or not. + * + * @return Resolved if we can leave it, rejected if not. + */ + async canLeave(): Promise { + if (this.forceLeave) { + return true; + } + + if (AddonModForumHelper.instance.hasPostDataChanged(this.newDiscussion, this.originalData)) { + // Show confirmation if some data has been modified. + await CoreDomUtils.instance.showConfirm(Translate.instant('core.confirmcanceledit')); + } + + // Delete the local files from the tmp folder. + CoreFileUploader.instance.clearTmpFiles(this.newDiscussion.files); + + if (this.formElement) { + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId()); + } + + return true; + } + + /** + * Runs when the page is about to leave and no longer be the active page. + */ + ionViewWillLeave(): void { + this.syncObserver && this.syncObserver.off(); + delete this.syncObserver; + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + if (this.syncId) { + CoreSync.instance.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId); + } + this.isDestroyed = true; + } + +} diff --git a/src/addons/mod/forum/services/database/offline.ts b/src/addons/mod/forum/services/database/offline.ts new file mode 100644 index 000000000..ce28a9776 --- /dev/null +++ b/src/addons/mod/forum/services/database/offline.ts @@ -0,0 +1,124 @@ +// (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'; +import { AddonModForumOfflineDiscussion, AddonModForumOfflineReply } from '../offline'; + +/** + * 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'], + }, + ], +}; + +export type AddonModForumOfflineDiscussionDBRecord = Omit & { + options: string; +}; + +export type AddonModForumOfflineReplyDBRecord = Omit & { + options: string; +}; diff --git a/src/addons/mod/forum/services/forum.ts b/src/addons/mod/forum/services/forum.ts index 9a30af0cb..d3eabeaaf 100644 --- a/src/addons/mod/forum/services/forum.ts +++ b/src/addons/mod/forum/services/forum.ts @@ -14,12 +14,38 @@ 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'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreFileEntry } from '@features/fileuploader/services/fileuploader'; +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, AddonModForumOfflineDiscussion, AddonModForumReplyOptions } from './offline'; const ROOT_CACHE_KEY = 'mmaModForum:'; +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [AddonModForumProvider.NEW_DISCUSSION_EVENT]: AddonModForumNewDiscussionData; + [AddonModForumProvider.REPLY_DISCUSSION_EVENT]: AddonModForumReplyDiscussionData; + [AddonModForumProvider.CHANGE_DISCUSSION_EVENT]: AddonModForumChangeDiscussionData; + [AddonModForumProvider.MARK_READ_EVENT]: AddonModForumMarkReadData; + } + +} + /** * Service that provides some features for forums. * @@ -29,6 +55,77 @@ const ROOT_CACHE_KEY = 'mmaModForum:'; 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 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. @@ -40,6 +137,273 @@ export class AddonModForumProvider { 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. + 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, + }; + + return 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: AddonModForumDiscussion[]): Promise; + formatDiscussionsGroups(cmId: number, discussions: AddonModForumOfflineDiscussion[]): Promise; + formatDiscussionsGroups( + cmId: number, + discussions: AddonModForumDiscussion[] | AddonModForumOfflineDiscussion[], + ): 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. * @@ -63,6 +427,552 @@ export class AddonModForumProvider { 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 this.translateWSPost(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: AddonModForumLegacyPost[]): AddonModForumPost[] => posts.map((post) => { + const newPost: AddonModForumPost = { + 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 ('groupname' in post && typeof post['groupname'] === 'string') { + 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: AddonModForumWSPost[]): AddonModForumPost[] => { + posts.forEach(post => this.translateWSPost(post)); + + return posts as unknown as AddonModForumPost[]; + }; + + 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'); + } + + const posts = isGetDiscussionPostsAvailable + ? translateTagsFormatToLegacy((response as AddonModForumGetDiscussionPostsWSResponse).posts) + : translateLegacyPostsFormat((response as AddonModForumGetForumDiscussionPostsWSResponse).posts); + + this.storeUserData(posts); + + return { + ...response, + posts, + }; + } + + /** + * 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'); + } + + 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 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. * @@ -70,7 +980,361 @@ export class AddonModForumProvider { * @return Promise resolved when the data is invalidated. */ async invalidateForumData(courseId: number): Promise { - await CoreSites.instance.getCurrentSite()?.invalidateWsCacheForKey(this.getForumDataCacheKey(courseId)); + 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 as unknown as AddonModForumAddDiscussionPostWSOptionsObject, + 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. + throw 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, + + options: CoreUtils.instance.objectToArrayOfObjects< + AddonModForumAddDiscussionPostWSOptionsArray[0], + AddonModForumAddDiscussionPostWSOptionsObject + >( + 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, + }; + + return 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, + }; + + 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: AddonModForumPost[] | AddonModForumDiscussion[]): void { + const users = {}; + + list.forEach((entry: AddonModForumPost | AddonModForumDiscussion) => { + if ('author' in entry) { + 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 ('userid' in entry && !isNaN(userId) && !users[userId]) { + users[userId] = { + id: userId, + fullname: entry.userfullname, + profileimageurl: entry.userpictureurl, + }; + } + const userModified = parseInt(entry['usermodified']); + if ('usermodified' in entry && !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, + + options: CoreUtils.instance.objectToArrayOfObjects< + AddonModForumUpdateDiscussionPostWSOptionsArray[0], + AddonModForumUpdateDiscussionPostWSOptionsObject + >( + options || {}, + 'name', + 'value', + ), + }; + + const response = await site.write('mod_forum_update_discussion_post', params); + + return response && response.status; + } + + /** + * 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. + * + * @param post Post returned by the new WS. + * @return Post using the same format as other WebServices. + */ + protected translateWSPost(post: AddonModForumWSPost): AddonModForumPost { + (post as unknown as AddonModForumPost).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 post as unknown as AddonModForumPost; } } @@ -126,7 +1390,821 @@ export type AddonModForumData = { 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. + groupname?: string; // Group name (not returned by WS). + 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 | boolean; // 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. +}; + +/** + * Forum post data returned by web services. + */ +export type AddonModForumPost = { + id: number; // Id. + subject: string; // Subject. + replysubject?: string; // Replysubject. + message: string; // Message. + author: { + id?: number; // Id. + fullname?: string; // Fullname. + urls?: { + profileimage?: string; // The URL for the use profile image. + }; + groups?: { // Groups. + name: string; // Name. + }[]; + }; + discussionid: number; // Discussionid. + hasparent: boolean; // Hasparent. + parentid?: number; // Parentid. + timecreated: number | false; // Timecreated. + unread?: boolean; // Unread. + isprivatereply: boolean; // Isprivatereply. + capabilities: { + reply: boolean; // Whether the user can reply to the post. + 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. + 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. + }; + attachment?: 0 | 1; + attachments?: (CoreFileEntry | AddonModForumWSPostAttachment)[]; + messageinlinefiles?: CoreWSExternalFile[]; + haswordcount?: boolean; // Haswordcount. + wordcount?: number; // Wordcount. + 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. + }[]; +}; + +/** + * 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 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. +}; + +/** + * Reply info. + */ +export type AddonModForumReply = { + id: number; + subject: string; + message: string; + files: (CoreFileEntry | AddonModForumWSPostAttachment)[]; +}; + +/** + * 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; +}; + +/** + * Forum post attachement data returned by web services. + */ +export type AddonModForumWSPostAttachment = { + 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. + }; +}; + +/** + * Forum post data returned by web services. + */ +export type AddonModForumWSPost = { + 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: AddonModForumWSPostAttachment[]; // Attachments. + 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. + }; +}; + +/** + * 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: AddonModForumWSPost; + 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: AddonModForumWSPost; + 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: AddonModForumWSPost[]; + 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; + +/** + * Data passed to NEW_DISCUSSION_EVENT event. + */ +export type AddonModForumNewDiscussionData = { + forumId: number; + cmId: number; + discussionIds?: number[] | null; + discTimecreated?: number; +}; + +/** + * Data passed to REPLY_DISCUSSION_EVENT event. + */ +export type AddonModForumReplyDiscussionData = { + forumId: number; + discussionId: number; + cmId: number; +}; + +/** + * Data passed to CHANGE_DISCUSSION_EVENT event. + */ +export type AddonModForumChangeDiscussionData = { + forumId: number; + discussionId: number; + cmId: number; + deleted?: boolean; + post?: AddonModForumPost; + locked?: boolean; + pinned?: boolean; + starred?: boolean; +}; + +/** + * Data passed to MARK_READ_EVENT event. + */ +export type AddonModForumMarkReadData = { + courseId: number; + moduleId: number; +}; diff --git a/src/addons/mod/forum/services/handlers/discussion-link.ts b/src/addons/mod/forum/services/handlers/discussion-link.ts new file mode 100644 index 000000000..bc4dc5d2a --- /dev/null +++ b/src/addons/mod/forum/services/handlers/discussion-link.ts @@ -0,0 +1,96 @@ +// (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 { Params } from '@angular/router'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { AddonModForumModuleHandlerService } from './module'; + +/** + * Handler to treat links to forum review. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModForumDiscussionLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModForumDiscussionLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModForum'; + pattern = /\/mod\/forum\/discuss\.php.*([&?]d=\d+)/; + + /** + * Get the list of actions for a link (url). + * + * @param siteIds List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @param data Extra data to handle the URL. + * @return List of (or promise resolved with list of) actions. + */ + getActions( + siteIds: string[], + url: string, + params: Params, + courseId?: number, + data?: any, + ): CoreContentLinksAction[] | Promise { + data = data || {}; + + // On 3.6 downwards, it will open the discussion but without knowing the lock status of the discussion. + // However canreply will be false. + + return [{ + action: (siteId): void => { + const discussionId = parseInt(params.d, 10); + const pageParams: Params = { + forumId: data.instance && parseInt(data.instance, 10), + cmId: data.cmid && parseInt(data.cmid, 10), + courseId: courseId || parseInt(params.courseid, 10) || parseInt(params.cid, 10), + }; + + if (data.postid || params.urlHash) { + pageParams.postId = parseInt(data.postid || params.urlHash.replace('p', '')); + } + + if (params.parent) { + pageParams.parent = parseInt(params.parent); + } + + CoreNavigator.instance.navigateToSitePath( + `${AddonModForumModuleHandlerService.PAGE_NAME}/discussion/${discussionId}`, + { siteId, params: pageParams }, + ); + }, + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return Whether the handler is enabled for the URL and site. + */ + async isEnabled(): Promise { + return true; + } + +} + +export class AddonModForumDiscussionLinkHandler extends makeSingleton(AddonModForumDiscussionLinkHandlerService) {} diff --git a/src/addons/mod/forum/services/handlers/index-link.ts b/src/addons/mod/forum/services/handlers/index-link.ts new file mode 100644 index 000000000..b15d728bb --- /dev/null +++ b/src/addons/mod/forum/services/handlers/index-link.ts @@ -0,0 +1,33 @@ +// (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 { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to forum index. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModForumIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler { + + name = 'AddonModForumIndexLinkHandler'; + + constructor() { + super('AddonModForum', 'forum', 'f'); + } + +} + +export class AddonModForumIndexLinkHandler extends makeSingleton(AddonModForumIndexLinkHandlerService) {} diff --git a/src/addons/mod/forum/services/handlers/list-link.ts b/src/addons/mod/forum/services/handlers/list-link.ts new file mode 100644 index 000000000..6338c88e0 --- /dev/null +++ b/src/addons/mod/forum/services/handlers/list-link.ts @@ -0,0 +1,33 @@ +// (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 { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to forum list page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModForumListLinkHandlerService extends CoreContentLinksModuleListHandler { + + name = 'AddonModForumListLinkHandler'; + + constructor() { + super('AddonModForum', 'forum'); + } + +} + +export class AddonModForumListLinkHandler extends makeSingleton(AddonModForumListLinkHandlerService) {} 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..8df24e921 --- /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'; +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: 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 => { + 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/handlers/post-link.ts b/src/addons/mod/forum/services/handlers/post-link.ts new file mode 100644 index 000000000..4a4e9bf61 --- /dev/null +++ b/src/addons/mod/forum/services/handlers/post-link.ts @@ -0,0 +1,86 @@ +// (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 { Params } from '@angular/router'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { makeSingleton } from '@singletons'; +import { AddonModForumModuleHandlerService } from './module'; + +/** + * Content links handler for forum new discussion. + * Match mod/forum/post.php?forum=6 with a valid data. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModForumPostLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModForumPostLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModForum'; + pattern = /\/mod\/forum\/post\.php.*([?&](forum)=\d+)/; + + /** + * Get the list of actions for a link (url). + * + * @param siteIds List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return List of (or promise resolved with list of) actions. + */ + getActions( + siteIds: string[], + url: string, + params: Params, + ): CoreContentLinksAction[] | Promise { + return [{ + action: async (siteId): Promise => { + const modal = await CoreDomUtils.instance.showModalLoading(); + const forumId = parseInt(params.forum, 10); + + try { + const module = await CoreCourse.instance.getModuleBasicInfoByInstance(forumId, 'forum', siteId); + + await CoreNavigator.instance.navigateToSitePath( + `${AddonModForumModuleHandlerService.PAGE_NAME}/${module.course}/${module.id}/new/0`, + { siteId, params: { forumId: module.instance } }, + ); + } finally { + // Just in case. In fact we need to dismiss the modal before showing a toast or error message. + modal.dismiss(); + } + }, + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return Whether the handler is enabled for the URL and site. + */ + async isEnabled(siteId: string, url: string, params: Params): Promise { + return typeof params.forum != 'undefined'; + } + +} + +export class AddonModForumPostLinkHandler extends makeSingleton(AddonModForumPostLinkHandlerService) {} diff --git a/src/addons/mod/forum/services/handlers/prefetch.ts b/src/addons/mod/forum/services/handlers/prefetch.ts new file mode 100644 index 000000000..858856133 --- /dev/null +++ b/src/addons/mod/forum/services/handlers/prefetch.ts @@ -0,0 +1,353 @@ +// (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 { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler'; +import { AddonModForum, AddonModForumData, AddonModForumPost, AddonModForumProvider } from '../forum'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreFilepool } from '@services/filepool'; +import { CoreWSExternalFile } from '@services/ws'; +import { CoreCourse, CoreCourseAnyModuleData, CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreUser } from '@features/user/services/user'; +import { CoreGroups, CoreGroupsProvider } from '@services/groups'; +import { CoreUtils } from '@services/utils/utils'; +import { AddonModForumSync } from '../sync'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to prefetch forums. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModForumPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase { + + name = 'AddonModForum'; + modName = 'forum'; + component = AddonModForumProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^discussions$/; + + /** + * Get list of files. If not defined, we'll assume they're in module.contents. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @return Promise resolved with the list of files. + */ + async getFiles(module: CoreCourseAnyModuleData, courseId: number): Promise { + try { + const forum = await AddonModForum.instance.getForum(courseId, module.id); + + const files = this.getIntroFilesFromInstance(module, forum); + + // Get posts. + const posts = await this.getPostsForPrefetch(forum, { cmId: module.id }); + + // Add posts attachments and embedded files. + files.concat(this.getPostsFiles(posts)); + + return files; + } catch (error) { + // Forum not found, return empty list. + return []; + } + } + + /** + * Given a list of forum posts, return a list with all the files (attachments and embedded files). + * + * @param posts Forum posts. + * @return Files. + */ + protected getPostsFiles(posts: AddonModForumPost[]): CoreWSExternalFile[] { + let files: CoreWSExternalFile[] = []; + const getInlineFiles = CoreSites.instance.getCurrentSite()?.isVersionGreaterEqualThan('3.2'); + + posts.forEach((post) => { + if (post.attachments && post.attachments.length) { + files = files.concat(post.attachments as CoreWSExternalFile[]); + } + if (getInlineFiles && post.messageinlinefiles && post.messageinlinefiles.length) { + files = files.concat(post.messageinlinefiles); + } else if (post.message && !getInlineFiles) { + files = files.concat(CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(post.message)); + } + }); + + return files; + } + + /** + * Get the posts to be prefetched. + * + * @param forum Forum instance. + * @param options Other options. + * @return Promise resolved with array of posts. + */ + protected getPostsForPrefetch( + forum: AddonModForumData, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const promises = AddonModForum.instance.getAvailableSortOrders().map((sortOrder) => { + // Get discussions in first 2 pages. + const discussionsOptions = { + sortOrder: sortOrder.value, + numPages: 2, + ...options, // Include all options. + }; + + return AddonModForum.instance.getDiscussionsInPages(forum.id, discussionsOptions).then((response) => { + if (response.error) { + throw new Error('Failed getting discussions'); + } + + const promises: Promise<{ posts: AddonModForumPost[] }>[] = []; + + response.discussions.forEach((discussion) => { + promises.push(AddonModForum.instance.getDiscussionPosts(discussion.discussion, options)); + }); + + return Promise.all(promises); + }); + }); + + return Promise.all(promises).then((results) => { + // Each order has returned its own list of posts. Merge all the lists, preventing duplicates. + const posts: AddonModForumPost[] = []; + const postIds = {}; // To make the array unique. + + results.forEach((orderResults) => { + orderResults.forEach((orderResult) => { + orderResult.posts.forEach((post) => { + if (!postIds[post.id]) { + postIds[post.id] = true; + posts.push(post); + } + }); + }); + }); + + return posts; + }); + } + + /** + * Invalidate the prefetched content. + * + * @param moduleId The module ID. + * @param courseId The course ID the module belongs to. + * @return Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return AddonModForum.instance.invalidateContent(moduleId, courseId); + } + + /** + * Invalidate WS calls needed to determine module status (usually, to check if module is downloadable). + * It doesn't need to invalidate check updates. It should NOT invalidate files nor all the prefetched data. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when invalidated. + */ + async invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise { + // Invalidate forum data to recalculate unread message count badge. + const promises: Promise[] = []; + + promises.push(AddonModForum.instance.invalidateForumData(courseId)); + promises.push(CoreCourse.instance.invalidateModule(module.id)); + + await Promise.all(promises); + } + + /** + * Prefetch a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when done. + */ + prefetch(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean): Promise { + return this.prefetchPackage(module, courseId, this.prefetchForum.bind(this, module, courseId, single)); + } + + /** + * Prefetch a forum. + * + * @param module The module object returned by WS. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected async prefetchForum( + module: CoreCourseAnyModuleData, + courseId: number, + single: boolean, + siteId: string, + ): Promise { + const commonOptions = { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + const modOptions = { + cmId: module.id, + ...commonOptions, // Include all common options. + }; + + // Get the forum data. + const forum = await AddonModForum.instance.getForum(courseId, module.id, commonOptions); + const promises: Promise[] = []; + + // Prefetch the posts. + promises.push(this.getPostsForPrefetch(forum, modOptions).then((posts) => { + const promises: Promise[] = []; + + const files = this.getIntroFilesFromInstance(module, forum).concat(this.getPostsFiles(posts)); + promises.push(CoreFilepool.instance.addFilesToQueue(siteId, files, this.component, module.id)); + + // Prefetch groups data. + promises.push(this.prefetchGroupsInfo(forum, courseId, !!forum.cancreatediscussions, siteId)); + + // Prefetch avatars. + promises.push(CoreUser.instance.prefetchUserAvatars(posts, 'userpictureurl', siteId)); + + return Promise.all(promises); + })); + + // Prefetch access information. + promises.push(AddonModForum.instance.getAccessInformation(forum.id, modOptions)); + + // Prefetch sort order preference. + if (AddonModForum.instance.isDiscussionListSortingAvailable()) { + promises.push(CoreUser.instance.getUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER, siteId)); + } + + await Promise.all(promises); + } + + /** + * Prefetch groups info for a forum. + * + * @param module The module object returned by WS. + * @param courseI Course ID the module belongs to. + * @param canCreateDiscussions Whether the user can create discussions in the forum. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when group data has been prefetched. + */ + protected async prefetchGroupsInfo( + forum: AddonModForumData, + courseId: number, + canCreateDiscussions: boolean, + siteId?: string, + ): Promise { + const options = { + cmId: forum.cmid, + siteId, + }; + + // Check group mode. + try { + const mode = await CoreGroups.instance.getActivityGroupMode(forum.cmid, siteId); + + if (mode !== CoreGroupsProvider.SEPARATEGROUPS && mode !== CoreGroupsProvider.VISIBLEGROUPS) { + // Activity doesn't use groups. Prefetch canAddDiscussionToAll to determine if user can pin/attach. + await CoreUtils.instance.ignoreErrors(AddonModForum.instance.canAddDiscussionToAll(forum.id, options)); + + return; + } + + // Activity uses groups, prefetch allowed groups. + const result = await CoreGroups.instance.getActivityAllowedGroups(forum.cmid, undefined, siteId); + if (mode === CoreGroupsProvider.SEPARATEGROUPS) { + // Groups are already filtered by WS. Prefetch canAddDiscussionToAll to determine if user can pin/attach. + await CoreUtils.instance.ignoreErrors(AddonModForum.instance.canAddDiscussionToAll(forum.id, options)); + + return; + } + + if (canCreateDiscussions) { + // Prefetch data to check the visible groups when creating discussions. + const response = await CoreUtils.instance.ignoreErrors( + AddonModForum.instance.canAddDiscussionToAll(forum.id, options), + { status: false }, + ); + + if (response.status) { + // User can post to all groups, nothing else to prefetch. + return; + } + + // The user can't post to all groups, let's check which groups he can post to. + await Promise.all( + result.groups.map( + async (group) => CoreUtils.instance.ignoreErrors( + AddonModForum.instance.canAddDiscussion(forum.id, group.id, options), + ), + ), + ); + } + } catch (error) { + // Ignore errors if cannot create discussions. + if (canCreateDiscussions) { + throw error; + } + } + } + + /** + * Sync a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async sync( + module: CoreCourseAnyModuleData, + courseId: number, + siteId?: string, + ): Promise { + const promises: Promise[] = []; + + promises.push(AddonModForumSync.instance.syncForumDiscussions(module.instance!, undefined, siteId)); + promises.push(AddonModForumSync.instance.syncForumReplies(module.instance!, undefined, siteId)); + promises.push(AddonModForumSync.instance.syncRatings(module.id, undefined, true, siteId)); + + const results = await Promise.all(promises); + + return results.reduce( + (a, b) => ({ + updated: a.updated || b.updated, + warnings: (a.warnings || []).concat(b.warnings || []), + }), + { + updated: false, + warnings: [], + }, + ); + } + +} + +export class AddonModForumPrefetchHandler extends makeSingleton(AddonModForumPrefetchHandlerService) {} + +/** + * Data returned by a forum sync. + */ +export type AddonModForumSyncResult = { + warnings: string[]; // List of warnings. + updated: boolean; // Whether some data was sent to the server or offline data was updated. +}; diff --git a/src/addons/mod/forum/services/handlers/push-click.ts b/src/addons/mod/forum/services/handlers/push-click.ts new file mode 100644 index 000000000..05e3f1dfc --- /dev/null +++ b/src/addons/mod/forum/services/handlers/push-click.ts @@ -0,0 +1,88 @@ +// (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 { Params } from '@angular/router'; + +import { AddonModForum } from '@addons/mod/forum/services/forum'; +import { CoreNavigator } from '@services/navigator'; +import { CorePushNotificationsClickHandler } from '@features/pushnotifications/services/push-delegate'; +import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; + +import { AddonModForumModuleHandlerService } from './module'; + +/** + * Handler for forum push notifications clicks. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModForumPushClickHandlerService implements CorePushNotificationsClickHandler { + + name = 'AddonModForumPushClickHandler'; + priority = 200; + featureName = 'CoreCourseModuleDelegate_AddonModForum'; + + /** + * Check if a notification click is handled by this handler. + * + * @param notification The notification to check. + * @return Whether the notification click is handled by this handler + */ + async handles(notification: NotificationData): Promise { + return CoreUtils.instance.isTrueOrOne(notification.notif) + && notification.moodlecomponent == 'mod_forum' + && notification.name == 'posts'; + } + + /** + * Handle the notification click. + * + * @param notification The notification to check. + * @return Promise resolved when done. + */ + async handleClick(notification: NotificationData): Promise { + const contextUrlParams = CoreUrlUtils.instance.extractUrlParams(notification.contexturl); + const data = notification.customdata || {}; + const courseId = Number(notification.courseid); + const discussionId = Number(contextUrlParams.d || data.discussionid); + const cmId = Number(data.cmid); + const pageParams: Params = { + forumId: Number(data.instance), + }; + + if (data.postid || contextUrlParams.urlHash) { + pageParams.postId = Number(data.postid || contextUrlParams.urlHash.replace('p', '')); + } + + await CoreUtils.instance.ignoreErrors( + AddonModForum.instance.invalidateDiscussionPosts(pageParams.discussionId, undefined, notification.site), + ); + + await CoreNavigator.instance.navigateToSitePath( + `${AddonModForumModuleHandlerService.PAGE_NAME}/${courseId}/${cmId}/${discussionId}`, + { siteId: notification.site, params: pageParams }, + ); + } + +} + +export class AddonModForumPushClickHandler extends makeSingleton(AddonModForumPushClickHandlerService) {} + +type NotificationData = CorePushNotificationsNotificationBasicData & { + courseid: number; + discussionid: number; + contexturl: string; +}; diff --git a/src/addons/mod/forum/services/handlers/sync-cron.ts b/src/addons/mod/forum/services/handlers/sync-cron.ts new file mode 100644 index 000000000..86f6aff10 --- /dev/null +++ b/src/addons/mod/forum/services/handlers/sync-cron.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 { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { AddonModForumSync } from '../sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModForumSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonModForumSyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonModForumSync.instance.syncAllForums(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonModForumSync.instance.syncInterval; + } + +} + +export class AddonModForumSyncCronHandler extends makeSingleton(AddonModForumSyncCronHandlerService) {} diff --git a/src/addons/mod/forum/services/handlers/tag-area.ts b/src/addons/mod/forum/services/handlers/tag-area.ts new file mode 100644 index 000000000..f93819405 --- /dev/null +++ b/src/addons/mod/forum/services/handlers/tag-area.ts @@ -0,0 +1,62 @@ +// (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 { CoreTagAreaHandler } from '@features/tag/services/tag-area-delegate'; +import { CoreTagFeedComponent } from '@features/tag/components/feed/feed'; +import { CoreTagHelper, CoreTagFeedElement } from '@features/tag/services/tag-helper'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support tags. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModForumTagAreaHandlerService implements CoreTagAreaHandler { + + name = 'AddonModForumTagAreaHandler'; + type = 'mod_forum/forum_posts'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Parses the rendered content of a tag index and returns the items. + * + * @param content Rendered content. + * @return Area items (or promise resolved with the items). + */ + parseContent(content: string): CoreTagFeedElement[] { + return CoreTagHelper.instance.parseFeedContent(content); + } + + /** + * Get the component to use to display items. + * + * @param injector Injector. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type | Promise> { + return CoreTagFeedComponent; + } + +} + +export class AddonModForumTagAreaHandler extends makeSingleton(AddonModForumTagAreaHandlerService) {} diff --git a/src/addons/mod/forum/services/helper.ts b/src/addons/mod/forum/services/helper.ts new file mode 100644 index 000000000..25266f75b --- /dev/null +++ b/src/addons/mod/forum/services/helper.ts @@ -0,0 +1,520 @@ +// (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 { FileEntry } from '@ionic-native/file/ngx'; +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, + AddonModForumAddDiscussionWSOptionsObject, + AddonModForumData, + AddonModForumDiscussion, + AddonModForumPost, + AddonModForumProvider, +} from './forum'; +import { AddonModForumDiscussionOptions, AddonModForumOffline, AddonModForumOfflineReply } from './offline'; + +/** + * 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?: CoreFileEntry[], + options?: AddonModForumDiscussionOptions, + 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: CoreFileUploaderStoreFilesResult; + + // 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) { + 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 (groupOptions && attachmentsIds[index]) { + groupOptions.attachmentsid = attachmentsIds[index]; + } + + try { + const discussionId = await AddonModForum.instance.addNewDiscussionOnline( + forumId, + subject, + message, + groupOptions as unknown as AddonModForumAddDiscussionWSOptionsObject, + 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: AddonModForumOfflineReply, siteId?: string): Promise { + const reply: AddonModForumPost = { + 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?.private, + }; + const promises: Promise[] = []; + + // Treat attachments if any. + if (offlineReply.options && offlineReply.options.attachmentsid) { + const attachments = offlineReply.options.attachmentsid; + + reply.attachments = typeof attachments === 'object' && 'online' in attachments ? attachments.online : []; + + if (typeof attachments === 'object' && attachments.offline) { + promises.push( + this + .getReplyStoredFiles(offlineReply.forumid, reply.parentid!, siteId, offlineReply.userid) + .then(files => { + reply.attachments = reply.attachments!.concat(files as unknown as []); + + return; + }), + ); + } + } + + // 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: AddonModForumData): boolean { + const now = Date.now() / 1000; + + return !!forum.cutoffdate && 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.ts b/src/addons/mod/forum/services/offline.ts new file mode 100644 index 000000000..8ece6b1b2 --- /dev/null +++ b/src/addons/mod/forum/services/offline.ts @@ -0,0 +1,444 @@ +// (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 { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { CoreFile } from '@services/file'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { makeSingleton } from '@singletons'; +import { AddonModForumProvider } from './forum'; +import { + AddonModForumOfflineDiscussionDBRecord, + AddonModForumOfflineReplyDBRecord, + DISCUSSIONS_TABLE, + REPLIES_TABLE, +} from './database/offline'; + +/** + * 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: AddonModForumOfflineDiscussionDBRecord = { + 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: AddonModForumOfflineReplyDBRecord = { + 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 = { + attachmentsid?: number | CoreFileUploaderStoreFilesResult; + discussionsubscribe?: boolean; + discussionpinned?: boolean; +}; + +export type AddonModForumReplyOptions = { + private?: boolean; + attachmentsid?: number | CoreFileUploaderStoreFilesResult; +}; + +export type AddonModForumOfflineDiscussion = { + forumid: number; + name: string; + courseid: number; + subject: string; + message: string; + options: AddonModForumDiscussionOptions; + groupid: number; + groupname?: string; + 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; +}; diff --git a/src/addons/mod/forum/services/sync.ts b/src/addons/mod/forum/services/sync.ts new file mode 100644 index 000000000..1f99f2ec2 --- /dev/null +++ b/src/addons/mod/forum/services/sync.ts @@ -0,0 +1,655 @@ +// (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 { makeSingleton, Translate } from '@singletons'; +import { CoreArray } from '@singletons/array'; +import { CoreEvents, CoreEventSiteData } from '@singletons/events'; +import { + AddonModForum, + AddonModForumAddDiscussionPostWSOptionsObject, + AddonModForumAddDiscussionWSOptionsObject, + AddonModForumProvider, +} from './forum'; +import { AddonModForumHelper } from './helper'; +import { AddonModForumOffline, AddonModForumOfflineDiscussion, AddonModForumOfflineReply } from './offline'; + +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [AddonModForumSyncProvider.AUTO_SYNCED]: AddonModForumAutoSyncData; + [AddonModForumSyncProvider.MANUAL_SYNCED]: AddonModForumManualSyncData; + } + +} + +/** + * 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 as unknown as AddonModForumAddDiscussionWSOptionsObject, + 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<{ + updated: boolean; + warnings: string[]; + }> { + // @todo + + return { updated: true, warnings: [] }; + } + + /** + * 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 as unknown as AddonModForumAddDiscussionPostWSOptionsObject, + 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 isDiscussion 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: AddonModForumOfflineDiscussion | AddonModForumOfflineReply, + isDiscussion: boolean, + siteId?: string, + userId?: number, + ): Promise { + const attachments = post && post.options && post.options.attachmentsid; + + if (!attachments) { + return; + } + + // Has some attachments to sync. + let files = typeof attachments === 'object' && attachments.online ? attachments.online : []; + + if (typeof attachments === 'object' && attachments.offline) { + // Has offline files. + try { + const postAttachments = isDiscussion + ? await AddonModForumHelper.instance.getNewDiscussionStoredFiles( + forumId, + (post as AddonModForumOfflineDiscussion).timecreated, + siteId, + ) + : await AddonModForumHelper.instance.getReplyStoredFiles( + forumId, + (post as AddonModForumOfflineReply).postid, + siteId, + userId, + ); + + files = files.concat(postAttachments as unknown as []); + } catch (error) { + // Folder not found, no files to add. + } + } + + return 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; + } + +} + +export class AddonModForumSync extends makeSingleton(AddonModForumSyncProvider) {} + +/** + * Result of forum sync. + */ +export type AddonModForumSyncResult = { + updated: boolean; + warnings: string[]; +}; + +/** + * Data passed to AUTO_SYNCED event. + */ +export type AddonModForumAutoSyncData = CoreEventSiteData & { + forumId: number; + userId: number; + warnings: string[]; + discussionId?: number; +}; + +/** + * Data passed to MANUAL_SYNCED event. + */ +export type AddonModForumManualSyncData = CoreEventSiteData & { + forumId: number; + userId: number; + source: string; + discussionId?: number; +}; diff --git a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html index ab10934b6..35033669b 100644 --- a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html +++ b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html @@ -53,7 +53,7 @@ -
+ {{ 'addon.mod_lesson.enterpassword' | translate }} diff --git a/src/addons/mod/lesson/pages/player/player.html b/src/addons/mod/lesson/pages/player/player.html index e534fbc27..bf33c0984 100644 --- a/src/addons/mod/lesson/pages/player/player.html +++ b/src/addons/mod/lesson/pages/player/player.html @@ -53,7 +53,7 @@ - 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/addons/qtype/match/component/addon-qtype-match.html b/src/addons/qtype/match/component/addon-qtype-match.html index f388fff84..495d0ea09 100644 --- a/src/addons/qtype/match/component/addon-qtype-match.html +++ b/src/addons/qtype/match/component/addon-qtype-match.html @@ -1,4 +1,4 @@ -
+
{ } // If this item is already selected, do nothing. + const itemRoute = this.getItemRoute(route); const itemPath = this.getItemPath(item); + const selectedItemPath = itemRoute ? this.getSelectedItemPath(itemRoute.snapshot) : null; - if (route.firstChild?.routeConfig?.path === itemPath) { + if (selectedItemPath === itemPath) { return; } // Navigate to item. - const path = route.firstChild ? `../${itemPath}` : itemPath; const params = this.getItemQueryParams(item); + const pathPrefix = selectedItemPath ? selectedItemPath.split('/').fill('../').join('') : ''; - await CoreNavigator.instance.navigate(path, { params }); + await CoreNavigator.instance.navigate(pathPrefix + itemPath, { + params, + reset: CoreScreen.instance.isTablet, + }); } /** @@ -220,4 +225,20 @@ export abstract class CorePageItemsListManager { */ protected abstract getSelectedItemPath(route: ActivatedRouteSnapshot): string | null; + /** + * Get the active item route, if any. + * + * @param pageRoute Page route. + * @return Item route. + */ + private getItemRoute(pageRoute: ActivatedRoute): ActivatedRoute | null { + let itemRoute = pageRoute.firstChild; + + while (itemRoute && !itemRoute.component) { + itemRoute = itemRoute.firstChild; + } + + return itemRoute; + } + } diff --git a/src/core/components/split-view/split-view.ts b/src/core/components/split-view/split-view.ts index 870db7056..27af0425a 100644 --- a/src/core/components/split-view/split-view.ts +++ b/src/core/components/split-view/split-view.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { AfterViewInit, Component, ElementRef, HostBinding, Input, OnDestroy, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/core'; import { ActivatedRouteSnapshot } from '@angular/router'; import { IonContent, IonRouterOutlet } from '@ionic/angular'; import { CoreScreen } from '@services/screen'; @@ -33,7 +33,6 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy { @ViewChild(IonContent) menuContent!: IonContent; @ViewChild(IonRouterOutlet) contentOutlet!: IonRouterOutlet; - @HostBinding('class') classes = ''; @Input() placeholderText = 'core.emptysplit'; @Input() mode?: CoreSplitViewMode; isNested = false; @@ -47,6 +46,10 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy { return this.outletRouteSubject.value; } + get outletActivated(): boolean { + return this.contentOutlet.isActivated; + } + get outletRouteObservable(): Observable { return this.outletRouteSubject.asObservable(); } @@ -92,7 +95,7 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy { classes.push('nested'); } - this.classes = classes.join(' '); + this.element.nativeElement.setAttribute('class', classes.join(' ')); } /** @@ -119,13 +122,4 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy { return CoreSplitViewMode.MenuAndContent; } - /** - * Check if both panels are shown. It depends on screen width. - * - * @return If split view is enabled. - */ - isOn(): boolean { - return this.contentOutlet.isActivated; - } - } 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/course/components/format/core-course-format.html b/src/core/features/course/components/format/core-course-format.html index bb9043094..143673386 100644 --- a/src/core/features/course/components/format/core-course-format.html +++ b/src/core/features/course/components/format/core-course-format.html @@ -115,7 +115,7 @@ -
+
{ + return { + ngModule: CoreCourseContentsRoutingModule, + providers: [ + { provide: COURSE_CONTENTS_ROUTES, multi: true, useValue: routes }, + ], + }; + } + +} diff --git a/src/core/features/course/pages/contents/contents.module.ts b/src/core/features/course/pages/contents/contents.module.ts index 698eaeca7..54b6f07fb 100644 --- a/src/core/features/course/pages/contents/contents.module.ts +++ b/src/core/features/course/pages/contents/contents.module.ts @@ -12,23 +12,34 @@ // 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 { Injector, NgModule } from '@angular/core'; +import { RouterModule, ROUTES, Routes } from '@angular/router'; -import { CoreSharedModule } from '@/core/shared.module'; -import { CoreCourseContentsPage } from './contents'; import { CoreCourseComponentsModule } from '@features/course/components/components.module'; +import { CoreSharedModule } from '@/core/shared.module'; +import { resolveModuleRoutes } from '@/app/app-routing.module'; -const routes: Routes = [ - { - path: '', - component: CoreCourseContentsPage, - }, -]; +import { CoreCourseContentsPage } from './contents'; +import { COURSE_CONTENTS_ROUTES } from './contents-routing.module'; + +function buildRoutes(injector: Injector): Routes { + const routes = resolveModuleRoutes(injector, COURSE_CONTENTS_ROUTES); + + return [ + { + path: '', + component: CoreCourseContentsPage, + children: routes.children, + }, + ...routes.siblings, + ]; +} @NgModule({ + providers: [ + { provide: ROUTES, multi: true, useFactory: buildRoutes, deps: [Injector] }, + ], imports: [ - RouterModule.forChild(routes), CoreSharedModule, CoreCourseComponentsModule, ], 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/login/pages/credentials/credentials.html b/src/core/features/login/pages/credentials/credentials.html index 428ab492e..3b393f0b0 100644 --- a/src/core/features/login/pages/credentials/credentials.html +++ b/src/core/features/login/pages/credentials/credentials.html @@ -29,7 +29,7 @@

{{siteUrl}}

- + - @@ -74,7 +74,7 @@ -
diff --git a/src/core/features/login/pages/forgotten-password/forgotten-password.html b/src/core/features/login/pages/forgotten-password/forgotten-password.html index 2736767f6..ed7c39de0 100644 --- a/src/core/features/login/pages/forgotten-password/forgotten-password.html +++ b/src/core/features/login/pages/forgotten-password/forgotten-password.html @@ -14,7 +14,7 @@ - + {{ 'core.login.searchby' | translate }} diff --git a/src/core/features/sitehome/services/sitehome.ts b/src/core/features/sitehome/services/sitehome.ts index e6a852aae..85c75ba51 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'; /** * Items with index 1 and 3 were removed on 2.5 and not being supported in the app. diff --git a/src/core/services/app.ts b/src/core/services/app.ts index 6ba239381..f00f28944 100644 --- a/src/core/services/app.ts +++ b/src/core/services/app.ts @@ -14,7 +14,6 @@ import { Injectable } from '@angular/core'; import { Params } from '@angular/router'; -import { Connection } from '@ionic-native/network/ngx'; import { CoreDB } from '@services/db'; import { CoreEvents } from '@singletons/events'; @@ -341,8 +340,9 @@ export class CoreAppProvider { return false; } - let online = Network.instance.type !== null && Number(Network.instance.type) != Connection.NONE && - Number(Network.instance.type) != Connection.UNKNOWN; + let online = Network.instance.type !== null && Network.instance.type != Network.instance.Connection.NONE && + Network.instance.type != Network.instance.Connection.UNKNOWN; + // Double check we are not online because we cannot rely 100% in Cordova APIs. Also, check it in browser. if (!online && navigator.onLine) { online = true; @@ -363,9 +363,14 @@ export class CoreAppProvider { return false; } - const limited = [Connection.CELL_2G, Connection.CELL_3G, Connection.CELL_4G, Connection.CELL]; + const limited = [ + Network.instance.Connection.CELL_2G, + Network.instance.Connection.CELL_3G, + Network.instance.Connection.CELL_4G, + Network.instance.Connection.CELL, + ]; - return limited.indexOf(Number(type)) > -1; + return limited.indexOf(type) > -1; } /** diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index 813078fde..f26315593 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -743,7 +743,7 @@ export class CoreDomUtilsProvider { * @param error Error to check. * @return Whether it's a canceled error. */ - isCanceledError(error: CoreError | CoreTextErrorObject | string): boolean { + isCanceledError(error: CoreError | CoreTextErrorObject | string | null): boolean { return error instanceof CoreCanceledError; } @@ -1393,7 +1393,7 @@ export class CoreDomUtilsProvider { * @return Promise resolved with the alert modal. */ async showErrorModalDefault( - error: CoreError | CoreTextErrorObject | string, + error: CoreError | CoreTextErrorObject | string | null, defaultError: string, needsTranslate?: boolean, autocloseTime?: number, @@ -1409,7 +1409,7 @@ export class CoreDomUtilsProvider { errorMessage = CoreTextUtils.instance.getErrorMessageFromError(error); } - return this.showErrorModal(typeof errorMessage == 'string' ? error : defaultError, needsTranslate, autocloseTime); + return this.showErrorModal(typeof errorMessage == 'string' ? error! : defaultError, needsTranslate, autocloseTime); } /** 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/events.ts b/src/core/singletons/events.ts index 030b21ce7..cc311602e 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -28,6 +28,25 @@ export interface CoreEventObserver { off: () => void; } +/** + * Event payloads. + */ +export interface CoreEventsData { + [CoreEvents.SITE_UPDATED]: CoreEventSiteUpdatedData; + [CoreEvents.SITE_ADDED]: CoreEventSiteAddedData; + [CoreEvents.SESSION_EXPIRED]: CoreEventSessionExpiredData; + [CoreEvents.CORE_LOADING_CHANGED]: CoreEventLoadingChangedData; + [CoreEvents.COURSE_STATUS_CHANGED]: CoreEventCourseStatusChanged; + [CoreEvents.PACKAGE_STATUS_CHANGED]: CoreEventPackageStatusChanged; + [CoreEvents.USER_DELETED]: CoreEventUserDeletedData; + [CoreEvents.FORM_ACTION]: CoreEventFormActionData; + [CoreEvents.NOTIFICATION_SOUND_CHANGED]: CoreEventNotificationSoundChangedData; + [CoreEvents.SELECT_COURSE_TAB]: CoreEventSelectCourseTabData; + [CoreEvents.COMPLETION_MODULE_VIEWED]: CoreEventCompletionModuleViewedData; + [CoreEvents.SECTION_STATUS_CHANGED]: CoreEventSectionStatusChangedData; + [CoreEvents.ACTIVITY_DATA_SENT]: CoreEventActivityDataSentData; +}; + /* * Service to send and listen to events. */ @@ -84,15 +103,15 @@ export class CoreEvents { * @param siteId Site where to trigger the event. Undefined won't check the site. * @return Observer to stop listening. */ - static on( - eventName: string, - callBack: (value: T & { siteId?: string }) => void, + static on( + eventName: Event, + callBack: (value: CoreEventData & { siteId?: string }) => void, siteId?: string, ): CoreEventObserver { // If it's a unique event and has been triggered already, call the callBack. // We don't need to create an observer because the event won't be triggered again. if (this.uniqueEvents[eventName]) { - callBack( this.uniqueEvents[eventName].data); + callBack(this.uniqueEvents[eventName].data as CoreEventData & { siteId?: string }); // Return a fake observer to prevent errors. return { @@ -106,14 +125,16 @@ export class CoreEvents { if (typeof this.observables[eventName] == 'undefined') { // No observable for this event, create a new one. - this.observables[eventName] = new Subject(); + this.observables[eventName] = new Subject(); } - const subscription = this.observables[eventName].subscribe((value: T & {siteId?: string}) => { - if (!siteId || value.siteId == siteId) { - callBack(value); - } - }); + const subscription = this.observables[eventName].subscribe( + (value: CoreEventData & { siteId?: string }) => { + if (!siteId || value.siteId == siteId) { + callBack(value); + } + }, + ); // Create and return a CoreEventObserver. return { @@ -155,7 +176,11 @@ export class CoreEvents { * @param data Data to pass to the observers. * @param siteId Site where to trigger the event. Undefined means no Site. */ - static trigger(eventName: string, data?: T, siteId?: string): void { + static trigger( + eventName: Event, + data?: CoreEventData, + siteId?: string, + ): void { this.logger.debug(`Event '${eventName}' triggered.`); if (this.observables[eventName]) { if (siteId) { @@ -172,7 +197,11 @@ export class CoreEvents { * @param data Data to pass to the observers. * @param siteId Site where to trigger the event. Undefined means no Site. */ - static triggerUnique(eventName: string, data: T, siteId?: string): void { + static triggerUnique( + eventName: Event, + data: CoreEventData, + siteId?: string, + ): void { if (this.uniqueEvents[eventName]) { this.logger.debug(`Unique event '${eventName}' ignored because it was already triggered.`); } else { @@ -196,6 +225,11 @@ export class CoreEvents { } +/** + * Resolve payload type for a given event. + */ +export type CoreEventData = Event extends keyof CoreEventsData ? CoreEventsData[Event] : Fallback; + /** * Some events contains siteId added by the trigger function. This type is intended to be combined with others. */ 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.dark.scss b/src/theme/theme.dark.scss index 13496a06b..141723271 100644 --- a/src/theme/theme.dark.scss +++ b/src/theme/theme.dark.scss @@ -93,4 +93,7 @@ --core-question-feedback-background-color: var(--yellow-dark); --core-dd-question-selected-shadow: 2px 2px 4px var(--gray-light); + + --addon-forum-border-color: var(--custom-forum-border-color, var(--gray-darker)); + --addon-forum-highlight-color: var(--custom-forum-highlight-color, var(--gray-dark)); } diff --git a/src/theme/theme.light.scss b/src/theme/theme.light.scss index 8d623e909..9170acbe9 100644 --- a/src/theme/theme.light.scss +++ b/src/theme/theme.light.scss @@ -183,6 +183,10 @@ --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); + --addon-forum-border-color: var(--custom-forum-border-color, var(--gray)); + --addon-forum-highlight-color: var(--custom-forum-highlight-color, var(--gray-lighter)); + --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));