// (C) Copyright 2015 Moodle Pty Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { Component, Optional, OnInit, OnDestroy, ViewChild, AfterViewInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { IonContent } from '@ionic/angular'; import { ModalOptions } from '@ionic/core'; import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; import { AddonModForum, AddonModForumData, AddonModForumProvider, AddonModForumSortOrder, AddonModForumDiscussion, AddonModForumNewDiscussionData, AddonModForumReplyDiscussionData, } from '@addons/mod/forum/services/forum'; import { AddonModForumOffline } from '@addons/mod/forum/services/forum-offline'; import { Translate } from '@singletons'; import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; import { AddonModForumHelper } from '@addons/mod/forum/services/forum-helper'; import { CoreGroups, CoreGroupsProvider } from '@services/groups'; import { CoreEvents, CoreEventObserver } from '@singletons/events'; import { AddonModForumAutoSyncData, AddonModForumManualSyncData, AddonModForumSyncProvider, AddonModForumSyncResult, } from '@addons/mod/forum/services/forum-sync'; import { CoreSites } from '@services/sites'; import { CoreUser } from '@features/user/services/user'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { CoreCourse } from '@features/course/services/course'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { AddonModForumDiscussionOptionsMenuComponent } from '../discussion-options-menu/discussion-options-menu'; import { AddonModForumSortOrderSelectorComponent } from '../sort-order-selector/sort-order-selector'; import { CoreScreen } from '@services/screen'; import { CoreArray } from '@singletons/array'; import { AddonModForumPrefetchHandler } from '../../services/handlers/prefetch'; import { AddonModForumModuleHandlerService } from '../../services/handlers/module'; import { CoreRatingProvider } from '@features/rating/services/rating'; import { CoreRatingSyncProvider } from '@features/rating/services/rating-sync'; import { CoreRatingOffline } from '@features/rating/services/rating-offline'; import { ContextLevel } from '@/core/constants'; import { AddonModForumDiscussionItem, AddonModForumDiscussionsSource } from '../../classes/forum-discussions-source'; import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; /** * Component that displays a forum entry page. */ @Component({ selector: 'addon-mod-forum-index', templateUrl: 'index.html', styleUrls: ['index.scss'], }) export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; component = AddonModForumProvider.COMPONENT; moduleName = 'forum'; descriptionNote?: string; discussions!: AddonModForumDiscussionsManager; discussionsItems: AddonModForumDiscussionItem[] = []; fetchFailed = false; canAddDiscussion = false; addDiscussionText!: string; availabilityMessage: string | null = null; sortingAvailable!: boolean; sortOrders: AddonModForumSortOrder[] = []; canPin = false; hasOfflineRatings = false; sortOrderSelectorModalOptions: ModalOptions = { component: AddonModForumSortOrderSelectorComponent, }; protected syncEventName = AddonModForumSyncProvider.AUTO_SYNCED; protected syncManualObserver?: CoreEventObserver; // It will observe the sync manual event. protected replyObserver?: CoreEventObserver; protected newDiscObserver?: CoreEventObserver; protected viewDiscObserver?: CoreEventObserver; protected changeDiscObserver?: CoreEventObserver; protected ratingOfflineObserver?: CoreEventObserver; protected ratingSyncObserver?: CoreEventObserver; protected sourceUnsubscribe?: () => void; constructor( public route: ActivatedRoute, @Optional() protected content?: IonContent, @Optional() courseContentsPage?: CoreCourseContentsPage, ) { super('AddonModForumIndexComponent', content, courseContentsPage); } get forum(): AddonModForumData | undefined { return this.discussions?.getSource().forum; } get selectedSortOrder(): AddonModForumSortOrder | undefined { return this.discussions?.getSource().selectedSortOrder ?? undefined; } /** * Check whether a discussion is online. * * @param discussion Discussion * @return Whether the discussion is online. */ isOnlineDiscussion(discussion: AddonModForumDiscussionItem): boolean { return this.discussions && this.discussions.getSource().isOnlineDiscussion(discussion); } /** * Check whether a discussion is offline. * * @param discussion Discussion * @return Whether the discussion is offline. */ isOfflineDiscussion(discussion: AddonModForumDiscussionItem): boolean { return this.discussions && this.discussions.getSource().isOfflineDiscussion(discussion); } /** * Component being initialized. */ async ngOnInit(): Promise { this.addDiscussionText = Translate.instant('addon.mod_forum.addanewdiscussion'); this.sortingAvailable = AddonModForum.isDiscussionListSortingAvailable(); this.sortOrders = AddonModForum.getAvailableSortOrders(); this.sortOrderSelectorModalOptions.componentProps = { sortOrders: this.sortOrders, }; await super.ngOnInit(); // Initialize discussions manager. const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( AddonModForumDiscussionsSource, [this.courseId, this.module.id, this.courseContentsPage ? `${AddonModForumModuleHandlerService.PAGE_NAME}/` : ''], ); this.sourceUnsubscribe = source.addListener({ onItemsUpdated: async discussions => { this.discussionsItems = discussions.filter(discussion => !source.isNewDiscussionForm(discussion)); if (!this.forum) { return; } // Check if there are replies for discussions stored in offline. const hasOffline = await AddonModForumOffline.hasForumReplies(this.forum.id); this.hasOffline = this.hasOffline || hasOffline; if (hasOffline) { // Only update new fetched discussions. const promises = discussions.map(async (discussion) => { if (!this.discussions.getSource().isOnlineDiscussion(discussion)) { return; } // Get offline discussions. const replies = await AddonModForumOffline.getDiscussionReplies(discussion.discussion); discussion.numreplies = Number(discussion.numreplies) + replies.length; }); await Promise.all(promises); } }, onReset: () => { this.discussionsItems = []; }, }); this.discussions = new AddonModForumDiscussionsManager(source, this); // Refresh data if this forum discussion is synchronized from discussions list. this.syncManualObserver = CoreEvents.on(AddonModForumSyncProvider.MANUAL_SYNCED, (data) => { this.autoSyncEventReceived(data); }, this.siteId); // Listen for discussions added. When a discussion is added, we reload the data. this.newDiscObserver = CoreEvents.on( AddonModForumProvider.NEW_DISCUSSION_EVENT, this.eventReceived.bind(this, true), ); this.replyObserver = CoreEvents.on( AddonModForumProvider.REPLY_DISCUSSION_EVENT, this.eventReceived.bind(this, false), ); this.changeDiscObserver = CoreEvents.on(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data => { if (!this.forum) { return; } if (this.forum.id === data.forumId || data.cmId === this.module.id) { AddonModForum.invalidateDiscussionsList(this.forum.id).finally(() => { if (data.discussionId) { // Discussion changed, search it in the list of discussions. const discussion = this.discussions.items.find( (disc) => this.discussions.getSource().isOnlineDiscussion(disc) && data.discussionId == disc.discussion, ) as AddonModForumDiscussion; if (discussion) { if (data.locked !== undefined) { discussion.locked = data.locked; } if (data.pinned !== undefined) { discussion.pinned = data.pinned; } if (data.starred !== undefined) { discussion.starred = data.starred; } this.showLoadingAndRefresh(false); } } if (data.deleted !== undefined && data.deleted) { if (data.post?.parentid == 0 && CoreScreen.isTablet && !this.discussions.empty) { // Discussion deleted, clear details page. this.discussions.select(this.discussions[0]); } this.showLoadingAndRefresh(false); } }); } }); // Listen for offline ratings saved and synced. this.ratingOfflineObserver = CoreEvents.on(CoreRatingProvider.RATING_SAVED_EVENT, (data) => { if (this.forum && data.component == 'mod_forum' && data.ratingArea == 'post' && data.contextLevel == ContextLevel.MODULE && data.instanceId == this.forum.cmid) { this.hasOfflineRatings = true; } }); this.ratingSyncObserver = CoreEvents.on(CoreRatingSyncProvider.SYNCED_EVENT, async (data) => { if (this.forum && data.component == 'mod_forum' && data.ratingArea == 'post' && data.contextLevel == ContextLevel.MODULE && data.instanceId == this.forum.cmid) { this.hasOfflineRatings = await CoreRatingOffline.hasRatings('mod_forum', 'post', ContextLevel.MODULE, this.forum.cmid); } }); } async ngAfterViewInit(): Promise { await this.loadContent(false, true); this.discussions.start(this.splitView); } /** * Component being destroyed. */ ngOnDestroy(): void { super.ngOnDestroy(); this.syncManualObserver && this.syncManualObserver.off(); this.newDiscObserver && this.newDiscObserver.off(); this.replyObserver && this.replyObserver.off(); this.viewDiscObserver && this.viewDiscObserver.off(); this.changeDiscObserver && this.changeDiscObserver.off(); this.ratingOfflineObserver && this.ratingOfflineObserver.off(); this.ratingSyncObserver && this.ratingSyncObserver.off(); this.sourceUnsubscribe && this.sourceUnsubscribe(); this.discussions.destroy(); } /** * Download the component contents. * * @param refresh Whether we're refreshing data. * @param sync If the refresh needs syncing. * @param showErrors Wether to show errors to the user or hide them. */ protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { this.fetchFailed = false; try { await Promise.all([ this.fetchForum(sync, showErrors), this.fetchSortOrderPreference(), ]); if (!this.forum) { return; } await Promise.all([ refresh ? this.discussions.reload() : this.discussions.load(), CoreRatingOffline.hasRatings('mod_forum', 'post', ContextLevel.MODULE, this.forum.cmid).then((hasRatings) => { this.hasOfflineRatings = hasRatings; return; }), ]); } catch (error) { if (refresh) { CoreDomUtils.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true); this.fetchFailed = true; // Set to prevent infinite calls with infinite-loading. } else { // Get forum failed, retry without using cache since it might be a new activity. await this.refreshContent(sync); } } this.fillContextMenu(refresh); } private async fetchForum(sync: boolean = false, showErrors: boolean = false): Promise { if (!this.courseId || !this.module) { return; } await this.discussions.getSource().loadForum(); if (!this.forum) { return; } const forum = this.forum; this.description = forum.intro || this.description; this.availabilityMessage = AddonModForumHelper.getAvailabilityMessage(forum); this.descriptionNote = Translate.instant('addon.mod_forum.numdiscussions', { numdiscussions: forum.numdiscussions, }); this.dataRetrieved.emit(forum); switch (forum.type) { case 'news': case 'blog': this.addDiscussionText = Translate.instant('addon.mod_forum.addanewtopic'); break; case 'qanda': this.addDiscussionText = Translate.instant('addon.mod_forum.addanewquestion'); break; default: this.addDiscussionText = Translate.instant('addon.mod_forum.addanewdiscussion'); } if (sync) { // Try to synchronize the forum. const updated = await this.syncActivity(showErrors); if (updated) { // Sync successful, send event. CoreEvents.trigger(AddonModForumSyncProvider.MANUAL_SYNCED, { forumId: forum.id, userId: CoreSites.getCurrentSiteUserId(), source: 'index', }, CoreSites.getCurrentSiteId()); } } const promises: Promise[] = []; // Check if the activity uses groups. promises.push( CoreGroups.instance .getActivityGroupMode(forum.cmid) .then(async mode => { this.discussions.getSource().usesGroups = mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS; return; }), ); promises.push( AddonModForum.instance .getAccessInformation(forum.id, { cmId: this.module.id }) .then(async accessInfo => { // Disallow adding discussions if cut-off date is reached and the user has not the // capability to override it. // Just in case the forum was fetched from WS when the cut-off date was not reached but it is now. const cutoffDateReached = AddonModForumHelper.isCutoffDateReached(forum) && !accessInfo.cancanoverridecutoff; this.canAddDiscussion = !!forum.cancreatediscussions && !cutoffDateReached; return; }), ); if (AddonModForum.isSetPinStateAvailableForSite()) { // Use the canAddDiscussion WS to check if the user can pin discussions. promises.push( AddonModForum.instance .canAddDiscussionToAll(forum.id, { cmId: this.module.id }) .then(async response => { this.canPin = !!response.canpindiscussions; return; }) .catch(async () => { this.canPin = false; return; }), ); } else { this.canPin = false; } await Promise.all(promises); } /** * Convenience function to load more forum discussions. * * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. * @return Promise resolved when done. */ async fetchMoreDiscussions(complete: () => void): Promise { try { this.fetchFailed = false; await this.discussions.load(); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true); this.fetchFailed = true; } finally { complete(); } } /** * Convenience function to fetch the sort order preference. * * @return Promise resolved when done. */ protected async fetchSortOrderPreference(): Promise { const getSortOrder = async () => { if (!this.sortingAvailable) { return null; } const value = await CoreUtils.ignoreErrors( CoreUser.getUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER), ); return value ? parseInt(value, 10) : null; }; const value = await getSortOrder(); const selectedOrder = this.sortOrders.find(sortOrder => sortOrder.value === value) || this.sortOrders[0]; this.discussions.getSource().selectedSortOrder = selectedOrder; if (this.sortOrderSelectorModalOptions.componentProps) { this.sortOrderSelectorModalOptions.componentProps.selected = selectedOrder.value; } } /** * Perform the invalidate content function. * * @return Resolved when done. */ protected async invalidateContent(): Promise { const promises: Promise[] = []; promises.push(AddonModForum.invalidateForumData(this.courseId)); if (this.forum) { promises.push(AddonModForum.invalidateDiscussionsList(this.forum.id)); promises.push(CoreGroups.invalidateActivityGroupMode(this.forum.cmid)); promises.push(AddonModForum.invalidateAccessInformation(this.forum.id)); } if (this.sortingAvailable) { promises.push(CoreUser.invalidateUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER)); } await Promise.all(promises); } /** * Performs the sync of the activity. * * @return Promise resolved when done. */ protected sync(): Promise { return AddonModForumPrefetchHandler.sync(this.module, this.courseId); } /** * Checks if sync has succeed from result sync data. * * @param result Data returned on the sync function. * @return Whether it succeed or not. */ protected hasSyncSucceed(result: AddonModForumSyncResult): boolean { return result.updated; } /** * Compares sync event data with current data to check if refresh content is needed. * * @param syncEventData Data receiven on sync observer. * @return True if refresh is needed, false otherwise. */ protected isRefreshSyncNeeded(syncEventData: AddonModForumAutoSyncData | AddonModForumManualSyncData): boolean { return !!this.forum && (!('source' in syncEventData) || syncEventData.source != 'index') && syncEventData.forumId == this.forum.id && syncEventData.userId == CoreSites.getCurrentSiteUserId(); } /** * Function called when we receive an event of new discussion or reply to discussion. * * @param isNewDiscussion Whether it's a new discussion event. * @param data Event data. */ protected eventReceived( isNewDiscussion: boolean, data: AddonModForumNewDiscussionData | AddonModForumReplyDiscussionData, ): void { if ((this.forum && this.forum.id === data.forumId) || data.cmId === this.module.id) { this.showLoadingAndRefresh(false).finally(() => { // If it's a new discussion in tablet mode, try to open it. if (isNewDiscussion && CoreScreen.isTablet) { const newDiscussionData = data as AddonModForumNewDiscussionData; const discussion = this.discussions.items.find(disc => { if (this.discussions.getSource().isOfflineDiscussion(disc)) { return disc.timecreated === newDiscussionData.discTimecreated; } if (this.discussions.getSource().isOnlineDiscussion(disc)) { return CoreArray.contains(newDiscussionData.discussionIds ?? [], disc.discussion); } return false; }); if (discussion || !this.discussions.empty) { this.discussions.select(discussion ?? this.discussions.items[0]); } } }); // Check completion since it could be configured to complete once the user adds a new discussion or replies. CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); } } /** * Opens the new discussion form. * * @param timeCreated Creation time of the offline discussion. */ openNewDiscussion(): void { this.discussions.select(AddonModForumDiscussionsSource.NEW_DISCUSSION); } /** * Changes the sort order. * * @param sortOrder Sort order new data. */ async setSortOrder(sortOrder: AddonModForumSortOrder): Promise { if (sortOrder.value != this.discussions.getSource().selectedSortOrder?.value) { this.discussions.getSource().selectedSortOrder = sortOrder; this.discussions.getSource().setDirty(true); if (this.sortOrderSelectorModalOptions.componentProps) { this.sortOrderSelectorModalOptions.componentProps.selected = sortOrder.value; } try { await CoreUser.setUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER, sortOrder.value.toFixed(0)); await this.showLoadingAndFetch(); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error updating preference.'); } } } /** * Display the sort order selector modal. */ async showSortOrderSelector(): Promise { const modalData = await CoreDomUtils.openModal(this.sortOrderSelectorModalOptions); if (modalData) { this.setSortOrder(modalData); } } /** * Show the context menu. * * @param event Click Event. * @param discussion Discussion. */ async showOptionsMenu(event: Event, discussion: AddonModForumDiscussion): Promise { if (!this.forum) { return; } event.preventDefault(); event.stopPropagation(); const popoverData = await CoreDomUtils.openPopover<{ action?: string; value: boolean }>({ component: AddonModForumDiscussionOptionsMenuComponent, componentProps: { discussion, forumId: this.forum.id, cmId: this.module.id, }, event, }); if (popoverData && popoverData.action) { switch (popoverData.action) { case 'lock': discussion.locked = popoverData.value; break; case 'pin': discussion.pinned = popoverData.value; break; case 'star': discussion.starred = popoverData.value; break; default: break; } } } } /** * Discussions manager. */ class AddonModForumDiscussionsManager extends CoreListItemsManager { page: AddonModForumIndexComponent; constructor(source: AddonModForumDiscussionsSource, page: AddonModForumIndexComponent) { super(source, page.route.component); this.page = page; } /** * @inheritdoc */ protected getDefaultItem(): AddonModForumDiscussionItem | null { const source = this.getSource(); return this.items.find(discussion => !source.isNewDiscussionForm(discussion)) || null; } /** * @inheritdoc */ protected async logActivity(): Promise { const forum = this.getSource().forum; if (!forum) { return; } CoreUtils.ignoreErrors( AddonModForum.instance .logView(forum.id, forum.name) .then(async () => { CoreCourse.checkModuleCompletion(this.page.courseId, this.page.module.completiondata); return; }), ); } }