Merge pull request #1309 from albertgasset/MOBILE-2341

Mobile 2341
main
Juan Leyva 2018-05-16 21:19:35 +02:00 committed by GitHub
commit a0cbd54100
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 4885 additions and 45 deletions

View File

@ -0,0 +1,50 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CorePipesModule } from '@pipes/pipes.module';
import { CoreCourseComponentsModule } from '@core/course/components/components.module';
import { AddonModForumIndexComponent } from './index/index';
import { AddonModForumPostComponent } from './post/post';
@NgModule({
declarations: [
AddonModForumIndexComponent,
AddonModForumPostComponent
],
imports: [
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
CorePipesModule,
CoreCourseComponentsModule
],
providers: [
],
exports: [
AddonModForumIndexComponent,
AddonModForumPostComponent
],
entryComponents: [
AddonModForumIndexComponent
]
})
export class AddonModForumComponentsModule {}

View File

@ -0,0 +1,101 @@
<!-- Buttons to add to the header. -->
<core-navbar-buttons end>
<core-context-menu>
<core-context-menu-item *ngIf="externalUrl" [priority]="900" [content]="'core.openinbrowser' | translate" [href]="externalUrl" [iconAction]="'open'"></core-context-menu-item>
<core-context-menu-item *ngIf="description" [priority]="800" [content]="'core.moduleintro' | translate" (action)="expandDescription()" [iconAction]="'arrow-forward'"></core-context-menu-item>
<core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'addon.mod_forum.refreshdiscussions' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="loaded && hasOffline && isOnline" [priority]="600" [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(null, $event, true)" [iconAction]="syncIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch()" [iconAction]="prefetchStatusIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="400" [content]="size" [iconDescription]="'cube'" (action)="removeFiles()" [iconAction]="'trash'"></core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>
<!-- Content. -->
<core-split-view>
<ion-content>
<ion-refresher [enabled]="loaded" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded" class="core-loading-center">
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId" [note]="descriptionNote"></core-course-module-description>
<!-- Forum discussions found to be synchronized -->
<ion-card class="core-warning-card" icon-start *ngIf="hasOffline">
<ion-icon name="warning"></ion-icon> {{ 'core.hasdatatosync' | translate:{$a: moduleName} }}
</ion-card>
<ng-container *ngIf="forum && discussions.length > 0">
<div padding-horizontal margin-vertical *ngIf="forum.cancreatediscussions">
<button ion-button block (click)="openNewDiscussion()">
{{addDiscussionText}}
</button>
</div>
<ion-card *ngFor="let discussion of offlineDiscussions" (click)="openNewDiscussion(discussion.timecreated)" [class.addon-forum-discussion-selected]="discussion.timecreated == -selectedDiscussion">
<ion-item text-wrap>
<ion-avatar item-start core-user-link [userId]="discussion.userid" [courseId]="courseId">
<img [src]="discussion.userpictureurl" onError="this.src='assets/img/user-avatar.png'" core-external-content [alt]="'core.pictureof' | translate:{$a: discussion.userfullname}" role="presentation">
</ion-avatar>
<h2>{{discussion.subject}}</h2>
<p *ngIf="discussion.userfullname">
<ion-note float-right padding-left><ion-icon name="time"></ion-icon> {{ 'core.notsent' | translate }}</ion-note>
{{discussion.userfullname}}
</p>
</ion-item>
<ion-card-content>
<ion-note text-right *ngIf="discussion.groupname">
<ion-icon name="people"></ion-icon> {{ discussion.groupname }}
</ion-note>
<p><core-format-text [clean]="true" [maxHeight]="60" [component]="component" [componentId]="componentId" [text]="discussion.message"></core-format-text></p>
</ion-card-content>
</ion-card>
<ion-card *ngFor="let discussion of discussions" (click)="openDiscussion(discussion)" [class.addon-forum-discussion-selected]="discussion.discussion == selectedDiscussion">
<ion-item text-wrap>
<ion-avatar item-start core-user-link [userId]="discussion.userid" [courseId]="courseId">
<img [src]="discussion.userpictureurl" onError="this.src='assets/img/user-avatar.png'" core-external-content [alt]="'core.pictureof' | translate:{$a: discussion.userfullname}" role="presentation">
</ion-avatar>
<h2><ion-icon name="pin" *ngIf="discussion.pinned"></ion-icon> {{discussion.subject}}</h2>
<p>
<ion-note float-right padding-left>
{{discussion.created | coreDateDayOrTime}}
<div *ngIf="discussion.numunread"><ion-icon name="record"></ion-icon> {{ 'addon.mod_forum.unreadpostsnumber' | translate:{ '$a' : discussion.numunread} }}</div>
</ion-note>
{{discussion.userfullname}}
</p>
</ion-item>
<ion-card-content>
<core-format-text [clean]="true" [maxHeight]="60" [component]="component" [componentId]="componentId" [text]="discussion.message"></core-format-text>
</ion-card-content>
<ion-row text-center>
<ion-col *ngIf="discussion.groupname">
<ion-note>
<ion-icon name="people"></ion-icon> {{ discussion.groupname }}
</ion-note>
</ion-col>
<ion-col>
<ion-note>
<ion-icon name="chatboxes"></ion-icon> {{ 'addon.mod_forum.numreplies' | translate:{numreplies: discussion.numreplies} }}
</ion-note>
</ion-col>
<ion-col *ngIf="discussion.timemodified > discussion.created">
<ion-note>
<ion-icon name="time"></ion-icon> {{discussion.timemodified | coreTimeAgo}}
</ion-note>
</ion-col>
</ion-row>
</ion-card>
</ng-container>
<core-empty-box *ngIf="forum && discussions.length == 0" icon="chatbubbles" [message]="'addon.mod_forum.forumnodiscussionsyet' | translate">
<div padding *ngIf="forum.cancreatediscussions">
<button ion-button block (click)="openNewDiscussion()">
{{ 'addon.mod_forum.addanewdiscussion' | translate }}
</button>
</div>
</core-empty-box>
<ion-infinite-scroll [enabled]="canLoadMore" (ionInfinite)="$event.waitFor(fetchMoreDiscussions())">
<ion-infinite-scroll-content></ion-infinite-scroll-content>
</ion-infinite-scroll>
</core-loading>
</ion-content>
</core-split-view>

View File

@ -0,0 +1,5 @@
addon-mod-forum-index {
.addon-forum-discussion-selected {
border-top: 5px solid $core-color-light;
}
}

View File

@ -0,0 +1,440 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Optional, Injector, ViewChild } from '@angular/core';
import { Content, NavController } from 'ionic-angular';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component';
import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
import { CoreUserProvider } from '@core/user/providers/user';
import { CoreGroupsProvider } from '@providers/groups';
import { AddonModForumProvider } from '../../providers/forum';
import { AddonModForumHelperProvider } from '../../providers/helper';
import { AddonModForumOfflineProvider } from '../../providers/offline';
import { AddonModForumSyncProvider } from '../../providers/sync';
import { AddonModForumPrefetchHandler } from '../../providers/prefetch-handler';
/**
* Component that displays a forum entry page.
*/
@Component({
selector: 'addon-mod-forum-index',
templateUrl: 'index.html',
})
export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityComponent {
@ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent;
component = AddonModForumProvider.COMPONENT;
moduleName = 'forum';
descriptionNote: string;
forum: any;
canLoadMore = false;
discussions = [];
offlineDiscussions = [];
selectedDiscussion = 0; // Disucssion ID or negative timecreated if it's an offline discussion.
addDiscussionText = this.translate.instant('addon.mod_forum.addanewdiscussion');
protected syncEventName = AddonModForumSyncProvider.AUTO_SYNCED;
protected page = 0;
protected trackPosts = false;
protected usesGroups = false;
protected syncManualObserver: any; // It will observe the sync manual event.
protected replyObserver: any;
protected newDiscObserver: any;
protected viewDiscObserver: any;
constructor(injector: Injector,
@Optional() protected content: Content,
protected navCtrl: NavController,
protected groupsProvider: CoreGroupsProvider,
protected userProvider: CoreUserProvider,
protected forumProvider: AddonModForumProvider,
protected forumHelper: AddonModForumHelperProvider,
protected forumOffline: AddonModForumOfflineProvider,
protected forumSync: AddonModForumSyncProvider,
protected prefetchDelegate: CoreCourseModulePrefetchDelegate,
protected prefetchHandler: AddonModForumPrefetchHandler) {
super(injector);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
super.ngOnInit();
// Refresh data if this forum discussion is synchronized from discussions list.
this.syncManualObserver = this.eventsProvider.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 = this.eventsProvider.on(AddonModForumProvider.NEW_DISCUSSION_EVENT, this.eventReceived.bind(this));
this.replyObserver = this.eventsProvider.on(AddonModForumProvider.REPLY_DISCUSSION_EVENT, this.eventReceived.bind(this));
// Select the curren opened discussion.
this.viewDiscObserver = this.eventsProvider.on(AddonModForumProvider.VIEW_DISCUSSION_EVENT, (data) => {
if (this.forum && this.forum.id == data.forumId) {
this.selectedDiscussion = this.splitviewCtrl.isOn() ? data.discussion : 0;
}
}, this.sitesProvider.getCurrentSiteId());
this.loadContent(false, true).then(() => {
if (!this.forum) {
return;
}
if (this.splitviewCtrl.isOn()) {
// Load the first discussion.
if (this.offlineDiscussions.length > 0) {
this.openNewDiscussion(this.offlineDiscussions[0].timecreated);
} else if (this.discussions.length > 0) {
this.openDiscussion(this.discussions[0]);
}
}
this.forumProvider.logView(this.forum.id).then(() => {
this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus);
}).catch((error) => {
// Ignore errors.
});
});
}
/**
* Download the component contents.
*
* @param {boolean} [refresh=false] Whether we're refreshing data.
* @param {boolean} [sync=false] If the refresh needs syncing.
* @param {boolean} [showErrors=false] Wether to show errors to the user or hide them.
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<any> {
return this.forumProvider.getForum(this.courseId, this.module.id).then((forum) => {
this.forum = forum;
this.description = forum.intro || this.description;
this.descriptionNote = this.translate.instant('addon.mod_forum.numdiscussions', {numdiscussions: forum.numdiscussions});
if (typeof forum.istracked != 'undefined') {
this.trackPosts = forum.istracked;
}
this.dataRetrieved.emit(forum);
switch (forum.type) {
case 'news':
case 'blog':
this.addDiscussionText = this.translate.instant('addon.mod_forum.addanewtopic');
break;
case 'qanda':
this.addDiscussionText = this.translate.instant('addon.mod_forum.addanewquestion');
break;
default:
this.addDiscussionText = this.translate.instant('addon.mod_forum.addanewdiscussion');
}
if (sync) {
// Try to synchronize the forum.
return this.syncActivity(showErrors).then((updated) => {
if (updated) {
// Sync successful, send event.
this.eventsProvider.trigger(AddonModForumSyncProvider.MANUAL_SYNCED, {
forumId: forum.id,
userId: this.sitesProvider.getCurrentSiteUserId(),
source: 'index',
}, this.sitesProvider.getCurrentSiteId());
}
});
}
}).then(() => {
// Check if the activity uses groups.
return this.groupsProvider.getActivityGroupMode(this.forum.cmid).then((mode) => {
this.usesGroups = (mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS);
});
}).then(() => {
return Promise.all([
this.fetchOfflineDiscussion(),
this.fetchDiscussions(refresh),
]);
}).catch((message) => {
if (!refresh) {
// Get forum failed, retry without using cache since it might be a new activity.
return this.refreshContent(sync);
}
this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.errorgetforum', true);
this.canLoadMore = false; // Set to false to prevent infinite calls with infinite-loading.
});
}
/**
* Convenience function to fetch offline discussions.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchOfflineDiscussion(): Promise<any> {
return this.forumOffline.getNewDiscussions(this.forum.id).then((offlineDiscussions) => {
this.hasOffline = !!offlineDiscussions.length;
if (this.hasOffline) {
let promise;
if (this.usesGroups) {
promise = this.forumProvider.formatDiscussionsGroups(this.forum.cmid, offlineDiscussions);
} else {
promise = Promise.resolve(offlineDiscussions);
}
return promise.then((offlineDiscussions) => {
// Fill user data for Offline discussions (should be already cached).
const userPromises = [];
offlineDiscussions.forEach((discussion) => {
if (discussion.parent != 0 || this.forum.type != 'single') {
// Do not show author for first post and type single.
userPromises.push(this.userProvider.getProfile(discussion.userid, this.courseId, true)
.then((user) => {
discussion.userfullname = user.fullname;
discussion.userpictureurl = user.profileimageurl;
}).catch(() => {
// Ignore errors.
}));
}
});
return Promise.all(userPromises).then(() => {
// Sort discussion by time (newer first).
offlineDiscussions.sort((a, b) => b.timecreated - a.timecreated);
this.offlineDiscussions = offlineDiscussions;
});
});
} else {
this.offlineDiscussions = [];
}
});
}
/**
* Convenience function to get forum discussions.
*
* @param {boolean} refresh Whether we're refreshing data.
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchDiscussions(refresh: boolean): Promise<any> {
if (refresh) {
this.page = 0;
}
return this.forumProvider.getDiscussions(this.forum.id, this.page).then((response) => {
let promise;
if (this.usesGroups) {
promise = this.forumProvider.formatDiscussionsGroups(this.forum.cmid, response.discussions);
} else {
promise = Promise.resolve(response.discussions);
}
return promise.then((discussions) => {
if (this.forum.type == 'single') {
// Hide author for first post and type single.
for (const x in discussions) {
if (discussions[x].userfullname && discussions[x].parent == 0) {
discussions[x].userfullname = false;
break;
}
}
}
if (typeof this.forum.istracked == 'undefined' && !this.trackPosts) {
// If any discussion has unread posts, the whole forum is being tracked.
for (const y in discussions) {
if (discussions[y].numunread > 0) {
this.trackPosts = true;
break;
}
}
}
if (this.page == 0) {
this.discussions = discussions;
} else {
this.discussions = this.discussions.concat(discussions);
}
this.canLoadMore = response.canLoadMore;
this.page++;
// Check if there are replies for discussions stored in offline.
return this.forumOffline.hasForumReplies(this.forum.id).then((hasOffline) => {
const offlinePromises = [];
this.hasOffline = this.hasOffline || hasOffline;
if (hasOffline) {
// Only update new fetched discussions.
discussions.forEach((discussion) => {
// Get offline discussions.
offlinePromises.push(this.forumOffline.getDiscussionReplies(discussion.discussion).then((replies) => {
discussion.numreplies = parseInt(discussion.numreplies, 10) + replies.length;
}));
});
}
return Promise.all(offlinePromises);
});
});
});
}
/**
* Convenience function to load more forum discussions.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchMoreDiscussions(): Promise<any> {
return this.fetchDiscussions(false).catch((message) => {
this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.errorgetforum', true);
this.canLoadMore = false; // Set to false to prevent infinite calls with infinite-loading.
});
}
/**
* Perform the invalidate content function.
*
* @return {Promise<any>} Resolved when done.
*/
protected invalidateContent(): Promise<any> {
const promises = [];
promises.push(this.forumProvider.invalidateForumData(this.courseId));
if (this.forum) {
promises.push(this.forumProvider.invalidateDiscussionsList(this.forum.id));
promises.push(this.groupsProvider.invalidateActivityGroupMode(this.forum.cmid));
}
return Promise.all(promises);
}
/**
* Performs the sync of the activity.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected sync(): Promise<boolean> {
const promises = [];
promises.push(this.forumSync.syncForumDiscussions(this.forum.id).then((result) => {
if (result.warnings && result.warnings.length) {
this.domUtils.showErrorModal(result.warnings[0]);
}
return result;
}));
promises.push(this.forumSync.syncForumReplies(this.forum.id).then((result) => {
if (result.warnings && result.warnings.length) {
this.domUtils.showErrorModal(result.warnings[0]);
}
return result;
}));
return Promise.all(promises).then((results) => {
return results.reduce((a, b) => ({
updated: a.updated || b.updated,
warnings: (a.warnings || []).concat(b.warnings || []),
}), {updated: false});
});
}
/**
* Checks if sync has succeed from result sync data.
*
* @param {any} result Data returned on the sync function.
* @return {boolean} Whether it succeed or not.
*/
protected hasSyncSucceed(result: any): boolean {
return result.updated;
}
/**
* Compares sync event data with current data to check if refresh content is needed.
*
* @param {any} syncEventData Data receiven on sync observer.
* @return {boolean} True if refresh is needed, false otherwise.
*/
protected isRefreshSyncNeeded(syncEventData: any): boolean {
return this.forum && syncEventData.source != 'index' && syncEventData.forumId == this.forum.id &&
syncEventData.userId == this.sitesProvider.getCurrentSiteUserId();
}
/**
* Function called when we receive an event of new discussion or reply to discussion.
*
* @param {any} data Event data.
*/
protected eventReceived(data: any): void {
if ((this.forum && this.forum.id === data.forumId) || data.cmId === this.module.id) {
this.showLoadingAndRefresh(false);
// Check completion since it could be configured to complete once the user adds a new discussion or replies.
this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus);
}
}
/**
* Opens a discussion.
*
* @param {any} discussion Discussion object.
*/
openDiscussion(discussion: any): void {
const params = {
courseId: this.courseId,
cmId: this.module.id,
forumId: this.forum.id,
discussionId: discussion.discussion,
trackPosts: this.trackPosts,
locked: discussion.locked && !discussion.canreply
};
this.splitviewCtrl.push('AddonModForumDiscussionPage', params);
}
/**
* Opens the new discussion form.
*
* @param {number} [timeCreated=0] Creation time of the offline discussion.
*/
openNewDiscussion(timeCreated: number = 0): void {
const params = {
courseId: this.courseId,
cmId: this.module.id,
forumId: this.forum.id,
timeCreated: timeCreated,
};
this.splitviewCtrl.push('AddonModForumNewDiscussionPage', params);
}
/**
* 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();
}
}

View File

@ -0,0 +1,61 @@
<ion-item text-wrap>
<ion-avatar item-start (click)="openUserProfile(post.userid)">
<img [src]="post.userpictureurl" onError="this.src='assets/img/user-avatar.png'" core-external-content [alt]="'core.pictureof' | translate:{$a: post.userfullname}" role="presentation">
</ion-avatar>
<h2><span [class.core-bold]="post.parent == 0">{{post.subject}}</span></h2>
<p>
<ion-note float-right padding-left *ngIf="!post.modified"><ion-icon name="time"></ion-icon> {{ 'core.notsent' | translate }}</ion-note>
<ion-note float-right padding-left *ngIf="post.modified">
{{post.modified | coreDateDayOrTime}}
<div *ngIf="unread"><ion-icon name="record"></ion-icon> {{ 'addon.mod_forum.unread' | translate }}</div>
</ion-note>
{{post.userfullname}}
</p>
</ion-item>
<ion-card-content>
<core-format-text [component]="component" [componentId]="componentId" [text]="post.message"></core-format-text>
<div *ngFor="let attachment of post.attachments">
<!-- Files already attached to the submission. -->
<core-file *ngIf="!attachment.name" [file]="attachment" [component]="component" [componentId]="componentId"></core-file>
<!-- Files stored in offline to be sent later. -->
<core-local-file *ngIf="attachment.name" [file]="attachment"></core-local-file>
</div>
</ion-card-content>
<ion-item text-right *ngIf="post.id && post.canreply">
<button ion-button icon-left clear small (click)="showReply()" [attr.aria-controls]="'addon-forum-reply-edit-form-' + uniqueId" [attr.aria-expanded]="replyData.replyingTo === post.id">
<ion-icon name="undo"></ion-icon> {{ 'addon.mod_forum.reply' | translate }}
</button>
</ion-item>
<ion-item text-right *ngIf="!post.id && (!replyData.isEditing || replyData.replyingTo != post.parent)">
<button ion-button icon-left clear small (click)="editReply()" [attr.aria-controls]="'addon-forum-reply-edit-form-' + uniqueId" [attr.aria-expanded]="replyData.replyingTo === post.parent">
<ion-icon name="create"></ion-icon> {{ 'addon.mod_forum.edit' | translate }}
</button>
</ion-item>
<ion-list [id]="'addon-forum-reply-edit-form-' + uniqueId" *ngIf="(post.id && !replyData.isEditing && replyData.replyingTo == post.id) || (!post.id && replyData.isEditing && replyData.replyingTo == post.parent)">
<ion-item>
<ion-label stacked>{{ 'addon.mod_forum.subject' | translate }}</ion-label>
<ion-input type="text" [placeholder]="'addon.mod_forum.subject' | translate" [(ngModel)]="replyData.subject"></ion-input>
</ion-item>
<ion-item>
<ion-label stacked>{{ 'addon.mod_forum.message' | translate }}</ion-label>
<core-rich-text-editor item-content [control]="messageControl" (contentChanged)="onMessageChange($event)" [placeholder]="'addon.mod_forum.message' | translate" [name]="'mod_forum_reply_' + post.id"></core-rich-text-editor>
<!-- @todo: Attributes that were passed to RTE in Ionic 1 but now they aren't supported yet:
[component]="component" [componentId]="componentId" -->
</ion-item>
<core-attachments *ngIf="forum.id && forum.maxattachments > 0" [files]="replyData.files" [maxSize]="forum.maxbytes" [maxSubmissions]="forum.maxattachments" [component]="component" [componentId]="forum.cmid" [allowOffline]="true"></core-attachments>
<ion-grid>
<ion-row>
<ion-col>
<button ion-button block (click)="reply()" [disabled]="replyData.subject == '' || replyData.message == null">{{ 'addon.mod_forum.posttoforum' | translate }}</button>
</ion-col>
<ion-col>
<button ion-button block color="light" (click)="cancel()">{{ 'core.cancel' | translate }}</button>
</ion-col>
</ion-row>
<ion-row *ngIf="replyData.isEditing">
<ion-col>
<button ion-button block color="light" (click)="discard()">{{ 'core.discard' | translate }}</button>
</ion-col>
</ion-row>
</ion-grid>
</ion-list>

View File

@ -0,0 +1,313 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Input, Output, Optional, EventEmitter, OnInit, OnDestroy } from '@angular/core';
import { FormControl } from '@angular/forms';
import { NavController } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { CoreSyncProvider } from '@providers/sync';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { AddonModForumProvider } from '../../providers/forum';
import { AddonModForumHelperProvider } from '../../providers/helper';
import { AddonModForumOfflineProvider } from '../../providers/offline';
import { AddonModForumSyncProvider } from '../../providers/sync';
/**
* Components that shows a discussion post, its attachments and the action buttons allowed (reply, etc.).
*/
@Component({
selector: 'addon-mod-forum-post',
templateUrl: 'post.html',
})
export class AddonModForumPostComponent implements OnInit, OnDestroy {
@Input() post: any; // Post.
@Input() courseId: number; // Post's course ID.
@Input() discussionId: number; // Post's' discussion ID.
@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: any; // The forum the post belongs to. Required for attachments and offline posts.
@Input() defaultSubject: string; // Default subject to set to new posts.
@Output() onPostChange: EventEmitter<void>; // Event emitted when a reply is posted or modified.
messageControl = new FormControl();
uniqueId: string;
protected syncId: string;
constructor(
private navCtrl: NavController,
private uploaderProvider: CoreFileUploaderProvider,
private syncProvider: CoreSyncProvider,
private domUtils: CoreDomUtilsProvider,
private textUtils: CoreTextUtilsProvider,
private translate: TranslateService,
private forumProvider: AddonModForumProvider,
private forumHelper: AddonModForumHelperProvider,
private forumOffline: AddonModForumOfflineProvider,
private forumSync: AddonModForumSyncProvider,
@Optional() private svComponent: CoreSplitViewComponent) {
this.onPostChange = new EventEmitter<void>();
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.uniqueId = this.post.id ? 'reply' + this.post.id : 'edit' + this.post.parent;
}
/**
* Opens the profile of a user.
*
* @param {number} userId
*/
openUserProfile(userId: number): void {
// Decide which navCtrl to use. If this page is inside a split view, use the split view's master nav.
const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl;
navCtrl.push('CoreUserProfilePage', {userId, courseId: this.courseId});
}
/**
* Set data to new post, clearing temporary files and updating original data.
*
* @param {number} [replyingTo] Id of post beeing replied.
* @param {boolean} [isEditing] True it's an offline reply beeing edited, false otherwise.
* @param {string} [subject] Subject of the reply.
* @param {string} [message] Message of the reply.
* @param {any[]} [files] Reply attachments.
*/
protected setReplyData(replyingTo?: number, isEditing?: boolean, subject?: string, message?: string, files?: any[]): void {
// Delete the local files from the tmp folder if any.
this.uploaderProvider.clearTmpFiles(this.replyData.files);
this.replyData.replyingTo = replyingTo || 0;
this.replyData.isEditing = !!isEditing;
this.replyData.subject = subject || this.defaultSubject || '';
this.replyData.message = message || null;
this.replyData.files = files || [];
// 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();
}
/**
* Set this post as being replied to.
*/
showReply(): void {
if (this.replyData.isEditing) {
// User is editing a post, data needs to be resetted. Ask confirm if there is unsaved data.
this.confirmDiscard().then(() => {
this.setReplyData(this.post.id);
}).catch(() => {
// Cancelled.
});
} else if (!this.replyData.replyingTo) {
// User isn't replying, it's a brand new reply. Initialize the data.
this.setReplyData(this.post.id);
} else {
// The post being replied has changed but the data will be kept.
this.replyData.replyingTo = this.post.id;
this.messageControl.setValue(this.replyData.message);
}
}
/**
* Set this post as being edited to.
*/
editReply(): void {
// Ask confirm if there is unsaved data.
this.confirmDiscard().then(() => {
this.syncId = this.forumSync.getDiscussionSyncId(this.discussionId);
this.syncProvider.blockOperation(AddonModForumProvider.COMPONENT, this.syncId);
this.setReplyData(this.post.parent, true, this.post.subject, this.post.message, this.post.attachments);
}).catch(() => {
// Cancelled.
});
}
/**
* Message changed.
*
* @param {string} text The new text.
*/
onMessageChange(text: string): void {
this.replyData.message = text;
}
/**
* Reply to this post.
*/
reply(): void {
if (!this.replyData.subject) {
this.domUtils.showErrorModal('addon.mod_forum.erroremptysubject', true);
return;
}
if (!this.replyData.message) {
this.domUtils.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: any = {};
const modal = this.domUtils.showModalLoading('core.sending', true);
// Check if rich text editor is enabled or not.
this.domUtils.isRichTextEditorEnabled().then((enabled) => {
if (!enabled) {
// Rich text editor not enabled, add some HTML to the message if needed.
message = this.textUtils.formatHtmlLines(message);
}
// Upload attachments first if any.
if (files.length) {
return this.forumHelper.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;
return this.forumHelper.uploadOrStoreReplyFiles(this.forum.id, replyingTo, files, true);
});
}
}).then((attach) => {
if (attach) {
options.attachmentsid = attach;
}
if (saveOffline) {
// Save post in offline.
return this.forumOffline.replyPost(replyingTo, this.discussionId, this.forum.id, this.forum.name,
this.courseId, subject, message, options).then(() => {
// Return false since it wasn't sent to server.
return false;
});
} else {
// Try to send it to server.
// Don't allow offline if there are attachments since they were uploaded fine.
return this.forumProvider.replyPost(replyingTo, this.discussionId, this.forum.id, this.forum.name,
this.courseId, subject, message, options, undefined, !files.length);
}
}).then((sent) => {
if (sent && this.forum.id) {
// Data sent to server, delete stored files (if any).
this.forumHelper.deleteReplyStoredFiles(this.forum.id, replyingTo);
}
// Reset data.
this.setReplyData();
this.onPostChange.emit();
if (this.syncId) {
this.syncProvider.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId);
}
}).catch((message) => {
this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.couldnotadd', true);
}).finally(() => {
modal.dismiss();
});
}
/**
* Cancel reply.
*/
cancel(): void {
this.confirmDiscard().then(() => {
// Reset data.
this.setReplyData();
if (this.syncId) {
this.syncProvider.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId);
}
}).catch(() => {
// Cancelled.
});
}
/**
* Discard offline reply.
*/
discard(): void {
this.domUtils.showConfirm(this.translate.instant('core.areyousure')).then(() => {
const promises = [];
promises.push(this.forumOffline.deleteReply(this.post.parent));
if (this.forum.id) {
promises.push(this.forumHelper.deleteReplyStoredFiles(this.forum.id, this.post.parent).catch(() => {
// Ignore errors, maybe there are no files.
}));
}
return Promise.all(promises).finally(() => {
// Reset data.
this.setReplyData();
this.onPostChange.emit();
if (this.syncId) {
this.syncProvider.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId);
}
});
}).catch(() => {
// Cancelled.
});
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
if (this.syncId) {
this.syncProvider.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId);
}
}
/**
* Confirm discard changes if any.
*
* @return {Promise<void>} Promise resolved if the user confirms or data was not changed and rejected otherwise.
*/
protected confirmDiscard(): Promise<void> {
if (this.forumHelper.hasPostDataChanged(this.replyData, this.originalData)) {
// Show confirmation if some data has been modified.
return this.domUtils.showConfirm(this.translate.instant('core.confirmloss'));
} else {
return Promise.resolve();
}
}
}

