From 2dd0aa4815d26508916d784e4aa5de8ca6c45e40 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 18 Feb 2021 11:49:15 +0100 Subject: [PATCH] MOBILE-3643 forum: Migrate discussion page --- .../mod/forum/components/components.module.ts | 7 +- .../mod/forum/components/post/post.html | 136 ++++ .../mod/forum/components/post/post.scss | 72 ++ src/addons/mod/forum/components/post/post.ts | 510 ++++++++++++ src/addons/mod/forum/forum-lazy.module.ts | 28 +- .../forum/pages/discussion/discussion.html | 106 +++ .../forum/pages/discussion/discussion.scss | 7 + .../mod/forum/pages/discussion/discussion.ts | 758 ++++++++++++++++++ .../mod/forum/services/forum.service.ts | 394 +++++---- .../mod/forum/services/helper.service.ts | 58 +- .../mod/forum/services/offline.service.ts | 12 +- src/addons/mod/forum/services/sync.service.ts | 57 +- src/theme/theme.dark.scss | 3 + src/theme/theme.light.scss | 2 + 14 files changed, 1947 insertions(+), 203 deletions(-) create mode 100644 src/addons/mod/forum/components/post/post.html create mode 100644 src/addons/mod/forum/components/post/post.scss create mode 100644 src/addons/mod/forum/components/post/post.ts create mode 100644 src/addons/mod/forum/pages/discussion/discussion.html create mode 100644 src/addons/mod/forum/pages/discussion/discussion.scss create mode 100644 src/addons/mod/forum/pages/discussion/discussion.ts diff --git a/src/addons/mod/forum/components/components.module.ts b/src/addons/mod/forum/components/components.module.ts index 45603fbcc..17a5497d1 100644 --- a/src/addons/mod/forum/components/components.module.ts +++ b/src/addons/mod/forum/components/components.module.ts @@ -14,23 +14,28 @@ import { NgModule } from '@angular/core'; -import { CoreSharedModule } from '@/core/shared.module'; 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 { AddonModForumIndexComponent } from './index/index'; +import { AddonModForumPostComponent } from './post/post'; @NgModule({ declarations: [ AddonModForumIndexComponent, + AddonModForumPostComponent, ], imports: [ CoreSharedModule, CoreCourseComponentsModule, CoreTagComponentsModule, + CoreEditorComponentsModule, ], exports: [ AddonModForumIndexComponent, + AddonModForumPostComponent, ], }) export class AddonModForumComponentsModule {} 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..1ac11c4df --- /dev/null +++ b/src/addons/mod/forum/components/post/post.html @@ -0,0 +1,136 @@ +
+ + + +
+

+ + + + + + +

