// (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/forum-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/forum-offline'; import { CoreUtils } from '@services/utils/utils'; import { AddonModForumHelper } from '@addons/mod/forum/services/forum-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'; import { CoreForms } from '@singletons/form'; 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 = {}; 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 { try { this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); this.forumId = CoreNavigator.getRequiredRouteNumberParam('forumId'); this.timeCreated = CoreNavigator.getRequiredRouteNumberParam('timeCreated'); } catch (error) { CoreDomUtils.showErrorModal(error); this.goBack(); return; } 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.getCurrentSiteUserId()) { CoreDomUtils.showAlertTranslated('core.notice', 'core.contenteditingsynced'); this.returnToDiscussions(); } }, CoreSites.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.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.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.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.getForumSyncId(this.forumId); await AddonModForumSync.waitForSync(this.syncId).then(() => { // Do not block if the scope is already destroyed. if (!this.isDestroyed) { CoreSync.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.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.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.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 { let promise: Promise; if (check) { // We need to check if the user can add a discussion to all participants. promise = AddonModForum.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.invalidateActivityGroupMode(this.cmId), CoreGroups.invalidateActivityAllowedGroups(this.cmId), AddonModForum.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.clearTmpFiles(this.newDiscussion.files); CoreEvents.trigger( AddonModForumProvider.NEW_DISCUSSION_EVENT, { forumId: this.forumId, cmId: this.cmId, discussionIds: discussionIds, discTimecreated: discTimecreated, }, CoreSites.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.clone(this.newDiscussion); } else { CoreNavigator.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.showErrorModal('addon.mod_forum.erroremptysubject', true); return; } if (!message) { CoreDomUtils.showErrorModal('addon.mod_forum.erroremptymessage', true); return; } const modal = await CoreDomUtils.showModalLoading('core.sending', true); // Add some HTML to the message if needed. message = CoreTextUtils.formatHtmlLines(message); if (pin) { options.discussionpinned = true; } const groupIds = this.newDiscussion.postToAllGroups ? this.groupIds : [this.newDiscussion.groupId]; try { const discussionIds = await AddonModForumHelper.addNewDiscussion( this.forumId, forumName, this.courseId, subject, message, attachments, options, groupIds, discTimecreated, ); if (discussionIds) { // Data sent to server, delete stored files (if any). AddonModForumHelper.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.showErrorModalDefault(null, 'addon.mod_forum.errorposttoallgroups', true); } CoreForms.triggerFormSubmittedEvent( this.formElement, !!discussionIds, CoreSites.getCurrentSiteId(), ); this.returnToDiscussions(discussionIds, discTimecreated); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.mod_forum.cannotcreatediscussion', true); } finally { modal.dismiss(); } } /** * Discard an offline saved discussion. */ async discard(): Promise { try { await CoreDomUtils.showConfirm(Translate.instant('core.areyousure')); const promises: Promise[] = []; promises.push(AddonModForumOffline.deleteNewDiscussion(this.forumId, this.timeCreated)); promises.push( CoreUtils.ignoreErrors( AddonModForumHelper.deleteNewDiscussionStoredFiles(this.forumId, this.timeCreated), ), ); await Promise.all(promises); CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.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.hasPostDataChanged(this.newDiscussion, this.originalData)) { // Show confirmation if some data has been modified. await CoreDomUtils.showConfirm(Translate.instant('core.confirmcanceledit')); } // Delete the local files from the tmp folder. CoreFileUploader.clearTmpFiles(this.newDiscussion.files); if (this.formElement) { CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.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; } /** * Helper function to go back. */ protected goBack(): void { if (this.splitView?.outletActivated) { CoreNavigator.navigate('../../'); } else { CoreNavigator.back(); } } /** * Page destroyed. */ ngOnDestroy(): void { if (this.syncId) { CoreSync.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId); } this.isDestroyed = true; } }