View File

@ -0,0 +1,60 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CoreCronDelegate } from '@providers/cron';
import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate';
import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate';
import { AddonModForumProvider } from './providers/forum';
import { AddonModForumOfflineProvider } from './providers/offline';
import { AddonModForumHelperProvider } from './providers/helper';
import { AddonModForumSyncProvider } from './providers/sync';
import { AddonModForumModuleHandler } from './providers/module-handler';
import { AddonModForumPrefetchHandler } from './providers/prefetch-handler';
import { AddonModForumSyncCronHandler } from './providers/sync-cron-handler';
import { AddonModForumIndexLinkHandler } from './providers/index-link-handler';
import { AddonModForumDiscussionLinkHandler } from './providers/discussion-link-handler';
import { AddonModForumComponentsModule } from './components/components.module';
@NgModule({
declarations: [
],
imports: [
AddonModForumComponentsModule,
],
providers: [
AddonModForumProvider,
AddonModForumOfflineProvider,
AddonModForumHelperProvider,
AddonModForumSyncProvider,
AddonModForumModuleHandler,
AddonModForumPrefetchHandler,
AddonModForumSyncCronHandler,
AddonModForumIndexLinkHandler,
AddonModForumDiscussionLinkHandler,
]
})
export class AddonModForumModule {
constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModForumModuleHandler,
prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModForumPrefetchHandler,
cronDelegate: CoreCronDelegate, syncHandler: AddonModForumSyncCronHandler, linksDelegate: CoreContentLinksDelegate,
indexHandler: AddonModForumIndexLinkHandler, discussionHandler: AddonModForumDiscussionLinkHandler) {
moduleDelegate.registerHandler(moduleHandler);
prefetchDelegate.registerHandler(prefetchHandler);
cronDelegate.register(syncHandler);
linksDelegate.registerHandler(indexHandler);
linksDelegate.registerHandler(discussionHandler);
}
}

View File

@ -0,0 +1,34 @@
{
"addanewdiscussion": "Add a new discussion topic",
"addanewquestion": "Add a new question",
"addanewtopic": "Add a new topic",
"cannotadddiscussion": "Adding discussions to this forum requires group membership.",
"cannotadddiscussionall": "You do not have permission to add a new discussion topic for all participants.",
"cannotcreatediscussion": "Could not create new discussion",
"couldnotadd": "Could not add your post due to an unknown error",
"discussion": "Discussion",
"discussionlocked": "This discussion has been locked so you can no longer reply to it.",
"discussionpinned": "Pinned",
"discussionsubscription": "Discussion subscription",
"edit": "Edit",
"erroremptymessage": "Post message cannot be empty",
"erroremptysubject": "Post subject cannot be empty.",
"errorgetforum": "Error getting forum data.",
"errorgetgroups": "Error getting group settings.",
"forumnodiscussionsyet": "There are no discussions yet in this forum.",
"group": "Group",
"message": "Message",
"modeflatnewestfirst": "Display replies flat, with newest first",
"modeflatoldestfirst": "Display replies flat, with oldest first",
"modenested": "Display replies in nested form",
"numdiscussions": "{{numdiscussions}} discussions",
"numreplies": "{{numreplies}} replies",
"posttoforum": "Post to forum",
"re": "Re:",
"refreshdiscussions": "Refresh discussions",
"refreshposts": "Refresh posts",
"reply": "Reply",
"subject": "Subject",
"unread": "Unread",
"unreadpostsnumber": "{{$a}} unread posts"
}

View File

@ -0,0 +1,61 @@
<ion-header>
<ion-navbar>
<ion-title *ngIf="discussion"><core-format-text [text]="discussion.subject"></core-format-text></ion-title>
<ion-buttons end>
<!-- The context menu will be added in here. -->
</ion-buttons>
</ion-navbar>
</ion-header>
<core-navbar-buttons end>
<core-context-menu>
<core-context-menu-item [priority]="650" *ngIf="discussionLoaded && !postHasOffline && isOnline" [content]="'addon.mod_forum.refreshposts' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item [priority]="550" *ngIf="discussionLoaded && !isSplitViewOn && postHasOffline && isOnline" [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(null, $event, true)" [iconAction]="syncIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item [hidden]="sort == 'flat-oldest'" [priority]="500" [content]="'addon.mod_forum.modeflatoldestfirst' | translate" (action)="changeSort('flat-oldest')" iconAction="arrow-round-down"></core-context-menu-item>
<core-context-menu-item [hidden]="sort == 'flat-newest'" [priority]="450" [content]="'addon.mod_forum.modeflatnewestfirst' | translate" (action)="changeSort('flat-newest')" iconAction="arrow-round-up"></core-context-menu-item>
<core-context-menu-item [hidden]="sort == 'nested'" [priority]="400" [content]="'addon.mod_forum.modenested' | translate" (action)="changeSort('nested')" iconAction="swap"></core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>
<ion-content>
<ion-refresher [enabled]="discussionLoaded" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="discussionLoaded">
<!-- Discussion replies found to be synchronized -->
<ion-card class="core-warning-card" *ngIf="postHasOffline">
<ion-icon name="warning"></ion-icon> {{ 'core.hasdatatosync' | translate:{$a: discussionStr} }}
</ion-card>
<ion-card class="core-warning-card" *ngIf="locked">
<ion-icon name="warning"></ion-icon> {{ 'addon.mod_forum.discussionlocked' | translate }}
</ion-card>
<ion-card *ngIf="discussion" margin-bottom>
<addon-mod-forum-post [post]="discussion" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="componentId" [replyData]="replyData" [originalData]="originalData" [defaultSubject]="defaultSubject" [forum]="forum" [trackPosts]="trackPosts" (onPostChange)="postListChanged()"></addon-mod-forum-post>
</ion-card>
<ion-card *ngIf="sort != 'nested'">
<ng-container *ngFor="let post of posts; first as first">
<ion-item-divider color="light" *ngIf="!first"></ion-item-divider>
<addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="componentId" [replyData]="replyData" [originalData]="originalData" [defaultSubject]="defaultSubject" [forum]="forum" [trackPosts]="trackPosts" (onPostChange)="postListChanged()"></addon-mod-forum-post>
</ng-container>
</ion-card>
<ng-container *ngIf="sort == 'nested'">
<ng-container *ngFor="let post of posts">
<ng-container *ngTemplateOutlet="nestedPosts; context: {post: post}"></ng-container>
</ng-container>
</ng-container>
<ng-template #nestedPosts let-post="post">
<ion-card>
<addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="componentId" [replyData]="replyData" [originalData]="originalData" [defaultSubject]="defaultSubject" [forum]="forum" [trackPosts]="trackPosts" (onPostChange)="postListChanged()"></addon-mod-forum-post>
</ion-card>
<div padding-left *ngIf="post.children.length && post.children[0].subject">
<ng-container *ngFor="let child of post.children">
<ng-container *ngTemplateOutlet="nestedPosts; context: {post: child}"></ng-container>
</ng-container>
</div>
</ng-template>
</core-loading>
</ion-content>

View File

@ -0,0 +1,35 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { AddonModForumComponentsModule } from '../../components/components.module';
import { AddonModForumDiscussionPage } from './discussion';
@NgModule({
declarations: [
AddonModForumDiscussionPage,
],
imports: [
CoreComponentsModule,
CoreDirectivesModule,
AddonModForumComponentsModule,
IonicPageModule.forChild(AddonModForumDiscussionPage),
TranslateModule.forChild()
],
})
export class AddonModForumDiscussionPageModule {}

View File

@ -0,0 +1,412 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Optional, OnDestroy, ViewChild } from '@angular/core';
import { IonicPage, NavParams, Content } from 'ionic-angular';
import { Network } from '@ionic-native/network';
import { TranslateService } from '@ngx-translate/core';
import { CoreAppProvider } from '@providers/app';
import { CoreEventsProvider } from '@providers/events';
import { CoreSitesProvider } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { AddonModForumProvider } from '../../providers/forum';
import { AddonModForumOfflineProvider } from '../../providers/offline';
import { AddonModForumHelperProvider } from '../../providers/helper';
import { AddonModForumSyncProvider } from '../../providers/sync';
type SortType = 'flat-newest' | 'flat-oldest' | 'nested';
/**
* Page that displays a forum discussion.
*/
@IonicPage({ segment: 'addon-mod-forum-discussion' })
@Component({
selector: 'page-addon-mod-forum-discussion',
templateUrl: 'discussion.html',
})
export class AddonModForumDiscussionPage implements OnDestroy {
@ViewChild(Content) content: Content;
courseId: number;
discussionId: number;
forum: any;
discussion: any;
posts: any[];
discussionLoaded = false;
defaultSubject: string;
isOnline: boolean;
isSplitViewOn: boolean;
locked: boolean;
postHasOffline: boolean;
sort: SortType = 'flat-oldest';
trackPosts: boolean;
replyData = {
replyingTo: 0,
isEditing: false,
subject: '',
message: null, // Null means empty or just white space.
files: [],
};
originalData = {
subject: null, // Null means original data is not set.
message: null, // Null means empty or just white space.
files: [],
};
refreshIcon = 'spinner';
syncIcon = 'spinner';
protected cmId: number;
protected forumId: number;
protected onlineObserver: any;
protected syncObserver: any;
protected syncManualObserver: any;
constructor(navParams: NavParams,
network: Network,
private appProvider: CoreAppProvider,
private eventsProvider: CoreEventsProvider,
private sitesProvider: CoreSitesProvider,
private domUtils: CoreDomUtilsProvider,
private utils: CoreUtilsProvider,
private translate: TranslateService,
private uploaderProvider: CoreFileUploaderProvider,
private forumProvider: AddonModForumProvider,
private forumOffline: AddonModForumOfflineProvider,
private forumHelper: AddonModForumHelperProvider,
private forumSync: AddonModForumSyncProvider,
@Optional() private svComponent: CoreSplitViewComponent) {
this.courseId = navParams.get('courseId');
this.cmId = navParams.get('cmId');
this.forumId = navParams.get('forumId');
this.discussionId = navParams.get('discussionId');
this.trackPosts = navParams.get('trackPosts');
this.locked = navParams.get('locked');
this.isOnline = this.appProvider.isOnline();
this.onlineObserver = network.onchange().subscribe((online) => {
this.isOnline = this.appProvider.isOnline();
});
this.isSplitViewOn = this.svComponent && this.svComponent.isOn();
}
/**
* View loaded.
*/
ionViewDidLoad(): void {
this.fetchPosts(true, false, true);
}
/**
* User entered the page that contains the component.
*/
ionViewDidEnter(): void {
// Refresh data if this discussion is synchronized automatically.
this.syncObserver = this.eventsProvider.on(AddonModForumSyncProvider.AUTO_SYNCED, (data) => {
if (data.forumId == this.forumId && this.discussionId == data.discussionId
&& data.userId == this.sitesProvider.getCurrentSiteUserId()) {
// Refresh the data.
this.discussionLoaded = false;
this.refreshPosts();
}
}, this.sitesProvider.getCurrentSiteId());
// Refresh data if this forum discussion is synchronized from discussions list.
this.syncManualObserver = this.eventsProvider.on(AddonModForumSyncProvider.MANUAL_SYNCED, (data) => {
if (data.source != 'discussion' && data.forumId == this.forumId &&
data.userId == this.sitesProvider.getCurrentSiteUserId()) {
// Refresh the data.
this.discussionLoaded = false;
this.refreshPosts();
}
}, this.sitesProvider.getCurrentSiteId());
// Trigger view event, to highlight the current opened discussion in the split view.
this.eventsProvider.trigger(AddonModForumProvider.VIEW_DISCUSSION_EVENT, {
forumId: this.forumId,
discussion: this.discussionId,
}, this.sitesProvider.getCurrentSiteId());
}
/**
* Check if we can leave the page or not.
*
* @return {boolean|Promise<void>} Resolved if we can leave it, rejected if not.
*/
ionViewCanLeave(): boolean | Promise<void> {
let promise: any;
if (this.forumHelper.hasPostDataChanged(this.replyData, this.originalData)) {
// Show confirmation if some data has been modified.
promise = this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit'));
} else {
promise = Promise.resolve();
}
return promise.then(() => {
// Delete the local files from the tmp folder.
this.uploaderProvider.clearTmpFiles(this.replyData.files);
});
}
/**
* Convenience function to get the forum.
*
* @return {Promise<any>} Promise resolved with the forum.
*/
protected fetchForum(): Promise<any> {
if (this.courseId && this.cmId) {
return this.forumProvider.getForum(this.courseId, this.cmId);
} else if (this.courseId && this.forumId) {
return this.forumProvider.getForumById(this.courseId, this.forumId);
} else {
// Cannot get the forum.
return Promise.reject(null);
}
}
/**
* Convenience function to get forum discussions.
*
* @param {boolean} [sync] Whether to try to synchronize the discussion.
* @param {boolean} [showErrors] Whether to show errors in a modal.
* @param {boolean} [forceMarkAsRead] Whether to mark all posts as read.
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchPosts(sync?: boolean, showErrors?: boolean, forceMarkAsRead?: boolean): Promise<any> {
let syncPromise;
if (sync) {
// Try to synchronize the forum.
syncPromise = this.syncDiscussion(showErrors).catch(() => {
// Ignore errors.
});
} else {
syncPromise = Promise.resolve();
}
let onlinePosts = [];
const offlineReplies = [];
let hasUnreadPosts = false;
return syncPromise.then(() => {
return this.forumProvider.getDiscussionPosts(this.discussionId).then((posts) => {
onlinePosts = posts;
}).then(() => {
// Check if there are responses stored in offline.
return this.forumOffline.getDiscussionReplies(this.discussionId).then((replies) => {
this.postHasOffline = !!replies.length;
const convertPromises = [];
// Index posts to allow quick access. Also check unread field.
const posts = {};
onlinePosts.forEach((post) => {
posts[post.id] = post;
hasUnreadPosts = hasUnreadPosts || !post.postread;
});
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(this.forumHelper.convertOfflineReplyToOnline(offlineReply).then((reply) => {
offlineReplies.push(reply);
// Disable reply of the parent. Reply in offline to the same post is not allowed, edit instead.
posts[reply.parent].canreply = false;
}));
});
return Promise.all(convertPromises).then(() => {
// Convert back to array.
onlinePosts = this.utils.objectToArray(posts);
});
});
});
}).then(() => {
const posts = offlineReplies.concat(onlinePosts);
this.discussion = this.forumProvider.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.
this.forumProvider.sortDiscussionPosts(posts, 'ASC');
this.posts = this.utils.formatTree(posts, 'parent', 'id', this.discussion.id);
} else {
// Set default reply subject.
const direction = this.sort == 'flat-newest' ? 'DESC' : 'ASC';
this.forumProvider.sortDiscussionPosts(posts, direction);
this.posts = posts;
}
this.defaultSubject = this.translate.instant('addon.mod_forum.re') + ' ' + this.discussion.subject;
this.replyData.subject = this.defaultSubject;
// Now try to get the forum.
return this.fetchForum().then((forum) => {
if (this.discussion.userfullname && this.discussion.parent == 0 && forum.type == 'single') {
// Hide author for first post and type single.
this.discussion.userfullname = null;
}
// "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.forum = forum;
}).catch(() => {
// Ignore errors.
this.forum = {};
});
}).catch((message) => {
this.domUtils.showErrorModal(message);
}).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.
this.forumProvider.logDiscussionView(this.discussionId).catch(() => {
// Ignore errors.
});
}
});
}
/**
* Tries to synchronize the posts discussion.
*
* @param {boolean} showErrors Whether to show errors in a modal.
* @return {Promise<any>} Promise resolved when done.
*/
protected syncDiscussion(showErrors: boolean): Promise<any> {
return this.forumSync.syncDiscussionReplies(this.discussionId).then((result) => {
if (result.warnings && result.warnings.length) {
this.domUtils.showErrorModal(result.warnings[0]);
}
if (result && result.updated) {
// Sync successful, send event.
this.eventsProvider.trigger(AddonModForumSyncProvider.MANUAL_SYNCED, {
forumId: this.forumId,
userId: this.sitesProvider.getCurrentSiteUserId(),
source: 'discussion'
}, this.sitesProvider.getCurrentSiteId());
}
return result.updated;
}).catch((error) => {
if (showErrors) {
this.domUtils.showErrorModalDefault(error, 'core.errorsync', true);
}
return Promise.reject(null);
});
}
/**
* Refresh the data.
*
* @param {any} [refresher] Refresher.
* @param {Function} [done] Function to call when done.
* @param {boolean} [showErrors=false] If show errors to the user of hide them.
* @return {Promise<any>} Promise resolved when done.
*/
doRefresh(refresher?: any, done?: () => void, showErrors: boolean = false): Promise<any> {
if (this.discussionLoaded) {
return this.refreshPosts(true, showErrors).finally(() => {
refresher && refresher.complete();
done && done();
});
}
return Promise.resolve();
}
/**
* Refresh posts.
*
* @param {boolean} [sync] Whether to try to synchronize the discussion.
* @param {boolean} [showErrors] Whether to show errors in a modal.
* @return {Promise<any>} Promise resolved when done.
*/
refreshPosts(sync?: boolean, showErrors?: boolean): Promise<any> {
this.content && this.content.scrollToTop();
this.refreshIcon = 'spinner';
this.syncIcon = 'spinner';
return this.forumProvider.invalidateDiscussionPosts(this.discussionId).catch(() => {
// Ignore errors.
}).then(() => {
return this.fetchPosts(sync, showErrors);
});
}
/**
* Function to change posts sorting
*
* @param {SortType} type Sort type.
* @return {Promise<any>} Promised resolved when done.
*/
changeSort(type: SortType): Promise<any> {
this.discussionLoaded = false;
this.sort = type;
this.content && this.content.scrollToTop();
return this.fetchPosts();
}
/**
* New post added.
*/
postListChanged(): void {
// Trigger an event to notify a new reply.
const data = {
forumId: this.forumId,
discussionId: this.discussionId,
cmId: this.cmId
};
this.eventsProvider.trigger(AddonModForumProvider.REPLY_DISCUSSION_EVENT, data, this.sitesProvider.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();
}
/**
* Page destroyed.
*/
ngOnDestroy(): void {
this.onlineObserver && this.onlineObserver.unsubscribe();
}
}

View File

@ -0,0 +1,11 @@
<ion-header>
<ion-navbar>
<ion-title><core-format-text [text]="title"></core-format-text></ion-title>
<ion-buttons end>
<!-- The buttons defined by the component will be added in here. -->
</ion-buttons>
</ion-navbar>
</ion-header>
<addon-mod-forum-index [module]="module" [courseId]="courseId" (dataRetrieved)="updateData($event)"></addon-mod-forum-index>

View File

@ -0,0 +1,33 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreDirectivesModule } from '@directives/directives.module';
import { AddonModForumComponentsModule } from '../../components/components.module';
import { AddonModForumIndexPage } from './index';
@NgModule({
declarations: [
AddonModForumIndexPage,
],
imports: [
CoreDirectivesModule,
AddonModForumComponentsModule,
IonicPageModule.forChild(AddonModForumIndexPage),
TranslateModule.forChild()
],
})
export class AddonModForumIndexPageModule {}

View File

@ -0,0 +1,48 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, ViewChild } from '@angular/core';
import { IonicPage, NavParams } from 'ionic-angular';
import { AddonModForumIndexComponent } from '../../components/index/index';
/**
* Page that displays a forum.
*/
@IonicPage({ segment: 'addon-mod-forum-index' })
@Component({
selector: 'page-addon-mod-forum-index',
templateUrl: 'index.html',
})
export class AddonModForumIndexPage {
@ViewChild(AddonModForumIndexComponent) forumComponent: AddonModForumIndexComponent;
title: string;
module: any;
courseId: number;
constructor(navParams: NavParams) {
this.module = navParams.get('module') || {};
this.courseId = navParams.get('courseId');
this.title = this.module.name;
}
/**
* Update some data based on the forum instance.
*
* @param {any} forum Forum instance.
*/
updateData(forum: any): void {
this.title = forum.name || this.title;
}
}

View File

@ -0,0 +1,53 @@
<ion-header>
<ion-navbar>
<ion-title>{{ 'addon.mod_forum.addanewdiscussion' | translate }}</ion-title>
<ion-buttons end>
<!-- The context menu will be added in here. -->
</ion-buttons>
</ion-navbar>
</ion-header>
<ion-content>
<ion-refresher [enabled]="groupsLoaded" (ionRefresh)="refreshGroups($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="groupsLoaded">
<ion-list *ngIf="showForm">
<ion-item>
<ion-label stacked>{{ 'addon.mod_forum.subject' | translate }}</ion-label>
<ion-input type="text" [placeholder]="'addon.mod_forum.subject' | translate" [(ngModel)]="newDiscussion.subject"></ion-input>
</ion-item>
<ion-item>
<ion-label stacked>{{ 'addon.mod_forum.message' | translate }}</ion-label>
<core-rich-text-editor item-content [control]="messageControl" (contentChanged)="onMessageChange($event)" [placeholder]="'addon.mod_forum.message' | translate" name="addon_mod_forum_new_discussion"></core-rich-text-editor>
<!-- @todo: Attributes that were passed to RTE in Ionic 1 but now they aren't supported yet:
[component]="component" [componentId]="forum.cmid" -->
</ion-item>
<ion-item *ngIf="showGroups">
<ion-label id="addon-mod-forum-groupslabel">{{ 'addon.mod_forum.group' | translate }}</ion-label>
<ion-select [(ngModel)]="newDiscussion.groupId" aria-labelledby="addon-mod-forum-groupslabel" interface="popover">
<ion-option *ngFor="let group of groups" [value]="group.id">{{ group.name }}</ion-option>
</ion-select>
</ion-item>
<ion-item>
<ion-label>{{ 'addon.mod_forum.discussionsubscription' | translate }}</ion-label>
<ion-toggle [(ngModel)]="newDiscussion.subscribe"></ion-toggle>
</ion-item>
<ion-item *ngIf="canPin">
<ion-label>{{ 'addon.mod_forum.discussionpinned' | translate }}</ion-label>
<ion-toggle [(ngModel)]="newDiscussion.pin"></ion-toggle>
</ion-item>
<core-attachments *ngIf="canCreateAttachments && forum && forum.maxattachments > 0" [files]="newDiscussion.files" [maxSize]="forum.maxbytes" [maxSubmissions]="forum.maxattachments" [component]="component" [componentId]="forum.cmid" [allowOffline]="true"></core-attachments>
<ion-item>
<ion-row>
<ion-col>
<button ion-button block (click)="add()" [disabled]="newDiscussion.subject == '' || newDiscussion.message == null">{{ 'addon.mod_forum.posttoforum' | translate }}</button>
</ion-col>
<ion-col *ngIf="hasOffline">
<button ion-button block color="light" (click)="discard()">{{ 'core.discard' | translate }}</button>
</ion-col>
</ion-row>
</ion-item>
</ion-list>
</core-loading>
</ion-content>

View File

@ -0,0 +1,33 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { AddonModForumNewDiscussionPage } from './new-discussion';
@NgModule({
declarations: [
AddonModForumNewDiscussionPage,
],
imports: [
CoreComponentsModule,
CoreDirectivesModule,
IonicPageModule.forChild(AddonModForumNewDiscussionPage),
TranslateModule.forChild()
],
})
export class AddonModForumNewDiscussionPageModule {}

View File

@ -0,0 +1,535 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnDestroy, Optional, ViewChild } from '@angular/core';
import { FormControl } from '@angular/forms';
import { IonicPage, NavController, NavParams } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreEventsProvider } from '@providers/events';
import { CoreGroupsProvider } from '@providers/groups';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSyncProvider } from '@providers/sync';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { CoreRichTextEditorComponent } from '@components/rich-text-editor/rich-text-editor.ts';
import { AddonModForumProvider } from '../../providers/forum';
import { AddonModForumOfflineProvider } from '../../providers/offline';
import { AddonModForumHelperProvider } from '../../providers/helper';
import { AddonModForumSyncProvider } from '../../providers/sync';
/**
* Page that displays the new discussion form.
*/
@IonicPage({ segment: 'addon-mod-forum-new-discussion' })
@Component({
selector: 'page-addon-mod-forum-new-discussion',
templateUrl: 'new-discussion.html',
})
export class AddonModForumNewDiscussionPage implements OnDestroy {
@ViewChild(CoreRichTextEditorComponent) messageEditor: CoreRichTextEditorComponent;
component = AddonModForumProvider.COMPONENT;
messageControl = new FormControl();
groupsLoaded = false;
showGroups = false;
hasOffline = false;
canCreateAttachments = true; // Assume we can by default.
canPin = false;
forum: any;
showForm = false;
groups = [];
newDiscussion = {
subject: '',
message: null, // Null means empty or just white space.
groupId: 0,
subscribe: true,
pin: false,
files: []
};
protected courseId: number;
protected cmId: number;
protected forumId: number;
protected timeCreated: number;
protected syncId: string;
protected syncObserver: any;
protected isDestroyed = false;
protected originalData: any;
constructor(navParams: NavParams,
private navCtrl: NavController,
private translate: TranslateService,
private domUtils: CoreDomUtilsProvider,
private eventsProvider: CoreEventsProvider,
private groupsProvider: CoreGroupsProvider,
private sitesProvider: CoreSitesProvider,
private syncProvider: CoreSyncProvider,
private uploaderProvider: CoreFileUploaderProvider,
private textUtils: CoreTextUtilsProvider,
private utils: CoreUtilsProvider,
private forumProvider: AddonModForumProvider,
private forumOffline: AddonModForumOfflineProvider,
private forumSync: AddonModForumSyncProvider,
private forumHelper: AddonModForumHelperProvider,
@Optional() private svComponent: CoreSplitViewComponent) {
this.courseId = navParams.get('courseId');
this.cmId = navParams.get('cmId');
this.forumId = navParams.get('forumId');
this.timeCreated = navParams.get('timeCreated');
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.fetchDiscussionData().finally(() => {
this.groupsLoaded = true;
});
}
/**
* User entered the page that contains the component.
*/
ionViewDidEnter(): void {
// Refresh data if this discussion is synchronized automatically.
this.syncObserver = this.eventsProvider.on(AddonModForumSyncProvider.AUTO_SYNCED, (data) => {
if (data.forumId == this.forumId && data.userId == this.sitesProvider.getCurrentSiteUserId()) {
this.domUtils.showAlertTranslated('core.notice', 'core.contenteditingsynced');
this.returnToDiscussions();
}
}, this.sitesProvider.getCurrentSiteId());
// Trigger view event, to highlight the current opened discussion in the split view.
this.eventsProvider.trigger(AddonModForumProvider.VIEW_DISCUSSION_EVENT, {
forumId: this.forumId,
discussion: -this.timeCreated,
}, this.sitesProvider.getCurrentSiteId());
}
/**
* Fetch if forum uses groups and the groups it uses.
*
* @param {boolean} [refresh] Whether we're refreshing data.
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchDiscussionData(refresh?: boolean): Promise<any> {
return this.groupsProvider.getActivityGroupMode(this.cmId).then((mode) => {
const promises = [];
if (mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS) {
promises.push(this.groupsProvider.getActivityAllowedGroups(this.cmId).then((forumGroups) => {
let promise;
if (mode === CoreGroupsProvider.VISIBLEGROUPS) {
// We need to check which of the returned groups the user can post to.
promise = this.validateVisibleGroups(forumGroups);
} else {
// WS already filters groups, no need to do it ourselves. Add "All participants" if needed.
promise = this.addAllParticipantsOption(forumGroups, true);
}
return promise.then((forumGroups) => {
if (forumGroups.length > 0) {
this.groups = forumGroups;
// Do not override group id.
this.newDiscussion.groupId = this.newDiscussion.groupId || forumGroups[0].id;
this.showGroups = true;
} else {
const message = mode === CoreGroupsProvider.SEPARATEGROUPS ?
'addon.mod_forum.cannotadddiscussionall' : 'addon.mod_forum.cannotadddiscussion';
return Promise.reject(this.translate.instant(message));
}
});
}));
} else {
this.showGroups = false;
// Use the canAddDiscussion WS to check if the user can add attachments and pin discussions.
promises.push(this.forumProvider.canAddDiscussionToAll(this.forumId).then((response) => {
this.canPin = !!response.canpindiscussions;
this.canCreateAttachments = !!response.cancreateattachment;
}).catch(() => {
// Ignore errors, use default values.
}));
}
// Get forum.
promises.push(this.forumProvider.getForum(this.courseId, this.cmId).then((forum) => {
this.forum = forum;
}));
// If editing a discussion, get offline data.
if (this.timeCreated && !refresh) {
this.syncId = this.forumSync.getForumSyncId(this.forumId);
promises.push(this.forumSync.waitForSync(this.syncId).then(() => {
// Do not block if the scope is already destroyed.
if (!this.isDestroyed) {
this.syncProvider.blockOperation(AddonModForumProvider.COMPONENT, this.syncId);
}
return this.forumOffline.getNewDiscussion(this.forumId, this.timeCreated).then((discussion) => {
this.hasOffline = true;
discussion.options = discussion.options || {};
this.newDiscussion.groupId = discussion.groupid ? discussion.groupid : this.newDiscussion.groupId;
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 (discussion.options.attachmentsid && discussion.options.attachmentsid.offline) {
return this.forumHelper.getNewDiscussionStoredFiles(this.forumId, this.timeCreated).then((files) => {
this.newDiscussion.files = files;
});
}
});
}));
}
return Promise.all(promises);
}).then(() => {
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((message) => {
this.domUtils.showErrorModalDefault(message, '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 {any[]} forumGroups Forum groups.
* @return {Promise<any>} Promise resolved when done.
*/
protected validateVisibleGroups(forumGroups: any[]): Promise<any> {
// We first check if the user can post to all the groups.
return this.forumProvider.canAddDiscussionToAll(this.forumId).catch(() => {
// The call failed, let's assume he can't.
return {
status: false,
canpindiscussions: false,
cancreateattachment: true
};
}).then((response) => {
this.canPin = !!response.canpindiscussions;
this.canCreateAttachments = !!response.cancreateattachment;
if (response.status) {
// The user can post to all groups, add the "All participants" option and return them all.
return this.addAllParticipantsOption(forumGroups, false);
} else {
// The user can't post to all groups, let's check which groups he can post to.
const promises = [];
const filtered = [];
forumGroups.forEach((group) => {
promises.push(this.forumProvider.canAddDiscussion(this.forumId, group.id).catch(() => {
/* 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. */
return {
status: true
};
}).then((response) => {
if (response.status) {
filtered.push(group);
}
}));
});
return Promise.all(promises).then(() => {
return filtered;
});
}
});
}
/**
* Filter forum groups, returning only those that are inside user groups.
*
* @param {any[]} forumGroups Forum groups.
* @param {any[]} userGroups User groups.
* @return {any[]} Filtered groups.
*/
protected filterGroups(forumGroups: any[], userGroups: any[]): any[] {
const filtered = [];
const userGroupsIds = userGroups.map((g) => g.id);
forumGroups.forEach((fg) => {
if (userGroupsIds.indexOf(fg.id) > -1) {
filtered.push(fg);
}
});
return filtered;
}
/**
* Add the "All participants" option to a list of groups if the user can add a discussion to all participants.
*
* @param {any[]} groups Groups.
* @param {boolean} check True to check if the user can add a discussion to all participants.
* @return {Promise<any[]>} Promise resolved with the list of groups.
*/
protected addAllParticipantsOption(groups: any[], check: boolean): Promise<any[]> {
if (!this.forumProvider.isAllParticipantsFixed()) {
// All participants has a bug, don't add it.
return Promise.resolve(groups);
}
let promise;
if (check) {
// We need to check if the user can add a discussion to all participants.
promise = this.forumProvider.canAddDiscussionToAll(this.forumId).then((response) => {
this.canPin = !!response.canpindiscussions;
this.canCreateAttachments = !!response.cancreateattachment;
return response.status;
}).catch(() => {
// The call failed, let's assume he can't.
return 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: -1,
name: this.translate.instant('core.allparticipants')
});
}
return groups;
});
}
/**
* Pull to refresh.
*
* @param {any} refresher Refresher.
*/
refreshGroups(refresher: any): void {
const promises = [
this.groupsProvider.invalidateActivityGroupMode(this.cmId),
this.groupsProvider.invalidateActivityAllowedGroups(this.cmId),
this.forumProvider.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 {number} [discussionId] Id of the new discussion.
*/
protected returnToDiscussions(discussionId?: number): void {
const data: any = {
forumId: this.forumId,
cmId: this.cmId,
discussionId: discussionId,
};
this.eventsProvider.trigger(AddonModForumProvider.NEW_DISCUSSION_EVENT, data, this.sitesProvider.getCurrentSiteId());
// Delete the local files from the tmp folder.
this.uploaderProvider.clearTmpFiles(this.newDiscussion.files);
if (this.svComponent && this.svComponent.isOn()) {
// Empty form.
this.hasOffline = false;
this.newDiscussion.subject = '';
this.newDiscussion.message = null;
this.newDiscussion.files = [];
this.messageEditor.clearText();
this.originalData = this.utils.clone(this.newDiscussion);
// Trigger view event, to highlight the current opened discussion in the split view.
this.eventsProvider.trigger(AddonModForumProvider.VIEW_DISCUSSION_EVENT, {
forumId: this.forumId,
discussion: 0,
}, this.sitesProvider.getCurrentSiteId());
} else {
this.originalData = null; // Avoid asking for confirmation.
this.navCtrl.pop();
}
}
/**
* Message changed.
*
* @param {string} text The new text.
*/
onMessageChange(text: string): void {
this.newDiscussion.message = text;
}
/**
* Add a new discussion.
*/
add(): void {
const forumName = this.forum.name;
const subject = this.newDiscussion.subject;
let message = this.newDiscussion.message;
const pin = this.newDiscussion.pin;
const groupId = this.newDiscussion.groupId;
const attachments = this.newDiscussion.files;
const discTimecreated = this.timeCreated || Date.now();
const options: any = {
discussionsubscribe: !!this.newDiscussion.subscribe
};
let saveOffline = false;
if (!subject) {
this.domUtils.showErrorModal('addon.mod_forum.erroremptysubject', true);
return;
}
if (!message) {
this.domUtils.showErrorModal('addon.mod_forum.erroremptymessage', true);
return;
}
const modal = this.domUtils.showModalLoading('core.sending', true);
// Check if rich text editor is enabled or not.
this.domUtils.isRichTextEditorEnabled().then((enabled) => {
if (!enabled) {
// Rich text editor not enabled, add some HTML to the message if needed.
message = this.textUtils.formatHtmlLines(message);
}
// Upload attachments first if any.
if (attachments.length) {
return this.forumHelper.uploadOrStoreNewDiscussionFiles(this.forumId, discTimecreated, attachments, false)
.catch(() => {
// Cannot upload them in online, save them in offline.
saveOffline = true;
return this.forumHelper.uploadOrStoreNewDiscussionFiles(this.forumId, discTimecreated, attachments, true);
});
}
}).then((attach) => {
if (attach) {
options.attachmentsid = attach;
}
if (pin) {
options.discussionpinned = true;
}
if (saveOffline) {
// Save discussion in offline.
return this.forumOffline.addNewDiscussion(this.forumId, forumName, this.courseId, subject,
message, options, groupId, discTimecreated).then(() => {
// Don't return anything.
});
} else {
// Try to send it to server.
// Don't allow offline if there are attachments since they were uploaded fine.
return this.forumProvider.addNewDiscussion(this.forumId, forumName, this.courseId, subject, message, options,
groupId, undefined, discTimecreated, !attachments.length);
}
}).then((discussionId) => {
if (discussionId) {
// Data sent to server, delete stored files (if any).
this.forumHelper.deleteNewDiscussionStoredFiles(this.forumId, discTimecreated);
}
this.returnToDiscussions(discussionId);
}).catch((message) => {
this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.cannotcreatediscussion', true);
}).finally(() => {
modal.dismiss();
});
}
/**
* Discard an offline saved discussion.
*/
discard(): void {
this.domUtils.showConfirm(this.translate.instant('core.areyousure')).then(() => {
const promises = [];
promises.push(this.forumOffline.deleteNewDiscussion(this.forumId, this.timeCreated));
promises.push(this.forumHelper.deleteNewDiscussionStoredFiles(this.forumId, this.timeCreated).catch(() => {
// Ignore errors, maybe there are no files.
}));
return Promise.all(promises).then(() => {
this.returnToDiscussions();
});
}).catch(() => {
// Cancelled.
});
}
/**
* Check if we can leave the page or not.
*
* @return {boolean|Promise<void>} Resolved if we can leave it, rejected if not.
*/
ionViewCanLeave(): boolean | Promise<void> {
let promise: any;
if (this.forumHelper.hasPostDataChanged(this.newDiscussion, this.originalData)) {
// Show confirmation if some data has been modified.
promise = this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit'));
} else {
promise = Promise.resolve();
}
return promise.then(() => {
// Delete the local files from the tmp folder.
this.uploaderProvider.clearTmpFiles(this.newDiscussion.files);
});
}
/**
* Runs when the page is about to leave and no longer be the active page.
*/
ionViewWillLeave(): void {
this.syncObserver && this.syncObserver.off();
}
/**
* Page destroyed.
*/
ngOnDestroy(): void {
if (this.syncId) {
this.syncProvider.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId);
}
this.isDestroyed = true;
}
}

View File

@ -0,0 +1,69 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler';
import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate';
import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper';
/**
* Handler to treat links to forum review.
*/
@Injectable()
export class AddonModForumDiscussionLinkHandler extends CoreContentLinksHandlerBase {
name = 'AddonModForumDiscussionLinkHandler';
featureName = 'CoreCourseModuleDelegate_AddonModForum';
pattern = /\/mod\/forum\/discuss\.php.*([\&\?]d=\d+)/;
constructor(protected domUtils: CoreDomUtilsProvider, protected linkHelper: CoreContentLinksHelperProvider) {
super();
}
/**
* Get the list of actions for a link (url).
*
* @param {string[]} siteIds List of sites the URL belongs to.
* @param {string} url The URL to treat.
* @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param {number} [courseId] Course ID related to the URL. Optional but recommended.
* @return {CoreContentLinksAction[]|Promise<CoreContentLinksAction[]>} List of (or promise resolved with list of) actions.
*/
getActions(siteIds: string[], url: string, params: any, courseId?: number):
CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
return [{
action: (siteId, navCtrl?): void => {
const pageParams = {
courseId: courseId || parseInt(params.courseid, 10) || parseInt(params.cid, 10),
discussionId: parseInt(params.d, 10),
};
this.linkHelper.goInSite(navCtrl, 'AddonModForumDiscussionPage', pageParams, siteId);
}
}];
}
/**
* Check if the handler is enabled for a certain site (site + user) and a URL.
* If not defined, defaults to true.
*
* @param {string} siteId The site ID.
* @param {string} url The URL to treat.
* @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param {number} [courseId] Course ID related to the URL. Optional but recommended.
* @return {boolean|Promise<boolean>} Whether the handler is enabled for the URL and site.
*/
isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise<boolean> {
return true;
}
}

View File

@ -0,0 +1,734 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { CoreAppProvider } from '@providers/app';
import { CoreFilepoolProvider } from '@providers/filepool';
import { CoreGroupsProvider } from '@providers/groups';
import { CoreSitesProvider } from '@providers/sites';
import { CoreUserProvider } from '@core/user/providers/user';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { AddonModForumOfflineProvider } from './offline';
/**
* Service that provides some features for forums.
*/
@Injectable()
export class AddonModForumProvider {
static COMPONENT = 'mmaModForum';
static DISCUSSIONS_PER_PAGE = 10; // Max of discussions per page.
static NEW_DISCUSSION_EVENT = 'addon_mod_forum_new_discussion';
static REPLY_DISCUSSION_EVENT = 'addon_mod_forum_reply_discussion';
static VIEW_DISCUSSION_EVENT = 'addon_mod_forum_view_discussion';
protected ROOT_CACHE_KEY = 'mmaModForum:';
constructor(private appProvider: CoreAppProvider,
private sitesProvider: CoreSitesProvider,
private groupsProvider: CoreGroupsProvider,
private filepoolProvider: CoreFilepoolProvider,
private userProvider: CoreUserProvider,
private translate: TranslateService,
private utils: CoreUtilsProvider,
private forumOffline: AddonModForumOfflineProvider) {}
/**
* Get cache key for can add discussion WS calls.
*
* @param {number} forumId Forum ID.
* @param {number} groupId Group ID.
* @return {string} Cache key.
*/
protected getCanAddDiscussionCacheKey(forumId: number, groupId: number): string {
return this.getCommonCanAddDiscussionCacheKey(forumId) + ':' + groupId;
}
/**
* Get common part of cache key for can add discussion WS calls.
*
* @param {number} forumId Forum ID.
* @return {string} Cache key.
*/
protected getCommonCanAddDiscussionCacheKey(forumId: number): string {
return this.ROOT_CACHE_KEY + 'canadddiscussion:' + forumId;
}
/**
* Get cache key for forum data WS calls.
*
* @param {number} courseId Course ID.
* @return {string} Cache key.
*/
protected getForumDataCacheKey(courseId: number): string {
return this.ROOT_CACHE_KEY + 'forum:' + courseId;
}
/**
* Get cache key for forum discussion posts WS calls.
*
* @param {number} discussionId Discussion ID.
* @return {string} Cache key.
*/
protected getDiscussionPostsCacheKey(discussionId: number): string {
return this.ROOT_CACHE_KEY + 'discussion:' + discussionId;
}
/**
* Get cache key for forum discussions list WS calls.
*
* @param {number} forumId Forum ID.
* @return {string} Cache key.
*/
protected getDiscussionsListCacheKey(forumId: number): string {
return this.ROOT_CACHE_KEY + 'discussions:' + forumId;
}
/**
* Add a new discussion.
*
* @param {number} forumId Forum ID.
* @param {string} name Forum name.
* @param {number} courseId Course ID the forum belongs to.
* @param {string} subject New discussion's subject.
* @param {string} message New discussion's message.
* @param {any} [options] Options (subscribe, pin, ...).
* @param {string} [groupId] Group this discussion belongs to.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [timeCreated] The time the discussion was created. Only used when editing discussion.
* @param {boolean} allowOffline True if it can be stored in offline, false otherwise.
* @return {Promise<any>} Promise resolved with discussion ID if sent online, resolved with false if stored offline.
*/
addNewDiscussion(forumId: number, name: string, courseId: number, subject: string, message: string, options?: any,
groupId?: number, siteId?: string, timeCreated?: number, allowOffline?: boolean): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
// Convenience function to store a message to be synchronized later.
const storeOffline = (): Promise<any> => {
return this.forumOffline.addNewDiscussion(forumId, name, courseId, subject, message, options,
groupId, timeCreated, siteId).then(() => {
return false;
});
};
// If we are editing an offline discussion, discard previous first.
let discardPromise;
if (timeCreated) {
discardPromise = this.forumOffline.deleteNewDiscussion(forumId, timeCreated, siteId);
} else {
discardPromise = Promise.resolve();
}
return discardPromise.then(() => {
if (!this.appProvider.isOnline() && allowOffline) {
// App is offline, store the action.
return storeOffline();
}
return this.addNewDiscussionOnline(forumId, subject, message, options, groupId, siteId).then((id) => {
// Success, return the discussion ID.
return id;
}).catch((error) => {
if (!allowOffline || this.utils.isWebServiceError(error)) {
// The WebService has thrown an error or offline not supported, reject.
return Promise.reject(error);
}
// Couldn't connect to server, store in offline.
return storeOffline();
});
});
}
/**
* Add a new discussion. It will fail if offline or cannot connect.
*
* @param {number} forumId Forum ID.
* @param {string} subject New discussion's subject.
* @param {string} message New discussion's message.
* @param {any} [options] Options (subscribe, pin, ...).
* @param {string} [groupId] Group this discussion belongs to.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when the discussion is created.
*/
addNewDiscussionOnline(forumId: number, subject: string, message: string, options?: any, groupId?: number, siteId?: string)
: Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
const params: any = {
forumid: forumId,
subject: subject,
message: message,
options: this.utils.objectToArrayOfObjects(options, 'name', 'value')
};
if (groupId) {
params.groupid = groupId;
}
return site.write('mod_forum_add_discussion', params).then((response) => {
// Other errors ocurring.
if (!response || !response.discussionid) {
return this.utils.createFakeWSError('');
} else {
return response.discussionid;
}
});
});
}
/**
* Check if a user can post to a certain group.
*
* @param {number} forumId Forum ID.
* @param {number} groupId Group ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with an object with the following properties:
* - status (boolean)
* - canpindiscussions (boolean)
* - cancreateattachment (boolean)
*/
canAddDiscussion(forumId: number, groupId: number, siteId?: string): Promise<any> {
const params = {
forumid: forumId,
groupid: groupId
};
const preSets = {
cacheKey: this.getCanAddDiscussionCacheKey(forumId, groupId)
};
return this.sitesProvider.getSite(siteId).then((site) => {
return site.read('mod_forum_can_add_discussion', params, preSets).then((result) => {
if (result) {
if (typeof result.canpindiscussions == 'undefined') {
// WS doesn't support it yet, default it to false to prevent students from seing the option.
result.canpindiscussions = false;
}
if (typeof result.cancreateattachment == 'undefined') {
// WS doesn't support it yet, default it to true since usually the users will be able to create them.
result.cancreateattachment = true;
}
return result;
}
return Promise.reject(null);
});
});
}
/**
* Check if a user can post to all groups.
*
* @param {number} forumId Forum ID.
* @return {Promise<any>} Promise resolved with an object with the following properties:
* - status (boolean)
* - canpindiscussions (boolean)
* - cancreateattachment (boolean)
*/
canAddDiscussionToAll(forumId: number): Promise<any> {
return this.canAddDiscussion(forumId, -1);
}
/**
* Extract the starting post of a discussion from a list of posts. The post is removed from the array passed as a parameter.
*
* @param {any[]} posts Posts to search.
* @return {any} Starting post or undefined if not found.
*/
extractStartingPost(posts: any[]): any {
// Check the last post first, since they'll usually be ordered by create time.
for (let i = posts.length - 1; i >= 0; i--) {
if (posts[i].parent == 0) {
return posts.splice(i, 1).pop(); // Remove it from the array.
}
}
return undefined;
}
/**
* There was a bug adding new discussions to All Participants (see MDL-57962). Check if it's fixed.
*
* @return {boolean} True if fixed, false otherwise.
*/
isAllParticipantsFixed(): boolean {
return this.sitesProvider.getCurrentSite().isVersionGreaterEqualThan(['3.1.5', '3.2.2']);
}
/**
* Format discussions, setting groupname if the discussion group is valid.
*
* @param {number} cmId Forum cmid.
* @param {any[]} discussions List of discussions to format.
* @return {Promise<any[]>} Promise resolved with the formatted discussions.
*/
formatDiscussionsGroups(cmId: number, discussions: any[]): Promise<any[]> {
discussions = this.utils.clone(discussions);
return this.groupsProvider.getActivityAllowedGroups(cmId).then((forumGroups) => {
const strAllParts = this.translate.instant('core.allparticipants');
// Turn groups into an object where each group is identified by id.
const groups = {};
forumGroups.forEach((fg) => {
groups[fg.id] = fg;
});
// Format discussions.
discussions.forEach((disc) => {
if (disc.groupid === -1) {
disc.groupname = strAllParts;
} else {
const group = groups[disc.groupid];
if (group) {
disc.groupname = group.name;
}
}
});
return discussions;
}).catch(() => {
return discussions;
});
}
/**
* Get all course forums.
*
* @param {number} courseId Course ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any[]>} Promise resolved when the forums are retrieved.
*/
getCourseForums(courseId: number, siteId?: string): Promise<any[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
const params = {
courseids: [courseId]
};
const preSets = {
cacheKey: this.getForumDataCacheKey(courseId)
};
return site.read('mod_forum_get_forums_by_courses', params, preSets);
});
}
/**
* Get a forum by course module ID.
*
* @param {number} courseId Course ID.
* @param {number} cmId Course module ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when the forum is retrieved.
*/
getForum(courseId: number, cmId: number, siteId?: string): Promise<any> {
return this.getCourseForums(courseId, siteId).then((forums) => {
const forum = forums.find((forum) => forum.cmid == cmId);
if (forum) {
return forum;
}
return Promise.reject(null);
});
}
/**
* Get a forum by forum ID.
*
* @param {number} courseId Course ID.
* @param {number} forumId Forum ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when the forum is retrieved.
*/
getForumById(courseId: number, forumId: number, siteId?: string): Promise<any> {
return this.getCourseForums(courseId, siteId).then((forums) => {
const forum = forums.find((forum) => forum.id == forumId);
if (forum) {
return forum;
}
return Promise.reject(null);
});
}
/**
* Get forum discussion posts.
*
* @param {number} discussionId Discussion ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any[]>} Promise resolved with forum posts.
*/
getDiscussionPosts(discussionId: number, siteId?: string): Promise<any> {
const params = {
discussionid: discussionId
};
const preSets = {
cacheKey: this.getDiscussionPostsCacheKey(discussionId)
};
return this.sitesProvider.getSite(siteId).then((site) => {
return site.read('mod_forum_get_forum_discussion_posts', params, preSets).then((response) => {
if (response) {
this.storeUserData(response.posts);
return response.posts;
} else {
return Promise.reject(null);
}
});
});
}
/**
* Sort forum discussion posts by an specified field.
*
* @param {any[]} posts Discussion posts to be sorted in place.
* @param {string} direction Direction of the sorting (ASC / DESC).
*/
sortDiscussionPosts(posts: any[], direction: string): void {
// @todo: Check children when sorting.
posts.sort((a, b) => {
a = parseInt(a.created, 10);
b = parseInt(b.created, 10);
if (direction == 'ASC') {
return a - b;
} else {
return b - a;
}
});
}
/**
* Get forum discussions.
*
* @param {number} forumId Forum ID.
* @param {number} [page=0] Page.
* @param {boolean} [forceCache] True to always get the value from cache. false otherwise.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with an object with:
* - discussions: List of discussions.
* - canLoadMore: True if there may be more discussions to load.
*/
getDiscussions(forumId: number, page: number = 0, forceCache?: boolean, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
const params = {
forumid: forumId,
sortby: 'timemodified',
sortdirection: 'DESC',
page: page,
perpage: AddonModForumProvider.DISCUSSIONS_PER_PAGE
};
const preSets: any = {
cacheKey: this.getDiscussionsListCacheKey(forumId)
};
if (forceCache) {
preSets.omitExpires = true;
}
return site.read('mod_forum_get_forum_discussions_paginated', params, preSets).then((response) => {
if (response) {
this.storeUserData(response.discussions);
return Promise.resolve({
discussions: response.discussions,
canLoadMore: response.discussions.length >= AddonModForumProvider.DISCUSSIONS_PER_PAGE,
});
} else {
return Promise.reject(null);
}
});
});
}
/**
* Get forum discussions in several pages.
* If a page fails, the discussions until that page will be returned along with a flag indicating an error occurred.
*
* @param {number} forumId Forum ID.
* @param {boolean} forceCache True to always get the value from cache, false otherwise.
* @param {number} [numPages] Number of pages to get. If not defined, all pages.
* @param {number} [startPage] Page to start. If not defined, first page.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with an object with:
* - discussions: List of discussions.
* - error: True if an error occurred, false otherwise.
*/
getDiscussionsInPages(forumId: number, forceCache?: boolean, numPages?: number, startPage?: number, siteId?: string)
: Promise<any> {
if (typeof numPages == 'undefined') {
numPages = -1;
}
startPage = startPage || 0;
const result = {
discussions: [],
error: false
};
if (!numPages) {
return Promise.resolve(result);
}
const getPage = (page: number): Promise<any> => {
// Get page discussions.
return this.getDiscussions(forumId, page, forceCache, siteId).then((response) => {
result.discussions = result.discussions.concat(response.discussions);
numPages--;
if (response.canLoadMore && numPages !== 0) {
return getPage(page + 1); // Get next page.
} else {
return result;
}
}).catch(() => {
// Error getting a page.
result.error = true;
return result;
});
};
return getPage(startPage);
}
/**
* Invalidates can add discussion WS calls.
*
* @param {number} forumId Forum ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when the data is invalidated.
*/
invalidateCanAddDiscussion(forumId: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.invalidateWsCacheForKeyStartingWith(this.getCommonCanAddDiscussionCacheKey(forumId));
});
}
/**
* Invalidate the prefetched content except files.
* To invalidate files, use AddonModForum#invalidateFiles.
*
* @param {number} moduleId The module ID.
* @param {number} courseId Course ID.
* @return {Promise<any>} Promise resolved when data is invalidated.
*/
invalidateContent(moduleId: number, courseId: number): Promise<any> {
// Get the forum first, we need the forum ID.
return this.getForum(courseId, moduleId).then((forum) => {
// We need to get the list of discussions to be able to invalidate their posts.
return this.getDiscussionsInPages(forum.id, true).then((response) => {
// Now invalidate the WS calls.
const promises = [];
promises.push(this.invalidateForumData(courseId));
promises.push(this.invalidateDiscussionsList(forum.id));
promises.push(this.invalidateCanAddDiscussion(forum.id));
response.discussions.forEach((discussion) => {
promises.push(this.invalidateDiscussionPosts(discussion.discussion));
});
return this.utils.allPromises(promises);
});
});
}
/**
* Invalidates forum discussion posts.
*
* @param {number} discussionId Discussion ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when the data is invalidated.
*/
invalidateDiscussionPosts(discussionId: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.invalidateWsCacheForKey(this.getDiscussionPostsCacheKey(discussionId));
});
}
/**
* Invalidates discussion list.
*
* @param {number} forumId Forum ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when the data is invalidated.
*/
invalidateDiscussionsList(forumId: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.invalidateWsCacheForKey(this.getDiscussionsListCacheKey(forumId));
});
}
/**
* Invalidate the prefetched files.
*
* @param {number} moduleId The module ID.
* @return {Promise<any>} Promise resolved when the files are invalidated.
*/
invalidateFiles(moduleId: number): Promise<any> {
const siteId = this.sitesProvider.getCurrentSiteId();
return this.filepoolProvider.invalidateFilesByComponent(siteId, AddonModForumProvider.COMPONENT, moduleId);
}
/**
* Invalidates forum data.
*
* @param {number} courseId Course ID.
* @return {Promise<any>} Promise resolved when the data is invalidated.
*/
invalidateForumData(courseId: number): Promise<any> {
return this.sitesProvider.getCurrentSite().invalidateWsCacheForKey(this.getForumDataCacheKey(courseId));
}
/**
* Report a forum as being viewed.
*
* @param {number} id Module ID.
* @return {Promise<any>} Promise resolved when the WS call is successful.
*/
logView(id: number): Promise<any> {
const params = {
forumid: id
};
return this.sitesProvider.getCurrentSite().write('mod_forum_view_forum', params);
}
/**
* Report a forum discussion as being viewed.
*
* @param {number} id Discussion ID.
* @return {Promise<any>} Promise resolved when the WS call is successful.
*/
logDiscussionView(id: number): Promise<any> {
const params = {
discussionid: id
};
return this.sitesProvider.getCurrentSite().write('mod_forum_view_forum_discussion', params);
}
/**
* Reply to a certain post.
*
* @param {number} postId ID of the post being replied.
* @param {number} discussionId ID of the discussion the user is replying to.
* @param {number} forumId ID of the forum the user is replying to.
* @param {string} name Forum name.
* @param {number} courseId Course ID the forum belongs to.
* @param {string} subject New post's subject.
* @param {string} message New post's message.
* @param {any} [options] Options (subscribe, attachments, ...).
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {boolean} [allowOffline] True if it can be stored in offline, false otherwise.
* @return {Promise<any>} Promise resolved with post ID if sent online, resolved with false if stored offline.
*/
replyPost(postId: number, discussionId: number, forumId: number, name: string, courseId: number, subject: string,
message: string, options?: any, siteId?: string, allowOffline?: boolean): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
// Convenience function to store a message to be synchronized later.
const storeOffline = (): Promise<boolean> => {
if (!forumId) {
// Not enough data to store in offline, reject.
return Promise.reject(this.translate.instant('core.networkerrormsg'));
}
return this.forumOffline.replyPost(postId, discussionId, forumId, name, courseId, subject, message, options, siteId)
.then(() => {
return false;
});
};
if (!this.appProvider.isOnline() && allowOffline) {
// App is offline, store the action.
return storeOffline();
}
// If there's already a reply to be sent to the server, discard it first.
return this.forumOffline.deleteReply(postId, siteId).then(() => {
return this.replyPostOnline(postId, subject, message, options, siteId).then(() => {
return true;
}).catch((error) => {
if (allowOffline && !this.utils.isWebServiceError(error)) {
// Couldn't connect to server, store in offline.
return storeOffline();
} else {
// The WebService has thrown an error or offline not supported, reject.
return Promise.reject(error);
}
});
});
}
/**
* Reply to a certain post. It will fail if offline or cannot connect.
*
* @param {number} postId ID of the post being replied.
* @param {string} subject New post's subject.
* @param {string} message New post's message.
* @param {any} [options] Options (subscribe, attachments, ...).
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<number>} Promise resolved with the created post id.
*/
replyPostOnline(postId: number, subject: string, message: string, options?: any, siteId?: string): Promise<number> {
return this.sitesProvider.getSite(siteId).then((site) => {
const params = {
postid: postId,
subject: subject,
message: message,
options: this.utils.objectToArrayOfObjects(options, 'name', 'value')
};
return site.write('mod_forum_add_discussion_post', params).then((response) => {
if (!response || !response.postid) {
return this.utils.createFakeWSError('');
} else {
return response.postid;
}
});
});
}
/**
* Store the users data from a discussions/posts list.
*
* @param {any[]} list Array of posts or discussions.
*/
protected storeUserData(list: any[]): void {
const users = {};
list.forEach((entry) => {
const userId = parseInt(entry.userid);
if (!isNaN(userId) && !users[userId]) {
users[userId] = {
id: userId,
fullname: entry.userfullname,
profileimageurl: entry.userpictureurl
};
}
const userModified = parseInt(entry.usermodified);
if (!isNaN(userModified) && !users[userModified]) {
users[userModified] = {
id: userModified,
fullname: entry.usermodifiedfullname,
profileimageurl: entry.usermodifiedpictureurl
};
}
});
this.userProvider.storeUsers(this.utils.objectToArray(users));
}
}

View File

@ -0,0 +1,243 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreFileProvider } from '@providers/file';
import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader';
import { CoreUserProvider } from '@core/user/providers/user';
import { AddonModForumProvider } from './forum';
import { AddonModForumOfflineProvider } from './offline';
/**
* Service that provides some features for forums.
*/
@Injectable()
export class AddonModForumHelperProvider {
constructor(private fileProvider: CoreFileProvider,
private uploaderProvider: CoreFileUploaderProvider,
private userProvider: CoreUserProvider,
private forumOffline: AddonModForumOfflineProvider) {}
/**
* Convert offline reply to online format in order to be compatible with them.
*
* @param {any} offlineReply Offline version of the reply.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with the object converted to Online.
*/
convertOfflineReplyToOnline(offlineReply: any, siteId?: string): Promise<any> {
const reply: any = {
attachments: [],
canreply: false,
children: [],
created: offlineReply.timecreated,
discussion: offlineReply.discussionid,
id: false,
mailed: 0,
mailnow: 0,
message: offlineReply.message,
messageformat: 1,
messagetrust: 0,
modified: false,
parent: offlineReply.postid,
postread: false,
subject: offlineReply.subject,
totalscore: 0,
userid: offlineReply.userid
},
promises = [];
// Treat attachments if any.
if (offlineReply.options && offlineReply.options.attachmentsid) {
reply.attachments = offlineReply.options.attachmentsid.online || [];
if (offlineReply.options.attachmentsid.offline) {
promises.push(this.getReplyStoredFiles(offlineReply.forumid, reply.parent, siteId, reply.userid)
.then((files) => {
reply.attachments = reply.attachments.concat(files);
}));
}
}
// Get user data.
promises.push(this.userProvider.getProfile(offlineReply.userid, offlineReply.courseid, true).then((user) => {
reply.userfullname = user.fullname;
reply.userpictureurl = user.profileimageurl;
}).catch(() => {
// Ignore errors.
}));
return Promise.all(promises).then(() => {
reply.attachment = reply.attachments.length > 0 ? 1 : 0;
return reply;
});
}
/**
* Delete stored attachment files for a new discussion.
*
* @param {number} forumId Forum ID.
* @param {number} timecreated The time the discussion was created.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when deleted.
*/
deleteNewDiscussionStoredFiles(forumId: number, timecreated: number, siteId?: string): Promise<any> {
return this.forumOffline.getNewDiscussionFolder(forumId, timecreated, siteId).then((folderPath) => {
return this.fileProvider.removeDir(folderPath).catch(() => {
// Ignore any errors, CoreFileProvider.removeDir fails if folder doesn't exists.
});
});
}
/**
* Delete stored attachment files for a reply.
*
* @param {number} forumId Forum ID.
* @param {number} postId ID of the post being replied.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the reply belongs to. If not defined, current user in site.
* @return {Promise<any>} Promise resolved when deleted.
*/
deleteReplyStoredFiles(forumId: number, postId: number, siteId?: string, userId?: number): Promise<any> {
return this.forumOffline.getReplyFolder(forumId, postId, siteId, userId).then((folderPath) => {
return this.fileProvider.removeDir(folderPath).catch(() => {
// Ignore any errors, CoreFileProvider.removeDir fails if folder doesn't exists.
});
});
}
/**
* Get a list of stored attachment files for a new discussion. See AddonModForumHelper#storeNewDiscussionFiles.
*
* @param {number} forumId Forum ID.
* @param {number} timecreated The time the discussion was created.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any[]>} Promise resolved with the files.
*/
getNewDiscussionStoredFiles(forumId: number, timecreated: number, siteId?: string): Promise<any[]> {
return this.forumOffline.getNewDiscussionFolder(forumId, timecreated, siteId).then((folderPath) => {
return this.uploaderProvider.getStoredFiles(folderPath);
});
}
/**
* Get a list of stored attachment files for a reply. See AddonModForumHelper#storeReplyFiles.
*
* @param {number} forumId Forum ID.
* @param {number} postId ID of the post being replied.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the reply belongs to. If not defined, current user in site.
* @return {Promise<any[]>} Promise resolved with the files.
*/
getReplyStoredFiles(forumId: number, postId: number, siteId?: string, userId?: number): Promise<any[]> {
return this.forumOffline.getReplyFolder(forumId, postId, siteId, userId).then((folderPath) => {
return this.uploaderProvider.getStoredFiles(folderPath);
});
}
/**
* Check if the data of a post/discussion has changed.
*
* @param {any} post Current data.
* @param {any} [original] Original ata.
* @return {boolean} True if data has changed, false otherwise.
*/
hasPostDataChanged(post: any, original?: any): boolean {
if (!original || original.subject == null) {
// There is no original data, assume it hasn't changed.
return false;
}
if (post.subject != original.subject || post.message != original.message) {
return true;
}
return this.uploaderProvider.areFileListDifferent(post.files, original.files);
}
/**
* Given a list of files (either online files or local files), store the local files in a local folder
* to be submitted later.
*
* @param {number} forumId Forum ID.
* @param {number} timecreated The time the discussion was created.
* @param {any[]} files List of files.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved if success, rejected otherwise.
*/
storeNewDiscussionFiles(forumId: number, timecreated: number, files: any[], siteId?: string): Promise<any> {
// Get the folder where to store the files.
return this.forumOffline.getNewDiscussionFolder(forumId, timecreated, siteId).then((folderPath) => {
return this.uploaderProvider.storeFilesToUpload(folderPath, files);
});
}
/**
* Given a list of files (either online files or local files), store the local files in a local folder
* to be submitted later.
*
* @param {number} forumId Forum ID.
* @param {number} postId ID of the post being replied.
* @param {any[]} files List of files.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the reply belongs to. If not defined, current user in site.
* @return {Promise<any>} Promise resolved if success, rejected otherwise.
*/
storeReplyFiles(forumId: number, postId: number, files: any[], siteId?: string, userId?: number): Promise<any> {
// Get the folder where to store the files.
return this.forumOffline.getReplyFolder(forumId, postId, siteId, userId).then((folderPath) => {
return this.uploaderProvider.storeFilesToUpload(folderPath, files);
});
}
/**
* Upload or store some files for a new discussion, depending if the user is offline or not.
*
* @param {number} forumId Forum ID.
* @param {number} timecreated The time the discussion was created.
* @param {any[]} files List of files.
* @param {boolean} offline True if files sould be stored for offline, false to upload them.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved if success.
*/
uploadOrStoreNewDiscussionFiles(forumId: number, timecreated: number, files: any[], offline: boolean, siteId?: string)
: Promise<any> {
if (offline) {
return this.storeNewDiscussionFiles(forumId, timecreated, files, siteId);
} else {
return this.uploaderProvider.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId);
}
}
/**
* Upload or store some files for a reply, depending if the user is offline or not.
*
* @param {number} forumId Forum ID.
* @param {number} postId ID of the post being replied.
* @param {any[]} files List of files.
* @param {boolean} offline True if files sould be stored for offline, false to upload them.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the reply belongs to. If not defined, current user in site.
* @return {Promise<any>} Promise resolved if success.
*/
uploadOrStoreReplyFiles(forumId: number, postId: number, files: any[], offline: boolean, siteId?: string, userId?: number)
: Promise<any> {
if (offline) {
return this.storeReplyFiles(forumId, postId, files, siteId, userId);
} else {
return this.uploaderProvider.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId);
}
}
}

View File

@ -0,0 +1,44 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreContentLinksModuleIndexHandler } from '@core/contentlinks/classes/module-index-handler';
import { CoreCourseHelperProvider } from '@core/course/providers/helper';
import { AddonModForumProvider } from './forum';
/**
* Handler to treat links to forum index.
*/
@Injectable()
export class AddonModForumIndexLinkHandler extends CoreContentLinksModuleIndexHandler {
name = 'AddonModForumIndexLinkHandler';
constructor(courseHelper: CoreCourseHelperProvider, protected forumProvider: AddonModForumProvider) {
super(courseHelper, 'AddonModForum', 'forum');
}
/**
* Check if the handler is enabled for a certain site (site + user) and a URL.
* If not defined, defaults to true.
*
* @param {string} siteId The site ID.
* @param {string} url The URL to treat.
* @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param {number} [courseId] Course ID related to the URL. Optional but recommended.
* @return {boolean|Promise<boolean>} Whether the handler is enabled for the URL and site.
*/
isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise<boolean> {
return true;
}
}

View File

@ -0,0 +1,81 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { NavController, NavOptions } from 'ionic-angular';
import { AddonModForumIndexComponent } from '../components/index/index';
import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate';
import { CoreCourseProvider } from '@core/course/providers/course';
/**
* Handler to support forum modules.
*/
@Injectable()
export class AddonModForumModuleHandler implements CoreCourseModuleHandler {
name = 'AddonModForum';
modName = 'forum';
constructor(private courseProvider: CoreCourseProvider) { }
/**
* Check if the handler is enabled on a site level.
*
* @return {boolean} Whether or not the handler is enabled on a site level.
*/
isEnabled(): boolean {
return true;
}
/**
* Get the data required to display the module in the course contents view.
*
* @param {any} module The module object.
* @param {number} courseId The course ID.
* @param {number} sectionId The section ID.
* @return {CoreCourseModuleHandlerData} Data to render the module.
*/
getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData {
return {
icon: this.courseProvider.getModuleIconSrc('forum'),
title: module.name,
class: 'addon-mod_forum-handler',
showDownloadButton: true,
action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void {
navCtrl.push('AddonModForumIndexPage', {module: module, courseId: courseId}, options);
}
};
}
/**
* Get the component to render the module. This is needed to support singleactivity course format.
* The component returned must implement CoreCourseModuleMainComponent.
*
* @param {any} course The course object.
* @param {any} module The module object.
* @return {any} The component to use, undefined if not found.
*/
getMainComponent(course: any, module: any): any {
return AddonModForumIndexComponent;
}
/**
* Whether to display the course refresher in single activity course format. If it returns false, a refresher must be
* included in the template that calls the doRefresh method of the component. Defaults to true.
*
* @return {boolean} Whether the refresher should be displayed.
*/
displayRefresherInSingleActivity(): boolean {
return false;
}
}

View File

@ -0,0 +1,454 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreFileProvider } from '@providers/file';
import { CoreSitesProvider } from '@providers/sites';
import { CoreTextUtilsProvider } from '@providers/utils/text';
/**
* Service to handle offline forum.
*/
@Injectable()
export class AddonModForumOfflineProvider {
// Variables for database.
protected DISCUSSIONS_TABLE = 'addon_mod_forum_discussions';
protected REPLIES_TABLE = 'addon_mod_forum_replies';
protected tablesSchema = [
{
name: this.DISCUSSIONS_TABLE,
columns: [
{
name: 'forumid',
type: 'INTEGER',
},
{
name: 'name',
type: 'TEXT',
},
{
name: 'courseid',
type: 'INTEGER',
},
{
name: 'subject',
type: 'TEXT',
},
{
name: 'message',
type: 'TEXT',
},
{
name: 'options',
type: 'TEXT',
},
{
name: 'groupid',
type: 'INTEGER',
},
{
name: 'userid',
type: 'INTEGER',
},
{
name: 'timecreated',
type: 'INTEGER',
}
],
primaryKeys: ['forumid', 'userid', 'timecreated']
},
{
name: this.REPLIES_TABLE,
columns: [
{
name: 'postid',
type: 'INTEGER',
},
{
name: 'discussionid',
type: 'INTEGER',
},
{
name: 'forumid',
type: 'INTEGER',
},
{
name: 'name',
type: 'TEXT',
},
{
name: 'courseid',
type: 'INTEGER',
},
{
name: 'subject',
type: 'TEXT',
},
{
name: 'message',
type: 'TEXT',
},
{
name: 'options',
type: 'TEXT',
},
{
name: 'userid',
type: 'INTEGER',
},
{
name: 'timecreated',
type: 'INTEGER',
}
],
primaryKeys: ['postid', 'userid']
}
];
constructor(private fileProvider: CoreFileProvider,
private sitesProvider: CoreSitesProvider,
private textUtils: CoreTextUtilsProvider) {
this.sitesProvider.createTablesFromSchema(this.tablesSchema);
}
/**
* Delete a forum offline discussion.
*
* @param {number} forumId Forum ID.
* @param {number} timeCreated The time the discussion was created.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the discussion belongs to. If not defined, current user in site.
* @return {Promise<any>} Promise resolved if stored, rejected if failure.
*/
deleteNewDiscussion(forumId: number, timeCreated: number, siteId?: string, userId?: number): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
const conditions = {
forumid: forumId,
userid: userId || site.getUserId(),
timecreated: timeCreated,
};
return site.getDb().deleteRecords(this.DISCUSSIONS_TABLE, conditions);
});
}
/**
* Get a forum offline discussion.
*
* @param {number} forumId Forum ID.
* @param {number} timeCreated The time the discussion was created.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the discussion belongs to. If not defined, current user in site.
* @return {Promise<any>} Promise resolved if stored, rejected if failure.
*/
getNewDiscussion(forumId: number, timeCreated: number, siteId?: string, userId?: number): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
const conditions = {
forumid: forumId,
userid: userId || site.getUserId(),
timecreated: timeCreated,
};
return site.getDb().getRecord(this.DISCUSSIONS_TABLE, conditions).then((record) => {
record.options = this.textUtils.parseJSON(record.options);
return record;
});
});
}
/**
* Get all offline new discussions.
*
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any[]>} Promise resolved with discussions.
*/
getAllNewDiscussions(siteId?: string): Promise<any[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.getDb().getRecords(this.DISCUSSIONS_TABLE).then(this.parseRecordOptions.bind(this));
});
}
/**
* Check if there are offline new discussions to send.
*
* @param {number} forumId Forum ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the discussions belong to. If not defined, current user in site.
* @return {Promise<boolean>} Promise resolved with boolean: true if has offline answers, false otherwise.
*/
hasNewDiscussions(forumId: number, siteId?: string, userId?: number): Promise<boolean> {
return this.getNewDiscussions(forumId, siteId, userId).then((discussions) => {
return !!discussions.length;
}).catch(() => {
// No offline data found, return false.
return false;
});
}
/**
* Get new discussions to be synced.
*
* @param {number} forumId Forum ID to get.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the discussions belong to. If not defined, current user in site.
* @return {Promise<any[]>} Promise resolved with the object to be synced.
*/
getNewDiscussions(forumId: number, siteId?: string, userId?: number): Promise<any[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
const conditions = {
forumid: forumId,
userid: userId || site.getUserId(),
};
return site.getDb().getRecords(this.DISCUSSIONS_TABLE, conditions).then(this.parseRecordOptions.bind(this));
});
}
/**
* Offline version for adding a new discussion to a forum.
*
* @param {number} forumId Forum ID.
* @param {string} name Forum name.
* @param {number} courseId Course ID the forum belongs to.
* @param {string} subject New discussion's subject.
* @param {string} message New discussion's message.
* @param {any} [options] Options (subscribe, pin, ...).
* @param {string} [groupId] Group this discussion belongs to.
* @param {number} [timeCreated] The time the discussion was created. If not defined, current time.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the discussion belong to. If not defined, current user in site.
* @return {Promise<any>} Promise resolved when new discussion is successfully saved.
*/
addNewDiscussion(forumId: number, name: string, courseId: number, subject: string, message: string, options?: any,
groupId?: number, timeCreated?: number, siteId?: string, userId?: number): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
const data = {
forumid: forumId,
name: name,
courseid: courseId,
subject: subject,
message: message,
options: JSON.stringify(options || {}),
groupid: groupId || -1,
userid: userId || site.getUserId(),
timecreated: timeCreated || new Date().getTime()
};
return site.getDb().insertRecord(this.DISCUSSIONS_TABLE, data);
});
}
/**
* Delete forum offline replies.
*
* @param {number} postId ID of the post being replied.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the reply belongs to. If not defined, current user in site.
* @return {Promise<any>} Promise resolved if stored, rejected if failure.
*/
deleteReply(postId: number, siteId?: string, userId?: number): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
const conditions = {
postid: postId,
userid: userId || site.getUserId(),
};
return site.getDb().deleteRecords(this.REPLIES_TABLE, conditions);
});
}
/**
* Get all offline replies.
*
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any[]>} Promise resolved with replies.
*/
getAllReplies(siteId?: string): Promise<any[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.getDb().getRecords(this.REPLIES_TABLE).then(this.parseRecordOptions.bind(this));
});
}
/**
* Check if there is an offline reply for a forum to be synced.
*
* @param {number} forumId ID of the forum being replied.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the replies belong to. If not defined, current user in site.
* @return {Promise<boolean>} Promise resolved with boolean: true if has offline answers, false otherwise.
*/
hasForumReplies(forumId: number, siteId?: string, userId?: number): Promise<boolean> {
return this.getForumReplies(forumId, siteId, userId).then((replies) => {
return !!replies.length;
}).catch(() => {
// No offline data found, return false.
return false;
});
}
/**
* Get the replies of a forum to be synced.
*
* @param {number} forumId ID of the forum being replied.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the replies belong to. If not defined, current user in site.
* @return {Promise<any[]>} Promise resolved with replies.
*/
getForumReplies(forumId: number, siteId?: string, userId?: number): Promise<any[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
const conditions = {
forumid: forumId,
userid: userId || site.getUserId(),
};
return site.getDb().getRecords(this.REPLIES_TABLE, conditions).then(this.parseRecordOptions.bind(this));
});
}
/**
* Check if there is an offline reply to be synced.
*
* @param {number} discussionId ID of the discussion the user is replying to.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the replies belong to. If not defined, current user in site.
* @return {Promise<boolean>} Promise resolved with boolean: true if has offline answers, false otherwise.
*/
hasDiscussionReplies(discussionId: number, siteId?: string, userId?: number): Promise<boolean> {
return this.getDiscussionReplies(discussionId, siteId, userId).then((replies) => {
return !!replies.length;
}).catch(() => {
// No offline data found, return false.
return false;
});
}
/**
* Get the replies of a discussion to be synced.
*
* @param {number} discussionId ID of the discussion the user is replying to.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the replies belong to. If not defined, current user in site.
* @return {Promise<any[]>} Promise resolved with discussions.
*/
getDiscussionReplies(discussionId: number, siteId?: string, userId?: number): Promise<any[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
const conditions = {
discussionid: discussionId,
userid: userId || site.getUserId(),
};
return site.getDb().getRecords(this.REPLIES_TABLE, conditions).then(this.parseRecordOptions.bind(this));
});
}
/**
* Offline version for replying to a certain post.
*
* @param {number} postId ID of the post being replied.
* @param {number} discussionId ID of the discussion the user is replying to.
* @param {number} forumId ID of the forum the user is replying to.
* @param {string} name Forum name.
* @param {number} courseId Course ID the forum belongs to.
* @param {string} subject New post's subject.
* @param {string} message New post's message.
* @param {any} [options] Options (subscribe, attachments, ...).
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the post belong to. If not defined, current user in site.
* @return {Promise<any>} Promise resolved when the post is created.
*/
replyPost(postId: number, discussionId: number, forumId: number, name: string, courseId: number, subject: string,
message: string, options?: any, siteId?: string, userId?: number): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
const data = {
postid: postId,
discussionid: discussionId,
forumid: forumId,
name: name,
courseid: courseId,
subject: subject,
message: message,
options: JSON.stringify(options || {}),
userid: userId || site.getUserId(),
timecreated: new Date().getTime()
};
return site.getDb().insertRecord(this.REPLIES_TABLE, data);
});
}
/**
* Get the path to the folder where to store files for offline attachments in a forum.
*
* @param {number} forumId Forum ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<string>} Promise resolved with the path.
*/
getForumFolder(forumId: number, siteId?: string): Promise<string> {
return this.sitesProvider.getSite(siteId).then((site) => {
const siteFolderPath = this.fileProvider.getSiteFolder(site.getId());
return this.textUtils.concatenatePaths(siteFolderPath, 'offlineforum/' + forumId);
});
}
/**
* Get the path to the folder where to store files for a new offline discussion.
*
* @param {number} forumId Forum ID.
* @param {number} timeCreated The time the discussion was created.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<string>} Promise resolved with the path.
*/
getNewDiscussionFolder(forumId: number, timeCreated: number, siteId?: string): Promise<string> {
return this.getForumFolder(forumId, siteId).then((folderPath) => {
return this.textUtils.concatenatePaths(folderPath, 'newdisc_' + timeCreated);
});
}
/**
* Get the path to the folder where to store files for a new offline reply.
*
* @param {number} forumId Forum ID.
* @param {number} postId ID of the post being replied.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the replies belong to. If not defined, current user in site.
* @return {Promise<string>} Promise resolved with the path.
*/
getReplyFolder(forumId: number, postId: number, siteId?: string, userId?: number): Promise<string> {
return this.getForumFolder(forumId, siteId).then((folderPath) => {
return this.sitesProvider.getSite(siteId).then((site) => {
userId = userId || site.getUserId();
return this.textUtils.concatenatePaths(folderPath, 'reply_' + postId + '_' + userId);
});
});
}
/**
* Parse "options" column of fetched records.
*
* @param {any[]} records List of records.
* @return {any[]} List of records with options parsed.
*/
protected parseRecordOptions(records: any[]): any[] {
records.forEach((record) => {
record.options = this.textUtils.parseJSON(record.options);
});
return records;
}
}

View File

@ -0,0 +1,240 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable, Injector } from '@angular/core';
import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler';
import { CoreGroupsProvider } from '@providers/groups';
import { CoreUserProvider } from '@core/user/providers/user';
import { AddonModForumProvider } from './forum';
/**
* Handler to prefetch forums.
*/
@Injectable()
export class AddonModForumPrefetchHandler extends CoreCourseModulePrefetchHandlerBase {
name = 'AddonModForum';
modName = 'forum';
component = AddonModForumProvider.COMPONENT;
updatesNames = /^configuration$|^.*files$|^discussions$/;
constructor(injector: Injector,
private groupsProvider: CoreGroupsProvider,
private userProvider: CoreUserProvider,
private forumProvider: AddonModForumProvider) {
super(injector);
}
/**
* Download the module.
*
* @param {any} module The module object returned by WS.
* @param {number} courseId Course ID.
* @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch.
* @return {Promise<any>} Promise resolved when all content is downloaded.
*/
download(module: any, courseId: number, dirPath?: string): Promise<any> {
// Same implementation for download or prefetch.
return this.prefetch(module, courseId, false, dirPath);
}
/**
* Get list of files. If not defined, we'll assume they're in module.contents.
*
* @param {any} module Module.
* @param {Number} courseId Course ID the module belongs to.
* @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section.
* @return {Promise<any[]>} Promise resolved with the list of files.
*/
getFiles(module: any, courseId: number, single?: boolean): Promise<any[]> {
return this.forumProvider.getForum(courseId, module.id).then((forum) => {
const files = this.getIntroFilesFromInstance(module, forum);
// Get posts.
return this.getPostsForPrefetch(forum.id).then((posts) => {
// Add posts attachments and embedded files.
return files.concat(this.getPostsFiles(posts));
});
}).catch(() => {
// Forum not found, return empty list.
return [];
});
}
/**
* Given a list of forum posts, return a list with all the files (attachments and embedded files).
*
* @param {any[]} posts Forum posts.
* @return {any[]} Files.
*/
protected getPostsFiles(posts: any[]): any[] {
let files = [];
posts.forEach((post) => {
if (post.attachments && post.attachments.length) {
files = files.concat(post.attachments);
}
if (post.message) {
files = files.concat(this.domUtils.extractDownloadableFilesFromHtmlAsFakeFileObjects(post.message));
}
});
return files;
}
/**
* Get the posts to be prefetched.
*
* @param {number} forumId Forum ID
* @return {Promise<any[]>} Promise resolved with array of posts.
*/
protected getPostsForPrefetch(forumId: number): Promise<any[]> {
// Get discussions in first 2 pages.
return this.forumProvider.getDiscussionsInPages(forumId, false, 2).then((response) => {
if (response.error) {
return Promise.reject(null);
}
const promises = [];
let posts = [];
response.discussions.forEach((discussion) => {
promises.push(this.forumProvider.getDiscussionPosts(discussion.discussion).then((ps) => {
posts = posts.concat(ps);
}));
});
return Promise.all(promises).then(() => {
return posts;
});
});
}
/**
* Invalidate the prefetched content.
*
* @param {number} moduleId The module ID.
* @param {number} courseId The course ID the module belongs to.
* @return {Promise<any>} Promise resolved when the data is invalidated.
*/
invalidateContent(moduleId: number, courseId: number): Promise<any> {
return this.forumProvider.invalidateContent(moduleId, courseId);
}
/**
* Prefetch a module.
*
* @param {any} module Module.
* @param {number} courseId Course ID the module belongs to.
* @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section.
* @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch.
* @return {Promise<any>} Promise resolved when done.
*/
prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string): Promise<any> {
return this.prefetchPackage(module, courseId, single, this.prefetchForum.bind(this));
}
/**
* Prefetch a forum.
*
* @param {any} module The module object returned by WS.
* @param {number} courseId Course ID the module belongs to.
* @param {boolean} single True if we're downloading a single module, false if we're downloading a whole section.
* @param {string} siteId Site ID.
* @return {Promise<any>} Promise resolved when done.
*/
protected prefetchForum(module: any, courseId: number, single: boolean, siteId: string): Promise<any> {
// Get the forum data.
return this.forumProvider.getForum(courseId, module.id).then((forum) => {
// Prefetch the posts.
return this.getPostsForPrefetch(forum.id).then((posts) => {
const promises = [];
// Prefetch user profiles.
const userIds = posts.map((post) => post.userid).filter((userId) => !!userId);
promises.push(this.userProvider.prefetchProfiles(userIds).catch(() => {
// Ignore failures.
}));
// Prefetch intro files, attachments and embedded files.
const files = this.getIntroFilesFromInstance(module, forum).concat(this.getPostsFiles(posts));
promises.push(this.filepoolProvider.addFilesToQueue(siteId, files, this.component, module.id));
// Prefetch groups data.
promises.push(this.prefetchGroupsInfo(forum, courseId, forum.cancreatediscussions));
return Promise.all(promises);
});
});
}
/**
* Prefetch groups info for a forum.
*
* @param {any} module The module object returned by WS.
* @param {number} courseI Course ID the module belongs to.
* @param {boolean} canCreateDiscussions Whether the user can create discussions in the forum.
* @return {Promise<any>} Promise resolved when group data has been prefetched.
*/
protected prefetchGroupsInfo(forum: any, courseId: number, canCreateDiscussions: boolean): any {
// Check group mode.
return this.groupsProvider.getActivityGroupMode(forum.cmid).then((mode) => {
if (mode !== CoreGroupsProvider.SEPARATEGROUPS && mode !== CoreGroupsProvider.VISIBLEGROUPS) {
// Activity doesn't use groups. Prefetch canAddDiscussionToAll to determine if user can pin/attach.
return this.forumProvider.canAddDiscussionToAll(forum.id).catch(() => {
// Ignore errors.
});
}
// Activity uses groups, prefetch allowed groups.
return this.groupsProvider.getActivityAllowedGroups(forum.cmid).then((groups) => {
if (mode === CoreGroupsProvider.SEPARATEGROUPS) {
// Groups are already filtered by WS. Prefetch canAddDiscussionToAll to determine if user can pin/attach.
return this.forumProvider.canAddDiscussionToAll(forum.id).catch(() => {
// Ignore errors.
});
}
if (canCreateDiscussions) {
// Prefetch data to check the visible groups when creating discussions.
return this.forumProvider.canAddDiscussionToAll(forum.id).catch(() => {
// The call failed, let's assume he can't.
return {
status: false
};
}).then((response) => {
if (response.status) {
// User can post to all groups, nothing else to prefetch.
return;
}
// The user can't post to all groups, let's check which groups he can post to.
const groupPromises = [];
groups.forEach((group) => {
groupPromises.push(this.forumProvider.canAddDiscussion(forum.id, group.id).catch(() => {
// Ignore errors.
}));
});
return Promise.all(groupPromises);
});
}
});
}).catch((error) => {
// Ignore errors if cannot create discussions.
if (canCreateDiscussions) {
return Promise.reject(error);
}
});
}
}

View File

@ -0,0 +1,47 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreCronHandler } from '@providers/cron';
import { AddonModForumSyncProvider } from './sync';
/**
* Synchronization cron handler.
*/
@Injectable()
export class AddonModForumSyncCronHandler implements CoreCronHandler {
name = 'AddonModForumSyncCronHandler';
constructor(private forumSync: AddonModForumSyncProvider) {}
/**
* Execute the process.
* Receives the ID of the site affected, undefined for all sites.
*
* @param {string} [siteId] ID of the site affected, undefined for all sites.
* @return {Promise<any>} Promise resolved when done, rejected if failure.
*/
execute(siteId?: string): Promise<any> {
return this.forumSync.syncAllForums(siteId);
}
/**
* Get the time between consecutive executions.
*
* @return {number} Time between consecutive executions (in ms).
*/
getInterval(): number {
return AddonModForumSyncProvider.SYNC_TIME;
}
}

View File

@ -0,0 +1,547 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { CoreSyncBaseProvider } from '@classes/base-sync';
import { CoreCourseProvider } from '@core/course/providers/course';
import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader';
import { CoreAppProvider } from '@providers/app';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreEventsProvider } from '@providers/events';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSyncProvider } from '@providers/sync';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { AddonModForumProvider } from './forum';
import { AddonModForumHelperProvider } from './helper';
import { AddonModForumOfflineProvider } from './offline';
/**
* Service to sync forums.
*/
@Injectable()
export class AddonModForumSyncProvider extends CoreSyncBaseProvider {
static AUTO_SYNCED = 'addon_mod_forum_autom_synced';
static MANUAL_SYNCED = 'addon_mod_forum_manual_synced';
static SYNC_TIME = 600000;
protected componentTranslate: string;
constructor(translate: TranslateService,
appProvider: CoreAppProvider,
courseProvider: CoreCourseProvider,
private eventsProvider: CoreEventsProvider,
loggerProvider: CoreLoggerProvider,
sitesProvider: CoreSitesProvider,
syncProvider: CoreSyncProvider,
textUtils: CoreTextUtilsProvider,
private uploaderProvider: CoreFileUploaderProvider,
private utils: CoreUtilsProvider,
private forumProvider: AddonModForumProvider,
private forumHelper: AddonModForumHelperProvider,
private forumOffline: AddonModForumOfflineProvider) {
super('AddonModForumSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate);
this.componentTranslate = courseProvider.translateModuleName('forum');
}
/**
* Try to synchronize all the forums in a certain site or in all sites.
*
* @param {string} [siteId] Site ID to sync. If not defined, sync all sites.
* @return {Promise<any>} Promise resolved if sync is successful, rejected if sync fails.
*/
syncAllForums(siteId?: string): Promise<any> {
return this.syncOnSites('all forums', this.syncAllForumsFunc.bind(this), [], siteId);
}
/**
* Sync all forums on a site.
*
* @param {string} [siteId] Site ID to sync. If not defined, sync all sites.
* @return {Promise<any>} Promise resolved if sync is successful, rejected if sync fails.
*/
protected syncAllForumsFunc(siteId?: string): Promise<any> {
const sitePromises = [];
// Sync all new discussions.
sitePromises.push(this.forumOffline.getAllNewDiscussions(siteId).then((discussions) => {
const promises = {};
// Do not sync same forum twice.
discussions.forEach((discussion) => {
if (typeof promises[discussion.forumid] != 'undefined') {
return;
}
promises[discussion.forumid] = this.syncForumDiscussionsIfNeeded(discussion.forumid, discussion.userid, siteId)
.then((result) => {
if (result && result.updated) {
// Sync successful, send event.
this.eventsProvider.trigger(AddonModForumSyncProvider.AUTO_SYNCED, {
forumId: discussion.forumid,
userId: discussion.userid,
warnings: result.warnings
}, siteId);
}
});
});
return Promise.all(this.utils.objectToArray(promises));
}));
// Sync all discussion replies.
sitePromises.push(this.forumOffline.getAllReplies(siteId).then((replies) => {
const promises = {};
// Do not sync same discussion twice.
replies.forEach((reply) => {
if (typeof promises[reply.discussionid] != 'undefined') {
return;
}
promises[reply.discussionid] = this.syncDiscussionRepliesIfNeeded(reply.discussionid, reply.userid, siteId)
.then((result) => {
if (result && result.updated) {
// Sync successful, send event.
this.eventsProvider.trigger(AddonModForumSyncProvider.AUTO_SYNCED, {
forumId: reply.forumid,
discussionId: reply.discussionid,
userId: reply.userid,
warnings: result.warnings
}, siteId);
}
});
});
return Promise.all(this.utils.objectToArray(promises));
}));
return Promise.all(sitePromises);
}
/**
* Sync a forum only if a certain time has passed since the last time.
*
* @param {number} forumId Forum ID.
* @param {number} userId User the discussion belong to.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when the forum is synced or if it doesn't need to be synced.
*/
syncForumDiscussionsIfNeeded(forumId: number, userId: number, siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const syncId = this.getForumSyncId(forumId, userId);
return this.isSyncNeeded(syncId, siteId).then((needed) => {
if (needed) {
return this.syncForumDiscussions(forumId, userId, siteId);
}
});
}
/**
* Synchronize all offline discussions of a forum.
*
* @param {number} forumId Forum ID to be synced.
* @param {number} [userId] User the discussions belong to.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved if sync is successful, rejected otherwise.
*/
syncForumDiscussions(forumId: number, userId?: number, siteId?: string): Promise<any> {
userId = userId || this.sitesProvider.getCurrentSiteUserId();
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const syncId = this.getForumSyncId(forumId, userId);
if (this.isSyncing(syncId, siteId)) {
// There's already a sync ongoing for this discussion, return the promise.
return this.getOngoingSync(syncId, siteId);
}
// Verify that forum isn't blocked.
if (this.syncProvider.isBlocked(AddonModForumProvider.COMPONENT, syncId, siteId)) {
this.logger.debug('Cannot sync forum ' + forumId + ' because it is blocked.');
return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate}));
}
this.logger.debug('Try to sync forum ' + forumId + ' for user ' + userId);
const result = {
warnings: [],
updated: false
};
// Get offline responses to be sent.
const syncPromise = this.forumOffline.getNewDiscussions(forumId, siteId, userId).catch(() => {
// No offline data found, return empty object.
return [];
}).then((discussions) => {
if (!discussions.length) {
// Nothing to sync.
return;
} else if (!this.appProvider.isOnline()) {
// Cannot sync in offline.
return Promise.reject(null);
}
const promises = [];
discussions.forEach((data) => {
data.options = data.options || {};
// First of all upload the attachments (if any).
const promise = this.uploadAttachments(forumId, data, true, siteId, userId).then((itemId) => {
// Now try to add the discussion.
data.options.attachmentsid = itemId;
return this.forumProvider.addNewDiscussionOnline(forumId, data.subject, data.message,
data.options, data.groupid, siteId);
});
promises.push(promise.then(() => {
result.updated = true;
return this.deleteNewDiscussion(forumId, data.timecreated, siteId, userId);
}).catch((error) => {
if (this.utils.isWebServiceError(error)) {
// The WebService has thrown an error, this means that responses cannot be submitted. Delete them.
result.updated = true;
return this.deleteNewDiscussion(forumId, data.timecreated, siteId, userId).then(() => {
// Responses deleted, add a warning.
result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: data.name,
error: error.error
}));
});
} else {
// Couldn't connect to server, reject.
return Promise.reject(error);
}
}));
});
return Promise.all(promises);
}).then(() => {
if (result.updated) {
// Data has been sent to server. Now invalidate the WS calls.
const promises = [
this.forumProvider.invalidateDiscussionsList(forumId, siteId),
this.forumProvider.invalidateCanAddDiscussion(forumId, siteId),
];
return Promise.all(promises).catch(() => {
// Ignore errors.
});
}
}).then(() => {
// Sync finished, set sync time.
return this.setSyncTime(syncId, siteId).catch(() => {
// Ignore errors.
});
}).then(() => {
// All done, return the warnings.
return result;
});
return this.addOngoingSync(syncId, syncPromise, siteId);
}
/**
* Synchronize all offline discussion replies of a forum.
*
* @param {number} forumId Forum ID to be synced.
* @param {number} [userId] User the discussions belong to.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved if sync is successful, rejected otherwise.
*/
syncForumReplies(forumId: number, userId?: number, siteId?: string): Promise<any> {
// Get offline forum replies to be sent.
return this.forumOffline.getForumReplies(forumId, siteId, userId).catch(() => {
// No offline data found, return empty list.
return [];
}).then((replies) => {
if (!replies.length) {
// Nothing to sync.
return { warnings: [], updated: false };
} else if (!this.appProvider.isOnline()) {
// Cannot sync in offline.
return Promise.reject(null);
}
const promises = {};
// Do not sync same discussion twice.
replies.forEach((reply) => {
if (typeof promises[reply.discussionid] != 'undefined') {
return;
}
promises[reply.discussionid] = this.syncDiscussionReplies(reply.discussionid, userId, siteId);
});
return Promise.all(this.utils.objectToArray(promises)).then((results) => {
return results.reduce((a, b) => ({
warnings: a.warnings.concat(b.warnings),
updated: a.updated || b.updated,
}), { warnings: [], updated: false });
});
});
}
/**
* Sync a forum discussion replies only if a certain time has passed since the last time.
*
* @param {number} discussionId Discussion ID to be synced.
* @param {number} [userId] User the posts belong to.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when the forum discussion is synced or if it doesn't need to be synced.
*/
syncDiscussionRepliesIfNeeded(discussionId: number, userId?: number, siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const syncId = this.getDiscussionSyncId(discussionId, userId);
return this.isSyncNeeded(syncId, siteId).then((needed) => {
if (needed) {
return this.syncDiscussionReplies(discussionId, userId, siteId);
}
});
}
/**
* Synchronize all offline replies from a discussion.
*
* @param {number} discussionId Discussion ID to be synced.
* @param {number} [userId] User the posts belong to.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved if sync is successful, rejected otherwise.
*/
syncDiscussionReplies(discussionId: number, userId?: number, siteId?: string): Promise<any> {
userId = userId || this.sitesProvider.getCurrentSiteUserId();
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const syncId = this.getDiscussionSyncId(discussionId, userId);
if (this.isSyncing(syncId, siteId)) {
// There's already a sync ongoing for this discussion, return the promise.
return this.getOngoingSync(syncId, siteId);
}
// Verify that forum isn't blocked.
if (this.syncProvider.isBlocked(AddonModForumProvider.COMPONENT, syncId, siteId)) {
this.logger.debug('Cannot sync forum discussion ' + discussionId + ' because it is blocked.');
return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate}));
}
this.logger.debug('Try to sync forum discussion ' + discussionId + ' for user ' + userId);
let forumId;
const result = {
warnings: [],
updated: false
};
// Get offline responses to be sent.
const syncPromise = this.forumOffline.getDiscussionReplies(discussionId, siteId, userId).catch(() => {
// No offline data found, return empty object.
return [];
}).then((replies) => {
if (!replies.length) {
// Nothing to sync.
return;
} else if (!this.appProvider.isOnline()) {
// Cannot sync in offline.
return Promise.reject(null);
}
const promises = [];
replies.forEach((data) => {
forumId = data.forumid;
data.options = data.options || {};
// First of all upload the attachments (if any).
const promise = this.uploadAttachments(forumId, data, false, siteId, userId).then((itemId) => {
// Now try to send the reply.
data.options.attachmentsid = itemId;
return this.forumProvider.replyPostOnline(data.postid, data.subject, data.message, data.options, siteId);
});
promises.push(promise.then(() => {
result.updated = true;
return this.deleteReply(forumId, data.postid, siteId, userId);
}).catch((error) => {
if (this.utils.isWebServiceError(error)) {
// The WebService has thrown an error, this means that responses cannot be submitted. Delete them.
result.updated = true;
return this.deleteReply(forumId, data.postid, siteId, userId).then(() => {
// Responses deleted, add a warning.
result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: data.name,
error: error.error
}));
});
} else {
// Couldn't connect to server, reject.
return Promise.reject(error);
}
}));
});
return Promise.all(promises);
}).then(() => {
// Data has been sent to server. Now invalidate the WS calls.
const promises = [];
if (forumId) {
promises.push(this.forumProvider.invalidateDiscussionsList(forumId, siteId));
}
promises.push(this.forumProvider.invalidateDiscussionPosts(discussionId, siteId));
return this.utils.allPromises(promises).catch(() => {
// Ignore errors.
});
}).then(() => {
// Sync finished, set sync time.
return this.setSyncTime(syncId, siteId).catch(() => {
// Ignore errors.
});
}).then(() => {
// All done, return the warnings.
return result;
});
return this.addOngoingSync(syncId, syncPromise, siteId);
}
/**
* Delete a new discussion.
*
* @param {number} forumId Forum ID the discussion belongs to.
* @param {number} timecreated The timecreated of the discussion.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the discussion belongs to. If not defined, current user in site.
* @return {Promise<any>} Promise resolved when deleted.
*/
protected deleteNewDiscussion(forumId: number, timecreated: number, siteId?: string, userId?: number): Promise<any> {
const promises = [];
promises.push(this.forumOffline.deleteNewDiscussion(forumId, timecreated, siteId, userId));
promises.push(this.forumHelper.deleteNewDiscussionStoredFiles(forumId, timecreated, siteId).catch(() => {
// Ignore errors, maybe there are no files.
}));
return Promise.all(promises);
}
/**
* Delete a new discussion.
*
* @param {number} forumId Forum ID the discussion belongs to.
* @param {number} postId ID of the post being replied.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the discussion belongs to. If not defined, current user in site.
* @return {Promise<any>} Promise resolved when deleted.
*/
protected deleteReply(forumId: number, postId: number, siteId?: string, userId?: number): Promise<any> {
const promises = [];
promises.push(this.forumOffline.deleteReply(postId, siteId, userId));
promises.push(this.forumHelper.deleteReplyStoredFiles(forumId, postId, siteId, userId).catch(() => {
// Ignore errors, maybe there are no files.
}));
return Promise.all(promises);
}
/**
* Upload attachments of an offline post/discussion.
*
* @param {number} forumId Forum ID the post belongs to.
* @param {any} post Offline post or discussion.
* @param {boolean} isDisc True if it's a new discussion, false if it's a reply.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User the reply belongs to. If not defined, current user in site.
* @return {Promise<any>} Promise resolved with draftid if uploaded, resolved with undefined if nothing to upload.
*/
protected uploadAttachments(forumId: number, post: any, isDisc: boolean, siteId?: string, userId?: number): Promise<any> {
const attachments = post && post.options && post.options.attachmentsid;
if (attachments) {
// Has some attachments to sync.
let files = attachments.online || [];
let promise;
if (attachments.offline) {
// Has offline files.
if (isDisc) {
promise = this.forumHelper.getNewDiscussionStoredFiles(forumId, post.timecreated, siteId);
} else {
promise = this.forumHelper.getReplyStoredFiles(forumId, post.postid, siteId, userId);
}
promise.then((atts) => {
files = files.concat(atts);
}).catch(() => {
// Folder not found, no files to add.
});
} else {
promise = Promise.resolve();
}
return promise.then(() => {
return this.uploaderProvider.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId);
});
}
// No attachments, resolve.
return Promise.resolve();
}
/**
* Get the ID of a forum sync.
*
* @param {number} forumId Forum ID.
* @param {number} [userId] User the responses belong to.. If not defined, current user.
* @return {string} Sync ID.
*/
getForumSyncId(forumId: number, userId?: number): string {
userId = userId || this.sitesProvider.getCurrentSiteUserId();
return 'forum#' + forumId + '#' + userId;
}
/**
* Get the ID of a discussion sync.
*
* @param {number} discussionId Discussion ID.
* @param {number} [userId] User the responses belong to.. If not defined, current user.
* @return {string} Sync ID.
*/
getDiscussionSyncId(discussionId: number, userId?: number): string {
userId = userId || this.sitesProvider.getCurrentSiteUserId();
return 'discussion#' + discussionId + '#' + userId;
}
}

View File

@ -84,6 +84,7 @@ import { AddonModLabelModule } from '@addon/mod/label/label.module';
import { AddonModResourceModule } from '@addon/mod/resource/resource.module';
import { AddonModFeedbackModule } from '@addon/mod/feedback/feedback.module';
import { AddonModFolderModule } from '@addon/mod/folder/folder.module';
import { AddonModForumModule } from '@addon/mod/forum/forum.module';
import { AddonModPageModule } from '@addon/mod/page/page.module';
import { AddonModQuizModule } from '@addon/mod/quiz/quiz.module';
import { AddonModScormModule } from '@addon/mod/scorm/scorm.module';
@ -184,6 +185,7 @@ export const CORE_PROVIDERS: any[] = [
AddonModResourceModule,
AddonModFeedbackModule,
AddonModFolderModule,
AddonModForumModule,
AddonModPageModule,
AddonModQuizModule,
AddonModScormModule,

View File

@ -84,25 +84,6 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR
}
}
/**
* Refresh the data.
*
* @param {any} [refresher] Refresher.
* @param {Function} [done] Function to call when done.
* @param {boolean} [showErrors=false] If show errors to the user of hide them.
* @return {Promise<any>} Promise resolved when done.
*/
doRefresh(refresher?: any, done?: () => void, showErrors: boolean = false): Promise<any> {
if (this.loaded) {
return this.refreshContent(true, showErrors).finally(() => {
refresher && refresher.complete();
done && done();
});
}
return Promise.resolve();
}
/**
* Compares sync event data with current data to check if refresh content is needed.
*

View File

@ -17,7 +17,8 @@ import { TranslateService } from '@ngx-translate/core';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreCourseHelperProvider } from '@core/course/providers/helper';
import { CoreCourseModuleMainComponent } from '@core/course/providers/module-delegate';
import { CoreCourseModuleMainComponent, CoreCourseModuleDelegate } from '@core/course/providers/module-delegate';
import { CoreCourseSectionPage } from '@core/course/pages/section/section.ts';
/**
* Template class to easily create CoreCourseModuleMainComponent of resources (or activities without syncing).
@ -50,12 +51,16 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
protected courseHelper: CoreCourseHelperProvider;
protected translate: TranslateService;
protected domUtils: CoreDomUtilsProvider;
protected moduleDelegate: CoreCourseModuleDelegate;
protected courseSectionPage: CoreCourseSectionPage;
constructor(injector: Injector) {
this.textUtils = injector.get(CoreTextUtilsProvider);
this.courseHelper = injector.get(CoreCourseHelperProvider);
this.translate = injector.get(TranslateService);
this.domUtils = injector.get(CoreDomUtilsProvider);
this.moduleDelegate = injector.get(CoreCourseModuleDelegate);
this.courseSectionPage = injector.get(CoreCourseSectionPage, null);
this.dataRetrieved = new EventEmitter();
}
@ -73,15 +78,27 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
/**
* Refresh the data.
*
* @param {any} [refresher] Refresher.
* @param {Function} [done] Function to call when done.
* @param {any} [refresher] Refresher.
* @param {Function} [done] Function to call when done.
* @param {boolean} [showErrors=false] If show errors to the user of hide them.
* @return {Promise<any>} Promise resolved when done.
*/
doRefresh(refresher?: any, done?: () => void): Promise<any> {
doRefresh(refresher?: any, done?: () => void, showErrors: boolean = false): Promise<any> {
if (this.loaded) {
return this.refreshContent().finally(() => {
refresher && refresher.complete();
done && done();
/* If it's a single activity course and the refresher is displayed within the component,
call doRefresh on the section page to refresh the course data. */
let promise;
if (this.courseSectionPage && !this.moduleDelegate.displayRefresherInSingleActivity(this.module.modname)) {
promise = this.courseSectionPage.doRefresh();
} else {
promise = Promise.resolve();
}
return promise.finally(() => {
return this.refreshContent(true, showErrors).finally(() => {
refresher && refresher.complete();
done && done();
});
});
}
@ -91,9 +108,11 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
/**
* Perform the refresh content function.
*
* @param {boolean} [sync=false] If the refresh needs syncing.
* @param {boolean} [showErrors=false] Wether to show errors to the user or hide them.
* @return {Promise<any>} Resolved when done.
*/
protected refreshContent(): Promise<any> {
protected refreshContent(sync: boolean = false, showErrors: boolean = false): Promise<any> {
this.refreshIcon = 'spinner';
return this.invalidateContent().catch(() => {

View File

@ -14,6 +14,7 @@
import { Injectable, Injector } from '@angular/core';
import { CoreCourseFormatHandler } from '../../../providers/format-delegate';
import { CoreCourseModuleDelegate } from '../../../providers/module-delegate';
import { CoreCourseFormatSingleActivityComponent } from '../components/singleactivity';
/**
@ -24,7 +25,7 @@ export class CoreCourseFormatSingleActivityHandler implements CoreCourseFormatHa
name = 'CoreCourseFormatSingleActivity';
format = 'singleactivity';
constructor() {
constructor(private moduleDelegate: CoreCourseModuleDelegate) {
// Nothing to do.
}
@ -83,6 +84,22 @@ export class CoreCourseFormatSingleActivityHandler implements CoreCourseFormatHa
return false;
}
/**
* Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format,
* and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true.
*
* @param {any} course The course to check.
* @param {any[]} sections List of course sections.
* @return {boolean} Whether the refresher should be displayed.
*/
displayRefresher(course: any, sections: any[]): boolean {
if (sections && sections[0] && sections[0].modules && sections[0].modules[0]) {
return this.moduleDelegate.displayRefresherInSingleActivity(sections[0].modules[0].modname);
} else {
return true;
}
}
/**
* Return the Component to use to display the course format instead of using the default one.
* Use it if you want to display a format completely different from the default one.

View File

@ -17,7 +17,7 @@
</core-context-menu>
</core-navbar-buttons>
<ion-content>
<ion-refresher [enabled]="dataLoaded" (ionRefresh)="doRefresh($event)">
<ion-refresher [enabled]="dataLoaded && displayRefresher" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>

View File

@ -54,6 +54,7 @@ export class CoreCourseSectionPage implements OnDestroy {
};
moduleId: number;
displayEnableDownload: boolean;
displayRefresher: boolean;
protected module: any;
protected completionObserver;
@ -188,6 +189,9 @@ export class CoreCourseSectionPage implements OnDestroy {
// Get the title again now that we have sections.
this.title = this.courseFormatDelegate.getCourseTitle(this.course, this.sections);
// Get whether to show the refresher now that we have sections.
this.displayRefresher = this.courseFormatDelegate.displayRefresher(this.course, this.sections);
});
}));
@ -212,13 +216,23 @@ export class CoreCourseSectionPage implements OnDestroy {
/**
* Refresh the data.
*
* @param {any} refresher Refresher.
* @param {any} [refresher] Refresher.
* @return {Promise<any>} Promise resolved when done.
*/
doRefresh(refresher: any): void {
this.invalidateData().finally(() => {
this.loadData(true).finally(() => {
this.formatComponent.doRefresh(refresher).finally(() => {
refresher.complete();
doRefresh(refresher?: any): Promise<any> {
return this.invalidateData().finally(() => {
return this.loadData(true).finally(() => {
/* Do not call doRefresh on the format component if the refresher is defined in the format component
to prevent an inifinite loop. */
let promise;
if (this.displayRefresher) {
promise = this.formatComponent.doRefresh(refresher);
} else {
promise = Promise.resolve();
}
return promise.finally(() => {
refresher && refresher.complete();
});
});
});

View File

@ -77,6 +77,18 @@ export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler {
return true;
}
/**
* Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format,
* and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true.
*
* @param {any} course The course to check.
* @param {any[]} sections List of course sections.
* @return {boolean} Whether the refresher should be displayed.
*/
displayRefresher?(course: any, sections: any[]): boolean {
return true;
}
/**
* Given a list of sections, get the "current" section that should be displayed first.
*

View File

@ -89,4 +89,14 @@ export class CoreCourseModuleDefaultHandler implements CoreCourseModuleHandler {
// We can't inject CoreCourseUnsupportedModuleComponent here due to circular dependencies.
// Don't return anything, by default it will use CoreCourseUnsupportedModuleComponent.
}
/**
* Whether to display the course refresher in single activity course format. If it returns false, a refresher must be
* included in the template that calls the doRefresh method of the component. Defaults to true.
*
* @return {boolean} Whether the refresher should be displayed.
*/
displayRefresherInSingleActivity(): boolean {
return true;
}
}

View File

@ -65,6 +65,16 @@ export interface CoreCourseFormatHandler extends CoreDelegateHandler {
*/
displaySectionSelector?(course: any): boolean;
/**
* Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format,
* and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true.
*
* @param {any} course The course to check.
* @param {any[]} sections List of course sections.
* @type {boolean} Whether the refresher should be displayed.
*/
displayRefresher?(course: any, sections: any[]): boolean;
/**
* Given a list of sections, get the "current" section that should be displayed first. Defaults to first section.
*
@ -183,6 +193,18 @@ export class CoreCourseFormatDelegate extends CoreDelegate {
return this.executeFunctionOnEnabled(course.format, 'displayEnableDownload', [course]);
}
/**
* Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format,
* and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true.
*
* @param {any} course The course to check.
* @param {any[]} sections List of course sections.
* @return {boolean} Whether the refresher should be displayed.
*/
displayRefresher(course: any, sections: any[]): boolean {
return this.executeFunctionOnEnabled(course.format, 'displayRefresher', [course, sections]);
}
/**
* Whether the default section selector should be displayed. Defaults to true.
*

View File

@ -53,6 +53,14 @@ export interface CoreCourseModuleHandler extends CoreDelegateHandler {
* @return {any|Promise<any>} The component (or promise resolved with component) to use, undefined if not found.
*/
getMainComponent(injector: Injector, course: any, module: any): any | Promise<any>;
/**
* Whether to display the course refresher in single activity course format. If it returns false, a refresher must be
* included in the template that calls the doRefresh method of the component. Defaults to true.
*
* @return {boolean} Whether the refresher should be displayed.
*/
displayRefresherInSingleActivity?(): boolean;
}
/**
@ -247,4 +255,15 @@ export class CoreCourseModuleDelegate extends CoreDelegate {
return false;
}
/**
* Whether to display the course refresher in single activity course format. If it returns false, a refresher must be
* included in the template that calls the doRefresh method of the component. Defaults to true.
*
* @param {any} modname The name of the module type.
* @return {boolean} Whether the refresher should be displayed.
*/
displayRefresherInSingleActivity(modname: string): boolean {
return this.executeFunctionOnEnabled(modname, 'displayRefresherInSingleActivity');
}
}

View File

@ -65,11 +65,11 @@ export class CoreSyncProvider {
* Block a component and ID so it cannot be synchronized.
*
* @param {string} component Component name.
* @param {number} id Unique ID per component.
* @param {string | number} id Unique ID per component.
* @param {string} [operation] Operation name. If not defined, a default text is used.
* @param {string} [siteId] Site ID. If not defined, current site.
*/
blockOperation(component: string, id: number, operation?: string, siteId?: string): void {
blockOperation(component: string, id: string | number, operation?: string, siteId?: string): void {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const uniqueId = this.getUniqueSyncBlockId(component, id);
@ -104,10 +104,10 @@ export class CoreSyncProvider {
* Clear all blocks for a certain component.
*
* @param {string} component Component name.
* @param {number} id Unique ID per component.
* @param {string | number} id Unique ID per component.
* @param {string} [siteId] Site ID. If not defined, current site.
*/
clearBlocks(component: string, id: number, siteId?: string): void {
clearBlocks(component: string, id: string | number, siteId?: string): void {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const uniqueId = this.getUniqueSyncBlockId(component, id);
@ -150,10 +150,10 @@ export class CoreSyncProvider {
* Convenience function to create unique identifiers for a component and id.
*
* @param {string} component Component name.
* @param {number} id Unique ID per component.
* @param {string | number} id Unique ID per component.
* @return {string} Unique sync id.
*/
protected getUniqueSyncBlockId(component: string, id: number): string {
protected getUniqueSyncBlockId(component: string, id: string | number): string {
return component + '#' + id;
}
@ -162,11 +162,11 @@ export class CoreSyncProvider {
* One block can have different operations. Here we check how many operations are being blocking the object.
*
* @param {string} component Component name.
* @param {number} id Unique ID per component.
* @param {string | number} id Unique ID per component.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {boolean} Whether it's blocked.
*/
isBlocked(component: string, id: number, siteId?: string): boolean {
isBlocked(component: string, id: string | number, siteId?: string): boolean {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
if (!this.blockedItems[siteId]) {
@ -185,11 +185,11 @@ export class CoreSyncProvider {
* Unblock an operation on a component and ID.
*
* @param {string} component Component name.
* @param {number} id Unique ID per component.
* @param {string | number} id Unique ID per component.
* @param {string} [operation] Operation name. If not defined, a default text is used.
* @param {string} [siteId] Site ID. If not defined, current site.
*/
unblockOperation(component: string, id: number, operation?: string, siteId?: string): void {
unblockOperation(component: string, id: string | number, operation?: string, siteId?: string): void {
operation = operation || '-';
siteId = siteId || this.sitesProvider.getCurrentSiteId();