+ + + + + + + + +
+ +
+
+
+ +
+ {{ '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..3d4be6ec6 --- /dev/null +++ b/src/addons/mod/forum/components/post/post.scss @@ -0,0 +1,72 @@ +@import "../../../../../theme/globals.scss"; + +: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..85279dddc --- /dev/null +++ b/src/addons/mod/forum/components/post/post.ts @@ -0,0 +1,510 @@ +// (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, +} from '../../services/forum.service'; +import { CoreTag } from '@features/tag/services/tag'; +import { Translate } from '@singletons'; +import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; +import { IonContent } from '@ionic/angular'; +import { AddonModForumSync } from '../../services/sync.service'; +import { CoreSync } from '@services/sync'; +import { CoreTextUtils } from '@services/utils/text'; +import { AddonModForumHelper } from '../../services/helper.service'; +import { AddonModForumOffline, AddonModForumReplyOptions } from '../../services/offline.service'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * 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, + ) {} + + /** + * 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?: any[], + 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 e Click Event. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + showOptionsMenu(e: Event): void { + alert('Options menu not implemented'); + + // @todo + } + + /** + * Shows a form modal to edit an online post. + */ + editPost(): void { + alert('Edit post not implemented'); + + // @todo + } + + /** + * 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/forum-lazy.module.ts b/src/addons/mod/forum/forum-lazy.module.ts index 6e549fa44..827da9d7a 100644 --- a/src/addons/mod/forum/forum-lazy.module.ts +++ b/src/addons/mod/forum/forum-lazy.module.ts @@ -19,12 +19,37 @@ import { CoreSharedModule } from '@/core/shared.module'; import { AddonModForumComponentsModule } from './components/components.module'; import { AddonModForumIndexPage } from './pages/index'; +import { AddonModForumDiscussionPage } from './pages/discussion/discussion'; +import { conditionalRoutes } from '@/app/app-routing.module'; +import { CoreScreen } from '@services/screen'; -const routes: Routes = [ +const mobileRoutes: Routes = [ { path: ':courseId/:cmId', component: AddonModForumIndexPage, }, + { + path: ':courseId/:cmId/:discussionId', + component: AddonModForumDiscussionPage, + }, +]; + +const tabletRoutes: Routes = [ + { + path: ':courseId/:cmId', + component: AddonModForumIndexPage, + children: [ + { + path: ':discussionId', + component: AddonModForumDiscussionPage, + }, + ], + }, +]; + +const routes: Routes = [ + ...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile), + ...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet), ]; @NgModule({ @@ -35,6 +60,7 @@ const routes: Routes = [ ], declarations: [ AddonModForumIndexPage, + AddonModForumDiscussionPage, ], }) export class AddonModForumLazyModule {} 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..80dbe61fb --- /dev/null +++ b/src/addons/mod/forum/pages/discussion/discussion.html @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate:{$a: discussionStr} }} + + + + + + + + {{ availabilityMessage }} + + + + + + + {{ 'addon.mod_forum.discussionlocked' | translate }} + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+
+
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/discussion/discussion.ts b/src/addons/mod/forum/pages/discussion/discussion.ts new file mode 100644 index 000000000..39ff0b77e --- /dev/null +++ b/src/addons/mod/forum/pages/discussion/discussion.ts @@ -0,0 +1,758 @@ +// (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 } from '@angular/core'; +import { CoreUser } from '@features/user/services/user'; +import { IonContent } from '@ionic/angular'; +import { CoreApp } from '@services/app'; +import { CoreNavigator } from '@services/navigator'; +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.service'; +import { AddonModForumHelper } from '../../services/helper.service'; +import { AddonModForumOffline } from '../../services/offline.service'; +import { AddonModForumSync, AddonModForumSyncProvider } from '../../services/sync.service'; + +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 { + + @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(protected elementRef: ElementRef) {} + + 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, + ); + }); + } + } + + /** + * 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'; + } + + /** + * 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: any) => { + 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: any) => { + if (data.source != 'discussion' && data.forumId == this.forumId && + data.userId == CoreSites.instance.getCurrentSiteUserId()) { + // Refresh the data. + this.discussionLoaded = false; + this.refreshPosts(); + } + }, CoreSites.instance.getCurrentSiteId()); + + // Trigger view event, to highlight the current opened discussion in the split view. + CoreEvents.trigger(AddonModForumProvider.VIEW_DISCUSSION_EVENT, { + forumId: this.forumId, + discussion: this.discussionId, + }, CoreSites.instance.getCurrentSiteId()); + + this.changeDiscObserver = CoreEvents.on(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, (data: any) => { + 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) { + // @todo + // if (this.svComponent && this.svComponent.isOn()) { + // this.svComponent.emptyDetails(); + // } else { + // this.navCtrl.pop(); + // } + } else { + this.discussionLoaded = false; + this.refreshPosts(); + } + } + }); + } + }); + } + + // @todo + // /** + // * Check if we can leave the page or not. + // * + // * @return Resolved if we can leave it, rejected if not. + // */ + // async ionViewCanLeave(): Promise { + + // if (AddonModForumHelper.instance.hasPostDataChanged(this.replyData, this.originalData)) { + // // Show confirmation if some data has been modified. + // await CoreDomUtils.instance.showConfirm(this.translate.instant('core.confirmcanceledit')); + // } + + // // Delete the local files from the tmp folder. + // this.uploaderProvider.clearTmpFiles(this.replyData.files); + + // this.leavingPage = true; + // } + + /** + * 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. Should not happen. + 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; + } + } 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; + }); + } + + /** + * 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 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/services/forum.service.ts b/src/addons/mod/forum/services/forum.service.ts index 4e81215e0..a26fac535 100644 --- a/src/addons/mod/forum/services/forum.service.ts +++ b/src/addons/mod/forum/services/forum.service.ts @@ -16,6 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreFileEntry } from '@features/fileuploader/services/fileuploader'; import { CoreUser } from '@features/user/services/user'; import { CoreApp } from '@services/app'; import { CoreFilepool } from '@services/filepool'; @@ -25,7 +26,7 @@ import { CoreUrlUtils } from '@services/utils/url'; import { CoreUtils } from '@services/utils/utils'; import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; import { makeSingleton, Translate } from '@singletons'; -import { AddonModForumOffline, AddonModForumReplyOptions } from './offline.service'; +import { AddonModForumOffline, AddonModForumOfflineDiscussion, AddonModForumReplyOptions } from './offline.service'; const ROOT_CACHE_KEY = 'mmaModForum:'; @@ -275,13 +276,13 @@ export class AddonModForumProvider { * @return Promise resolved when done. * @since 3.8 */ - async deletePost(postId: number, siteId?: string): Promise { + async deletePost(postId: number, siteId?: string): Promise { const site = await CoreSites.instance.getSite(siteId); const params: AddonModForumDeletePostWSParams = { postid: postId, }; - await site.write('mod_forum_delete_post', params); + return site.write('mod_forum_delete_post', params); } /** @@ -355,7 +356,12 @@ export class AddonModForumProvider { * @param discussions List of discussions to format. * @return Promise resolved with the formatted discussions. */ - formatDiscussionsGroups(cmId: number, discussions: any[]): Promise { + 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) => { @@ -447,7 +453,7 @@ export class AddonModForumProvider { throw new Error('Post not found'); } - return response.post; + return this.translateWSPost(response.post); } /** @@ -539,9 +545,9 @@ export class AddonModForumProvider { ratinginfo?: AddonModForumRatingInfo; }> { // Convenience function to translate legacy data to new format. - const translateLegacyPostsFormat = (posts: any[]): any[] => posts.map((post) => { - const newPost = { - id: post.id , + const translateLegacyPostsFormat = (posts: AddonModForumLegacyPost[]): AddonModForumPost[] => posts.map((post) => { + const newPost: AddonModForumPost = { + id: post.id, discussionid: post.discussion, parentid: post.parent, hasparent: !!post.parent, @@ -563,8 +569,8 @@ export class AddonModForumProvider { tags: post.tags, }; - if (post.groupname) { - newPost.author['groups'] = [{ name: post.groupname }]; + if ('groupname' in post && typeof post['groupname'] === 'string') { + newPost.author['groups'] = [{ name: post['groupname'] }]; } return newPost; @@ -572,26 +578,10 @@ export class AddonModForumProvider { // For some reason, the new WS doesn't use the tags exporter so it returns a different format than other WebServices. // Convert the new format to the exporter one so it's the same as in other WebServices. - const translateTagsFormatToLegacy = (posts: any[]): any[] => { - posts.forEach((post) => { - post.tags = post.tags.map((tag) => { - const viewUrl = (tag.urls && tag.urls.view) || ''; - const params = CoreUrlUtils.instance.extractUrlParams(viewUrl); + const translateTagsFormatToLegacy = (posts: AddonModForumWSPost[]): AddonModForumPost[] => { + posts.forEach(post => this.translateWSPost(post)); - return { - id: tag.tagid, - taginstanceid: tag.id, - flag: tag.flag ? 1 : 0, - isstandard: tag.isstandard, - rawname: tag.displayname, - name: tag.displayname, - tagcollid: params.tc ? Number(params.tc) : undefined, - taginstancecontextid: params.from ? Number(params.from) : undefined, - }; - }); - }); - - return posts; + return posts as unknown as AddonModForumPost[]; }; const params: AddonModForumGetDiscussionPostsWSParams | AddonModForumGetForumDiscussionPostsWSParams = { @@ -619,15 +609,16 @@ export class AddonModForumProvider { throw new Error('Could not get forum posts'); } - if (isGetDiscussionPostsAvailable) { - response.posts = translateTagsFormatToLegacy((response as AddonModForumGetDiscussionPostsWSResponse).posts); - } else { - response.posts = translateLegacyPostsFormat((response as AddonModForumGetForumDiscussionPostsWSResponse).posts); - } + const posts = isGetDiscussionPostsAvailable + ? translateTagsFormatToLegacy((response as AddonModForumGetDiscussionPostsWSResponse).posts) + : translateLegacyPostsFormat((response as AddonModForumGetForumDiscussionPostsWSResponse).posts); - this.storeUserData(response.posts); + this.storeUserData(posts); - return response as AddonModForumGetDiscussionPostsWSResponse; + return { + ...response, + posts, + }; } /** @@ -790,7 +781,7 @@ export class AddonModForumProvider { throw new Error('Could not get discussions'); } - await this.storeUserData(response.discussions); + this.storeUserData(response.discussions); return { discussions: response.discussions, @@ -1093,7 +1084,13 @@ export class AddonModForumProvider { // If there's already a reply to be sent to the server, discard it first. try { await AddonModForumOffline.instance.deleteReply(postId, siteId); - await this.replyPostOnline(postId, subject, message, options, siteId); + await this.replyPostOnline( + postId, + subject, + message, + options as unknown as AddonModForumAddDiscussionPostWSOptionsObject, + siteId, + ); return true; } catch (error) { @@ -1157,7 +1154,12 @@ export class AddonModForumProvider { * @return Promise resolved when done. * @since 3.7 */ - async setLockState(forumId: number, discussionId: number, locked: boolean, siteId?: string): Promise { + async setLockState( + forumId: number, + discussionId: number, + locked: boolean, + siteId?: string, + ): Promise { const site = await CoreSites.instance.getSite(siteId); const params: AddonModForumSetLockStateWSParams = { forumid: forumId, @@ -1165,7 +1167,7 @@ export class AddonModForumProvider { targetstate: locked ? 0 : 1, }; - await site.write('mod_forum_set_lock_state', params); + return site.write('mod_forum_set_lock_state', params); } /** @@ -1211,7 +1213,7 @@ export class AddonModForumProvider { const site = await CoreSites.instance.getSite(siteId); const params: AddonModForumToggleFavouriteStateWSParams = { discussionid: discussionId, - targetstate: starred ? 1 : 0 as any, + targetstate: starred, }; await site.write('mod_forum_toggle_favourite_state', params); @@ -1222,30 +1224,30 @@ export class AddonModForumProvider { * * @param list Array of posts or discussions. */ - protected storeUserData(list: any[]): void { + protected storeUserData(list: AddonModForumPost[] | AddonModForumDiscussion[]): void { const users = {}; - list.forEach((entry) => { - if (entry.author) { + 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, + profileimageurl: entry.author.urls?.profileimage, }; } } - const userId = parseInt(entry.userid); - if (!isNaN(userId) && !users[userId]) { + 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 (!isNaN(userModified) && !users[userModified]) { + const userModified = parseInt(entry['usermodified']); + if ('usermodified' in entry && !isNaN(userModified) && !users[userModified]) { users[userModified] = { id: userModified, fullname: entry.usermodifiedfullname, @@ -1293,6 +1295,33 @@ export class AddonModForumProvider { 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; + } + } export class AddonModForum extends makeSingleton(AddonModForumProvider) {} @@ -1353,6 +1382,7 @@ 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. @@ -1386,6 +1416,49 @@ export type AddonModForumDiscussion = { 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. + }; + attachment?: 0 | 1; + attachments?: (CoreFileEntry | AddonModForumWSPostAttachment)[]; + 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. */ @@ -1427,112 +1500,6 @@ export type AddonModForumLegacyPost = { }[]; }; -/** - * Forum post data. - */ -export type AddonModForumPost = { - id: number; // Id. - subject: string; // Subject. - replysubject: string; // Replysubject. - message: string; // Message. - messageformat: number; // Message format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). - author: { - id?: number; // Id. - fullname?: string; // Fullname. - isdeleted?: boolean; // Isdeleted. - groups?: { // Groups. - id: number; // Id. - name: string; // Name. - urls: { - image?: string; // Image. - }; - }[]; - urls: { - profile?: string; // The URL for the use profile page. - profileimage?: string; // The URL for the use profile image. - }; - }; - discussionid: number; // Discussionid. - hasparent: boolean; // Hasparent. - parentid?: number; // Parentid. - timecreated: number; // Timecreated. - unread?: boolean; // Unread. - isdeleted: boolean; // Isdeleted. - isprivatereply: boolean; // Isprivatereply. - haswordcount: boolean; // Haswordcount. - wordcount?: number; // Wordcount. - charcount?: number; // Charcount. - capabilities: { - view: boolean; // Whether the user can view the post. - edit: boolean; // Whether the user can edit the post. - delete: boolean; // Whether the user can delete the post. - split: boolean; // Whether the user can split the post. - reply: boolean; // Whether the user can reply to the post. - selfenrol: boolean; // Whether the user can self enrol into the course. - export: boolean; // Whether the user can export the post. - controlreadstatus: boolean; // Whether the user can control the read status of the post. - canreplyprivately: boolean; // Whether the user can post a private reply. - }; - urls?: { - view?: string; // The URL used to view the post. - viewisolated?: string; // The URL used to view the post in isolation. - viewparent?: string; // The URL used to view the parent of the post. - edit?: string; // The URL used to edit the post. - delete?: string; // The URL used to delete the post. - - // The URL used to split the discussion with the selected post being the first post in the new discussion. - split?: string; - - reply?: string; // The URL used to reply to the post. - export?: string; // The URL used to export the post. - markasread?: string; // The URL used to mark the post as read. - markasunread?: string; // The URL used to mark the post as unread. - discuss?: string; // Discuss. - }; - attachments: { // Attachments. - contextid: number; // Contextid. - component: string; // Component. - filearea: string; // Filearea. - itemid: number; // Itemid. - filepath: string; // Filepath. - filename: string; // Filename. - isdir: boolean; // Isdir. - isimage: boolean; // Isimage. - timemodified: number; // Timemodified. - timecreated: number; // Timecreated. - filesize: number; // Filesize. - author: string; // Author. - license: string; // License. - filenameshort: string; // Filenameshort. - filesizeformatted: string; // Filesizeformatted. - icon: string; // Icon. - timecreatedformatted: string; // Timecreatedformatted. - timemodifiedformatted: string; // Timemodifiedformatted. - url: string; // Url. - urls: { - export?: string; // The URL used to export the attachment. - }; - html: { - plagiarism?: string; // The HTML source for the Plagiarism Response. - }; - }[]; - tags?: { // Tags. - id: number; // The ID of the Tag. - tagid: number; // The tagid. - isstandard: boolean; // Whether this is a standard tag. - displayname: string; // The display name of the tag. - flag: boolean; // Wehther this tag is flagged. - urls: { - view: string; // The URL to view the tag. - }; - }[]; - html?: { - rating?: string; // The HTML source to rate the post. - taglist?: string; // The HTML source to view the list of tags. - authorsubheading?: string; // The HTML source to view the author details. - }; -}; - /** * Forum rating info. */ @@ -1640,6 +1607,117 @@ export type AddonModForumSortOrder = { 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. */ @@ -1799,7 +1877,7 @@ export type AddonModForumAddDiscussionPostWSParams = { export type AddonModForumAddDiscussionPostWSResponse = { postid: number; // New post id. warnings?: CoreWSExternalWarning[]; - post: AddonModForumPost; + 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. @@ -1859,7 +1937,7 @@ export type AddonModForumGetDiscussionPostWSParams = { * Data returned by mod_forum_get_discussion_post WS. */ export type AddonModForumGetDiscussionPostWSResponse = { - post: AddonModForumPost; + post: AddonModForumWSPost; warnings?: CoreWSExternalWarning[]; }; @@ -1877,7 +1955,7 @@ export type AddonModForumGetDiscussionPostsWSParams = { * Data returned by mod_forum_get_discussion_posts WS. */ export type AddonModForumGetDiscussionPostsWSResponse = { - posts: AddonModForumPost[]; + posts: AddonModForumWSPost[]; forumid: number; // The forum id. courseid: number; // The forum course id. ratinginfo?: AddonModForumRatingInfo; // Rating information. diff --git a/src/addons/mod/forum/services/helper.service.ts b/src/addons/mod/forum/services/helper.service.ts index 04dc2b272..347e17056 100644 --- a/src/addons/mod/forum/services/helper.service.ts +++ b/src/addons/mod/forum/services/helper.service.ts @@ -21,8 +21,15 @@ import { CoreSites } from '@services/sites'; import { CoreTimeUtils } from '@services/utils/time'; import { CoreUtils } from '@services/utils/utils'; import { makeSingleton, Translate } from '@singletons'; -import { AddonModForum, AddonModForumData, AddonModForumProvider } from './forum.service'; -import { AddonModForumOffline } from './offline.service'; +import { + AddonModForum, + AddonModForumAddDiscussionWSOptionsObject, + AddonModForumData, + AddonModForumDiscussion, + AddonModForumPost, + AddonModForumProvider, +} from './forum.service'; +import { AddonModForumDiscussionOptions, AddonModForumOffline, AddonModForumOfflineReply } from './offline.service'; /** * Service that provides some features for forums. @@ -51,8 +58,8 @@ export class AddonModForumHelperProvider { courseId: number, subject: string, message: string, - attachments?: any[], - options?: any, + attachments?: CoreFileEntry[], + options?: AddonModForumDiscussionOptions, groupIds?: number[], timeCreated?: number, siteId?: string, @@ -62,14 +69,14 @@ export class AddonModForumHelperProvider { let saveOffline = false; const attachmentsIds: number[] = []; - let offlineAttachments: any; + 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) { + if (offlineAttachments && options) { options.attachmentsid = offlineAttachments; } @@ -122,7 +129,7 @@ export class AddonModForumHelperProvider { const promises = groupIds.map(async (groupId, index) => { const groupOptions = CoreUtils.instance.clone(options); - if (attachmentsIds[index]) { + if (groupOptions && attachmentsIds[index]) { groupOptions.attachmentsid = attachmentsIds[index]; } @@ -131,7 +138,7 @@ export class AddonModForumHelperProvider { forumId, subject, message, - groupOptions, + groupOptions as unknown as AddonModForumAddDiscussionWSOptionsObject, groupId, siteId, ); @@ -169,8 +176,8 @@ export class AddonModForumHelperProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the object converted to Online. */ - convertOfflineReplyToOnline(offlineReply: any, siteId?: string): Promise { - const reply: any = { + convertOfflineReplyToOnline(offlineReply: AddonModForumOfflineReply, siteId?: string): Promise { + const reply: AddonModForumPost = { id: -offlineReply.timecreated, discussionid: offlineReply.discussionid, parentid: offlineReply.postid, @@ -186,20 +193,25 @@ export class AddonModForumHelperProvider { reply: false, }, unread: false, - isprivatereply: offlineReply.options && offlineReply.options.private, - tags: null, + isprivatereply: !!offlineReply.options?.private, }; const promises: Promise[] = []; // Treat attachments if any. if (offlineReply.options && offlineReply.options.attachmentsid) { - reply.attachments = offlineReply.options.attachmentsid.online || []; + const attachments = offlineReply.options.attachmentsid; - if (offlineReply.options.attachmentsid.offline) { + 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)), + .getReplyStoredFiles(offlineReply.forumid, reply.parentid!, siteId, offlineReply.userid) + .then(files => { + reply.attachments = reply.attachments!.concat(files as unknown as []); + + return; + }), ); } } @@ -219,7 +231,7 @@ export class AddonModForumHelperProvider { ); return Promise.all(promises).then(() => { - reply.attachment = reply.attachments.length > 0 ? 1 : 0; + reply.attachment = reply.attachments!.length > 0 ? 1 : 0; return reply; }); @@ -293,10 +305,10 @@ export class AddonModForumHelperProvider { * @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 { + getDiscussionById(forumId: number, cmId: number, discussionId: number, siteId?: string): Promise { siteId = siteId || CoreSites.instance.getCurrentSiteId(); - const findDiscussion = async (page: number): Promise => { + const findDiscussion = async (page: number): Promise => { const response = await AddonModForum.instance.getDiscussions(forumId, { cmId, page, @@ -330,7 +342,7 @@ export class AddonModForumHelperProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the files. */ - async getNewDiscussionStoredFiles(forumId: number, timecreated: number, siteId?: string): Promise { + async getNewDiscussionStoredFiles(forumId: number, timecreated: number, siteId?: string): Promise { const folderPath = await AddonModForumOffline.instance.getNewDiscussionFolder(forumId, timecreated, siteId); return CoreFileUploader.instance.getStoredFiles(folderPath); @@ -345,7 +357,7 @@ export class AddonModForumHelperProvider { * @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 { + 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); @@ -380,10 +392,10 @@ export class AddonModForumHelperProvider { * * @param forum Forum instance. */ - isCutoffDateReached(forum: any): boolean { + isCutoffDateReached(forum: AddonModForumData): boolean { const now = Date.now() / 1000; - return forum.cutoffdate > 0 && forum.cutoffdate < now; + return !!forum.cutoffdate && forum.cutoffdate > 0 && forum.cutoffdate < now; } /** diff --git a/src/addons/mod/forum/services/offline.service.ts b/src/addons/mod/forum/services/offline.service.ts index ba6a422ea..2cf6a0802 100644 --- a/src/addons/mod/forum/services/offline.service.ts +++ b/src/addons/mod/forum/services/offline.service.ts @@ -13,6 +13,7 @@ // 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'; @@ -401,8 +402,14 @@ export class AddonModForumOfflineProvider { export class AddonModForumOffline extends makeSingleton(AddonModForumOfflineProvider) {} -export type AddonModForumDiscussionOptions = Record; -export type AddonModForumReplyOptions = Record; +export type AddonModForumDiscussionOptions = { + attachmentsid: number | CoreFileUploaderStoreFilesResult; +}; + +export type AddonModForumReplyOptions = { + private?: boolean; + attachmentsid?: number | CoreFileUploaderStoreFilesResult; +}; export type AddonModForumOfflineDiscussion = { forumid: number; @@ -412,6 +419,7 @@ export type AddonModForumOfflineDiscussion = { message: string; options: AddonModForumDiscussionOptions; groupid: number; + groupname?: string; userid: number; timecreated: number; }; diff --git a/src/addons/mod/forum/services/sync.service.ts b/src/addons/mod/forum/services/sync.service.ts index 58b256be5..a7b2a564e 100644 --- a/src/addons/mod/forum/services/sync.service.ts +++ b/src/addons/mod/forum/services/sync.service.ts @@ -23,10 +23,15 @@ import { CoreSites } from '@services/sites'; import { CoreSync } from '@services/sync'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; -import { Translate } from '@singletons'; +import { makeSingleton, Translate } from '@singletons'; import { CoreArray } from '@singletons/array'; import { CoreEvents } from '@singletons/events'; -import { AddonModForum, AddonModForumProvider } from './forum.service'; +import { + AddonModForum, + AddonModForumAddDiscussionPostWSOptionsObject, + AddonModForumAddDiscussionWSOptionsObject, + AddonModForumProvider, +} from './forum.service'; import { AddonModForumHelper } from './helper.service'; import { AddonModForumOffline, AddonModForumOfflineDiscussion, AddonModForumOfflineReply } from './offline.service'; @@ -72,7 +77,7 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider { - const sitePromises: Promise[] = []; + const sitePromises: Promise[] = []; // Sync all new discussions. const syncDiscussions = async (discussions: AddonModForumOfflineDiscussion[]) => { @@ -239,13 +244,13 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider { + async syncRatings(cmId?: number, discussionId?: number, force?: boolean, siteId?: string): Promise<{ + updated: boolean; + warnings: string[]; + }> { // @todo + + return { updated: true, warnings: [] }; } /** @@ -437,7 +447,7 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider { + ): Promise { const attachments = post && post.options && post.options.attachmentsid; if (!attachments) { @@ -545,22 +555,31 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider