commit
25a592e0b1
|
@ -570,7 +570,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
if (this.svComponent?.isOn()) {
|
||||
if (this.svComponent?.outletActivated) {
|
||||
// Empty form.
|
||||
this.hasOffline = false;
|
||||
this.form.reset(this.originalData);
|
||||
|
|
|
@ -96,7 +96,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
|
|||
this.notificationsEnabled = CoreLocalNotifications.instance.isAvailable();
|
||||
this.siteHomeId = CoreSites.instance.getCurrentSiteHomeId();
|
||||
this.currentSiteId = CoreSites.instance.getCurrentSiteId();
|
||||
this.isSplitViewOn = this.svComponent?.isOn();
|
||||
this.isSplitViewOn = this.svComponent?.outletActivated;
|
||||
|
||||
// Check if site supports editing and deleting. No need to check allowed types, event.canedit already does it.
|
||||
this.canEdit = AddonCalendar.instance.canEditEventsInSite();
|
||||
|
|
|
@ -121,9 +121,9 @@ export class AddonCalendarListPage implements OnInit, OnDestroy {
|
|||
this.refreshEvents(true, false).finally(() => {
|
||||
|
||||
// In tablet mode try to open the event (only if it's an online event).
|
||||
if (this.splitviewCtrl?.isOn() && data.eventId > 0) {
|
||||
if (this.splitviewCtrl?.outletActivated && data.eventId > 0) {
|
||||
this.gotoEvent(data.eventId);
|
||||
} else if (this.splitviewCtrl?.isOn()) {
|
||||
} else if (this.splitviewCtrl?.outletActivated) {
|
||||
// Discussion added, clear details page.
|
||||
this.emptySplitView();
|
||||
}
|
||||
|
@ -133,7 +133,7 @@ export class AddonCalendarListPage implements OnInit, OnDestroy {
|
|||
|
||||
// Listen for new event discarded event. When it does, reload the data.
|
||||
this.discardedObserver = CoreEvents.on(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, () => {
|
||||
if (this.splitviewCtrl?.isOn()) {
|
||||
if (this.splitviewCtrl?.outletActivated) {
|
||||
// Discussion added, clear details page.
|
||||
this.emptySplitView();
|
||||
}
|
||||
|
@ -155,7 +155,8 @@ export class AddonCalendarListPage implements OnInit, OnDestroy {
|
|||
this.eventsLoaded = false;
|
||||
this.refreshEvents();
|
||||
|
||||
if (this.splitviewCtrl?.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) {
|
||||
if (this.splitviewCtrl?.outletActivated &&
|
||||
this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) {
|
||||
// Current selected event was deleted. Clear details.
|
||||
this.emptySplitView();
|
||||
}
|
||||
|
@ -168,7 +169,8 @@ export class AddonCalendarListPage implements OnInit, OnDestroy {
|
|||
this.refreshEvents();
|
||||
}
|
||||
|
||||
if (this.splitviewCtrl?.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) {
|
||||
if (this.splitviewCtrl?.outletActivated &&
|
||||
this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) {
|
||||
// Current selected event was deleted. Clear details.
|
||||
this.emptySplitView();
|
||||
}
|
||||
|
@ -185,7 +187,7 @@ export class AddonCalendarListPage implements OnInit, OnDestroy {
|
|||
this.hasOffline = true;
|
||||
} else {
|
||||
// Event deleted, clear the details if needed and refresh the view.
|
||||
if (this.splitviewCtrl?.isOn()) {
|
||||
if (this.splitviewCtrl?.outletActivated) {
|
||||
this.emptySplitView();
|
||||
}
|
||||
|
||||
|
@ -255,7 +257,7 @@ export class AddonCalendarListPage implements OnInit, OnDestroy {
|
|||
|
||||
await this.fetchData(false, true, false);
|
||||
|
||||
if (!this.eventId && this.splitviewCtrl?.isOn() && this.events.length > 0) {
|
||||
if (!this.eventId && this.splitviewCtrl?.outletActivated && this.events.length > 0) {
|
||||
// Take first online event and load it. If no online event, load the first offline.
|
||||
if (this.onlineEvents[0]) {
|
||||
this.gotoEvent(this.onlineEvents[0].id);
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
<ion-item class="ion-text-wrap addon-messages-conversation-item"
|
||||
*ngFor="let contact of confirmedContacts" [title]="contact.fullname" detail
|
||||
(click)="selectUser(contact.id)" [class.core-selected-item]="contact.id == selectedUserId">
|
||||
<core-user-avatar slot="start" core-user-avatar [user]="contact"
|
||||
<core-user-avatar slot="start" [user]="contact"
|
||||
[checkOnline]="contact.showonlinestatus" [linkProfile]="false">
|
||||
</core-user-avatar>
|
||||
<ion-label>
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
|
||||
import { CoreEditorComponentsModule } from '@features/editor/components/components.module';
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
import { CoreTagComponentsModule } from '@features/tag/components/components.module';
|
||||
|
||||
import { AddonModForumDiscussionOptionsMenuComponent } from './discussion-options-menu/discussion-options-menu';
|
||||
import { AddonModForumEditPostComponent } from './edit-post/edit-post';
|
||||
import { AddonModForumIndexComponent } from './index/index';
|
||||
import { AddonModForumPostComponent } from './post/post';
|
||||
import { AddonModForumPostOptionsMenuComponent } from './post-options-menu/post-options-menu';
|
||||
import { AddonModForumSortOrderSelectorComponent } from './sort-order-selector/sort-order-selector';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AddonModForumDiscussionOptionsMenuComponent,
|
||||
AddonModForumEditPostComponent,
|
||||
AddonModForumIndexComponent,
|
||||
AddonModForumPostComponent,
|
||||
AddonModForumPostOptionsMenuComponent,
|
||||
AddonModForumSortOrderSelectorComponent,
|
||||
],
|
||||
imports: [
|
||||
CoreSharedModule,
|
||||
CoreCourseComponentsModule,
|
||||
CoreTagComponentsModule,
|
||||
CoreEditorComponentsModule,
|
||||
],
|
||||
exports: [
|
||||
AddonModForumIndexComponent,
|
||||
AddonModForumPostComponent,
|
||||
],
|
||||
})
|
||||
export class AddonModForumComponentsModule {}
|
|
@ -0,0 +1,36 @@
|
|||
<ion-item class="ion-text-wrap" (click)="setLockState(true)" *ngIf="discussion.canlock && !discussion.locked">
|
||||
<ion-icon name="fa-lock" slot="start"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>{{ 'addon.mod_forum.lockdiscussion' | translate }}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" (click)="setLockState(false)" *ngIf="discussion.canlock && discussion.locked">
|
||||
<ion-icon name="fa-unlock" slot="start"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>{{ 'addon.mod_forum.unlockdiscussion' | translate }}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" (click)="setPinState(true)" *ngIf="canPin && !discussion.pinned">
|
||||
<ion-icon name="fa-map-pin" slot="start"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>{{ 'addon.mod_forum.pindiscussion' | translate }}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" (click)="setPinState(false)" *ngIf="canPin && discussion.pinned">
|
||||
<ion-icon name="fa-map-pin" slot="start" class="icon-slash"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>{{ 'addon.mod_forum.unpindiscussion' | translate }}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" (click)="toggleFavouriteState(true)" *ngIf="discussion.canfavourite && !discussion.starred">
|
||||
<ion-icon name="fa-star" slot="start"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>{{ 'addon.mod_forum.addtofavourites' | translate }}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" (click)="toggleFavouriteState(false)" *ngIf="discussion.canfavourite && discussion.starred">
|
||||
<ion-icon name="fa-star" slot="start" class="icon-slash"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>{{ 'addon.mod_forum.removefromfavourites' | translate }}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
|
@ -0,0 +1,143 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { PopoverController } from '@singletons';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
import { AddonModForum, AddonModForumDiscussion, AddonModForumProvider } from '../../services/forum';
|
||||
|
||||
/**
|
||||
* This component is meant to display a popover with the discussion options.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'addon-forum-discussion-options-menu',
|
||||
templateUrl: 'discussion-options-menu.html',
|
||||
})
|
||||
export class AddonModForumDiscussionOptionsMenuComponent implements OnInit {
|
||||
|
||||
@Input() discussion!: AddonModForumDiscussion; // The discussion.
|
||||
@Input() forumId!: number; // The forum Id.
|
||||
@Input() cmId!: number; // The component module Id.
|
||||
|
||||
canPin = false;
|
||||
|
||||
/**
|
||||
* Component being initialized.
|
||||
*/
|
||||
async ngOnInit(): Promise<void> {
|
||||
if (!AddonModForum.instance.isSetPinStateAvailableForSite()) {
|
||||
this.canPin = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the canAddDiscussion WS to check if the user can pin discussions.
|
||||
try {
|
||||
const response = await AddonModForum.instance.canAddDiscussionToAll(this.forumId, { cmId: this.cmId });
|
||||
|
||||
this.canPin = !!response.canpindiscussions;
|
||||
} catch (error) {
|
||||
this.canPin = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock or unlock the discussion.
|
||||
*
|
||||
* @param locked True to lock the discussion, false to unlock.
|
||||
*/
|
||||
async setLockState(locked: boolean): Promise<void> {
|
||||
const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
|
||||
|
||||
try {
|
||||
const response = await AddonModForum.instance.setLockState(this.forumId, this.discussion.discussion, locked);
|
||||
const data = {
|
||||
forumId: this.forumId,
|
||||
discussionId: this.discussion.discussion,
|
||||
cmId: this.cmId,
|
||||
locked: response.locked,
|
||||
};
|
||||
|
||||
CoreEvents.trigger(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data, CoreSites.instance.getCurrentSiteId());
|
||||
PopoverController.instance.dismiss({ action: 'lock', value: locked });
|
||||
CoreDomUtils.instance.showToast('addon.mod_forum.lockupdated', true);
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModal(error);
|
||||
PopoverController.instance.dismiss();
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin or unpin the discussion.
|
||||
*
|
||||
* @param pinned True to pin the discussion, false to unpin it.
|
||||
*/
|
||||
async setPinState(pinned: boolean): Promise<void> {
|
||||
const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
|
||||
|
||||
try {
|
||||
await AddonModForum.instance.setPinState(this.discussion.discussion, pinned);
|
||||
|
||||
const data = {
|
||||
forumId: this.forumId,
|
||||
discussionId: this.discussion.discussion,
|
||||
cmId: this.cmId,
|
||||
pinned: pinned,
|
||||
};
|
||||
|
||||
CoreEvents.trigger(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data, CoreSites.instance.getCurrentSiteId());
|
||||
PopoverController.instance.dismiss({ action: 'pin', value: pinned });
|
||||
CoreDomUtils.instance.showToast('addon.mod_forum.pinupdated', true);
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModal(error);
|
||||
PopoverController.instance.dismiss();
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Star or unstar the discussion.
|
||||
*
|
||||
* @param starred True to star the discussion, false to unstar it.
|
||||
*/
|
||||
async toggleFavouriteState(starred: boolean): Promise<void> {
|
||||
const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
|
||||
|
||||
try {
|
||||
await AddonModForum.instance.toggleFavouriteState(this.discussion.discussion, starred);
|
||||
|
||||
const data = {
|
||||
forumId: this.forumId,
|
||||
discussionId: this.discussion.discussion,
|
||||
cmId: this.cmId,
|
||||
starred: starred,
|
||||
};
|
||||
|
||||
CoreEvents.trigger(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data, CoreSites.instance.getCurrentSiteId());
|
||||
PopoverController.instance.dismiss({ action: 'star', value: starred });
|
||||
CoreDomUtils.instance.showToast('addon.mod_forum.favouriteupdated', true);
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModal(error);
|
||||
PopoverController.instance.dismiss();
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ 'addon.mod_forum.yourreply' | translate }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
|
||||
<ion-icon name="close" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<form #editFormEl>
|
||||
<ion-item>
|
||||
<ion-label position="stacked">{{ 'addon.mod_forum.subject' | translate }}</ion-label>
|
||||
<ion-input type="text" [placeholder]="'addon.mod_forum.subject' | translate" [(ngModel)]="replyData.subject" name="subject">
|
||||
</ion-input>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-label position="stacked">{{ 'addon.mod_forum.message' | translate }}</ion-label>
|
||||
<core-rich-text-editor item-content elementId="message"
|
||||
[name]="'mod_forum_reply_' + replyData.id" [control]="messageControl"
|
||||
[placeholder]="'addon.mod_forum.replyplaceholder' | translate" [autoSave]="true"
|
||||
[component]="component" [componentId]="componentId" [draftExtraParams]="{edit: replyData.id}"
|
||||
contextLevel="module" [contextInstanceId]="forum.cmid"
|
||||
(contentChanged)="onMessageChange($event)">
|
||||
</core-rich-text-editor>
|
||||
</ion-item>
|
||||
<ion-item-divider class="ion-text-wrap core-expandable" (click)="toggleAdvanced()">
|
||||
<ion-icon *ngIf="!advanced" name="fa-caret-right" slot="start"></ion-icon>
|
||||
<ion-icon *ngIf="advanced" name="fa-caret-down" slot="start"></ion-icon>
|
||||
<ion-label>{{ 'addon.mod_forum.advanced' | translate }}</ion-label>
|
||||
</ion-item-divider>
|
||||
<ng-container *ngIf="advanced">
|
||||
<core-attachments *ngIf="forum.id && forum.maxattachments > 0"
|
||||
[maxSize]="forum.maxbytes" [maxSubmissions]="forum.maxattachments" [allowOffline]="true" [files]="replyData.files"
|
||||
[component]="component" [componentId]="forum.cmid">
|
||||
</core-attachments>
|
||||
</ng-container>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col>
|
||||
<ion-button expand="block" (click)="reply($event)" [disabled]="replyData.subject == '' || replyData.message == null">
|
||||
{{ 'core.savechanges' | translate }}
|
||||
</ion-button>
|
||||
</ion-col>
|
||||
<ion-col>
|
||||
<ion-button expand="block" color="light" (click)="closeModal()">{{ 'core.cancel' | translate }}</ion-button>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</form>
|
||||
</ion-content>
|
|
@ -0,0 +1,148 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, ViewChild, ElementRef, Input, OnInit } from '@angular/core';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { CoreFileEntry, CoreFileUploader } from '@features/fileuploader/services/fileuploader';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { ModalController, Translate } from '@singletons';
|
||||
import { AddonModForumData, AddonModForumPost, AddonModForumReply } from '@addons/mod/forum/services/forum';
|
||||
import { AddonModForumHelper } from '@addons/mod/forum/services/helper';
|
||||
|
||||
/**
|
||||
* Page that displays a form to edit discussion post.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'addon-mod-forum-edit-post',
|
||||
templateUrl: 'edit-post.html',
|
||||
})
|
||||
export class AddonModForumEditPostComponent implements OnInit {
|
||||
|
||||
@ViewChild('editFormEl') formElement!: ElementRef;
|
||||
|
||||
@Input() component!: string; // Component this post belong to.
|
||||
@Input() componentId!: number; // Component ID.
|
||||
@Input() forum!: AddonModForumData; // The forum the post belongs to. Required for attachments and offline posts.
|
||||
@Input() post!: AddonModForumPost;
|
||||
|
||||
messageControl = new FormControl();
|
||||
advanced = false; // Display all form fields.
|
||||
replyData!: AddonModForumReply;
|
||||
originalData!: Omit<AddonModForumReply, 'id'>; // Object with the original post data. Usually shared between posts.
|
||||
|
||||
protected forceLeave = false; // To allow leaving the page without checking for changes.
|
||||
|
||||
ngOnInit(): void {
|
||||
// @todo Override android back button to show confirmation before dismiss.
|
||||
|
||||
this.replyData = {
|
||||
id: this.post.id,
|
||||
subject: this.post.subject,
|
||||
message: this.post.message,
|
||||
files: this.post.attachments || [],
|
||||
};
|
||||
|
||||
// Delete the local files from the tmp folder if any.
|
||||
CoreFileUploader.instance.clearTmpFiles(this.replyData.files as CoreFileEntry[]);
|
||||
|
||||
// Update rich text editor.
|
||||
this.messageControl.setValue(this.replyData.message);
|
||||
|
||||
// Update original data.
|
||||
this.originalData = {
|
||||
subject: this.replyData.subject,
|
||||
message: this.replyData.message,
|
||||
files: this.replyData.files.slice(),
|
||||
};
|
||||
|
||||
// Show advanced fields if any of them has not the default value.
|
||||
this.advanced = this.replyData.files.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message changed.
|
||||
*
|
||||
* @param text The new text.
|
||||
*/
|
||||
onMessageChange(text: string): void {
|
||||
this.replyData.message = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal.
|
||||
*
|
||||
* @param data Data to return to the page.
|
||||
*/
|
||||
async closeModal(data?: AddonModForumReply): Promise<void> {
|
||||
const confirmDismiss = await this.confirmDismiss();
|
||||
|
||||
if (!confirmDismiss) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId());
|
||||
} else {
|
||||
CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId());
|
||||
}
|
||||
|
||||
ModalController.instance.dismiss(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to this post.
|
||||
*
|
||||
* @param e Click event.
|
||||
*/
|
||||
reply(e: Event): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Close the modal, sending the input data.
|
||||
this.forceLeave = true;
|
||||
this.closeModal(this.replyData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide advanced form fields.
|
||||
*/
|
||||
toggleAdvanced(): void {
|
||||
this.advanced = !this.advanced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we can leave the page or not.
|
||||
*
|
||||
* @return Resolved if we can leave it, rejected if not.
|
||||
*/
|
||||
private async confirmDismiss(): Promise<boolean> {
|
||||
if (this.forceLeave || !AddonModForumHelper.instance.hasPostDataChanged(this.replyData, this.originalData)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Show confirmation if some data has been modified.
|
||||
await CoreDomUtils.instance.showConfirm(Translate.instant('core.confirmcanceledit'));
|
||||
|
||||
// Delete the local files from the tmp folder.
|
||||
CoreFileUploader.instance.clearTmpFiles(this.replyData.files as CoreFileEntry[]);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,157 @@
|
|||
<!-- Buttons to add to the header. -->
|
||||
<core-navbar-buttons slot="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="blog"
|
||||
[priority]="750" content="{{'addon.blog.blog' | translate}}" [iconAction]="'fa-newspaper-o'" (action)="gotoBlog()">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item *ngIf="discussions.loaded && !(hasOffline || hasOfflineRatings) && isOnline"
|
||||
[priority]="700" [content]="'addon.mod_forum.refreshdiscussions' | translate" [iconAction]="refreshIcon" [closeOnClick]="false"
|
||||
(action)="doRefresh(null, $event)">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item *ngIf="discussions.loaded && (hasOffline || hasOfflineRatings) && isOnline"
|
||||
[priority]="600" [content]="'core.settings.synchronizenow' | translate" [iconAction]="syncIcon" [closeOnClick]="false"
|
||||
(action)="doRefresh(null, $event, true)">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item *ngIf="prefetchStatusIcon"
|
||||
[priority]="500" [content]="prefetchText" [iconAction]="prefetchStatusIcon" [closeOnClick]="false"
|
||||
(action)="prefetch($event)">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item *ngIf="size"
|
||||
iconDescription="cube" iconAction="trash"
|
||||
[priority]="400" [content]="'core.clearstoreddata' | translate:{$a: size}" [closeOnClick]="false"
|
||||
(action)="removeFiles($event)">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item *ngIf="sortingAvailable"
|
||||
iconAction="fa-sort"
|
||||
[priority]="300" [content]="'core.sort' | translate"
|
||||
(action)="showSortOrderSelector()">
|
||||
</core-context-menu-item>
|
||||
</core-context-menu>
|
||||
</core-navbar-buttons>
|
||||
|
||||
<!-- Content. -->
|
||||
<core-split-view>
|
||||
<ion-refresher slot="fixed" [disabled]="!discussions.loaded" (ionRefresh)="doRefresh($event)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
||||
<core-loading [hideUntil]="discussions.loaded" class="core-loading-center">
|
||||
<core-course-module-description *ngIf="forum && forum.type != 'single'"
|
||||
[description]="description" [component]="component" [componentId]="componentId" [note]="descriptionNote"
|
||||
contextLevel="module" [contextInstanceId]="module && module.id" [courseId]="courseId">
|
||||
</core-course-module-description>
|
||||
|
||||
<!-- Forum discussions found to be synchronized -->
|
||||
<ion-card class="core-warning-card" *ngIf="hasOffline || hasOfflineRatings">
|
||||
<ion-item>
|
||||
<ion-icon name="fas-exclamation-triangle" slot="start"></ion-icon>
|
||||
<ion-label>{{ 'core.hasdatatosync' | translate:{$a: moduleName} }}</ion-label>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
|
||||
<!-- Cut-off date or due date message -->
|
||||
<ion-card class="core-info-card" *ngIf="availabilityMessage">
|
||||
<ion-item>
|
||||
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
|
||||
<ion-label>{{ availabilityMessage }}</ion-label>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
|
||||
<ng-container *ngIf="forum">
|
||||
<core-empty-box *ngIf="discussions.empty" icon="chatbubbles" [message]="'addon.mod_forum.forumnodiscussionsyet' | translate">
|
||||
</core-empty-box>
|
||||
|
||||
<div *ngIf="!discussions.empty && sortingAvailable && selectedSortOrder" class="ion-text-wrap addon-forum-sorting-select">
|
||||
<ion-button *ngIf="sortingAvailable" id="addon-mod-forum-sort-order-button"
|
||||
class="core-button-select button-no-uppercase"
|
||||
aria-haspopup="true" aria-controls="addon-mod-forum-sort-order-selector"
|
||||
[attr.aria-label]="('core.sort' | translate)" [attr.aria-expanded]="sortOrderSelectorExpanded"
|
||||
(click)="showSortOrderSelector()">
|
||||
<span class="core-button-select-text">{{ selectedSortOrder.label | translate }}</span>
|
||||
<div class="select-icon" slot="end"><div class="select-icon-inner"></div></div>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<ion-item *ngFor="let discussion of discussions.items"
|
||||
class="addon-mod-forum-discussion" detail="true"
|
||||
[lines]="discussion.groupname && 'none'" [class.core-selected-item]="discussions.isSelected(discussion)"
|
||||
(click)="discussions.select(discussion)">
|
||||
<ion-label>
|
||||
<div class="addon-mod-forum-discussion-title">
|
||||
<h2 class="ion-text-wrap">
|
||||
<ion-icon name="fa-map-pin" *ngIf="discussion.pinned"></ion-icon>
|
||||
<ion-icon name="fa-star" class="addon-forum-star" *ngIf="!discussion.pinned && discussion.starred"></ion-icon>
|
||||
<core-format-text
|
||||
[text]="discussion.subject"
|
||||
contextLevel="module" [contextInstanceId]="module && module.id" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
</h2>
|
||||
<ion-button *ngIf="canPin || discussion.canlock || discussion.canfavourite"
|
||||
fill="clear" color="dark"
|
||||
[attr.aria-label]="('core.displayoptions' | translate)"
|
||||
(click)="showOptionsMenu($event, discussion)">
|
||||
<ion-icon name="ellipsis-vertical" slot="icon-only">
|
||||
</ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
<div class="addon-mod-forum-discussion-info">
|
||||
<core-user-avatar *ngIf="discussion.userfullname" [user]="discussion" slot="start" [courseId]="courseId">
|
||||
</core-user-avatar>
|
||||
<div class="addon-mod-forum-discussion-author">
|
||||
<h3 *ngIf="discussion.userfullname">{{discussion.userfullname}}</h3>
|
||||
<p *ngIf="discussion.groupname"><ion-icon name="people"></ion-icon> {{ discussion.groupname }}</p>
|
||||
<p *ngIf="discussions.isOnlineDiscussion(discussion)">
|
||||
{{discussion.created * 1000 | coreFormatDate: "strftimerecentfull"}}
|
||||
</p>
|
||||
<p *ngIf="discussions.isOfflineDiscussion(discussion)">
|
||||
<ion-icon name="time"></ion-icon>
|
||||
{{ 'core.notsent' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ion-row *ngIf="discussions.isOnlineDiscussion(discussion)"
|
||||
class="ion-text-center addon-mod-forum-discussion-more-info">
|
||||
<ion-col class="ion-text-start">
|
||||
<ion-note>
|
||||
<ion-icon name="time"></ion-icon> {{ 'addon.mod_forum.lastpost' | translate }}
|
||||
<ng-container *ngIf="discussion.timemodified > discussion.created">
|
||||
{{ discussion.timemodified | coreTimeAgo }}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="discussion.timemodified <= discussion.created">
|
||||
{{ discussion.created | coreTimeAgo }}
|
||||
</ng-container>
|
||||
</ion-note>
|
||||
</ion-col>
|
||||
<ion-col class="ion-text-end">
|
||||
<ion-note>
|
||||
<ion-icon name="fas-comments"></ion-icon>
|
||||
{{ 'addon.mod_forum.numreplies' | translate:{numreplies: discussion.numreplies} }}
|
||||
<ion-badge *ngIf="discussion.numunread" class="ion-text-center"
|
||||
[attr.aria-label]="'addon.mod_forum.unreadpostsnumber' | translate:{ '$a' : discussion.numunread}">
|
||||
{{ discussion.numunread }}
|
||||
</ion-badge>
|
||||
</ion-note>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<core-infinite-loading
|
||||
[enabled]="discussions.onlineLoaded && !discussions.completed" [error]="fetchMoreDiscussionsFailed"
|
||||
(action)="fetchMoreDiscussions($event)">
|
||||
</core-infinite-loading>
|
||||
</ng-container>
|
||||
</core-loading>
|
||||
|
||||
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="forum && canAddDiscussion">
|
||||
<ion-fab-button (click)="openNewDiscussion()" [attr.aria-label]="addDiscussionText">
|
||||
<ion-icon name="add"></ion-icon>
|
||||
</ion-fab-button>
|
||||
</ion-fab>
|
||||
</core-split-view>
|
|
@ -0,0 +1,65 @@
|
|||
@import "~theme/globals";
|
||||
|
||||
:host {
|
||||
|
||||
.addon-forum-sorting-select {
|
||||
display: flex;
|
||||
|
||||
.core-button-select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.core-button-select-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.addon-forum-star {
|
||||
color: var(--core-color);
|
||||
}
|
||||
|
||||
.addon-mod-forum-discussion.item {
|
||||
|
||||
ion-label {
|
||||
margin-top: 4px;
|
||||
|
||||
h2 {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
|
||||
ion-icon {
|
||||
@include margin(0, 6px, 0, 0);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
core-user-avatar {
|
||||
--core-avatar-size: var(--addon-forum-avatar-size);
|
||||
|
||||
@include margin(0, 8px, 0, 0);
|
||||
}
|
||||
|
||||
.addon-mod-forum-discussion-title,
|
||||
.addon-mod-forum-discussion-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.addon-mod-forum-discussion-title h2,
|
||||
.addon-mod-forum-discussion-info .addon-mod-forum-discussion-author {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.addon-mod-forum-discussion-more-info {
|
||||
font-size: 1.4rem;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,816 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, Optional, OnInit, OnDestroy, ViewChild, AfterViewInit } from '@angular/core';
|
||||
import { ActivatedRoute, ActivatedRouteSnapshot, Params } from '@angular/router';
|
||||
import { IonContent } from '@ionic/angular';
|
||||
import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component';
|
||||
import {
|
||||
AddonModForum,
|
||||
AddonModForumData,
|
||||
AddonModForumProvider,
|
||||
AddonModForumSortOrder,
|
||||
AddonModForumDiscussion,
|
||||
AddonModForumNewDiscussionData,
|
||||
AddonModForumReplyDiscussionData,
|
||||
} from '@addons/mod/forum/services/forum';
|
||||
import { AddonModForumOffline, AddonModForumOfflineDiscussion } from '@addons/mod/forum/services/offline';
|
||||
import { ModalController, PopoverController, Translate } from '@singletons';
|
||||
import { CoreCourseContentsPage } from '@features/course/pages/contents/contents';
|
||||
import { AddonModForumHelper } from '@addons/mod/forum/services/helper';
|
||||
import { CoreGroups, CoreGroupsProvider } from '@services/groups';
|
||||
import { CoreEvents, CoreEventObserver } from '@singletons/events';
|
||||
import {
|
||||
AddonModForumAutoSyncData,
|
||||
AddonModForumManualSyncData,
|
||||
AddonModForumSyncProvider,
|
||||
AddonModForumSyncResult,
|
||||
} from '@addons/mod/forum/services/sync';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreUser } from '@features/user/services/user';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { CoreCourse } from '@features/course/services/course';
|
||||
import { CorePageItemsListManager } from '@classes/page-items-list-manager';
|
||||
import { CoreSplitViewComponent } from '@components/split-view/split-view';
|
||||
import { AddonModForumDiscussionOptionsMenuComponent } from '../discussion-options-menu/discussion-options-menu';
|
||||
import { AddonModForumSortOrderSelectorComponent } from '../sort-order-selector/sort-order-selector';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
import { CoreArray } from '@singletons/array';
|
||||
import { AddonModForumPrefetchHandler } from '../../services/handlers/prefetch';
|
||||
import { AddonModForumModuleHandlerService } from '../../services/handlers/module';
|
||||
|
||||
/**
|
||||
* Component that displays a forum entry page.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'addon-mod-forum-index',
|
||||
templateUrl: 'index.html',
|
||||
styleUrls: ['index.scss'],
|
||||
})
|
||||
export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
|
||||
|
||||
component = AddonModForumProvider.COMPONENT;
|
||||
moduleName = 'forum';
|
||||
descriptionNote?: string;
|
||||
forum?: AddonModForumData;
|
||||
fetchMoreDiscussionsFailed = false;
|
||||
discussions: AddonModForumDiscussionsManager;
|
||||
canAddDiscussion = false;
|
||||
addDiscussionText!: string;
|
||||
availabilityMessage: string | null = null;
|
||||
sortingAvailable!: boolean;
|
||||
sortOrders: AddonModForumSortOrder[] = [];
|
||||
selectedSortOrder: AddonModForumSortOrder | null = null;
|
||||
sortOrderSelectorExpanded = false;
|
||||
canPin = false;
|
||||
|
||||
protected syncEventName = AddonModForumSyncProvider.AUTO_SYNCED;
|
||||
protected page = 0;
|
||||
trackPosts = false;
|
||||
protected usesGroups = false;
|
||||
protected syncManualObserver?: CoreEventObserver; // It will observe the sync manual event.
|
||||
protected replyObserver?: CoreEventObserver;
|
||||
protected newDiscObserver?: CoreEventObserver;
|
||||
protected viewDiscObserver?: CoreEventObserver;
|
||||
protected changeDiscObserver?: CoreEventObserver;
|
||||
|
||||
hasOfflineRatings?: boolean;
|
||||
protected ratingOfflineObserver: any;
|
||||
protected ratingSyncObserver: any;
|
||||
|
||||
constructor(
|
||||
route: ActivatedRoute,
|
||||
@Optional() protected content?: IonContent,
|
||||
@Optional() courseContentsPage?: CoreCourseContentsPage,
|
||||
) {
|
||||
super('AddonModForumIndexComponent', content, courseContentsPage);
|
||||
|
||||
this.discussions = new AddonModForumDiscussionsManager(
|
||||
route.component,
|
||||
this,
|
||||
courseContentsPage ? `${AddonModForumModuleHandlerService.PAGE_NAME}/` : '',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component being initialized.
|
||||
*/
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.addDiscussionText = Translate.instance.instant('addon.mod_forum.addanewdiscussion');
|
||||
this.sortingAvailable = AddonModForum.instance.isDiscussionListSortingAvailable();
|
||||
this.sortOrders = AddonModForum.instance.getAvailableSortOrders();
|
||||
|
||||
await super.ngOnInit();
|
||||
|
||||
// Refresh data if this forum discussion is synchronized from discussions list.
|
||||
this.syncManualObserver = CoreEvents.on(AddonModForumSyncProvider.MANUAL_SYNCED, (data) => {
|
||||
this.autoSyncEventReceived(data);
|
||||
}, this.siteId);
|
||||
|
||||
// Listen for discussions added. When a discussion is added, we reload the data.
|
||||
this.newDiscObserver = CoreEvents.on(
|
||||
AddonModForumProvider.NEW_DISCUSSION_EVENT,
|
||||
this.eventReceived.bind(this, true),
|
||||
);
|
||||
this.replyObserver = CoreEvents.on(
|
||||
AddonModForumProvider.REPLY_DISCUSSION_EVENT,
|
||||
this.eventReceived.bind(this, false),
|
||||
);
|
||||
this.changeDiscObserver = CoreEvents.on(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data => {
|
||||
if ((this.forum && this.forum.id === data.forumId) || data.cmId === this.module!.id) {
|
||||
AddonModForum.instance.invalidateDiscussionsList(this.forum!.id).finally(() => {
|
||||
if (data.discussionId) {
|
||||
// Discussion changed, search it in the list of discussions.
|
||||
const discussion = this.discussions.items.find(
|
||||
(disc) => this.discussions.isOnlineDiscussion(disc) && data.discussionId == disc.discussion,
|
||||
) as AddonModForumDiscussion;
|
||||
|
||||
if (discussion) {
|
||||
if (typeof data.locked != 'undefined') {
|
||||
discussion.locked = data.locked;
|
||||
}
|
||||
if (typeof data.pinned != 'undefined') {
|
||||
discussion.pinned = data.pinned;
|
||||
}
|
||||
if (typeof data.starred != 'undefined') {
|
||||
discussion.starred = data.starred;
|
||||
}
|
||||
|
||||
this.showLoadingAndRefresh(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof data.deleted != 'undefined' && data.deleted) {
|
||||
if (data.post?.parentid == 0 && CoreScreen.instance.isTablet && !this.discussions.empty) {
|
||||
// Discussion deleted, clear details page.
|
||||
this.discussions.select(this.discussions[0]);
|
||||
}
|
||||
|
||||
this.showLoadingAndRefresh(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @todo Listen for offline ratings saved and synced.
|
||||
}
|
||||
|
||||
async ngAfterViewInit(): Promise<void> {
|
||||
await this.loadContent(false, true);
|
||||
|
||||
if (!this.forum) {
|
||||
return;
|
||||
}
|
||||
|
||||
CoreUtils.instance.ignoreErrors(
|
||||
AddonModForum.instance
|
||||
.logView(this.forum.id, this.forum.name)
|
||||
.then(async () => {
|
||||
CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata);
|
||||
|
||||
return;
|
||||
}),
|
||||
);
|
||||
|
||||
this.discussions.start(this.splitView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component being destroyed.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
super.ngOnDestroy();
|
||||
|
||||
this.syncManualObserver && this.syncManualObserver.off();
|
||||
this.newDiscObserver && this.newDiscObserver.off();
|
||||
this.replyObserver && this.replyObserver.off();
|
||||
this.viewDiscObserver && this.viewDiscObserver.off();
|
||||
this.changeDiscObserver && this.changeDiscObserver.off();
|
||||
this.ratingOfflineObserver && this.ratingOfflineObserver.off();
|
||||
this.ratingSyncObserver && this.ratingSyncObserver.off();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the component contents.
|
||||
*
|
||||
* @param refresh Whether we're refreshing data.
|
||||
* @param sync If the refresh needs syncing.
|
||||
* @param showErrors Wether to show errors to the user or hide them.
|
||||
*/
|
||||
protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> {
|
||||
this.fetchMoreDiscussionsFailed = false;
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
promises.push(this.fetchForum(sync, showErrors));
|
||||
promises.push(this.fetchSortOrderPreference());
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
await Promise.all([
|
||||
this.fetchOfflineDiscussions(),
|
||||
this.fetchDiscussions(refresh),
|
||||
// @todo fetch hasOfflineRatings.
|
||||
]);
|
||||
} catch (error) {
|
||||
if (refresh) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true);
|
||||
|
||||
this.fetchMoreDiscussionsFailed = true; // Set to prevent infinite calls with infinite-loading.
|
||||
} else {
|
||||
// Get forum failed, retry without using cache since it might be a new activity.
|
||||
await this.refreshContent(sync);
|
||||
}
|
||||
}
|
||||
|
||||
this.fillContextMenu(refresh);
|
||||
}
|
||||
|
||||
private async fetchForum(sync: boolean = false, showErrors: boolean = false): Promise<void> {
|
||||
if (!this.courseId || !this.module) {
|
||||
return;
|
||||
}
|
||||
|
||||
const forum = await AddonModForum.instance.getForum(this.courseId, this.module.id);
|
||||
|
||||
this.forum = forum;
|
||||
this.description = forum.intro || this.description;
|
||||
this.availabilityMessage = AddonModForumHelper.instance.getAvailabilityMessage(forum);
|
||||
this.descriptionNote = Translate.instant('addon.mod_forum.numdiscussions', {
|
||||
numdiscussions: forum.numdiscussions,
|
||||
});
|
||||
|
||||
if (typeof forum.istracked != 'undefined') {
|
||||
this.trackPosts = forum.istracked;
|
||||
}
|
||||
|
||||
this.dataRetrieved.emit(forum);
|
||||
|
||||
switch (forum.type) {
|
||||
case 'news':
|
||||
case 'blog':
|
||||
this.addDiscussionText = Translate.instant('addon.mod_forum.addanewtopic');
|
||||
break;
|
||||
case 'qanda':
|
||||
this.addDiscussionText = Translate.instant('addon.mod_forum.addanewquestion');
|
||||
break;
|
||||
default:
|
||||
this.addDiscussionText = Translate.instant('addon.mod_forum.addanewdiscussion');
|
||||
}
|
||||
|
||||
if (sync) {
|
||||
// Try to synchronize the forum.
|
||||
const updated = await this.syncActivity(showErrors);
|
||||
|
||||
if (updated) {
|
||||
// Sync successful, send event.
|
||||
CoreEvents.trigger(AddonModForumSyncProvider.MANUAL_SYNCED, {
|
||||
forumId: forum.id,
|
||||
userId: CoreSites.instance.getCurrentSiteUserId(),
|
||||
source: 'index',
|
||||
}, CoreSites.instance.getCurrentSiteId());
|
||||
}
|
||||
}
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
// Check if the activity uses groups.
|
||||
promises.push(
|
||||
CoreGroups.instance
|
||||
.getActivityGroupMode(this.forum.cmid)
|
||||
.then(async mode => {
|
||||
this.usesGroups = mode === CoreGroupsProvider.SEPARATEGROUPS
|
||||
|| mode === CoreGroupsProvider.VISIBLEGROUPS;
|
||||
|
||||
return;
|
||||
}),
|
||||
);
|
||||
|
||||
promises.push(
|
||||
AddonModForum.instance
|
||||
.getAccessInformation(this.forum.id, { cmId: this.module!.id })
|
||||
.then(async accessInfo => {
|
||||
// Disallow adding discussions if cut-off date is reached and the user has not the
|
||||
// capability to override it.
|
||||
// Just in case the forum was fetched from WS when the cut-off date was not reached but it is now.
|
||||
const cutoffDateReached = AddonModForumHelper.instance.isCutoffDateReached(this.forum!)
|
||||
&& !accessInfo.cancanoverridecutoff;
|
||||
this.canAddDiscussion = !!this.forum?.cancreatediscussions && !cutoffDateReached;
|
||||
|
||||
return;
|
||||
}),
|
||||
);
|
||||
|
||||
if (AddonModForum.instance.isSetPinStateAvailableForSite()) {
|
||||
// Use the canAddDiscussion WS to check if the user can pin discussions.
|
||||
promises.push(
|
||||
AddonModForum.instance
|
||||
.canAddDiscussionToAll(this.forum.id, { cmId: this.module!.id })
|
||||
.then(async response => {
|
||||
this.canPin = !!response.canpindiscussions;
|
||||
|
||||
return;
|
||||
})
|
||||
.catch(async () => {
|
||||
this.canPin = false;
|
||||
|
||||
return;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.canPin = false;
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to fetch offline discussions.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fetchOfflineDiscussions(): Promise<void> {
|
||||
const forum = this.forum!;
|
||||
let offlineDiscussions = await AddonModForumOffline.instance.getNewDiscussions(forum.id);
|
||||
this.hasOffline = !!offlineDiscussions.length;
|
||||
|
||||
if (!this.hasOffline) {
|
||||
this.discussions.setOfflineDiscussions([]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.usesGroups) {
|
||||
offlineDiscussions = await AddonModForum.instance.formatDiscussionsGroups(forum.cmid, offlineDiscussions);
|
||||
}
|
||||
|
||||
// Fill user data for Offline discussions (should be already cached).
|
||||
const promises = offlineDiscussions.map(async (discussion: any) => {
|
||||
if (discussion.parent === 0 || forum.type === 'single') {
|
||||
// Do not show author for first post and type single.
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await CoreUser.instance.getProfile(discussion.userid, this.courseId, true);
|
||||
|
||||
discussion.userfullname = user.fullname;
|
||||
discussion.userpictureurl = user.profileimageurl;
|
||||
} catch (error) {
|
||||
// Ignore errors.
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Sort discussion by time (newer first).
|
||||
offlineDiscussions.sort((a, b) => b.timecreated - a.timecreated);
|
||||
|
||||
this.discussions.setOfflineDiscussions(offlineDiscussions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to get forum discussions.
|
||||
*
|
||||
* @param refresh Whether we're refreshing data.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fetchDiscussions(refresh: boolean): Promise<void> {
|
||||
const forum = this.forum!;
|
||||
this.fetchMoreDiscussionsFailed = false;
|
||||
|
||||
if (refresh) {
|
||||
this.page = 0;
|
||||
}
|
||||
|
||||
const response = await AddonModForum.instance.getDiscussions(forum.id, {
|
||||
cmId: forum.cmid,
|
||||
sortOrder: this.selectedSortOrder!.value,
|
||||
page: this.page,
|
||||
});
|
||||
let discussions = response.discussions;
|
||||
|
||||
if (this.usesGroups) {
|
||||
discussions = await AddonModForum.instance.formatDiscussionsGroups(forum.cmid, discussions);
|
||||
}
|
||||
|
||||
// Hide author for first post and type single.
|
||||
if (forum.type === 'single') {
|
||||
for (const discussion of discussions) {
|
||||
if (discussion.userfullname && discussion.parent === 0) {
|
||||
discussion.userfullname = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If any discussion has unread posts, the whole forum is being tracked.
|
||||
if (typeof forum.istracked === 'undefined' && !this.trackPosts) {
|
||||
for (const discussion of discussions) {
|
||||
if (discussion.numunread > 0) {
|
||||
this.trackPosts = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.page === 0) {
|
||||
this.discussions.setOnlineDiscussions(discussions, response.canLoadMore);
|
||||
} else {
|
||||
this.discussions.setItems(this.discussions.items.concat(discussions), response.canLoadMore);
|
||||
}
|
||||
|
||||
this.page++;
|
||||
|
||||
// Check if there are replies for discussions stored in offline.
|
||||
const hasOffline = await AddonModForumOffline.instance.hasForumReplies(forum.id);
|
||||
|
||||
this.hasOffline = this.hasOffline || hasOffline;
|
||||
|
||||
if (hasOffline) {
|
||||
// Only update new fetched discussions.
|
||||
const promises = discussions.map(async (discussion) => {
|
||||
// Get offline discussions.
|
||||
const replies = await AddonModForumOffline.instance.getDiscussionReplies(discussion.discussion);
|
||||
|
||||
discussion.numreplies = Number(discussion.numreplies) + replies.length;
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to load more forum discussions.
|
||||
*
|
||||
* @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async fetchMoreDiscussions(complete: () => void): Promise<void> {
|
||||
try {
|
||||
await this.fetchDiscussions(false);
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true);
|
||||
|
||||
this.fetchMoreDiscussionsFailed = true;
|
||||
} finally {
|
||||
complete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to fetch the sort order preference.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fetchSortOrderPreference(): Promise<void> {
|
||||
const getSortOrder = async () => {
|
||||
if (!this.sortingAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = await CoreUtils.instance.ignoreErrors(
|
||||
CoreUser.instance.getUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER),
|
||||
);
|
||||
|
||||
return value ? parseInt(value, 10) : null;
|
||||
};
|
||||
|
||||
const value = await getSortOrder();
|
||||
|
||||
this.selectedSortOrder = this.sortOrders.find(sortOrder => sortOrder.value === value) || this.sortOrders[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the invalidate content function.
|
||||
*
|
||||
* @return Resolved when done.
|
||||
*/
|
||||
protected async invalidateContent(): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
promises.push(AddonModForum.instance.invalidateForumData(this.courseId!));
|
||||
|
||||
if (this.forum) {
|
||||
promises.push(AddonModForum.instance.invalidateDiscussionsList(this.forum.id));
|
||||
promises.push(CoreGroups.instance.invalidateActivityGroupMode(this.forum.cmid));
|
||||
promises.push(AddonModForum.instance.invalidateAccessInformation(this.forum.id));
|
||||
}
|
||||
|
||||
if (this.sortingAvailable) {
|
||||
promises.push(CoreUser.instance.invalidateUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the sync of the activity.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected sync(): Promise<AddonModForumSyncResult> {
|
||||
return AddonModForumPrefetchHandler.instance.sync(this.module!, this.courseId!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if sync has succeed from result sync data.
|
||||
*
|
||||
* @param result Data returned on the sync function.
|
||||
* @return Whether it succeed or not.
|
||||
*/
|
||||
protected hasSyncSucceed(result: AddonModForumSyncResult): boolean {
|
||||
return result.updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares sync event data with current data to check if refresh content is needed.
|
||||
*
|
||||
* @param syncEventData Data receiven on sync observer.
|
||||
* @return True if refresh is needed, false otherwise.
|
||||
*/
|
||||
protected isRefreshSyncNeeded(syncEventData: AddonModForumAutoSyncData | AddonModForumManualSyncData): boolean {
|
||||
return !!this.forum
|
||||
&& (!('source' in syncEventData) || syncEventData.source != 'index')
|
||||
&& syncEventData.forumId == this.forum.id
|
||||
&& syncEventData.userId == CoreSites.instance.getCurrentSiteUserId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Function called when we receive an event of new discussion or reply to discussion.
|
||||
*
|
||||
* @param isNewDiscussion Whether it's a new discussion event.
|
||||
* @param data Event data.
|
||||
*/
|
||||
protected eventReceived(
|
||||
isNewDiscussion: boolean,
|
||||
data: AddonModForumNewDiscussionData | AddonModForumReplyDiscussionData,
|
||||
): void {
|
||||
if ((this.forum && this.forum.id === data.forumId) || data.cmId === this.module?.id) {
|
||||
this.showLoadingAndRefresh(false).finally(() => {
|
||||
// If it's a new discussion in tablet mode, try to open it.
|
||||
if (isNewDiscussion && CoreScreen.instance.isTablet) {
|
||||
const newDiscussionData = data as AddonModForumNewDiscussionData;
|
||||
const discussion = this.discussions.items.find(disc => {
|
||||
if (this.discussions.isOfflineDiscussion(disc)) {
|
||||
return disc.timecreated === newDiscussionData.discTimecreated;
|
||||
}
|
||||
|
||||
if (this.discussions.isOnlineDiscussion(disc)) {
|
||||
return CoreArray.contains(newDiscussionData.discussionIds ?? [], disc.discussion);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (discussion || !this.discussions.empty) {
|
||||
this.discussions.select(discussion ?? this.discussions.items[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Check completion since it could be configured to complete once the user adds a new discussion or replies.
|
||||
CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the new discussion form.
|
||||
*
|
||||
* @param timeCreated Creation time of the offline discussion.
|
||||
*/
|
||||
openNewDiscussion(): void {
|
||||
this.discussions.select({ newDiscussion: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the sort order selector modal.
|
||||
*/
|
||||
async showSortOrderSelector(): Promise<void> {
|
||||
if (!this.sortingAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = await ModalController.instance.create({
|
||||
component: AddonModForumSortOrderSelectorComponent,
|
||||
componentProps: {
|
||||
sortOrders: this.sortOrders,
|
||||
selected: this.selectedSortOrder!.value,
|
||||
},
|
||||
});
|
||||
|
||||
modal.present();
|
||||
|
||||
this.sortOrderSelectorExpanded = true;
|
||||
|
||||
const result = await modal.onDidDismiss<AddonModForumSortOrder>();
|
||||
|
||||
this.sortOrderSelectorExpanded = false;
|
||||
|
||||
if (result.data && result.data.value != this.selectedSortOrder?.value) {
|
||||
this.selectedSortOrder = result.data;
|
||||
this.page = 0;
|
||||
|
||||
try {
|
||||
await CoreUser.instance.setUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER, result.data.value.toFixed(0));
|
||||
await this.showLoadingAndFetch();
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'Error updating preference.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the context menu.
|
||||
*
|
||||
* @param event Click Event.
|
||||
* @param discussion Discussion.
|
||||
*/
|
||||
async showOptionsMenu(event: Event, discussion: AddonModForumDiscussion): Promise<void> {
|
||||
const popover = await PopoverController.instance.create({
|
||||
component: AddonModForumDiscussionOptionsMenuComponent,
|
||||
componentProps: {
|
||||
discussion,
|
||||
forumId: this.forum!.id,
|
||||
cmId: this.module!.id,
|
||||
},
|
||||
event,
|
||||
});
|
||||
|
||||
popover.present();
|
||||
|
||||
const result = await popover.onDidDismiss<{ action?: string; value: boolean }>();
|
||||
|
||||
if (result.data && result.data.action) {
|
||||
switch (result.data.action) {
|
||||
case 'lock':
|
||||
discussion.locked = result.data.value;
|
||||
break;
|
||||
case 'pin':
|
||||
discussion.pinned = result.data.value;
|
||||
break;
|
||||
case 'star':
|
||||
discussion.starred = result.data.value;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Type to select the new discussion form.
|
||||
*/
|
||||
type NewDiscussionForm = { newDiscussion: true };
|
||||
|
||||
/**
|
||||
* Type of items that can be held by the discussions manager.
|
||||
*/
|
||||
type DiscussionItem = AddonModForumDiscussion | AddonModForumOfflineDiscussion | NewDiscussionForm;
|
||||
|
||||
/**
|
||||
* Discussions manager.
|
||||
*/
|
||||
class AddonModForumDiscussionsManager extends CorePageItemsListManager<DiscussionItem> {
|
||||
|
||||
onlineLoaded = false;
|
||||
|
||||
private discussionsPathPrefix: string;
|
||||
private component: AddonModForumIndexComponent;
|
||||
|
||||
constructor(pageComponent: unknown, component: AddonModForumIndexComponent, discussionsPathPrefix: string) {
|
||||
super(pageComponent);
|
||||
|
||||
this.component = component;
|
||||
this.discussionsPathPrefix = discussionsPathPrefix;
|
||||
}
|
||||
|
||||
get onlineDiscussions(): AddonModForumDiscussion[] {
|
||||
return this.items.filter(discussion => this.isOnlineDiscussion(discussion)) as AddonModForumDiscussion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
getItemQueryParams(discussion: DiscussionItem): Params {
|
||||
return {
|
||||
courseId: this.component.courseId,
|
||||
cmId: this.component.module!.id,
|
||||
forumId: this.component.forum!.id,
|
||||
...(this.isOnlineDiscussion(discussion) ? { discussion, trackPosts: this.component.trackPosts } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to infer NewDiscussionForm objects.
|
||||
*
|
||||
* @param discussion Item to check.
|
||||
* @return Whether the item is a new discussion form.
|
||||
*/
|
||||
isNewDiscussionForm(discussion: DiscussionItem): discussion is NewDiscussionForm {
|
||||
return 'newDiscussion' in discussion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to infer AddonModForumDiscussion objects.
|
||||
*
|
||||
* @param discussion Item to check.
|
||||
* @return Whether the item is an online discussion.
|
||||
*/
|
||||
isOfflineDiscussion(discussion: DiscussionItem): discussion is AddonModForumOfflineDiscussion {
|
||||
return !this.isNewDiscussionForm(discussion)
|
||||
&& !this.isOnlineDiscussion(discussion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to infer AddonModForumDiscussion objects.
|
||||
*
|
||||
* @param discussion Item to check.
|
||||
* @return Whether the item is an online discussion.
|
||||
*/
|
||||
isOnlineDiscussion(discussion: DiscussionItem): discussion is AddonModForumDiscussion {
|
||||
return 'id' in discussion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update online discussion items.
|
||||
*
|
||||
* @param onlineDiscussions Online discussions
|
||||
*/
|
||||
setOnlineDiscussions(onlineDiscussions: AddonModForumDiscussion[], hasMoreItems: boolean = false): void {
|
||||
const otherDiscussions = this.items.filter(discussion => !this.isOnlineDiscussion(discussion));
|
||||
|
||||
this.setItems(otherDiscussions.concat(onlineDiscussions), hasMoreItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update offline discussion items.
|
||||
*
|
||||
* @param offlineDiscussions Offline discussions
|
||||
*/
|
||||
setOfflineDiscussions(offlineDiscussions: AddonModForumOfflineDiscussion[]): void {
|
||||
const otherDiscussions = this.items.filter(discussion => !this.isOfflineDiscussion(discussion));
|
||||
|
||||
this.setItems((offlineDiscussions as DiscussionItem[]).concat(otherDiscussions), this.hasMoreItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
setItems(discussions: DiscussionItem[], hasMoreItems: boolean = false): void {
|
||||
super.setItems(discussions, hasMoreItems);
|
||||
|
||||
this.onlineLoaded = this.onlineLoaded || discussions.some(discussion => this.isOnlineDiscussion(discussion));
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected getItemPath(discussion: DiscussionItem): string {
|
||||
const getRelativePath = () => {
|
||||
if (this.isOnlineDiscussion(discussion)) {
|
||||
return discussion.discussion;
|
||||
}
|
||||
|
||||
if (this.isOfflineDiscussion(discussion)) {
|
||||
return `new/${discussion.timecreated}`;
|
||||
}
|
||||
|
||||
return 'new/0';
|
||||
};
|
||||
|
||||
return this.discussionsPathPrefix + getRelativePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected getSelectedItemPath(route: ActivatedRouteSnapshot): string | null {
|
||||
if (route.params.discussionId) {
|
||||
return this.discussionsPathPrefix + route.params.discussionId;
|
||||
}
|
||||
|
||||
if (route.params.timeCreated) {
|
||||
return this.discussionsPathPrefix + `new/${route.params.timeCreated}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
<core-loading [hideUntil]="loaded" class="core-loading-center">
|
||||
<ion-item class="ion-text-wrap" (click)="editPost()" *ngIf="offlinePost || (canEdit && isOnline)">
|
||||
<ion-icon name="create" slot="start"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>{{ 'addon.mod_forum.edit' | translate }}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" (click)="deletePost()" *ngIf="offlinePost || (canDelete && isOnline)">
|
||||
<ion-icon name="trash" slot="start"></ion-icon>
|
||||
<ion-label>
|
||||
<h2 *ngIf="!offlinePost">{{ 'addon.mod_forum.delete' | translate }}</h2>
|
||||
<h2 *ngIf="offlinePost">{{ 'core.discard' | translate }}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" (click)="dismiss()" *ngIf="wordCount">
|
||||
<ion-label>
|
||||
<h2>{{ 'core.numwords' | translate: {'$a': wordCount} }}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" [href]="url" *ngIf="url" core-link capture="false">
|
||||
<ion-icon name="open" slot="start"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>{{ 'core.openinbrowser' | translate }}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</core-loading>
|
|
@ -0,0 +1,11 @@
|
|||
:host {
|
||||
core-loading:not(.core-loading-loaded) > .core-loading-container {
|
||||
position: relative !important;
|
||||
padding-top: 10px !important;
|
||||
padding-bottom: 10px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
core-loading > .core-loading-container .core-loading-message {
|
||||
display: none;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
||||
import { CoreApp } from '@services/app';
|
||||
import { AddonModForum, AddonModForumPost } from '@addons/mod/forum/services/forum';
|
||||
import { Network, NgZone, PopoverController } from '@singletons';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
|
||||
/**
|
||||
* This component is meant to display a popover with the post options.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'addon-forum-post-options-menu',
|
||||
templateUrl: 'post-options-menu.html',
|
||||
styleUrls: ['./post-options-menu.scss'],
|
||||
})
|
||||
export class AddonModForumPostOptionsMenuComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() post!: AddonModForumPost; // The post.
|
||||
@Input() cmId!: number;
|
||||
@Input() forumId!: number; // The forum Id.
|
||||
|
||||
wordCount?: number | null; // Number of words when available.
|
||||
canEdit = false;
|
||||
canDelete = false;
|
||||
loaded = false;
|
||||
url?: string;
|
||||
isOnline!: boolean;
|
||||
offlinePost!: boolean;
|
||||
|
||||
protected onlineObserver?: Subscription;
|
||||
|
||||
/**
|
||||
* Component being initialized.
|
||||
*/
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.isOnline = CoreApp.instance.isOnline();
|
||||
|
||||
this.onlineObserver = Network.instance.onChange().subscribe(() => {
|
||||
// Execute the callback in the Angular zone, so change detection doesn't stop working.
|
||||
NgZone.instance.run(() => {
|
||||
this.isOnline = CoreApp.instance.isOnline();
|
||||
});
|
||||
});
|
||||
|
||||
if (this.post.id > 0) {
|
||||
const site = CoreSites.instance.getCurrentSite()!;
|
||||
this.url = site.createSiteUrl('/mod/forum/discuss.php', { d: this.post.discussionid.toString() }, 'p' + this.post.id);
|
||||
this.offlinePost = false;
|
||||
} else {
|
||||
// Offline post, you can edit or discard the post.
|
||||
this.loaded = true;
|
||||
this.offlinePost = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.post.capabilities.delete == 'undefined') {
|
||||
if (this.forumId) {
|
||||
try {
|
||||
this.post =
|
||||
await AddonModForum.instance.getDiscussionPost(this.forumId, this.post.discussionid, this.post.id, {
|
||||
cmId: this.cmId,
|
||||
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
||||
});
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting discussion post.');
|
||||
}
|
||||
} else {
|
||||
this.loaded = true;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.canDelete = !!this.post.capabilities.delete && AddonModForum.instance.isDeletePostAvailable();
|
||||
this.canEdit = !!this.post.capabilities.edit && AddonModForum.instance.isUpdatePostAvailable();
|
||||
this.wordCount = (this.post.haswordcount && this.post.wordcount) || null;
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component destroyed.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.onlineObserver?.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the popover.
|
||||
*/
|
||||
dismiss(): void {
|
||||
PopoverController.instance.dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a post.
|
||||
*/
|
||||
deletePost(): void {
|
||||
if (!this.offlinePost) {
|
||||
PopoverController.instance.dismiss({ action: 'delete' });
|
||||
} else {
|
||||
PopoverController.instance.dismiss({ action: 'deleteoffline' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a post.
|
||||
*/
|
||||
editPost(): void {
|
||||
if (!this.offlinePost) {
|
||||
PopoverController.instance.dismiss({ action: 'edit' });
|
||||
} else {
|
||||
PopoverController.instance.dismiss({ action: 'editoffline' });
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,153 @@
|
|||
<div class="addon-mod_forum-post">
|
||||
<ion-card-header class="ion-text-wrap ion-no-padding" id="addon-mod_forum-post-{{post.id}}">
|
||||
<ion-item class="ion-text-wrap" [class.highlight]="highlight" lines="none">
|
||||
<ion-label>
|
||||
<div class="addon-mod-forum-post-title" *ngIf="displaySubject">
|
||||
<h2 class="ion-text-wrap">
|
||||
<ion-icon name="fa-map-pin" *ngIf="discussion && !post.parentid && discussion.pinned">
|
||||
</ion-icon>
|
||||
<ion-icon name="fa-star" class="addon-forum-star"
|
||||
*ngIf="discussion && !post.parentid && !discussion.pinned && discussion.starred">
|
||||
</ion-icon>
|
||||
<core-format-text
|
||||
[text]="post.subject"
|
||||
contextLevel="module" [contextInstanceId]="forum && forum.cmid" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
</h2>
|
||||
<ion-note *ngIf="trackPosts && post.unread"
|
||||
class="ion-float-end ion-padding-left ion-text-end" [attr.aria-label]="'addon.mod_forum.unread' | translate">
|
||||
<ion-icon name="fa-circle" color="primary">
|
||||
</ion-icon>
|
||||
</ion-note>
|
||||
<ion-button *ngIf="optionsMenuEnabled"
|
||||
fill="clear" color="dark" [attr.aria-label]="('core.displayoptions' | translate)"
|
||||
(click)="showOptionsMenu($event)">
|
||||
<ion-icon name="ellipsis-vertical" slot="icon-only">
|
||||
</ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
<div class="addon-mod-forum-post-info">
|
||||
<core-user-avatar *ngIf="post.author && post.author.fullname" [user]="post.author" slot="start" [courseId]="courseId">
|
||||
</core-user-avatar>
|
||||
<div class="addon-mod-forum-post-author">
|
||||
<h3 *ngIf="post.author && post.author.fullname">{{post.author.fullname}}</h3>
|
||||
<p *ngIf="post.author && post.author.groups">
|
||||
<ng-container *ngFor="let group of post.author.groups">
|
||||
<ion-icon name="people"></ion-icon> {{ group.name }}
|
||||
</ng-container>
|
||||
</p>
|
||||
<p *ngIf="post.timecreated">{{post.timecreated * 1000 | coreFormatDate: "strftimerecentfull"}}</p>
|
||||
<p *ngIf="!post.timecreated"><ion-icon name="time"></ion-icon> {{ 'core.notsent' | translate }}</p>
|
||||
</div>
|
||||
<ng-container *ngIf="!displaySubject">
|
||||
<ion-note *ngIf="trackPosts && post.unread"
|
||||
class="ion-float-end ion-padding-left ion-text-end" [attr.aria-label]="'addon.mod_forum.unread' | translate">
|
||||
<ion-icon name="fa-circle" color="primary">
|
||||
</ion-icon>
|
||||
</ion-note>
|
||||
<ion-button *ngIf="optionsMenuEnabled"
|
||||
fill="clear" color="dark" [attr.aria-label]="('core.displayoptions' | translate)"
|
||||
(click)="showOptionsMenu($event)">
|
||||
<ion-icon name="ellipsis-vertical" slot="icon-only">
|
||||
</ion-icon>
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-card-header>
|
||||
<ion-card-content [class]="post.parentid == 0 ? 'ion-padding-top' : ''">
|
||||
<div class="ion-padding-bottom" *ngIf="post.isprivatereply">
|
||||
<ion-note color="danger">{{ 'addon.mod_forum.postisprivatereply' | translate }}</ion-note>
|
||||
</div>
|
||||
<core-format-text [component]="component" [componentId]="componentId" [text]="post.message"
|
||||
contextLevel="module" [contextInstanceId]="forum && forum.cmid" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
<div lines="none" *ngIf="post.attachments && post.attachments.length > 0">
|
||||
<core-files [files]="post.attachments" [component]="component" [componentId]="componentId" showInline="true">
|
||||
</core-files>
|
||||
</div>
|
||||
</ion-card-content>
|
||||
<div class="addon-mod-forum-post-more-info">
|
||||
<ion-item class="ion-text-wrap" *ngIf="tagsEnabled && post.tags && post.tags.length > 0" lines="none">
|
||||
<div slot="start">{{ 'core.tag.tags' | translate }}:</div>
|
||||
<ion-label>
|
||||
<core-tag-list [tags]="post.tags"></core-tag-list>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<!-- @todo -->
|
||||
<!-- <core-rating-rate *ngIf="forum && ratingInfo"
|
||||
[ratingInfo]="ratingInfo" contextLevel="module" [instanceId]="componentId" [itemId]="post.id"
|
||||
[itemSetId]="discussionId" [courseId]="courseId" [aggregateMethod]="forum.assessed" [scaleId]="forum.scale"
|
||||
[userId]="post.author.id" (onUpdate)="ratingUpdated()">
|
||||
</core-rating-rate>
|
||||
<core-rating-aggregate *ngIf="forum && ratingInfo"
|
||||
[ratingInfo]="ratingInfo" contextLevel="module" [instanceId]="componentId" [itemId]="post.id"
|
||||
[courseId]="courseId" [aggregateMethod]="forum.assessed" [scaleId]="forum.scale">
|
||||
</core-rating-aggregate> -->
|
||||
|
||||
<ion-item *ngIf="post.id > 0 && post.capabilities.reply && !post.isprivatereply"
|
||||
class="ion-no-padding ion-text-end addon-forum-reply-button">
|
||||
<ion-label>
|
||||
<ion-button fill="clear" size="small"
|
||||
[attr.aria-controls]="'addon-forum-reply-edit-form-' + uniqueId"
|
||||
[attr.aria-expanded]="replyData.replyingTo === post.id"
|
||||
(click)="showReplyForm()">
|
||||
<ion-icon name="fa-reply" slot="start">
|
||||
</ion-icon> {{ 'addon.mod_forum.reply' | translate }}
|
||||
</ion-button>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
<form *ngIf="showForm"
|
||||
[id]="'addon-forum-reply-edit-form-' + uniqueId" #replyFormEl>
|
||||
<ion-item>
|
||||
<ion-label position="stacked">{{ 'addon.mod_forum.subject' | translate }}</ion-label>
|
||||
<ion-input type="text" [placeholder]="'addon.mod_forum.subject' | translate" [(ngModel)]="replyData.subject" name="subject">
|
||||
</ion-input>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-label position="stacked">{{ 'addon.mod_forum.message' | translate }}</ion-label>
|
||||
<core-rich-text-editor item-content elementId="message" contextLevel="module"
|
||||
[control]="messageControl" [placeholder]="'addon.mod_forum.replyplaceholder' | translate"
|
||||
[name]="'mod_forum_reply_' + post.id" [component]="component" [componentId]="componentId" [autoSave]="true"
|
||||
[contextInstanceId]="forum && forum.cmid" [draftExtraParams]="{reply: post.id}"
|
||||
(contentChanged)="onMessageChange($event)">
|
||||
</core-rich-text-editor>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="accessInfo.canpostprivatereply">
|
||||
<ion-label>{{ 'addon.mod_forum.privatereply' | translate }}</ion-label>
|
||||
<ion-checkbox slot="end" [(ngModel)]="replyData.isprivatereply" name="isprivatereply"></ion-checkbox>
|
||||
</ion-item>
|
||||
<ng-container *ngIf="forum.id && forum.maxattachments > 0">
|
||||
<ion-item-divider class="core-expandable ion-text-wrap" (click)="toggleAdvanced()">
|
||||
<ion-label>
|
||||
<ion-icon *ngIf="!advanced" name="fa-caret-right" slot="start">
|
||||
</ion-icon>
|
||||
<ion-icon *ngIf="advanced" name="fa-caret-down" slot="start">
|
||||
</ion-icon>
|
||||
{{ 'addon.mod_forum.advanced' | translate }}
|
||||
</ion-label>
|
||||
</ion-item-divider>
|
||||
<ng-container *ngIf="advanced">
|
||||
<core-attachments
|
||||
[files]="replyData.files" [maxSize]="forum.maxbytes" [maxSubmissions]="forum.maxattachments"
|
||||
[component]="component" [componentId]="forum.cmid" [allowOffline]="true">
|
||||
</core-attachments>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col>
|
||||
<ion-button expand="block" (click)="reply()" [disabled]="replyData.subject == '' || replyData.message == null">
|
||||
{{ 'addon.mod_forum.posttoforum' | translate }}
|
||||
</ion-button>
|
||||
</ion-col>
|
||||
<ion-col>
|
||||
<ion-button expand="block" color="light" (click)="cancel()">{{ 'core.cancel' | translate }}</ion-button>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</form>
|
||||
</div>
|
|
@ -0,0 +1,72 @@
|
|||
@import "~theme/globals";
|
||||
|
||||
:host .addon-mod_forum-post {
|
||||
background-color: var(--white);
|
||||
border-bottom: 1px solid var(--addon-forum-border-color);
|
||||
|
||||
.addon-forum-star {
|
||||
color: var(--core-color);
|
||||
}
|
||||
|
||||
ion-card-header .item {
|
||||
|
||||
&.highlight::part(native) {
|
||||
background-color: var(--addon-forum-highlight-color);
|
||||
}
|
||||
|
||||
ion-label {
|
||||
margin-top: 4px;
|
||||
|
||||
h2 {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
|
||||
ion-icon {
|
||||
@include margin(0, 6px, 0, 0);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
core-user-avatar {
|
||||
--core-avatar-size: var(--addon-forum-avatar-size);
|
||||
|
||||
@include margin(0, 8px, 0, 0);
|
||||
}
|
||||
|
||||
.addon-mod-forum-post-title,
|
||||
.addon-mod-forum-post-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.addon-mod-forum-post-info {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.addon-mod-forum-post-title + .addon-mod-forum-post-info {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.addon-mod-forum-post-title h2,
|
||||
.addon-mod-forum-post-info .addon-mod-forum-post-author {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ion-card-content {
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.item .item-inner {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.addon-mod-forum-post-more-info div {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,601 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Optional,
|
||||
Output,
|
||||
SimpleChange,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import {
|
||||
AddonModForum,
|
||||
AddonModForumAccessInformation,
|
||||
AddonModForumData,
|
||||
AddonModForumDiscussion,
|
||||
AddonModForumPost,
|
||||
AddonModForumProvider,
|
||||
AddonModForumReply,
|
||||
AddonModForumUpdateDiscussionPostWSOptionsObject,
|
||||
AddonModForumWSPostAttachment,
|
||||
} from '../../services/forum';
|
||||
import { CoreTag } from '@features/tag/services/tag';
|
||||
import { ModalController, PopoverController, Translate } from '@singletons';
|
||||
import { CoreFileEntry, CoreFileUploader } from '@features/fileuploader/services/fileuploader';
|
||||
import { IonContent } from '@ionic/angular';
|
||||
import { AddonModForumSync } from '../../services/sync';
|
||||
import { CoreSync } from '@services/sync';
|
||||
import { CoreTextUtils } from '@services/utils/text';
|
||||
import { AddonModForumHelper } from '../../services/helper';
|
||||
import { AddonModForumOffline, AddonModForumReplyOptions } from '../../services/offline';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { AddonModForumPostOptionsMenuComponent } from '../post-options-menu/post-options-menu';
|
||||
import { AddonModForumEditPostComponent } from '../edit-post/edit-post';
|
||||
|
||||
/**
|
||||
* Components that shows a discussion post, its attachments and the action buttons allowed (reply, etc.).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'addon-mod-forum-post',
|
||||
templateUrl: 'post.html',
|
||||
styleUrls: ['post.scss'],
|
||||
})
|
||||
export class AddonModForumPostComponent implements OnInit, OnDestroy, OnChanges {
|
||||
|
||||
@Input() post!: AddonModForumPost; // Post.
|
||||
@Input() courseId!: number; // Post's course ID.
|
||||
@Input() discussionId!: number; // Post's' discussion ID.
|
||||
@Input() discussion?: AddonModForumDiscussion; // Post's' discussion, only for starting posts.
|
||||
@Input() component!: string; // Component this post belong to.
|
||||
@Input() componentId!: number; // Component ID.
|
||||
@Input() replyData: any; // Object with the new post data. Usually shared between posts.
|
||||
@Input() originalData: any; // Object with the original post data. Usually shared between posts.
|
||||
@Input() trackPosts!: boolean; // True if post is being tracked.
|
||||
@Input() forum!: AddonModForumData; // The forum the post belongs to. Required for attachments and offline posts.
|
||||
@Input() accessInfo!: AddonModForumAccessInformation; // Forum access information.
|
||||
@Input() parentSubject?: string; // Subject of parent post.
|
||||
@Input() ratingInfo?: any; // TODO CoreRatingInfo; // Rating info item.
|
||||
@Input() leavingPage?: boolean; // Whether the page that contains this post is being left and will be destroyed.
|
||||
@Input() highlight = false;
|
||||
@Output() onPostChange: EventEmitter<void> = new EventEmitter<void>(); // Event emitted when a reply is posted or modified.
|
||||
|
||||
@ViewChild('replyFormEl') formElement!: ElementRef;
|
||||
|
||||
messageControl = new FormControl();
|
||||
|
||||
uniqueId!: string;
|
||||
defaultReplySubject!: string;
|
||||
advanced = false; // Display all form fields.
|
||||
tagsEnabled!: boolean;
|
||||
displaySubject = true;
|
||||
optionsMenuEnabled = false;
|
||||
|
||||
protected syncId!: string;
|
||||
|
||||
constructor(
|
||||
protected elementRef: ElementRef,
|
||||
@Optional() protected content?: IonContent,
|
||||
) {}
|
||||
|
||||
get showForm(): boolean {
|
||||
return this.post.id > 0
|
||||
? !this.replyData.isEditing && this.replyData.replyingTo === this.post.id
|
||||
: this.replyData.isEditing && this.replyData.replyingTo === this.post.parentid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component being initialized.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.tagsEnabled = CoreTag.instance.areTagsAvailableInSite();
|
||||
this.uniqueId = this.post.id > 0 ? 'reply' + this.post.id : 'edit' + this.post.parentid;
|
||||
|
||||
const reTranslated = Translate.instant('addon.mod_forum.re');
|
||||
this.displaySubject = !this.parentSubject ||
|
||||
(this.post.subject != this.parentSubject && this.post.subject != `Re: ${this.parentSubject}` &&
|
||||
this.post.subject != `${reTranslated} ${this.parentSubject}`);
|
||||
this.defaultReplySubject = this.post.replysubject || ((this.post.subject.startsWith('Re: ') ||
|
||||
this.post.subject.startsWith(reTranslated)) ? this.post.subject : `${reTranslated} ${this.post.subject}`);
|
||||
|
||||
this.optionsMenuEnabled = this.post.id < 0 || (AddonModForum.instance.isGetDiscussionPostAvailable() &&
|
||||
(AddonModForum.instance.isDeletePostAvailable() || AddonModForum.instance.isUpdatePostAvailable()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect changes on input properties.
|
||||
*/
|
||||
ngOnChanges(changes: {[name: string]: SimpleChange}): void {
|
||||
if (changes.leavingPage && this.leavingPage) {
|
||||
// Download all courses is enabled now, initialize it.
|
||||
CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an online post.
|
||||
*/
|
||||
async deletePost(): Promise<void> {
|
||||
try {
|
||||
await CoreDomUtils.instance.showDeleteConfirm('addon.mod_forum.deletesure');
|
||||
|
||||
const modal = await CoreDomUtils.instance.showModalLoading('core.deleting', true);
|
||||
|
||||
try {
|
||||
const response = await AddonModForum.instance.deletePost(this.post.id);
|
||||
|
||||
const data = {
|
||||
forumId: this.forum.id,
|
||||
discussionId: this.discussionId,
|
||||
cmId: this.forum.cmid,
|
||||
deleted: response.status,
|
||||
post: this.post,
|
||||
};
|
||||
|
||||
CoreEvents.trigger(
|
||||
AddonModForumProvider.CHANGE_DISCUSSION_EVENT,
|
||||
data,
|
||||
CoreSites.instance.getCurrentSiteId(),
|
||||
);
|
||||
|
||||
CoreDomUtils.instance.showToast('addon.mod_forum.deletedpost', true);
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModal(error);
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
}
|
||||
} catch (error) {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data to new reply post, clearing temporary files and updating original data.
|
||||
*
|
||||
* @param replyingTo Id of post beeing replied.
|
||||
* @param isEditing True it's an offline reply beeing edited, false otherwise.
|
||||
* @param subject Subject of the reply.
|
||||
* @param message Message of the reply.
|
||||
* @param isPrivate True if it's private reply.
|
||||
* @param files Reply attachments.
|
||||
*/
|
||||
protected setReplyFormData(
|
||||
replyingTo?: number,
|
||||
isEditing?: boolean,
|
||||
subject?: string,
|
||||
message?: string,
|
||||
files?: (CoreFileEntry | AddonModForumWSPostAttachment)[],
|
||||
isPrivate?: boolean,
|
||||
): void {
|
||||
// Delete the local files from the tmp folder if any.
|
||||
CoreFileUploader.instance.clearTmpFiles(this.replyData.files);
|
||||
|
||||
this.replyData.replyingTo = replyingTo || 0;
|
||||
this.replyData.isEditing = !!isEditing;
|
||||
this.replyData.subject = subject || this.defaultReplySubject || '';
|
||||
this.replyData.message = message || null;
|
||||
this.replyData.files = files || [];
|
||||
this.replyData.isprivatereply = !!isPrivate;
|
||||
|
||||
// Update rich text editor.
|
||||
this.messageControl.setValue(this.replyData.message);
|
||||
|
||||
// Update original data.
|
||||
this.originalData.subject = this.replyData.subject;
|
||||
this.originalData.message = this.replyData.message;
|
||||
this.originalData.files = this.replyData.files.slice();
|
||||
this.originalData.isprivatereply = this.replyData.isprivatereply;
|
||||
|
||||
// Show advanced fields if any of them has not the default value.
|
||||
this.advanced = this.replyData.files.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the context menu.
|
||||
*
|
||||
* @param event Click Event.
|
||||
*/
|
||||
async showOptionsMenu(event: Event): Promise<void> {
|
||||
const popover = await PopoverController.instance.create({
|
||||
component: AddonModForumPostOptionsMenuComponent,
|
||||
componentProps: {
|
||||
post: this.post,
|
||||
forumId: this.forum.id,
|
||||
cmId: this.forum.cmid,
|
||||
},
|
||||
event,
|
||||
});
|
||||
|
||||
popover.present();
|
||||
|
||||
const result = await popover.onDidDismiss<{ action?: string }>();
|
||||
|
||||
if (result.data && result.data.action) {
|
||||
switch (result.data.action) {
|
||||
case 'edit':
|
||||
this.editPost();
|
||||
break;
|
||||
case 'editoffline':
|
||||
this.editOfflineReply();
|
||||
break;
|
||||
case 'delete':
|
||||
this.deletePost();
|
||||
break;
|
||||
case 'deleteoffline':
|
||||
this.discardOfflineReply();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a form modal to edit an online post.
|
||||
*/
|
||||
async editPost(): Promise<void> {
|
||||
const modal = await ModalController.instance.create({
|
||||
component: AddonModForumEditPostComponent,
|
||||
componentProps: {
|
||||
post: this.post,
|
||||
component: this.component,
|
||||
componentId: this.componentId,
|
||||
forum: this.forum,
|
||||
},
|
||||
backdropDismiss: false,
|
||||
});
|
||||
|
||||
modal.present();
|
||||
|
||||
const result = await modal.onDidDismiss<AddonModForumReply>();
|
||||
const data = result.data;
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add some HTML to the message if needed.
|
||||
const message = CoreTextUtils.instance.formatHtmlLines(data.message);
|
||||
const files = data.files;
|
||||
const options: AddonModForumUpdateDiscussionPostWSOptionsObject = {};
|
||||
|
||||
const sendingModal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
|
||||
|
||||
try {
|
||||
// Upload attachments first if any.
|
||||
if (files.length) {
|
||||
const attachment = await AddonModForumHelper.instance.uploadOrStoreReplyFiles(
|
||||
this.forum.id,
|
||||
this.post.id,
|
||||
files,
|
||||
false,
|
||||
);
|
||||
|
||||
options.attachmentsid = attachment;
|
||||
}
|
||||
|
||||
// Try to send it to server.
|
||||
const sent = await AddonModForum.instance.updatePost(this.post.id, data.subject, message, options);
|
||||
|
||||
if (sent && this.forum.id) {
|
||||
// Data sent to server, delete stored files (if any).
|
||||
AddonModForumHelper.instance.deleteReplyStoredFiles(this.forum.id, this.post.id);
|
||||
|
||||
this.onPostChange.emit();
|
||||
this.post.subject = data.subject;
|
||||
this.post.message = message;
|
||||
this.post.attachments = data.files;
|
||||
}
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_forum.couldnotupdate', true);
|
||||
} finally {
|
||||
sendingModal.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this post as being replied to.
|
||||
*/
|
||||
async showReplyForm(): Promise<void> {
|
||||
if (this.replyData.isEditing) {
|
||||
// User is editing a post, data needs to be resetted. Ask confirm if there is unsaved data.
|
||||
try {
|
||||
await this.confirmDiscard();
|
||||
this.setReplyFormData(this.post.id);
|
||||
|
||||
if (this.content) {
|
||||
setTimeout(() => {
|
||||
CoreDomUtils.instance.scrollToElementBySelector(
|
||||
this.elementRef.nativeElement,
|
||||
this.content,
|
||||
'#addon-forum-reply-edit-form-' + this.uniqueId,
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Cancelled.
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.replyData.replyingTo) {
|
||||
// User isn't replying, it's a brand new reply. Initialize the data.
|
||||
this.setReplyFormData(this.post.id);
|
||||
} else {
|
||||
// The post being replied has changed but the data will be kept.
|
||||
this.replyData.replyingTo = this.post.id;
|
||||
|
||||
if (this.replyData.subject == this.originalData.subject) {
|
||||
// Update subject only if it hadn't been modified
|
||||
this.replyData.subject = this.defaultReplySubject;
|
||||
this.originalData.subject = this.defaultReplySubject;
|
||||
}
|
||||
|
||||
this.messageControl.setValue(this.replyData.message);
|
||||
}
|
||||
|
||||
if (this.content) {
|
||||
setTimeout(() => {
|
||||
CoreDomUtils.instance.scrollToElementBySelector(
|
||||
this.elementRef.nativeElement,
|
||||
this.content,
|
||||
'#addon-forum-reply-edit-form-' + this.uniqueId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this post as being edited to.
|
||||
*/
|
||||
async editOfflineReply(): Promise<void> {
|
||||
// Ask confirm if there is unsaved data.
|
||||
try {
|
||||
await this.confirmDiscard();
|
||||
|
||||
this.syncId = AddonModForumSync.instance.getDiscussionSyncId(this.discussionId);
|
||||
CoreSync.instance.blockOperation(AddonModForumProvider.COMPONENT, this.syncId);
|
||||
|
||||
this.setReplyFormData(
|
||||
this.post.parentid,
|
||||
true,
|
||||
this.post.subject,
|
||||
this.post.message,
|
||||
this.post.attachments,
|
||||
this.post.isprivatereply,
|
||||
);
|
||||
} catch (error) {
|
||||
// Cancelled.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Message changed.
|
||||
*
|
||||
* @param text The new text.
|
||||
*/
|
||||
onMessageChange(text: string): void {
|
||||
this.replyData.message = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to this post.
|
||||
*/
|
||||
async reply(): Promise<void> {
|
||||
if (!this.replyData.subject) {
|
||||
CoreDomUtils.instance.showErrorModal('addon.mod_forum.erroremptysubject', true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.replyData.message) {
|
||||
CoreDomUtils.instance.showErrorModal('addon.mod_forum.erroremptymessage', true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let saveOffline = false;
|
||||
let message = this.replyData.message;
|
||||
const subject = this.replyData.subject;
|
||||
const replyingTo = this.replyData.replyingTo;
|
||||
const files = this.replyData.files || [];
|
||||
const options: AddonModForumReplyOptions = {};
|
||||
const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
|
||||
|
||||
// Add some HTML to the message if needed.
|
||||
message = CoreTextUtils.instance.formatHtmlLines(message);
|
||||
|
||||
// Set private option if checked.
|
||||
if (this.replyData.isprivatereply) {
|
||||
options.private = true;
|
||||
}
|
||||
|
||||
// Upload attachments first if any.
|
||||
let attachments;
|
||||
|
||||
if (files.length) {
|
||||
try {
|
||||
attachments = await AddonModForumHelper.instance.uploadOrStoreReplyFiles(this.forum.id, replyingTo, files, false);
|
||||
} catch (error) {
|
||||
|
||||
// Cannot upload them in online, save them in offline.
|
||||
if (!this.forum.id) {
|
||||
// Cannot store them in offline without the forum ID. Reject.
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
saveOffline = true;
|
||||
attachments = await AddonModForumHelper.instance.uploadOrStoreReplyFiles(this.forum.id, replyingTo, files, true);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (attachments) {
|
||||
options.attachmentsid = attachments;
|
||||
}
|
||||
|
||||
let sent;
|
||||
if (saveOffline) {
|
||||
// Save post in offline.
|
||||
await AddonModForumOffline.instance.replyPost(
|
||||
replyingTo,
|
||||
this.discussionId,
|
||||
this.forum.id,
|
||||
this.forum.name,
|
||||
this.courseId,
|
||||
subject,
|
||||
message,
|
||||
options,
|
||||
);
|
||||
|
||||
// Set sent to false since it wasn't sent to server.
|
||||
sent = false;
|
||||
} else {
|
||||
// Try to send it to server.
|
||||
// Don't allow offline if there are attachments since they were uploaded fine.
|
||||
sent = await AddonModForum.instance.replyPost(
|
||||
replyingTo,
|
||||
this.discussionId,
|
||||
this.forum.id,
|
||||
this.forum.name,
|
||||
this.courseId,
|
||||
subject,
|
||||
message,
|
||||
options,
|
||||
undefined,
|
||||
!files.length,
|
||||
);
|
||||
}
|
||||
|
||||
if (sent && this.forum.id) {
|
||||
// Data sent to server, delete stored files (if any).
|
||||
AddonModForumHelper.instance.deleteReplyStoredFiles(this.forum.id, replyingTo);
|
||||
}
|
||||
|
||||
// Reset data.
|
||||
this.setReplyFormData();
|
||||
|
||||
this.onPostChange.emit();
|
||||
|
||||
CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, sent, CoreSites.instance.getCurrentSiteId());
|
||||
|
||||
if (this.syncId) {
|
||||
CoreSync.instance.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId);
|
||||
}
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_forum.couldnotadd', true);
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel reply.
|
||||
*/
|
||||
async cancel(): Promise<void> {
|
||||
try {
|
||||
await this.confirmDiscard();
|
||||
|
||||
// Reset data.
|
||||
this.setReplyFormData();
|
||||
|
||||
CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId());
|
||||
|
||||
if (this.syncId) {
|
||||
CoreSync.instance.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId);
|
||||
}
|
||||
} catch (error) {
|
||||
// Cancelled.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard offline reply.
|
||||
*/
|
||||
async discardOfflineReply(): Promise<void> {
|
||||
try {
|
||||
await CoreDomUtils.instance.showDeleteConfirm();
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
promises.push(AddonModForumOffline.instance.deleteReply(this.post.parentid!));
|
||||
|
||||
if (this.forum.id) {
|
||||
promises.push(AddonModForumHelper.instance.deleteReplyStoredFiles(this.forum.id, this.post.parentid!).catch(() => {
|
||||
// Ignore errors, maybe there are no files.
|
||||
}));
|
||||
}
|
||||
|
||||
await CoreUtils.instance.ignoreErrors(Promise.all(promises));
|
||||
|
||||
// Reset data.
|
||||
this.setReplyFormData();
|
||||
|
||||
this.onPostChange.emit();
|
||||
|
||||
if (this.syncId) {
|
||||
CoreSync.instance.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId);
|
||||
}
|
||||
} catch (error) {
|
||||
// Cancelled.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function called when rating is updated online.
|
||||
*/
|
||||
ratingUpdated(): void {
|
||||
AddonModForum.instance.invalidateDiscussionPosts(this.discussionId, this.forum.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide advanced form fields.
|
||||
*/
|
||||
toggleAdvanced(): void {
|
||||
this.advanced = !this.advanced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component being destroyed.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
if (this.syncId) {
|
||||
CoreSync.instance.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm discard changes if any.
|
||||
*
|
||||
* @return Promise resolved if the user confirms or data was not changed and rejected otherwise.
|
||||
*/
|
||||
protected confirmDiscard(): Promise<void> {
|
||||
if (AddonModForumHelper.instance.hasPostDataChanged(this.replyData, this.originalData)) {
|
||||
// Show confirmation if some data has been modified.
|
||||
return CoreDomUtils.instance.showConfirm(Translate.instant('core.confirmloss'));
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ 'core.sort' | translate }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
|
||||
<ion-icon name="close" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<ion-list id="addon-mod-forum-sort-selector" role="menu" aria-labelledby="addon-mod-forum-sort-order-button">
|
||||
<ng-container *ngFor="let sortOrder of sortOrders">
|
||||
<ion-item class="ion-text-wrap" detail="false" role="menuitem"
|
||||
[class.core-selected-item]="selected == sortOrder.value" [attr.aria-label]="sortOrder.label | translate"
|
||||
(click)="selectSortOrder(sortOrder)">
|
||||
<ion-label>
|
||||
<h2>{{ sortOrder.label | translate }}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ion-list>
|
||||
</ion-content>
|
|
@ -0,0 +1,47 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { AddonModForumSortOrder } from '@addons/mod/forum/services/forum';
|
||||
import { ModalController } from '@singletons';
|
||||
|
||||
/**
|
||||
* Page that displays the sort selector.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'page-addon-mod-forum-sort-order-selector',
|
||||
templateUrl: 'sort-order-selector.html',
|
||||
})
|
||||
export class AddonModForumSortOrderSelectorComponent {
|
||||
|
||||
@Input() sortOrders!: AddonModForumSortOrder[];
|
||||
@Input() selected!: number;
|
||||
|
||||
/**
|
||||
* Close the modal.
|
||||
*/
|
||||
closeModal(): void {
|
||||
ModalController.instance.dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a sort order.
|
||||
*
|
||||
* @param sortOrder Selected sort order.
|
||||
*/
|
||||
selectSortOrder(sortOrder: AddonModForumSortOrder): void {
|
||||
ModalController.instance.dismiss(sortOrder);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
|
||||
import { conditionalRoutes } from '@/app/app-routing.module';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
|
||||
import { AddonModForumComponentsModule } from './components/components.module';
|
||||
import { AddonModForumIndexPage } from './pages/index';
|
||||
|
||||
const mobileRoutes: Routes = [
|
||||
{
|
||||
path: ':courseId/:cmId',
|
||||
component: AddonModForumIndexPage,
|
||||
},
|
||||
{
|
||||
path: ':courseId/:cmId/new/:timeCreated',
|
||||
loadChildren: () => import('./pages/new-discussion/new-discussion.module').then(m => m.AddonForumNewDiscussionPageModule),
|
||||
},
|
||||
{
|
||||
path: ':courseId/:cmId/:discussionId',
|
||||
loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule),
|
||||
},
|
||||
];
|
||||
|
||||
const tabletRoutes: Routes = [
|
||||
{
|
||||
path: ':courseId/:cmId',
|
||||
component: AddonModForumIndexPage,
|
||||
children: [
|
||||
{
|
||||
path: 'new/:timeCreated',
|
||||
loadChildren: () => import('./pages/new-discussion/new-discussion.module')
|
||||
.then(m => m.AddonForumNewDiscussionPageModule),
|
||||
},
|
||||
{
|
||||
path: ':discussionId',
|
||||
loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule),
|
||||
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const routes: Routes = [
|
||||
...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile),
|
||||
...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CoreSharedModule,
|
||||
AddonModForumComponentsModule,
|
||||
],
|
||||
declarations: [
|
||||
AddonModForumIndexPage,
|
||||
],
|
||||
})
|
||||
export class AddonModForumLazyModule {}
|
|
@ -0,0 +1,111 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import { conditionalRoutes } from '@/app/app-routing.module';
|
||||
import { CORE_SITE_SCHEMAS } from '@services/sites';
|
||||
import { CoreCourseContentsRoutingModule } from '@features/course/pages/contents/contents-routing.module';
|
||||
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
|
||||
import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
|
||||
import { AddonModForumComponentsModule } from './components/components.module';
|
||||
import { AddonModForumModuleHandler, AddonModForumModuleHandlerService } from './services/handlers/module';
|
||||
import { SITE_SCHEMA } from './services/database/offline';
|
||||
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
|
||||
import { AddonModForumPrefetchHandler } from './services/handlers/prefetch';
|
||||
import { CoreCronDelegate } from '@services/cron';
|
||||
import { AddonModForumSyncCronHandler } from './services/handlers/sync-cron';
|
||||
import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate';
|
||||
import { AddonModForumDiscussionLinkHandler } from './services/handlers/discussion-link';
|
||||
import { AddonModForumIndexLinkHandler } from './services/handlers/index-link';
|
||||
import { AddonModForumListLinkHandler } from './services/handlers/list-link';
|
||||
import { AddonModForumPostLinkHandler } from './services/handlers/post-link';
|
||||
import { CoreTagAreaDelegate } from '@features/tag/services/tag-area-delegate';
|
||||
import { AddonModForumTagAreaHandler } from './services/handlers/tag-area';
|
||||
import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate';
|
||||
import { AddonModForumPushClickHandler } from './services/handlers/push-click';
|
||||
|
||||
const mainMenuRoutes: Routes = [
|
||||
{
|
||||
path: `${AddonModForumModuleHandlerService.PAGE_NAME}/discussion/:discussionId`,
|
||||
loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule),
|
||||
},
|
||||
{
|
||||
path: AddonModForumModuleHandlerService.PAGE_NAME,
|
||||
loadChildren: () => import('./forum-lazy.module').then(m => m.AddonModForumLazyModule),
|
||||
},
|
||||
...conditionalRoutes(
|
||||
[
|
||||
{
|
||||
path: `course/index/contents/${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`,
|
||||
loadChildren: () => import('./pages/new-discussion/new-discussion.module')
|
||||
.then(m => m.AddonForumNewDiscussionPageModule),
|
||||
},
|
||||
{
|
||||
path: `course/index/contents/${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`,
|
||||
loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule),
|
||||
},
|
||||
],
|
||||
() => CoreScreen.instance.isMobile,
|
||||
),
|
||||
];
|
||||
|
||||
const courseContentsRoutes: Routes = conditionalRoutes(
|
||||
[
|
||||
{
|
||||
path: `${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`,
|
||||
loadChildren: () => import('./pages/new-discussion/new-discussion.module')
|
||||
.then(m => m.AddonForumNewDiscussionPageModule),
|
||||
},
|
||||
{
|
||||
path: `${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`,
|
||||
loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule),
|
||||
},
|
||||
],
|
||||
() => CoreScreen.instance.isTablet,
|
||||
);
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CoreMainMenuTabRoutingModule.forChild(mainMenuRoutes),
|
||||
CoreCourseContentsRoutingModule.forChild({ children: courseContentsRoutes }),
|
||||
AddonModForumComponentsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: CORE_SITE_SCHEMAS,
|
||||
useValue: [SITE_SCHEMA],
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
multi: true,
|
||||
useValue: () => {
|
||||
CoreCourseModuleDelegate.instance.registerHandler(AddonModForumModuleHandler.instance);
|
||||
CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModForumPrefetchHandler.instance);
|
||||
CoreCronDelegate.instance.register(AddonModForumSyncCronHandler.instance);
|
||||
CoreContentLinksDelegate.instance.registerHandler(AddonModForumDiscussionLinkHandler.instance);
|
||||
CoreContentLinksDelegate.instance.registerHandler(AddonModForumIndexLinkHandler.instance);
|
||||
CoreContentLinksDelegate.instance.registerHandler(AddonModForumListLinkHandler.instance);
|
||||
CoreContentLinksDelegate.instance.registerHandler(AddonModForumPostLinkHandler.instance);
|
||||
CoreTagAreaDelegate.instance.registerHandler(AddonModForumTagAreaHandler.instance);
|
||||
CorePushNotificationsDelegate.instance.registerClickHandler(AddonModForumPushClickHandler.instance);
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AddonModForumModule {}
|
|
@ -0,0 +1,66 @@
|
|||
{
|
||||
"addanewdiscussion": "Add a new discussion topic",
|
||||
"addanewquestion": "Add a new question",
|
||||
"addanewtopic": "Add a new topic",
|
||||
"addtofavourites": "Star this discussion",
|
||||
"advanced": "Advanced",
|
||||
"cannotadddiscussion": "Adding discussions to this forum requires group membership.",
|
||||
"cannotadddiscussionall": "You do not have permission to add a new discussion topic for all participants.",
|
||||
"cannotcreatediscussion": "Could not create new discussion",
|
||||
"couldnotadd": "Could not add your post due to an unknown error",
|
||||
"couldnotupdate": "Could not update your post due to an unknown error",
|
||||
"cutoffdatereached": "The cut-off date for posting to this forum is reached so you can no longer post to it.",
|
||||
"delete": "Delete",
|
||||
"deletedpost": "The post has been deleted",
|
||||
"deletesure": "Are you sure you want to delete this post?",
|
||||
"discussion": "Discussion",
|
||||
"discussionlistsortbycreatedasc": "Sort by creation date in ascending order",
|
||||
"discussionlistsortbycreateddesc": "Sort by creation date in descending order",
|
||||
"discussionlistsortbylastpostasc": "Sort by last post creation date in ascending order",
|
||||
"discussionlistsortbylastpostdesc": "Sort by last post creation date in descending order",
|
||||
"discussionlistsortbyrepliesasc": "Sort by number of replies in ascending order",
|
||||
"discussionlistsortbyrepliesdesc": "Sort by number of replies in descending order",
|
||||
"discussionlocked": "This discussion has been locked so you can no longer reply to it.",
|
||||
"discussionpinned": "Pinned",
|
||||
"discussionsubscription": "Discussion subscription",
|
||||
"edit": "Edit",
|
||||
"erroremptymessage": "Post message cannot be empty",
|
||||
"erroremptysubject": "Post subject cannot be empty.",
|
||||
"errorgetforum": "Error getting forum data.",
|
||||
"errorgetgroups": "Error getting group settings.",
|
||||
"errorposttoallgroups": "Could not create new discussion in all groups.",
|
||||
"favouriteupdated": "Your star option has been updated.",
|
||||
"forumnodiscussionsyet": "There are no discussions yet in this forum.",
|
||||
"group": "Group",
|
||||
"lastpost": "Last post",
|
||||
"lockdiscussion": "Lock this discussion",
|
||||
"lockupdated": "The lock option has been updated.",
|
||||
"message": "Message",
|
||||
"modeflatnewestfirst": "Display replies flat, with newest first",
|
||||
"modeflatoldestfirst": "Display replies flat, with oldest first",
|
||||
"modenested": "Display replies in nested form",
|
||||
"modulenameplural": "Forums",
|
||||
"numdiscussions": "{{numdiscussions}} discussions",
|
||||
"numreplies": "{{numreplies}} replies",
|
||||
"pindiscussion": "Pin this discussion",
|
||||
"pinupdated": "The pin option has been updated.",
|
||||
"postisprivatereply": "This is a private reply. It is not visible to other participants.",
|
||||
"posttoforum": "Post to forum",
|
||||
"posttomygroups": "Post a copy to all groups",
|
||||
"privatereply": "Reply privately",
|
||||
"re": "Re:",
|
||||
"refreshdiscussions": "Refresh discussions",
|
||||
"refreshposts": "Refresh posts",
|
||||
"removefromfavourites": "Unstar this discussion",
|
||||
"reply": "Reply",
|
||||
"replyplaceholder": "Write your reply...",
|
||||
"subject": "Subject",
|
||||
"tagarea_forum_posts": "Forum posts",
|
||||
"thisforumhasduedate": "The due date for posting to this forum is {{$a}}.",
|
||||
"thisforumisdue": "The due date for posting to this forum was {{$a}}.",
|
||||
"unlockdiscussion": "Unlock this discussion",
|
||||
"unpindiscussion": "Unpin this discussion",
|
||||
"unread": "Unread",
|
||||
"unreadpostsnumber": "{{$a}} unread posts",
|
||||
"yourreply": "Your reply"
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title *ngIf="startingPost">
|
||||
<core-format-text contextLevel="module" [text]="startingPost.subject" [contextInstanceId]="cmId" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<!-- The context menu will be added in here. -->
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<core-navbar-buttons slot="end">
|
||||
<core-context-menu>
|
||||
<core-context-menu-item *ngIf="discussionLoaded && !postHasOffline && isOnline"
|
||||
[priority]="650" [content]="'addon.mod_forum.refreshposts' | translate" [iconAction]="refreshIcon" [closeOnClick]="false"
|
||||
(action)="doRefresh(null, $event)">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item *ngIf="discussionLoaded && isMobile && postHasOffline && isOnline"
|
||||
[priority]="550" [content]="'core.settings.synchronizenow' | translate" [iconAction]="syncIcon" [closeOnClick]="false"
|
||||
(action)="doRefresh(null, $event, true)">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="sort == 'flat-oldest'"
|
||||
[priority]="500" [content]="'addon.mod_forum.modeflatoldestfirst' | translate" iconAction="arrow-round-down"
|
||||
(action)="changeSort('flat-oldest')">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="sort == 'flat-newest'"
|
||||
[priority]="450" [content]="'addon.mod_forum.modeflatnewestfirst' | translate" iconAction="arrow-round-up"
|
||||
(action)="changeSort('flat-newest')">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="sort == 'nested'"
|
||||
[priority]="400" [content]="'addon.mod_forum.modenested' | translate" iconAction="swap"
|
||||
(action)="changeSort('nested')">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="!discussion || !discussion.canlock || discussion.locked"
|
||||
[priority]="300" [content]="'addon.mod_forum.lockdiscussion' | translate" iconAction="fa-lock"
|
||||
(action)="setLockState(true)">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="!discussion || !discussion.canlock || !discussion.locked"
|
||||
[priority]="300" [content]="'addon.mod_forum.unlockdiscussion' | translate" iconAction="fa-unlock"
|
||||
(action)="setLockState(false)">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="!discussion || !canPin || discussion.pinned"
|
||||
[priority]="250" [content]="'addon.mod_forum.pindiscussion' | translate" iconAction="fa-map-pin"
|
||||
(action)="setPinState(true)">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="!discussion || !canPin || !discussion.pinned"
|
||||
[priority]="250" [content]="'addon.mod_forum.unpindiscussion' | translate" [iconSlash]="true" iconAction="fa-map-pin"
|
||||
(action)="setPinState(false)">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="!discussion || !discussion.canfavourite || discussion.starred"
|
||||
[priority]="200" [content]="'addon.mod_forum.addtofavourites' | translate" iconAction="fa-star"
|
||||
(action)="toggleFavouriteState(true)">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="!discussion || !discussion.canfavourite || !discussion.starred"
|
||||
[priority]="200" [content]="'addon.mod_forum.removefromfavourites' | translate" iconAction="fa-star" [iconSlash]="true"
|
||||
(action)="toggleFavouriteState(false)">
|
||||
</core-context-menu-item>
|
||||
</core-context-menu>
|
||||
</core-navbar-buttons>
|
||||
<ion-content>
|
||||
<ion-refresher slot="fixed" [disabled]="!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 || hasOfflineRatings">
|
||||
<ion-item>
|
||||
<ion-icon name="fas-exclamation-triangle" slot="start"></ion-icon>
|
||||
<ion-label>{{ 'core.hasdatatosync' | translate:{$a: discussionStr} }}</ion-label>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
|
||||
<!-- Cut-off date or due date message -->
|
||||
<ion-card class="core-info-card" *ngIf="availabilityMessage">
|
||||
<ion-item>
|
||||
<ion-icon name="information-circle" slot="start"></ion-icon>
|
||||
<ion-label>{{ availabilityMessage }}</ion-label>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
|
||||
<ion-card class="core-info-card" *ngIf="discussion && discussion.locked">
|
||||
<ion-item>
|
||||
<ion-icon name="fa-lock" slot="start"></ion-icon>
|
||||
<ion-label>{{ 'addon.mod_forum.discussionlocked' | translate }}</ion-label>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
|
||||
<div *ngIf="startingPost" class="ion-margin-bottom">
|
||||
<addon-mod-forum-post
|
||||
[post]="startingPost" [discussion]="discussion" [courseId]="courseId" [highlight]="true"
|
||||
[discussionId]="discussionId" [component]="component" [componentId]="cmId"
|
||||
[replyData]="replyData" [originalData]="originalData" [forum]="forum" [accessInfo]="accessInfo"
|
||||
[trackPosts]="trackPosts" [ratingInfo]="ratingInfo" [leavingPage]="leavingPage"
|
||||
(onPostChange)="postListChanged()">
|
||||
</addon-mod-forum-post>
|
||||
</div>
|
||||
|
||||
<ion-card *ngIf="sort != 'nested'">
|
||||
<ng-container *ngFor="let post of posts; first as first">
|
||||
<ion-item-divider *ngIf="!first"><ion-label></ion-label></ion-item-divider>
|
||||
<addon-mod-forum-post
|
||||
[post]="post" [courseId]="courseId" [discussionId]="discussionId"
|
||||
[component]="component" [componentId]="cmId" [replyData]="replyData"
|
||||
[originalData]="originalData" [parentSubject]="postSubjects[post.parentid]"
|
||||
[forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo"
|
||||
[leavingPage]="leavingPage"
|
||||
(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]="cmId" [replyData]="replyData" [originalData]="originalData"
|
||||
[parentSubject]="postSubjects[post.parentid]" [forum]="forum" [accessInfo]="accessInfo"
|
||||
[trackPosts]="trackPosts" [ratingInfo]="ratingInfo" [leavingPage]="leavingPage"
|
||||
(onPostChange)="postListChanged()">
|
||||
</addon-mod-forum-post>
|
||||
</ion-card>
|
||||
<div class="ion-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>
|
|
@ -0,0 +1,40 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
|
||||
import { AddonModForumComponentsModule } from '@addons/mod/forum/components/components.module';
|
||||
import { CanLeaveGuard } from '@guards/can-leave';
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
|
||||
import { AddonModForumDiscussionPage } from './discussion.page';
|
||||
|
||||
const routes: Routes = [{
|
||||
path: '',
|
||||
component: AddonModForumDiscussionPage,
|
||||
canDeactivate: [CanLeaveGuard],
|
||||
}];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CoreSharedModule,
|
||||
AddonModForumComponentsModule,
|
||||
],
|
||||
declarations: [
|
||||
AddonModForumDiscussionPage,
|
||||
],
|
||||
})
|
||||
export class AddonForumDiscussionPageModule {}
|
|
@ -0,0 +1,776 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, OnDestroy, ViewChild, OnInit, AfterViewInit, ElementRef, Optional } from '@angular/core';
|
||||
import { CoreSplitViewComponent } from '@components/split-view/split-view';
|
||||
import { CoreFileUploader } from '@features/fileuploader/services/fileuploader';
|
||||
import { CoreUser } from '@features/user/services/user';
|
||||
import { CanLeave } from '@guards/can-leave';
|
||||
import { IonContent } from '@ionic/angular';
|
||||
import { CoreApp } from '@services/app';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { Network, NgZone, Translate } from '@singletons';
|
||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||
import { Subscription } from 'rxjs';
|
||||
import {
|
||||
AddonModForum,
|
||||
AddonModForumAccessInformation,
|
||||
AddonModForumData,
|
||||
AddonModForumDiscussion,
|
||||
AddonModForumPost,
|
||||
AddonModForumProvider,
|
||||
AddonModForumRatingInfo,
|
||||
} from '../../services/forum';
|
||||
import { AddonModForumHelper } from '../../services/helper';
|
||||
import { AddonModForumOffline } from '../../services/offline';
|
||||
import { AddonModForumSync, AddonModForumSyncProvider } from '../../services/sync';
|
||||
|
||||
type SortType = 'flat-newest' | 'flat-oldest' | 'nested';
|
||||
|
||||
type Post = AddonModForumPost & { children?: Post[] };
|
||||
|
||||
/**
|
||||
* Page that displays a forum discussion.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'page-addon-mod-forum-discussion',
|
||||
templateUrl: 'discussion.html',
|
||||
styleUrls: ['discussion.scss'],
|
||||
})
|
||||
export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDestroy, CanLeave {
|
||||
|
||||
@ViewChild(IonContent) content!: IonContent;
|
||||
|
||||
courseId!: number;
|
||||
discussionId!: number;
|
||||
forum: Partial<AddonModForumData> = {};
|
||||
accessInfo: AddonModForumAccessInformation = {};
|
||||
discussion!: AddonModForumDiscussion;
|
||||
startingPost?: Post;
|
||||
posts!: Post[];
|
||||
discussionLoaded = false;
|
||||
postSubjects!: { [id: string]: string };
|
||||
isOnline!: boolean;
|
||||
postHasOffline!: boolean;
|
||||
sort: SortType = 'nested';
|
||||
trackPosts!: boolean;
|
||||
replyData = {
|
||||
replyingTo: 0,
|
||||
isEditing: false,
|
||||
subject: '',
|
||||
message: null, // Null means empty or just white space.
|
||||
files: [],
|
||||
isprivatereply: false,
|
||||
};
|
||||
|
||||
originalData = {
|
||||
subject: null, // Null means original data is not set.
|
||||
message: null, // Null means empty or just white space.
|
||||
files: [],
|
||||
isprivatereply: false,
|
||||
};
|
||||
|
||||
refreshIcon = 'spinner';
|
||||
syncIcon = 'spinner';
|
||||
discussionStr = '';
|
||||
component = AddonModForumProvider.COMPONENT;
|
||||
cmId!: number;
|
||||
canPin = false;
|
||||
availabilityMessage: string | null = null;
|
||||
leavingPage = false;
|
||||
|
||||
protected forumId!: number;
|
||||
protected postId!: number;
|
||||
protected parent!: number;
|
||||
protected onlineObserver?: Subscription;
|
||||
protected syncObserver?: CoreEventObserver;
|
||||
protected syncManualObserver?: CoreEventObserver;
|
||||
|
||||
ratingInfo?: AddonModForumRatingInfo;
|
||||
hasOfflineRatings!: boolean;
|
||||
protected ratingOfflineObserver?: CoreEventObserver;
|
||||
protected ratingSyncObserver?: CoreEventObserver;
|
||||
protected changeDiscObserver?: CoreEventObserver;
|
||||
|
||||
constructor(
|
||||
@Optional() protected splitView: CoreSplitViewComponent,
|
||||
protected elementRef: ElementRef,
|
||||
) {}
|
||||
|
||||
get isMobile(): boolean {
|
||||
return CoreScreen.instance.isMobile;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!;
|
||||
this.cmId = CoreNavigator.instance.getRouteNumberParam('cmId')!;
|
||||
this.forumId = CoreNavigator.instance.getRouteNumberParam('forumId')!;
|
||||
this.discussion = CoreNavigator.instance.getRouteParam<AddonModForumDiscussion>('discussion')!;
|
||||
this.discussionId = this.discussion
|
||||
? this.discussion.discussion
|
||||
: CoreNavigator.instance.getRouteNumberParam('discussionId')!;
|
||||
this.trackPosts = CoreNavigator.instance.getRouteBooleanParam('trackPosts')!;
|
||||
this.postId = CoreNavigator.instance.getRouteNumberParam('postId')!;
|
||||
this.parent = CoreNavigator.instance.getRouteNumberParam('parent')!;
|
||||
|
||||
this.isOnline = CoreApp.instance.isOnline();
|
||||
this.onlineObserver = Network.instance.onChange().subscribe(() => {
|
||||
// Execute the callback in the Angular zone, so change detection doesn't stop working.
|
||||
NgZone.instance.run(() => {
|
||||
this.isOnline = CoreApp.instance.isOnline();
|
||||
});
|
||||
});
|
||||
|
||||
this.discussionStr = Translate.instant('addon.mod_forum.discussion');
|
||||
}
|
||||
|
||||
/**
|
||||
* View loaded.
|
||||
*/
|
||||
async ngAfterViewInit(): Promise<void> {
|
||||
if (this.parent) {
|
||||
this.sort = 'nested'; // Force nested order.
|
||||
} else {
|
||||
this.sort = await this.getUserSort();
|
||||
}
|
||||
|
||||
await this.fetchPosts(true, false, true);
|
||||
|
||||
const scrollTo = this.postId || this.parent;
|
||||
if (scrollTo) {
|
||||
// Scroll to the post.
|
||||
setTimeout(() => {
|
||||
CoreDomUtils.instance.scrollToElementBySelector(
|
||||
this.elementRef.nativeElement,
|
||||
this.content,
|
||||
'#addon-mod_forum-post-' + scrollTo,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User entered the page that contains the component.
|
||||
*/
|
||||
async ionViewDidEnter(): Promise<void> {
|
||||
if (this.syncObserver) {
|
||||
// Already setup.
|
||||
return;
|
||||
}
|
||||
|
||||
// The discussion object was not passed as parameter.
|
||||
if (!this.discussion) {
|
||||
await this.loadDiscussion(this.forumId, this.cmId, this.discussionId);
|
||||
}
|
||||
|
||||
// Refresh data if this discussion is synchronized automatically.
|
||||
this.syncObserver = CoreEvents.on(AddonModForumSyncProvider.AUTO_SYNCED, data => {
|
||||
if (data.forumId == this.forumId && this.discussionId == data.discussionId
|
||||
&& data.userId == CoreSites.instance.getCurrentSiteUserId()) {
|
||||
// Refresh the data.
|
||||
this.discussionLoaded = false;
|
||||
this.refreshPosts();
|
||||
}
|
||||
}, CoreSites.instance.getCurrentSiteId());
|
||||
|
||||
// Refresh data if this forum discussion is synchronized from discussions list.
|
||||
this.syncManualObserver = CoreEvents.on(AddonModForumSyncProvider.MANUAL_SYNCED, data => {
|
||||
if (data.source != 'discussion' && data.forumId == this.forumId &&
|
||||
data.userId == CoreSites.instance.getCurrentSiteUserId()) {
|
||||
// Refresh the data.
|
||||
this.discussionLoaded = false;
|
||||
this.refreshPosts();
|
||||
}
|
||||
}, CoreSites.instance.getCurrentSiteId());
|
||||
|
||||
// Invalidate discussion list if it was not read.
|
||||
if (this.discussion.numunread > 0) {
|
||||
AddonModForum.instance.invalidateDiscussionsList(this.forumId);
|
||||
}
|
||||
|
||||
// @todo Listen for offline ratings saved and synced.
|
||||
|
||||
this.changeDiscObserver = CoreEvents.on(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data => {
|
||||
if ((this.forumId && this.forumId === data.forumId) || data.cmId === this.cmId) {
|
||||
AddonModForum.instance.invalidateDiscussionsList(this.forumId).finally(() => {
|
||||
if (typeof data.locked != 'undefined') {
|
||||
this.discussion.locked = data.locked;
|
||||
}
|
||||
if (typeof data.pinned != 'undefined') {
|
||||
this.discussion.pinned = data.pinned;
|
||||
}
|
||||
if (typeof data.starred != 'undefined') {
|
||||
this.discussion.starred = data.starred;
|
||||
}
|
||||
|
||||
if (typeof data.deleted != 'undefined' && data.deleted) {
|
||||
if (!data.post?.parentid) {
|
||||
if (this.splitView?.outletActivated) {
|
||||
CoreNavigator.instance.navigate('../');
|
||||
} else {
|
||||
CoreNavigator.instance.back();
|
||||
}
|
||||
} else {
|
||||
this.discussionLoaded = false;
|
||||
this.refreshPosts();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we can leave the page or not.
|
||||
*
|
||||
* @return Resolved if we can leave it, rejected if not.
|
||||
*/
|
||||
async canLeave(): Promise<boolean> {
|
||||
if (AddonModForumHelper.instance.hasPostDataChanged(this.replyData, this.originalData)) {
|
||||
// Show confirmation if some data has been modified.
|
||||
await CoreDomUtils.instance.showConfirm(Translate.instant('core.confirmcanceledit'));
|
||||
}
|
||||
|
||||
// Delete the local files from the tmp folder.
|
||||
CoreFileUploader.instance.clearTmpFiles(this.replyData.files);
|
||||
|
||||
this.leavingPage = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs when the page is about to leave and no longer be the active page.
|
||||
*/
|
||||
ionViewWillLeave(): void {
|
||||
this.syncObserver && this.syncObserver.off();
|
||||
this.syncManualObserver && this.syncManualObserver.off();
|
||||
this.ratingOfflineObserver && this.ratingOfflineObserver.off();
|
||||
this.ratingSyncObserver && this.ratingSyncObserver.off();
|
||||
this.changeDiscObserver && this.changeDiscObserver.off();
|
||||
delete this.syncObserver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page destroyed.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.onlineObserver && this.onlineObserver.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sort type configured by the current user.
|
||||
*
|
||||
* @return Promise resolved with the sort type.
|
||||
*/
|
||||
protected async getUserSort(): Promise<SortType> {
|
||||
try {
|
||||
const value = await CoreSites.instance.getCurrentSite()!.getLocalSiteConfig<SortType>('AddonModForumDiscussionSort');
|
||||
|
||||
return value;
|
||||
} catch (error) {
|
||||
try {
|
||||
const value = await CoreUser.instance.getUserPreference('forum_displaymode');
|
||||
|
||||
switch (Number(value)) {
|
||||
case 1:
|
||||
return 'flat-oldest';
|
||||
case -1:
|
||||
return 'flat-newest';
|
||||
case 3:
|
||||
return 'nested';
|
||||
case 2: // Threaded not implemented.
|
||||
default:
|
||||
// Not set, use default sort.
|
||||
// @TODO add fallback to $CFG->forum_displaymode.
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors.
|
||||
}
|
||||
}
|
||||
|
||||
return 'flat-oldest';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to get the forum.
|
||||
*
|
||||
* @return Promise resolved with the forum.
|
||||
*/
|
||||
protected fetchForum(): Promise<AddonModForumData> {
|
||||
if (this.courseId && this.cmId) {
|
||||
return AddonModForum.instance.getForum(this.courseId, this.cmId);
|
||||
}
|
||||
|
||||
if (this.courseId && this.forumId) {
|
||||
return AddonModForum.instance.getForumById(this.courseId, this.forumId);
|
||||
}
|
||||
|
||||
throw new Error('Cannot get the forum');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to get the posts.
|
||||
*
|
||||
* @param sync Whether to try to synchronize the discussion.
|
||||
* @param showErrors Whether to show errors in a modal.
|
||||
* @param forceMarkAsRead Whether to mark all posts as read.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fetchPosts(sync?: boolean, showErrors?: boolean, forceMarkAsRead?: boolean): Promise<void> {
|
||||
let onlinePosts: AddonModForumPost[] = [];
|
||||
const offlineReplies: AddonModForumPost[] = [];
|
||||
let hasUnreadPosts = false;
|
||||
|
||||
try {
|
||||
if (sync) {
|
||||
// Try to synchronize the forum.
|
||||
await CoreUtils.instance.ignoreErrors(this.syncDiscussion(!!showErrors));
|
||||
}
|
||||
|
||||
const response = await AddonModForum.instance.getDiscussionPosts(this.discussionId, { cmId: this.cmId });
|
||||
const replies = await AddonModForumOffline.instance.getDiscussionReplies(this.discussionId);
|
||||
const ratingInfo = response.ratinginfo;
|
||||
onlinePosts = response.posts;
|
||||
this.courseId = response.courseid || this.courseId;
|
||||
this.forumId = response.forumid || this.forumId;
|
||||
|
||||
// Check if there are responses stored in offline.
|
||||
this.postHasOffline = !!replies.length;
|
||||
const convertPromises: Promise<void>[] = [];
|
||||
|
||||
// Index posts to allow quick access. Also check unread field.
|
||||
const onlinePostsMap: Record<string, AddonModForumPost> = {};
|
||||
onlinePosts.forEach((post) => {
|
||||
onlinePostsMap[post.id] = post;
|
||||
hasUnreadPosts = hasUnreadPosts || !!post.unread;
|
||||
});
|
||||
|
||||
replies.forEach((offlineReply) => {
|
||||
// If we don't have forumId and courseId, get it from the post.
|
||||
if (!this.forumId) {
|
||||
this.forumId = offlineReply.forumid;
|
||||
}
|
||||
if (!this.courseId) {
|
||||
this.courseId = offlineReply.courseid;
|
||||
}
|
||||
|
||||
convertPromises.push(
|
||||
AddonModForumHelper.instance
|
||||
.convertOfflineReplyToOnline(offlineReply)
|
||||
.then(async reply => {
|
||||
offlineReplies.push(reply);
|
||||
|
||||
// Disable reply of the parent. Reply in offline to the same post is not allowed, edit instead.
|
||||
posts[reply.parentid!].capabilities.reply = false;
|
||||
|
||||
return;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all(convertPromises);
|
||||
|
||||
// Convert back to array.
|
||||
onlinePosts = CoreUtils.instance.objectToArray(onlinePostsMap);
|
||||
|
||||
let posts = offlineReplies.concat(onlinePosts);
|
||||
|
||||
this.startingPost = AddonModForum.instance.extractStartingPost(posts);
|
||||
|
||||
// If sort type is nested, normal sorting is disabled and nested posts will be displayed.
|
||||
if (this.sort == 'nested') {
|
||||
// Sort first by creation date to make format tree work.
|
||||
AddonModForum.instance.sortDiscussionPosts(posts, 'ASC');
|
||||
|
||||
const rootId = this.startingPost ? this.startingPost.id : (this.discussion ? this.discussion.id : 0);
|
||||
posts = CoreUtils.instance.formatTree(posts, 'parentid', 'id', rootId);
|
||||
} else {
|
||||
// Set default reply subject.
|
||||
const direction = this.sort == 'flat-newest' ? 'DESC' : 'ASC';
|
||||
AddonModForum.instance.sortDiscussionPosts(posts, direction);
|
||||
}
|
||||
|
||||
try {
|
||||
// Now try to get the forum.
|
||||
const forum = await this.fetchForum();
|
||||
// "forum.istracked" is more reliable than "trackPosts".
|
||||
if (typeof forum.istracked != 'undefined') {
|
||||
this.trackPosts = forum.istracked;
|
||||
}
|
||||
|
||||
this.forumId = forum.id;
|
||||
this.cmId = forum.cmid;
|
||||
this.courseId = forum.course;
|
||||
this.forum = forum;
|
||||
this.availabilityMessage = AddonModForumHelper.instance.getAvailabilityMessage(forum);
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
promises.push(
|
||||
AddonModForum.instance
|
||||
.getAccessInformation(this.forumId, { cmId: this.cmId })
|
||||
.then(async accessInfo => {
|
||||
this.accessInfo = accessInfo;
|
||||
|
||||
// Disallow replying if cut-off date is reached and the user has not the capability to override it.
|
||||
// Just in case the posts were fetched from WS when the cut-off date was not reached but it is now.
|
||||
if (AddonModForumHelper.instance.isCutoffDateReached(forum) && !accessInfo.cancanoverridecutoff) {
|
||||
posts.forEach((post) => {
|
||||
post.capabilities.reply = false;
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}),
|
||||
);
|
||||
|
||||
// The discussion object was not passed as parameter and there is no starting post.
|
||||
if (!this.discussion) {
|
||||
promises.push(this.loadDiscussion(this.forumId, this.cmId, this.discussionId));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
// Ignore errors.
|
||||
}
|
||||
|
||||
if (!this.discussion && !this.startingPost) {
|
||||
// The discussion object was not passed as parameter and there is no starting post. Should not happen.
|
||||
throw new Error('Invalid forum discussion.');
|
||||
}
|
||||
|
||||
if (this.startingPost && this.startingPost.author && this.forum.type == 'single') {
|
||||
// Hide author and groups for first post and type single.
|
||||
delete this.startingPost.author.fullname;
|
||||
delete this.startingPost.author.groups;
|
||||
}
|
||||
|
||||
this.posts = posts;
|
||||
this.ratingInfo = ratingInfo;
|
||||
this.postSubjects = this.getAllPosts().reduce(
|
||||
(postSubjects, post) => {
|
||||
postSubjects[post.id] = post.subject;
|
||||
|
||||
return postSubjects;
|
||||
},
|
||||
this.startingPost
|
||||
? { [this.startingPost.id]: this.startingPost.subject }
|
||||
: {},
|
||||
);
|
||||
|
||||
if (AddonModForum.instance.isSetPinStateAvailableForSite()) {
|
||||
// Use the canAddDiscussion WS to check if the user can pin discussions.
|
||||
try {
|
||||
const response = await AddonModForum.instance.canAddDiscussionToAll(this.forumId, { cmId: this.cmId });
|
||||
|
||||
this.canPin = !!response.canpindiscussions;
|
||||
} catch (error) {
|
||||
this.canPin = false;
|
||||
}
|
||||
} else {
|
||||
this.canPin = false;
|
||||
}
|
||||
|
||||
// @todo fetch hasOfflineRatings.
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModal(error);
|
||||
} finally {
|
||||
this.discussionLoaded = true;
|
||||
this.refreshIcon = 'refresh';
|
||||
this.syncIcon = 'sync';
|
||||
|
||||
if (forceMarkAsRead || (hasUnreadPosts && this.trackPosts)) {
|
||||
// // Add log in Moodle and mark unread posts as readed.
|
||||
AddonModForum.instance.logDiscussionView(this.discussionId, this.forumId || -1, this.forum.name).catch(() => {
|
||||
// Ignore errors.
|
||||
}).finally(() => {
|
||||
// Trigger mark read posts.
|
||||
CoreEvents.trigger(AddonModForumProvider.MARK_READ_EVENT, {
|
||||
courseId: this.courseId,
|
||||
moduleId: this.cmId,
|
||||
}, CoreSites.instance.getCurrentSiteId());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to load discussion.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param cmId Forum cmid.
|
||||
* @param discussionId Discussion ID.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async loadDiscussion(forumId: number, cmId: number, discussionId: number): Promise<void> {
|
||||
// Fetch the discussion if not passed as parameter.
|
||||
if (this.discussion || !forumId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const discussion = await AddonModForumHelper.instance.getDiscussionById(forumId, cmId, discussionId);
|
||||
|
||||
this.discussion = discussion;
|
||||
this.discussionId = this.discussion.discussion;
|
||||
} catch (error) {
|
||||
// Ignore errors.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to synchronize the posts discussion.
|
||||
*
|
||||
* @param showErrors Whether to show errors in a modal.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async syncDiscussion(showErrors: boolean): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
promises.push(
|
||||
AddonModForumSync.instance
|
||||
.syncDiscussionReplies(this.discussionId)
|
||||
.then((result) => {
|
||||
if (result.warnings && result.warnings.length) {
|
||||
CoreDomUtils.instance.showErrorModal(result.warnings[0]);
|
||||
}
|
||||
|
||||
if (result && result.updated) {
|
||||
// Sync successful, send event.
|
||||
CoreEvents.trigger(AddonModForumSyncProvider.MANUAL_SYNCED, {
|
||||
forumId: this.forumId,
|
||||
userId: CoreSites.instance.getCurrentSiteUserId(),
|
||||
source: 'discussion',
|
||||
}, CoreSites.instance.getCurrentSiteId());
|
||||
}
|
||||
|
||||
return;
|
||||
}),
|
||||
);
|
||||
|
||||
promises.push(
|
||||
AddonModForumSync.instance
|
||||
.syncRatings(this.cmId, this.discussionId)
|
||||
.then((result) => {
|
||||
if (result.warnings && result.warnings.length) {
|
||||
CoreDomUtils.instance.showErrorModal(result.warnings[0]);
|
||||
}
|
||||
|
||||
return;
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
if (showErrors) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'core.errorsync', true);
|
||||
}
|
||||
|
||||
throw new Error('Failed syncing discussion');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the data.
|
||||
*
|
||||
* @param refresher Refresher.
|
||||
* @param done Function to call when done.
|
||||
* @param showErrors If show errors to the user of hide them.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async doRefresh(refresher?: any, done?: () => void, showErrors: boolean = false): Promise<void> {
|
||||
if (this.discussionLoaded) {
|
||||
await this.refreshPosts(true, showErrors).finally(() => {
|
||||
refresher && refresher.complete();
|
||||
done && done();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh posts.
|
||||
*
|
||||
* @param sync Whether to try to synchronize the discussion.
|
||||
* @param showErrors Whether to show errors in a modal.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
refreshPosts(sync?: boolean, showErrors?: boolean): Promise<void> {
|
||||
this.content.scrollToTop();
|
||||
this.refreshIcon = 'spinner';
|
||||
this.syncIcon = 'spinner';
|
||||
|
||||
const promises = [
|
||||
AddonModForum.instance.invalidateForumData(this.courseId),
|
||||
AddonModForum.instance.invalidateDiscussionPosts(this.discussionId, this.forumId),
|
||||
AddonModForum.instance.invalidateAccessInformation(this.forumId),
|
||||
AddonModForum.instance.invalidateCanAddDiscussion(this.forumId),
|
||||
];
|
||||
|
||||
return CoreUtils.instance.allPromises(promises).catch(() => {
|
||||
// Ignore errors.
|
||||
}).then(() => this.fetchPosts(sync, showErrors));
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to change posts sorting
|
||||
*
|
||||
* @param type Sort type.
|
||||
* @return Promised resolved when done.
|
||||
*/
|
||||
changeSort(type: SortType): Promise<any> {
|
||||
this.discussionLoaded = false;
|
||||
this.sort = type;
|
||||
CoreSites.instance.getCurrentSite()!.setLocalSiteConfig('AddonModForumDiscussionSort', this.sort);
|
||||
this.content.scrollToTop();
|
||||
|
||||
return this.fetchPosts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock or unlock the discussion.
|
||||
*
|
||||
* @param locked True to lock the discussion, false to unlock.
|
||||
*/
|
||||
async setLockState(locked: boolean): Promise<void> {
|
||||
const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
|
||||
|
||||
try {
|
||||
const response = await AddonModForum.instance.setLockState(this.forumId, this.discussionId, locked);
|
||||
this.discussion.locked = response.locked;
|
||||
|
||||
const data = {
|
||||
forumId: this.forumId,
|
||||
discussionId: this.discussionId,
|
||||
cmId: this.cmId,
|
||||
locked: this.discussion.locked,
|
||||
};
|
||||
CoreEvents.trigger(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data, CoreSites.instance.getCurrentSiteId());
|
||||
|
||||
CoreDomUtils.instance.showToast('addon.mod_forum.lockupdated', true);
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModal(error);
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin or unpin the discussion.
|
||||
*
|
||||
* @param pinned True to pin the discussion, false to unpin it.
|
||||
*/
|
||||
async setPinState(pinned: boolean): Promise<void> {
|
||||
const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
|
||||
|
||||
try {
|
||||
await AddonModForum.instance.setPinState(this.discussionId, pinned);
|
||||
|
||||
this.discussion.pinned = pinned;
|
||||
|
||||
const data = {
|
||||
forumId: this.forumId,
|
||||
discussionId: this.discussionId,
|
||||
cmId: this.cmId,
|
||||
pinned: this.discussion.pinned,
|
||||
};
|
||||
CoreEvents.trigger(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data, CoreSites.instance.getCurrentSiteId());
|
||||
|
||||
CoreDomUtils.instance.showToast('addon.mod_forum.pinupdated', true);
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModal(error);
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Star or unstar the discussion.
|
||||
*
|
||||
* @param starred True to star the discussion, false to unstar it.
|
||||
*/
|
||||
async toggleFavouriteState(starred: boolean): Promise<void> {
|
||||
const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
|
||||
|
||||
try {
|
||||
await AddonModForum.instance.toggleFavouriteState(this.discussionId, starred);
|
||||
|
||||
this.discussion.starred = starred;
|
||||
|
||||
const data = {
|
||||
forumId: this.forumId,
|
||||
discussionId: this.discussionId,
|
||||
cmId: this.cmId,
|
||||
starred: this.discussion.starred,
|
||||
};
|
||||
CoreEvents.trigger(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data, CoreSites.instance.getCurrentSiteId());
|
||||
|
||||
CoreDomUtils.instance.showToast('addon.mod_forum.favouriteupdated', true);
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModal(error);
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* New post added.
|
||||
*/
|
||||
postListChanged(): void {
|
||||
// Trigger an event to notify a new reply.
|
||||
const data = {
|
||||
forumId: this.forumId,
|
||||
discussionId: this.discussionId,
|
||||
cmId: this.cmId,
|
||||
};
|
||||
CoreEvents.trigger(AddonModForumProvider.REPLY_DISCUSSION_EVENT, data, CoreSites.instance.getCurrentSiteId());
|
||||
|
||||
this.discussionLoaded = false;
|
||||
this.refreshPosts().finally(() => {
|
||||
this.discussionLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the posts contained in the discussion.
|
||||
*
|
||||
* @return Array containing all the posts of the discussion.
|
||||
*/
|
||||
protected getAllPosts(): Post[] {
|
||||
return this.posts.map(this.flattenPostHierarchy.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a post's hierarchy into an array.
|
||||
*
|
||||
* @param parent Parent post.
|
||||
* @return Array containing all the posts within the hierarchy (including the parent).
|
||||
*/
|
||||
protected flattenPostHierarchy(parent: Post): Post[] {
|
||||
const posts = [parent];
|
||||
const children = parent.children || [];
|
||||
|
||||
for (const child of children) {
|
||||
posts.push(...this.flattenPostHierarchy(child));
|
||||
}
|
||||
|
||||
return posts;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
:host {
|
||||
|
||||
.addon-forum-reply-button .label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>
|
||||
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="module?.id" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<!-- The buttons defined by the component will be added in here. -->
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<addon-mod-forum-index [module]="module" [courseId]="courseId" (dataRetrieved)="updateData($event)"></addon-mod-forum-index>
|
||||
</ion-content>
|
|
@ -0,0 +1,49 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
import { AddonModForumData } from '@addons/mod/forum/services/forum';
|
||||
import { CoreCourseAnyModuleData } from '@features/course/services/course';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
|
||||
@Component({
|
||||
selector: 'page-addon-mod-forum-index',
|
||||
templateUrl: 'index.html',
|
||||
})
|
||||
export class AddonModForumIndexPage implements OnInit {
|
||||
|
||||
title!: string;
|
||||
module!: CoreCourseAnyModuleData;
|
||||
courseId!: number;
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.module = CoreNavigator.instance.getRouteParam<CoreCourseAnyModuleData>('module')!;
|
||||
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!;
|
||||
this.title = this.module?.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update some data based on the forum instance.
|
||||
*
|
||||
* @param forum Forum instance.
|
||||
*/
|
||||
updateData(forum: AddonModForumData): void {
|
||||
this.title = forum.name || this.title;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ 'addon.mod_forum.addanewdiscussion' | translate }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<!-- The context menu will be added in here. -->
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<ion-refresher slot="fixed" [disabled]="!groupsLoaded" (ionRefresh)="refreshGroups($event.target)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
||||
<core-loading [hideUntil]="groupsLoaded">
|
||||
<form *ngIf="showForm" #newDiscFormEl>
|
||||
<ion-item>
|
||||
<ion-label position="stacked">{{ 'addon.mod_forum.subject' | translate }}</ion-label>
|
||||
<ion-input
|
||||
[(ngModel)]="newDiscussion.subject"
|
||||
type="text" [placeholder]="'addon.mod_forum.subject' | translate" name="subject">
|
||||
</ion-input>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-label position="stacked">{{ 'addon.mod_forum.message' | translate }}</ion-label>
|
||||
<core-rich-text-editor item-content name="addon_mod_forum_new_discussion" contextLevel="module" elementId="message"
|
||||
[control]="messageControl" [placeholder]="'addon.mod_forum.message' | translate" [component]="component"
|
||||
[componentId]="forum.cmid" [autoSave]="true" [contextInstanceId]="forum.cmid"
|
||||
(contentChanged)="onMessageChange($event)">
|
||||
</core-rich-text-editor>
|
||||
</ion-item>
|
||||
<ion-item-divider class="ion-text-wrap core-expandable" (click)="toggleAdvanced()">
|
||||
<ion-icon *ngIf="!advanced" name="fa-caret-right" slot="start">
|
||||
</ion-icon>
|
||||
<ion-icon *ngIf="advanced" name="fa-caret-down" slot="start">
|
||||
</ion-icon>
|
||||
<ion-label>{{ 'addon.mod_forum.advanced' | translate }}</ion-label>
|
||||
</ion-item-divider>
|
||||
<ng-container *ngIf="advanced">
|
||||
<ion-item *ngIf="showGroups && groupIds.length > 1 && accessInfo.cancanposttomygroups">
|
||||
<ion-label>{{ 'addon.mod_forum.posttomygroups' | translate }}</ion-label>
|
||||
<ion-toggle [(ngModel)]="newDiscussion.postToAllGroups" name="postallgroups"></ion-toggle>
|
||||
</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" [disabled]="newDiscussion.postToAllGroups"
|
||||
aria-labelledby="addon-mod-forum-groupslabel" interface="action-sheet" name="groupid">
|
||||
<ion-select-option *ngFor="let group of groups" [value]="group.id">{{ group.name }}</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-label>{{ 'addon.mod_forum.discussionsubscription' | translate }}</ion-label>
|
||||
<ion-toggle [(ngModel)]="newDiscussion.subscribe" name="subscribe"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item *ngIf="canPin">
|
||||
<ion-label>{{ 'addon.mod_forum.discussionpinned' | translate }}</ion-label>
|
||||
<ion-toggle [(ngModel)]="newDiscussion.pin" name="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>
|
||||
</ng-container>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<ion-row>
|
||||
<ion-col>
|
||||
<ion-button
|
||||
expand="block" [disabled]="newDiscussion.subject == '' || newDiscussion.message == null"
|
||||
(click)="add()">
|
||||
{{ 'addon.mod_forum.posttoforum' | translate }}
|
||||
</ion-button>
|
||||
</ion-col>
|
||||
<ion-col *ngIf="hasOffline">
|
||||
<ion-button expand="block" color="light" (click)="discard()">{{ 'core.discard' | translate }}</ion-button>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</form>
|
||||
</core-loading>
|
||||
</ion-content>
|
|
@ -0,0 +1,42 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
|
||||
import { AddonModForumComponentsModule } from '@addons/mod/forum/components/components.module';
|
||||
import { CanLeaveGuard } from '@guards/can-leave';
|
||||
import { CoreEditorComponentsModule } from '@features/editor/components/components.module';
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
|
||||
import { AddonModForumNewDiscussionPage } from './new-discussion.page';
|
||||
|
||||
const routes: Routes = [{
|
||||
path: '',
|
||||
component: AddonModForumNewDiscussionPage,
|
||||
canDeactivate: [CanLeaveGuard],
|
||||
}];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CoreSharedModule,
|
||||
AddonModForumComponentsModule,
|
||||
CoreEditorComponentsModule,
|
||||
],
|
||||
declarations: [
|
||||
AddonModForumNewDiscussionPage,
|
||||
],
|
||||
})
|
||||
export class AddonForumNewDiscussionPageModule {}
|
|
@ -0,0 +1,612 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, OnDestroy, ViewChild, ElementRef, OnInit, Optional } from '@angular/core';
|
||||
import { FileEntry } from '@ionic-native/file/ngx';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { CoreEvents, CoreEventObserver } from '@singletons/events';
|
||||
import { CoreGroup, CoreGroups, CoreGroupsProvider } from '@services/groups';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import {
|
||||
AddonModForum,
|
||||
AddonModForumAccessInformation,
|
||||
AddonModForumCanAddDiscussion,
|
||||
AddonModForumData,
|
||||
AddonModForumProvider,
|
||||
} from '@addons/mod/forum/services/forum';
|
||||
import { CoreEditorRichTextEditorComponent } from '@features/editor/components/rich-text-editor/rich-text-editor';
|
||||
import { AddonModForumSync, AddonModForumSyncProvider } from '@addons/mod/forum/services/sync';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { Translate } from '@singletons';
|
||||
import { CoreSync } from '@services/sync';
|
||||
import { AddonModForumDiscussionOptions, AddonModForumOffline } from '@addons/mod/forum/services/offline';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { AddonModForumHelper } from '@addons/mod/forum/services/helper';
|
||||
import { IonRefresher } from '@ionic/angular';
|
||||
import { CoreFileUploader } from '@features/fileuploader/services/fileuploader';
|
||||
import { CoreTextUtils } from '@services/utils/text';
|
||||
import { CanLeave } from '@guards/can-leave';
|
||||
import { CoreSplitViewComponent } from '@components/split-view/split-view';
|
||||
|
||||
type NewDiscussionData = {
|
||||
subject: string;
|
||||
message: string | null; // Null means empty or just white space.
|
||||
postToAllGroups: boolean;
|
||||
groupId: number;
|
||||
subscribe: boolean;
|
||||
pin: boolean;
|
||||
files: FileEntry[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Page that displays the new discussion form.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'page-addon-mod-forum-new-discussion',
|
||||
templateUrl: 'new-discussion.html',
|
||||
})
|
||||
export class AddonModForumNewDiscussionPage implements OnInit, OnDestroy, CanLeave {
|
||||
|
||||
@ViewChild('newDiscFormEl') formElement!: ElementRef;
|
||||
@ViewChild(CoreEditorRichTextEditorComponent) messageEditor!: CoreEditorRichTextEditorComponent;
|
||||
|
||||
component = AddonModForumProvider.COMPONENT;
|
||||
messageControl = new FormControl();
|
||||
groupsLoaded = false;
|
||||
showGroups = false;
|
||||
hasOffline = false;
|
||||
canCreateAttachments = true; // Assume we can by default.
|
||||
canPin = false;
|
||||
forum!: AddonModForumData;
|
||||
showForm = false;
|
||||
groups: CoreGroup[] = [];
|
||||
groupIds: number[] = [];
|
||||
newDiscussion: NewDiscussionData = {
|
||||
subject: '',
|
||||
message: null,
|
||||
postToAllGroups: false,
|
||||
groupId: 0,
|
||||
subscribe: true,
|
||||
pin: false,
|
||||
files: [],
|
||||
};
|
||||
|
||||
advanced = false; // Display all form fields.
|
||||
accessInfo: AddonModForumAccessInformation = {};
|
||||
|
||||
protected courseId!: number;
|
||||
protected cmId!: number;
|
||||
protected forumId!: number;
|
||||
protected timeCreated!: number;
|
||||
protected syncId!: string;
|
||||
protected syncObserver?: CoreEventObserver;
|
||||
protected isDestroyed = false;
|
||||
protected originalData?: Partial<NewDiscussionData>;
|
||||
protected forceLeave = false;
|
||||
|
||||
constructor(@Optional() protected splitView: CoreSplitViewComponent) {}
|
||||
|
||||
/**
|
||||
* Component being initialized.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!;
|
||||
this.cmId = CoreNavigator.instance.getRouteNumberParam('cmId')!;
|
||||
this.forumId = CoreNavigator.instance.getRouteNumberParam('forumId')!;
|
||||
this.timeCreated = CoreNavigator.instance.getRouteNumberParam('timeCreated')!;
|
||||
|
||||
this.fetchDiscussionData().finally(() => {
|
||||
this.groupsLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* User entered the page that contains the component.
|
||||
*/
|
||||
ionViewDidEnter(): void {
|
||||
if (this.syncObserver) {
|
||||
// Already setup.
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh data if this discussion is synchronized automatically.
|
||||
this.syncObserver = CoreEvents.on(AddonModForumSyncProvider.AUTO_SYNCED, data => {
|
||||
if (data.forumId == this.forumId && data.userId == CoreSites.instance.getCurrentSiteUserId()) {
|
||||
CoreDomUtils.instance.showAlertTranslated('core.notice', 'core.contenteditingsynced');
|
||||
this.returnToDiscussions();
|
||||
}
|
||||
}, CoreSites.instance.getCurrentSiteId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch if forum uses groups and the groups it uses.
|
||||
*
|
||||
* @param refresh Whether we're refreshing data.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fetchDiscussionData(refresh?: boolean): Promise<void> {
|
||||
try {
|
||||
const mode = await CoreGroups.instance.getActivityGroupMode(this.cmId);
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
if (mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS) {
|
||||
promises.push(
|
||||
CoreGroups.instance
|
||||
.getActivityAllowedGroups(this.cmId)
|
||||
.then((result) => {
|
||||
let promise;
|
||||
if (mode === CoreGroupsProvider.VISIBLEGROUPS) {
|
||||
// We need to check which of the returned groups the user can post to.
|
||||
promise = this.validateVisibleGroups(result.groups);
|
||||
} else {
|
||||
// WS already filters groups, no need to do it ourselves. Add "All participants" if needed.
|
||||
promise = this.addAllParticipantsOption(result.groups, true);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line promise/no-nesting
|
||||
return promise.then((forumGroups) => {
|
||||
if (forumGroups.length > 0) {
|
||||
this.groups = forumGroups;
|
||||
this.groupIds = forumGroups.map((group) => group.id).filter((id) => id > 0);
|
||||
// Do not override group id.
|
||||
this.newDiscussion.groupId = this.newDiscussion.groupId || forumGroups[0].id;
|
||||
this.showGroups = true;
|
||||
if (this.groupIds.length <= 1) {
|
||||
this.newDiscussion.postToAllGroups = false;
|
||||
}
|
||||
|
||||
return;
|
||||
} else {
|
||||
const message = mode === CoreGroupsProvider.SEPARATEGROUPS ?
|
||||
'addon.mod_forum.cannotadddiscussionall' : 'addon.mod_forum.cannotadddiscussion';
|
||||
|
||||
throw new Error(Translate.instant(message));
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.showGroups = false;
|
||||
this.newDiscussion.postToAllGroups = false;
|
||||
|
||||
// Use the canAddDiscussion WS to check if the user can add attachments and pin discussions.
|
||||
promises.push(
|
||||
CoreUtils.instance.ignoreErrors(
|
||||
AddonModForum.instance
|
||||
.canAddDiscussionToAll(this.forumId, { cmId: this.cmId })
|
||||
.then((response) => {
|
||||
this.canPin = !!response.canpindiscussions;
|
||||
this.canCreateAttachments = !!response.cancreateattachment;
|
||||
|
||||
return;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Get forum.
|
||||
promises.push(AddonModForum.instance.getForum(this.courseId, this.cmId).then((forum) => this.forum = forum));
|
||||
|
||||
// Get access information.
|
||||
promises.push(
|
||||
AddonModForum.instance
|
||||
.getAccessInformation(this.forumId, { cmId: this.cmId })
|
||||
.then((accessInfo) => this.accessInfo = accessInfo),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// If editing a discussion, get offline data.
|
||||
if (this.timeCreated && !refresh) {
|
||||
this.syncId = AddonModForumSync.instance.getForumSyncId(this.forumId);
|
||||
|
||||
await AddonModForumSync.instance.waitForSync(this.syncId).then(() => {
|
||||
// Do not block if the scope is already destroyed.
|
||||
if (!this.isDestroyed) {
|
||||
CoreSync.instance.blockOperation(AddonModForumProvider.COMPONENT, this.syncId);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line promise/no-nesting
|
||||
return AddonModForumOffline.instance
|
||||
.getNewDiscussion(this.forumId, this.timeCreated)
|
||||
.then(async (discussion) => {
|
||||
this.hasOffline = true;
|
||||
discussion.options = discussion.options || {};
|
||||
if (discussion.groupid == AddonModForumProvider.ALL_GROUPS) {
|
||||
this.newDiscussion.groupId = this.groups[0].id;
|
||||
this.newDiscussion.postToAllGroups = true;
|
||||
} else {
|
||||
this.newDiscussion.groupId = discussion.groupid;
|
||||
this.newDiscussion.postToAllGroups = false;
|
||||
}
|
||||
this.newDiscussion.subject = discussion.subject;
|
||||
this.newDiscussion.message = discussion.message;
|
||||
this.newDiscussion.subscribe = !!discussion.options.discussionsubscribe;
|
||||
this.newDiscussion.pin = !!discussion.options.discussionpinned;
|
||||
this.messageControl.setValue(discussion.message);
|
||||
|
||||
// Treat offline attachments if any.
|
||||
if (typeof discussion.options.attachmentsid === 'object' && discussion.options.attachmentsid.offline) {
|
||||
const files = await AddonModForumHelper.instance.getNewDiscussionStoredFiles(
|
||||
this.forumId,
|
||||
this.timeCreated,
|
||||
);
|
||||
|
||||
this.newDiscussion.files = files;
|
||||
}
|
||||
|
||||
// Show advanced fields by default if any of them has not the default value.
|
||||
if (
|
||||
!this.newDiscussion.subscribe ||
|
||||
this.newDiscussion.pin ||
|
||||
this.newDiscussion.files.length ||
|
||||
this.groups.length > 0 && this.newDiscussion.groupId != this.groups[0].id ||
|
||||
this.newDiscussion.postToAllGroups
|
||||
) {
|
||||
this.advanced = true;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.originalData) {
|
||||
// Initialize original data.
|
||||
this.originalData = {
|
||||
subject: this.newDiscussion.subject,
|
||||
message: this.newDiscussion.message,
|
||||
files: this.newDiscussion.files.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
this.showForm = true;
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_forum.errorgetgroups', true);
|
||||
|
||||
this.showForm = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate which of the groups returned by getActivityAllowedGroups in visible groups should be shown to post to.
|
||||
*
|
||||
* @param forumGroups Forum groups.
|
||||
* @return Promise resolved with the list of groups.
|
||||
*/
|
||||
protected async validateVisibleGroups(forumGroups: CoreGroup[]): Promise<CoreGroup[]> {
|
||||
let response: AddonModForumCanAddDiscussion;
|
||||
|
||||
// We first check if the user can post to all the groups.
|
||||
try {
|
||||
response = await AddonModForum.instance.canAddDiscussionToAll(this.forumId, { cmId: this.cmId });
|
||||
} catch (error) {
|
||||
// The call failed, let's assume he can't.
|
||||
response = {
|
||||
status: false,
|
||||
canpindiscussions: false,
|
||||
cancreateattachment: true,
|
||||
};
|
||||
}
|
||||
|
||||
this.canPin = !!response.canpindiscussions;
|
||||
this.canCreateAttachments = !!response.cancreateattachment;
|
||||
|
||||
// The user can post to all groups, add the "All participants" option and return them all.
|
||||
if (response.status) {
|
||||
return this.addAllParticipantsOption(forumGroups, false);
|
||||
}
|
||||
|
||||
// The user can't post to all groups, let's check which groups he can post to.
|
||||
const promises: Promise<unknown>[] = [];
|
||||
const filtered: CoreGroup[] = [];
|
||||
|
||||
forumGroups.forEach((group) => {
|
||||
promises.push(
|
||||
AddonModForum.instance
|
||||
.canAddDiscussion(this.forumId, group.id, { cmId: this.cmId })
|
||||
|
||||
// The call failed, let's return true so the group is shown.
|
||||
// If the user can't post to it an error will be shown when he tries to add the discussion.
|
||||
.catch(() =>({ status: true }))
|
||||
|
||||
.then((response) => {
|
||||
if (response.status) {
|
||||
filtered.push(group);
|
||||
}
|
||||
|
||||
return;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter forum groups, returning only those that are inside user groups.
|
||||
*
|
||||
* @param forumGroups Forum groups.
|
||||
* @param userGroups User groups.
|
||||
* @return Filtered groups.
|
||||
*/
|
||||
protected filterGroups(forumGroups: CoreGroup[], userGroups: CoreGroup[]): CoreGroup[] {
|
||||
const userGroupsIds = userGroups.map(group => group.id);
|
||||
|
||||
return forumGroups.filter(forumGroup => userGroupsIds.indexOf(forumGroup.id) > -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the "All participants" option to a list of groups if the user can add a discussion to all participants.
|
||||
*
|
||||
* @param groups Groups.
|
||||
* @param check True to check if the user can add a discussion to all participants.
|
||||
* @return Promise resolved with the list of groups.
|
||||
*/
|
||||
protected addAllParticipantsOption(groups: CoreGroup[], check: boolean): Promise<CoreGroup[]> {
|
||||
if (!AddonModForum.instance.isAllParticipantsFixed()) {
|
||||
// All participants has a bug, don't add it.
|
||||
return Promise.resolve(groups);
|
||||
}
|
||||
|
||||
let promise;
|
||||
|
||||
if (check) {
|
||||
// We need to check if the user can add a discussion to all participants.
|
||||
promise = AddonModForum.instance.canAddDiscussionToAll(this.forumId, { cmId: this.cmId }).then((response) => {
|
||||
this.canPin = !!response.canpindiscussions;
|
||||
this.canCreateAttachments = !!response.cancreateattachment;
|
||||
|
||||
return response.status;
|
||||
}).catch(() =>
|
||||
// The call failed, let's assume he can't.
|
||||
false);
|
||||
} else {
|
||||
// No need to check, assume the user can.
|
||||
promise = Promise.resolve(true);
|
||||
}
|
||||
|
||||
return promise.then((canAdd) => {
|
||||
if (canAdd) {
|
||||
groups.unshift({
|
||||
courseid: this.courseId,
|
||||
id: AddonModForumProvider.ALL_PARTICIPANTS,
|
||||
name: Translate.instant('core.allparticipants'),
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull to refresh.
|
||||
*
|
||||
* @param refresher Refresher.
|
||||
*/
|
||||
refreshGroups(refresher?: IonRefresher): void {
|
||||
const promises = [
|
||||
CoreGroups.instance.invalidateActivityGroupMode(this.cmId),
|
||||
CoreGroups.instance.invalidateActivityAllowedGroups(this.cmId),
|
||||
AddonModForum.instance.invalidateCanAddDiscussion(this.forumId),
|
||||
];
|
||||
|
||||
Promise.all(promises).finally(() => {
|
||||
this.fetchDiscussionData(true).finally(() => {
|
||||
refresher?.complete();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to update or return to discussions depending on device.
|
||||
*
|
||||
* @param discussionIds Ids of the new discussions.
|
||||
* @param discTimecreated The time created of the discussion (if offline).
|
||||
*/
|
||||
protected returnToDiscussions(discussionIds?: number[] | null, discTimecreated?: number): void {
|
||||
this.forceLeave = true;
|
||||
|
||||
// Delete the local files from the tmp folder.
|
||||
CoreFileUploader.instance.clearTmpFiles(this.newDiscussion.files);
|
||||
|
||||
CoreEvents.trigger(
|
||||
AddonModForumProvider.NEW_DISCUSSION_EVENT,
|
||||
{
|
||||
forumId: this.forumId,
|
||||
cmId: this.cmId,
|
||||
discussionIds: discussionIds,
|
||||
discTimecreated: discTimecreated,
|
||||
},
|
||||
CoreSites.instance.getCurrentSiteId(),
|
||||
);
|
||||
|
||||
if (this.splitView?.outletActivated) {
|
||||
// Empty form.
|
||||
this.hasOffline = false;
|
||||
this.newDiscussion.subject = '';
|
||||
this.newDiscussion.message = null;
|
||||
this.newDiscussion.files = [];
|
||||
this.newDiscussion.postToAllGroups = false;
|
||||
this.messageEditor.clearText();
|
||||
this.originalData = CoreUtils.instance.clone(this.newDiscussion);
|
||||
} else {
|
||||
CoreNavigator.instance.back();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Message changed.
|
||||
*
|
||||
* @param text The new text.
|
||||
*/
|
||||
onMessageChange(text: string): void {
|
||||
this.newDiscussion.message = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new discussion.
|
||||
*/
|
||||
async add(): Promise<void> {
|
||||
const forumName = this.forum.name;
|
||||
const subject = this.newDiscussion.subject;
|
||||
let message = this.newDiscussion.message || '';
|
||||
const pin = this.newDiscussion.pin;
|
||||
const attachments = this.newDiscussion.files;
|
||||
const discTimecreated = this.timeCreated || Date.now();
|
||||
const options: AddonModForumDiscussionOptions = {
|
||||
discussionsubscribe: !!this.newDiscussion.subscribe,
|
||||
};
|
||||
|
||||
if (!subject) {
|
||||
CoreDomUtils.instance.showErrorModal('addon.mod_forum.erroremptysubject', true);
|
||||
|
||||
return;
|
||||
}
|
||||
if (!message) {
|
||||
CoreDomUtils.instance.showErrorModal('addon.mod_forum.erroremptymessage', true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
|
||||
|
||||
// Add some HTML to the message if needed.
|
||||
message = CoreTextUtils.instance.formatHtmlLines(message);
|
||||
|
||||
if (pin) {
|
||||
options.discussionpinned = true;
|
||||
}
|
||||
|
||||
const groupIds = this.newDiscussion.postToAllGroups ? this.groupIds : [this.newDiscussion.groupId];
|
||||
|
||||
try {
|
||||
const discussionIds = await AddonModForumHelper.instance.addNewDiscussion(
|
||||
this.forumId,
|
||||
forumName,
|
||||
this.courseId,
|
||||
subject,
|
||||
message,
|
||||
attachments,
|
||||
options,
|
||||
groupIds,
|
||||
discTimecreated,
|
||||
);
|
||||
|
||||
if (discussionIds) {
|
||||
// Data sent to server, delete stored files (if any).
|
||||
AddonModForumHelper.instance.deleteNewDiscussionStoredFiles(this.forumId, discTimecreated);
|
||||
|
||||
CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'forum' });
|
||||
}
|
||||
|
||||
if (discussionIds && discussionIds.length < groupIds.length) {
|
||||
// Some discussions could not be created.
|
||||
CoreDomUtils.instance.showErrorModalDefault(null, 'addon.mod_forum.errorposttoallgroups', true);
|
||||
}
|
||||
|
||||
CoreDomUtils.instance.triggerFormSubmittedEvent(
|
||||
this.formElement,
|
||||
!!discussionIds,
|
||||
CoreSites.instance.getCurrentSiteId(),
|
||||
);
|
||||
|
||||
this.returnToDiscussions(discussionIds, discTimecreated);
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_forum.cannotcreatediscussion', true);
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard an offline saved discussion.
|
||||
*/
|
||||
async discard(): Promise<void> {
|
||||
try {
|
||||
await CoreDomUtils.instance.showConfirm(Translate.instant('core.areyousure'));
|
||||
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
promises.push(AddonModForumOffline.instance.deleteNewDiscussion(this.forumId, this.timeCreated));
|
||||
promises.push(
|
||||
CoreUtils.instance.ignoreErrors(
|
||||
AddonModForumHelper.instance.deleteNewDiscussionStoredFiles(this.forumId, this.timeCreated),
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId());
|
||||
|
||||
this.returnToDiscussions();
|
||||
} catch (error) {
|
||||
// Cancelled.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide advanced form fields.
|
||||
*/
|
||||
toggleAdvanced(): void {
|
||||
this.advanced = !this.advanced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we can leave the page or not.
|
||||
*
|
||||
* @return Resolved if we can leave it, rejected if not.
|
||||
*/
|
||||
async canLeave(): Promise<boolean> {
|
||||
if (this.forceLeave) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (AddonModForumHelper.instance.hasPostDataChanged(this.newDiscussion, this.originalData)) {
|
||||
// Show confirmation if some data has been modified.
|
||||
await CoreDomUtils.instance.showConfirm(Translate.instant('core.confirmcanceledit'));
|
||||
}
|
||||
|
||||
// Delete the local files from the tmp folder.
|
||||
CoreFileUploader.instance.clearTmpFiles(this.newDiscussion.files);
|
||||
|
||||
if (this.formElement) {
|
||||
CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs when the page is about to leave and no longer be the active page.
|
||||
*/
|
||||
ionViewWillLeave(): void {
|
||||
this.syncObserver && this.syncObserver.off();
|
||||
delete this.syncObserver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page destroyed.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
if (this.syncId) {
|
||||
CoreSync.instance.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId);
|
||||
}
|
||||
this.isDestroyed = true;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { CoreSiteSchema } from '@services/sites';
|
||||
import { AddonModForumOfflineDiscussion, AddonModForumOfflineReply } from '../offline';
|
||||
|
||||
/**
|
||||
* Database variables for AddonModForum service.
|
||||
*/
|
||||
export const DISCUSSIONS_TABLE = 'addon_mod_forum_discussions';
|
||||
export const REPLIES_TABLE = 'addon_mod_forum_replies';
|
||||
export const SITE_SCHEMA: CoreSiteSchema = {
|
||||
name: 'AddonModForumOfflineProvider',
|
||||
version: 1,
|
||||
tables: [
|
||||
{
|
||||
name: DISCUSSIONS_TABLE,
|
||||
columns: [
|
||||
{
|
||||
name: 'forumid',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
name: 'courseid',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'subject',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
name: 'options',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
name: 'groupid',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'userid',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'timecreated',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
],
|
||||
primaryKeys: ['forumid', 'userid', 'timecreated'],
|
||||
},
|
||||
{
|
||||
name: REPLIES_TABLE,
|
||||
columns: [
|
||||
{
|
||||
name: 'postid',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'discussionid',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'forumid',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
name: 'courseid',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'subject',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
name: 'options',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
name: 'userid',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'timecreated',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
],
|
||||
primaryKeys: ['postid', 'userid'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export type AddonModForumOfflineDiscussionDBRecord = Omit<AddonModForumOfflineDiscussion, 'options'> & {
|
||||
options: string;
|
||||
};
|
||||
|
||||
export type AddonModForumOfflineReplyDBRecord = Omit<AddonModForumOfflineReply, 'options'> & {
|
||||
options: string;
|
||||
};
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,96 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Params } from '@angular/router';
|
||||
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
||||
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { AddonModForumModuleHandlerService } from './module';
|
||||
|
||||
/**
|
||||
* Handler to treat links to forum review.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonModForumDiscussionLinkHandlerService extends CoreContentLinksHandlerBase {
|
||||
|
||||
name = 'AddonModForumDiscussionLinkHandler';
|
||||
featureName = 'CoreCourseModuleDelegate_AddonModForum';
|
||||
pattern = /\/mod\/forum\/discuss\.php.*([&?]d=\d+)/;
|
||||
|
||||
/**
|
||||
* Get the list of actions for a link (url).
|
||||
*
|
||||
* @param siteIds List of sites the URL belongs to.
|
||||
* @param url The URL to treat.
|
||||
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
|
||||
* @param courseId Course ID related to the URL. Optional but recommended.
|
||||
* @param data Extra data to handle the URL.
|
||||
* @return List of (or promise resolved with list of) actions.
|
||||
*/
|
||||
getActions(
|
||||
siteIds: string[],
|
||||
url: string,
|
||||
params: Params,
|
||||
courseId?: number,
|
||||
data?: any,
|
||||
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
|
||||
data = data || {};
|
||||
|
||||
// On 3.6 downwards, it will open the discussion but without knowing the lock status of the discussion.
|
||||
// However canreply will be false.
|
||||
|
||||
return [{
|
||||
action: (siteId): void => {
|
||||
const discussionId = parseInt(params.d, 10);
|
||||
const pageParams: Params = {
|
||||
forumId: data.instance && parseInt(data.instance, 10),
|
||||
cmId: data.cmid && parseInt(data.cmid, 10),
|
||||
courseId: courseId || parseInt(params.courseid, 10) || parseInt(params.cid, 10),
|
||||
};
|
||||
|
||||
if (data.postid || params.urlHash) {
|
||||
pageParams.postId = parseInt(data.postid || params.urlHash.replace('p', ''));
|
||||
}
|
||||
|
||||
if (params.parent) {
|
||||
pageParams.parent = parseInt(params.parent);
|
||||
}
|
||||
|
||||
CoreNavigator.instance.navigateToSitePath(
|
||||
`${AddonModForumModuleHandlerService.PAGE_NAME}/discussion/${discussionId}`,
|
||||
{ siteId, params: pageParams },
|
||||
);
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the handler is enabled for a certain site (site + user) and a URL.
|
||||
* If not defined, defaults to true.
|
||||
*
|
||||
* @param siteId The site ID.
|
||||
* @param url The URL to treat.
|
||||
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
|
||||
* @param courseId Course ID related to the URL. Optional but recommended.
|
||||
* @return Whether the handler is enabled for the URL and site.
|
||||
*/
|
||||
async isEnabled(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonModForumDiscussionLinkHandler extends makeSingleton(AddonModForumDiscussionLinkHandlerService) {}
|
|
@ -0,0 +1,33 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler';
|
||||
import { makeSingleton } from '@singletons';
|
||||
|
||||
/**
|
||||
* Handler to treat links to forum index.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonModForumIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler {
|
||||
|
||||
name = 'AddonModForumIndexLinkHandler';
|
||||
|
||||
constructor() {
|
||||
super('AddonModForum', 'forum', 'f');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonModForumIndexLinkHandler extends makeSingleton(AddonModForumIndexLinkHandlerService) {}
|
|
@ -0,0 +1,33 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler';
|
||||
import { makeSingleton } from '@singletons';
|
||||
|
||||
/**
|
||||
* Handler to treat links to forum list page.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonModForumListLinkHandlerService extends CoreContentLinksModuleListHandler {
|
||||
|
||||
name = 'AddonModForumListLinkHandler';
|
||||
|
||||
constructor() {
|
||||
super('AddonModForum', 'forum');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonModForumListLinkHandler extends makeSingleton(AddonModForumListLinkHandlerService) {}
|
|
@ -0,0 +1,173 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Injectable, Type } from '@angular/core';
|
||||
import { AddonModForum, AddonModForumProvider } from '../forum';
|
||||
import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course';
|
||||
import { CoreCourseModule } from '@features/course/services/course-helper';
|
||||
import { CoreNavigationOptions, CoreNavigator } from '@services/navigator';
|
||||
import { makeSingleton, Translate } from '@singletons';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate';
|
||||
import { CoreConstants } from '@/core/constants';
|
||||
import { AddonModForumIndexComponent } from '../../components/index';
|
||||
|
||||
/**
|
||||
* Handler to support forum modules.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonModForumModuleHandlerService implements CoreCourseModuleHandler {
|
||||
|
||||
static readonly PAGE_NAME = 'mod_forum';
|
||||
|
||||
name = 'AddonModForum';
|
||||
modName = 'forum';
|
||||
|
||||
supportedFeatures = {
|
||||
[CoreConstants.FEATURE_GROUPS]: true,
|
||||
[CoreConstants.FEATURE_GROUPINGS]: true,
|
||||
[CoreConstants.FEATURE_MOD_INTRO]: true,
|
||||
[CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true,
|
||||
[CoreConstants.FEATURE_COMPLETION_HAS_RULES]: true,
|
||||
[CoreConstants.FEATURE_GRADE_HAS_GRADE]: true,
|
||||
[CoreConstants.FEATURE_GRADE_OUTCOMES]: true,
|
||||
[CoreConstants.FEATURE_BACKUP_MOODLE2]: true,
|
||||
[CoreConstants.FEATURE_SHOW_DESCRIPTION]: true,
|
||||
[CoreConstants.FEATURE_RATE]: true,
|
||||
[CoreConstants.FEATURE_PLAGIARISM]: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the handler is enabled on a site level.
|
||||
*
|
||||
* @return Whether or not the handler is enabled on a site level.
|
||||
*/
|
||||
isEnabled(): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data required to display the module in the course contents view.
|
||||
*
|
||||
* @param module The module object.
|
||||
* @param courseId The course ID.
|
||||
* @param sectionId The section ID.
|
||||
* @return Data to render the module.
|
||||
*/
|
||||
getData(module: CoreCourseAnyModuleData, courseId: number): CoreCourseModuleHandlerData {
|
||||
const data: CoreCourseModuleHandlerData = {
|
||||
icon: CoreCourse.instance.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined),
|
||||
title: module.name,
|
||||
class: 'addon-mod_forum-handler',
|
||||
showDownloadButton: true,
|
||||
action(event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions): void {
|
||||
options = options || {};
|
||||
options.params = options.params || {};
|
||||
Object.assign(options.params, { module });
|
||||
|
||||
CoreNavigator.instance.navigateToSitePath(
|
||||
`${AddonModForumModuleHandlerService.PAGE_NAME}/${courseId}/${module.id}`,
|
||||
options,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
if ('afterlink' in module && !!module.afterlink) {
|
||||
data.extraBadgeColor = '';
|
||||
const match = />(\d+)[^<]+/.exec(module.afterlink);
|
||||
data.extraBadge = match ? Translate.instance.instant('addon.mod_forum.unreadpostsnumber', { $a : match[1] }) : '';
|
||||
} else {
|
||||
this.updateExtraBadge(data, courseId, module.id);
|
||||
}
|
||||
|
||||
const event = CoreEvents.on(
|
||||
AddonModForumProvider.MARK_READ_EVENT,
|
||||
eventData => {
|
||||
if (eventData.courseId !== courseId || eventData.moduleId !== module.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateExtraBadge(data, eventData.courseId, eventData.moduleId, eventData.siteId);
|
||||
},
|
||||
CoreSites.instance.getCurrentSiteId(),
|
||||
);
|
||||
|
||||
data.onDestroy = () => event.off();
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the component to render the module. This is needed to support singleactivity course format.
|
||||
* The component returned must implement CoreCourseModuleMainComponent.
|
||||
*
|
||||
* @return The component to use, undefined if not found.
|
||||
*/
|
||||
async getMainComponent(): Promise<Type<unknown> | undefined> {
|
||||
return AddonModForumIndexComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to display the course refresher in single activity course format. If it returns false, a refresher must be
|
||||
* included in the template that calls the doRefresh method of the component. Defaults to true.
|
||||
*
|
||||
* @return Whether the refresher should be displayed.
|
||||
*/
|
||||
displayRefresherInSingleActivity(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers an update for the extra badge text.
|
||||
*
|
||||
* @param data Course Module Handler data.
|
||||
* @param courseId Course ID.
|
||||
* @param moduleId Course module ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
*/
|
||||
async updateExtraBadge(data: CoreCourseModuleHandlerData, courseId: number, moduleId: number, siteId?: string): Promise<void> {
|
||||
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||
|
||||
if (!siteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.extraBadge = Translate.instance.instant('core.loading');
|
||||
data.extraBadgeColor = 'light';
|
||||
|
||||
await CoreUtils.instance.ignoreErrors(AddonModForum.instance.invalidateForumData(courseId));
|
||||
|
||||
try {
|
||||
// Handle unread posts.
|
||||
const forum = await AddonModForum.instance.getForum(courseId, moduleId, { siteId });
|
||||
|
||||
data.extraBadgeColor = '';
|
||||
data.extraBadge = forum.unreadpostscount
|
||||
? Translate.instance.instant(
|
||||
'addon.mod_forum.unreadpostsnumber',
|
||||
{ $a : forum.unreadpostscount },
|
||||
)
|
||||
: '';
|
||||
} catch (error) {
|
||||
// Ignore errors.
|
||||
data.extraBadgeColor = '';
|
||||
data.extraBadge = '';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonModForumModuleHandler extends makeSingleton(AddonModForumModuleHandlerService) {}
|
|
@ -0,0 +1,86 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Params } from '@angular/router';
|
||||
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
||||
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
||||
import { CoreCourse } from '@features/course/services/course';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { AddonModForumModuleHandlerService } from './module';
|
||||
|
||||
/**
|
||||
* Content links handler for forum new discussion.
|
||||
* Match mod/forum/post.php?forum=6 with a valid data.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonModForumPostLinkHandlerService extends CoreContentLinksHandlerBase {
|
||||
|
||||
name = 'AddonModForumPostLinkHandler';
|
||||
featureName = 'CoreCourseModuleDelegate_AddonModForum';
|
||||
pattern = /\/mod\/forum\/post\.php.*([?&](forum)=\d+)/;
|
||||
|
||||
/**
|
||||
* Get the list of actions for a link (url).
|
||||
*
|
||||
* @param siteIds List of sites the URL belongs to.
|
||||
* @param url The URL to treat.
|
||||
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
|
||||
* @param courseId Course ID related to the URL. Optional but recommended.
|
||||
* @return List of (or promise resolved with list of) actions.
|
||||
*/
|
||||
getActions(
|
||||
siteIds: string[],
|
||||
url: string,
|
||||
params: Params,
|
||||
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
|
||||
return [{
|
||||
action: async (siteId): Promise<void> => {
|
||||
const modal = await CoreDomUtils.instance.showModalLoading();
|
||||
const forumId = parseInt(params.forum, 10);
|
||||
|
||||
try {
|
||||
const module = await CoreCourse.instance.getModuleBasicInfoByInstance(forumId, 'forum', siteId);
|
||||
|
||||
await CoreNavigator.instance.navigateToSitePath(
|
||||
`${AddonModForumModuleHandlerService.PAGE_NAME}/${module.course}/${module.id}/new/0`,
|
||||
{ siteId, params: { forumId: module.instance } },
|
||||
);
|
||||
} finally {
|
||||
// Just in case. In fact we need to dismiss the modal before showing a toast or error message.
|
||||
modal.dismiss();
|
||||
}
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the handler is enabled for a certain site (site + user) and a URL.
|
||||
* If not defined, defaults to true.
|
||||
*
|
||||
* @param siteId The site ID.
|
||||
* @param url The URL to treat.
|
||||
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
|
||||
* @param courseId Course ID related to the URL. Optional but recommended.
|
||||
* @return Whether the handler is enabled for the URL and site.
|
||||
*/
|
||||
async isEnabled(siteId: string, url: string, params: Params): Promise<boolean> {
|
||||
return typeof params.forum != 'undefined';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonModForumPostLinkHandler extends makeSingleton(AddonModForumPostLinkHandlerService) {}
|
|
@ -0,0 +1,353 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler';
|
||||
import { AddonModForum, AddonModForumData, AddonModForumPost, AddonModForumProvider } from '../forum';
|
||||
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
||||
import { CoreFilepool } from '@services/filepool';
|
||||
import { CoreWSExternalFile } from '@services/ws';
|
||||
import { CoreCourse, CoreCourseAnyModuleData, CoreCourseCommonModWSOptions } from '@features/course/services/course';
|
||||
import { CoreUser } from '@features/user/services/user';
|
||||
import { CoreGroups, CoreGroupsProvider } from '@services/groups';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { AddonModForumSync } from '../sync';
|
||||
import { makeSingleton } from '@singletons';
|
||||
|
||||
/**
|
||||
* Handler to prefetch forums.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonModForumPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase {
|
||||
|
||||
name = 'AddonModForum';
|
||||
modName = 'forum';
|
||||
component = AddonModForumProvider.COMPONENT;
|
||||
updatesNames = /^configuration$|^.*files$|^discussions$/;
|
||||
|
||||
/**
|
||||
* Get list of files. If not defined, we'll assume they're in module.contents.
|
||||
*
|
||||
* @param module Module.
|
||||
* @param courseId Course ID the module belongs to.
|
||||
* @param single True if we're downloading a single module, false if we're downloading a whole section.
|
||||
* @return Promise resolved with the list of files.
|
||||
*/
|
||||
async getFiles(module: CoreCourseAnyModuleData, courseId: number): Promise<CoreWSExternalFile[]> {
|
||||
try {
|
||||
const forum = await AddonModForum.instance.getForum(courseId, module.id);
|
||||
|
||||
const files = this.getIntroFilesFromInstance(module, forum);
|
||||
|
||||
// Get posts.
|
||||
const posts = await this.getPostsForPrefetch(forum, { cmId: module.id });
|
||||
|
||||
// Add posts attachments and embedded files.
|
||||
files.concat(this.getPostsFiles(posts));
|
||||
|
||||
return files;
|
||||
} catch (error) {
|
||||
// Forum not found, return empty list.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of forum posts, return a list with all the files (attachments and embedded files).
|
||||
*
|
||||
* @param posts Forum posts.
|
||||
* @return Files.
|
||||
*/
|
||||
protected getPostsFiles(posts: AddonModForumPost[]): CoreWSExternalFile[] {
|
||||
let files: CoreWSExternalFile[] = [];
|
||||
const getInlineFiles = CoreSites.instance.getCurrentSite()?.isVersionGreaterEqualThan('3.2');
|
||||
|
||||
posts.forEach((post) => {
|
||||
if (post.attachments && post.attachments.length) {
|
||||
files = files.concat(post.attachments as CoreWSExternalFile[]);
|
||||
}
|
||||
if (getInlineFiles && post.messageinlinefiles && post.messageinlinefiles.length) {
|
||||
files = files.concat(post.messageinlinefiles);
|
||||
} else if (post.message && !getInlineFiles) {
|
||||
files = files.concat(CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(post.message));
|
||||
}
|
||||
});
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the posts to be prefetched.
|
||||
*
|
||||
* @param forum Forum instance.
|
||||
* @param options Other options.
|
||||
* @return Promise resolved with array of posts.
|
||||
*/
|
||||
protected getPostsForPrefetch(
|
||||
forum: AddonModForumData,
|
||||
options: CoreCourseCommonModWSOptions = {},
|
||||
): Promise<AddonModForumPost[]> {
|
||||
const promises = AddonModForum.instance.getAvailableSortOrders().map((sortOrder) => {
|
||||
// Get discussions in first 2 pages.
|
||||
const discussionsOptions = {
|
||||
sortOrder: sortOrder.value,
|
||||
numPages: 2,
|
||||
...options, // Include all options.
|
||||
};
|
||||
|
||||
return AddonModForum.instance.getDiscussionsInPages(forum.id, discussionsOptions).then((response) => {
|
||||
if (response.error) {
|
||||
throw new Error('Failed getting discussions');
|
||||
}
|
||||
|
||||
const promises: Promise<{ posts: AddonModForumPost[] }>[] = [];
|
||||
|
||||
response.discussions.forEach((discussion) => {
|
||||
promises.push(AddonModForum.instance.getDiscussionPosts(discussion.discussion, options));
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all(promises).then((results) => {
|
||||
// Each order has returned its own list of posts. Merge all the lists, preventing duplicates.
|
||||
const posts: AddonModForumPost[] = [];
|
||||
const postIds = {}; // To make the array unique.
|
||||
|
||||
results.forEach((orderResults) => {
|
||||
orderResults.forEach((orderResult) => {
|
||||
orderResult.posts.forEach((post) => {
|
||||
if (!postIds[post.id]) {
|
||||
postIds[post.id] = true;
|
||||
posts.push(post);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return posts;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the prefetched content.
|
||||
*
|
||||
* @param moduleId The module ID.
|
||||
* @param courseId The course ID the module belongs to.
|
||||
* @return Promise resolved when the data is invalidated.
|
||||
*/
|
||||
invalidateContent(moduleId: number, courseId: number): Promise<void> {
|
||||
return AddonModForum.instance.invalidateContent(moduleId, courseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate WS calls needed to determine module status (usually, to check if module is downloadable).
|
||||
* It doesn't need to invalidate check updates. It should NOT invalidate files nor all the prefetched data.
|
||||
*
|
||||
* @param module Module.
|
||||
* @param courseId Course ID the module belongs to.
|
||||
* @return Promise resolved when invalidated.
|
||||
*/
|
||||
async invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise<void> {
|
||||
// Invalidate forum data to recalculate unread message count badge.
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
promises.push(AddonModForum.instance.invalidateForumData(courseId));
|
||||
promises.push(CoreCourse.instance.invalidateModule(module.id));
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch a module.
|
||||
*
|
||||
* @param module Module.
|
||||
* @param courseId Course ID the module belongs to.
|
||||
* @param single True if we're downloading a single module, false if we're downloading a whole section.
|
||||
* @param dirPath Path of the directory where to store all the content files.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
prefetch(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean): Promise<void> {
|
||||
return this.prefetchPackage(module, courseId, this.prefetchForum.bind(this, module, courseId, single));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch a forum.
|
||||
*
|
||||
* @param module The module object returned by WS.
|
||||
* @param courseId Course ID the module belongs to.
|
||||
* @param single True if we're downloading a single module, false if we're downloading a whole section.
|
||||
* @param siteId Site ID.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async prefetchForum(
|
||||
module: CoreCourseAnyModuleData,
|
||||
courseId: number,
|
||||
single: boolean,
|
||||
siteId: string,
|
||||
): Promise<void> {
|
||||
const commonOptions = {
|
||||
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
||||
siteId,
|
||||
};
|
||||
const modOptions = {
|
||||
cmId: module.id,
|
||||
...commonOptions, // Include all common options.
|
||||
};
|
||||
|
||||
// Get the forum data.
|
||||
const forum = await AddonModForum.instance.getForum(courseId, module.id, commonOptions);
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
// Prefetch the posts.
|
||||
promises.push(this.getPostsForPrefetch(forum, modOptions).then((posts) => {
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
const files = this.getIntroFilesFromInstance(module, forum).concat(this.getPostsFiles(posts));
|
||||
promises.push(CoreFilepool.instance.addFilesToQueue(siteId, files, this.component, module.id));
|
||||
|
||||
// Prefetch groups data.
|
||||
promises.push(this.prefetchGroupsInfo(forum, courseId, !!forum.cancreatediscussions, siteId));
|
||||
|
||||
// Prefetch avatars.
|
||||
promises.push(CoreUser.instance.prefetchUserAvatars(posts, 'userpictureurl', siteId));
|
||||
|
||||
return Promise.all(promises);
|
||||
}));
|
||||
|
||||
// Prefetch access information.
|
||||
promises.push(AddonModForum.instance.getAccessInformation(forum.id, modOptions));
|
||||
|
||||
// Prefetch sort order preference.
|
||||
if (AddonModForum.instance.isDiscussionListSortingAvailable()) {
|
||||
promises.push(CoreUser.instance.getUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER, siteId));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch groups info for a forum.
|
||||
*
|
||||
* @param module The module object returned by WS.
|
||||
* @param courseI Course ID the module belongs to.
|
||||
* @param canCreateDiscussions Whether the user can create discussions in the forum.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved when group data has been prefetched.
|
||||
*/
|
||||
protected async prefetchGroupsInfo(
|
||||
forum: AddonModForumData,
|
||||
courseId: number,
|
||||
canCreateDiscussions: boolean,
|
||||
siteId?: string,
|
||||
): Promise<void> {
|
||||
const options = {
|
||||
cmId: forum.cmid,
|
||||
siteId,
|
||||
};
|
||||
|
||||
// Check group mode.
|
||||
try {
|
||||
const mode = await CoreGroups.instance.getActivityGroupMode(forum.cmid, siteId);
|
||||
|
||||
if (mode !== CoreGroupsProvider.SEPARATEGROUPS && mode !== CoreGroupsProvider.VISIBLEGROUPS) {
|
||||
// Activity doesn't use groups. Prefetch canAddDiscussionToAll to determine if user can pin/attach.
|
||||
await CoreUtils.instance.ignoreErrors(AddonModForum.instance.canAddDiscussionToAll(forum.id, options));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Activity uses groups, prefetch allowed groups.
|
||||
const result = await CoreGroups.instance.getActivityAllowedGroups(forum.cmid, undefined, siteId);
|
||||
if (mode === CoreGroupsProvider.SEPARATEGROUPS) {
|
||||
// Groups are already filtered by WS. Prefetch canAddDiscussionToAll to determine if user can pin/attach.
|
||||
await CoreUtils.instance.ignoreErrors(AddonModForum.instance.canAddDiscussionToAll(forum.id, options));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (canCreateDiscussions) {
|
||||
// Prefetch data to check the visible groups when creating discussions.
|
||||
const response = await CoreUtils.instance.ignoreErrors(
|
||||
AddonModForum.instance.canAddDiscussionToAll(forum.id, options),
|
||||
{ status: false },
|
||||
);
|
||||
|
||||
if (response.status) {
|
||||
// User can post to all groups, nothing else to prefetch.
|
||||
return;
|
||||
}
|
||||
|
||||
// The user can't post to all groups, let's check which groups he can post to.
|
||||
await Promise.all(
|
||||
result.groups.map(
|
||||
async (group) => CoreUtils.instance.ignoreErrors(
|
||||
AddonModForum.instance.canAddDiscussion(forum.id, group.id, options),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors if cannot create discussions.
|
||||
if (canCreateDiscussions) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a module.
|
||||
*
|
||||
* @param module Module.
|
||||
* @param courseId Course ID the module belongs to
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async sync(
|
||||
module: CoreCourseAnyModuleData,
|
||||
courseId: number,
|
||||
siteId?: string,
|
||||
): Promise<AddonModForumSyncResult> {
|
||||
const promises: Promise<AddonModForumSyncResult>[] = [];
|
||||
|
||||
promises.push(AddonModForumSync.instance.syncForumDiscussions(module.instance!, undefined, siteId));
|
||||
promises.push(AddonModForumSync.instance.syncForumReplies(module.instance!, undefined, siteId));
|
||||
promises.push(AddonModForumSync.instance.syncRatings(module.id, undefined, true, siteId));
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
return results.reduce(
|
||||
(a, b) => ({
|
||||
updated: a.updated || b.updated,
|
||||
warnings: (a.warnings || []).concat(b.warnings || []),
|
||||
}),
|
||||
{
|
||||
updated: false,
|
||||
warnings: [],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonModForumPrefetchHandler extends makeSingleton(AddonModForumPrefetchHandlerService) {}
|
||||
|
||||
/**
|
||||
* Data returned by a forum sync.
|
||||
*/
|
||||
export type AddonModForumSyncResult = {
|
||||
warnings: string[]; // List of warnings.
|
||||
updated: boolean; // Whether some data was sent to the server or offline data was updated.
|
||||
};
|
|
@ -0,0 +1,88 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Params } from '@angular/router';
|
||||
|
||||
import { AddonModForum } from '@addons/mod/forum/services/forum';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CorePushNotificationsClickHandler } from '@features/pushnotifications/services/push-delegate';
|
||||
import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications';
|
||||
import { CoreUrlUtils } from '@services/utils/url';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { makeSingleton } from '@singletons';
|
||||
|
||||
import { AddonModForumModuleHandlerService } from './module';
|
||||
|
||||
/**
|
||||
* Handler for forum push notifications clicks.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonModForumPushClickHandlerService implements CorePushNotificationsClickHandler {
|
||||
|
||||
name = 'AddonModForumPushClickHandler';
|
||||
priority = 200;
|
||||
featureName = 'CoreCourseModuleDelegate_AddonModForum';
|
||||
|
||||
/**
|
||||
* Check if a notification click is handled by this handler.
|
||||
*
|
||||
* @param notification The notification to check.
|
||||
* @return Whether the notification click is handled by this handler
|
||||
*/
|
||||
async handles(notification: NotificationData): Promise<boolean> {
|
||||
return CoreUtils.instance.isTrueOrOne(notification.notif)
|
||||
&& notification.moodlecomponent == 'mod_forum'
|
||||
&& notification.name == 'posts';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the notification click.
|
||||
*
|
||||
* @param notification The notification to check.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async handleClick(notification: NotificationData): Promise<void> {
|
||||
const contextUrlParams = CoreUrlUtils.instance.extractUrlParams(notification.contexturl);
|
||||
const data = notification.customdata || {};
|
||||
const courseId = Number(notification.courseid);
|
||||
const discussionId = Number(contextUrlParams.d || data.discussionid);
|
||||
const cmId = Number(data.cmid);
|
||||
const pageParams: Params = {
|
||||
forumId: Number(data.instance),
|
||||
};
|
||||
|
||||
if (data.postid || contextUrlParams.urlHash) {
|
||||
pageParams.postId = Number(data.postid || contextUrlParams.urlHash.replace('p', ''));
|
||||
}
|
||||
|
||||
await CoreUtils.instance.ignoreErrors(
|
||||
AddonModForum.instance.invalidateDiscussionPosts(pageParams.discussionId, undefined, notification.site),
|
||||
);
|
||||
|
||||
await CoreNavigator.instance.navigateToSitePath(
|
||||
`${AddonModForumModuleHandlerService.PAGE_NAME}/${courseId}/${cmId}/${discussionId}`,
|
||||
{ siteId: notification.site, params: pageParams },
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonModForumPushClickHandler extends makeSingleton(AddonModForumPushClickHandlerService) {}
|
||||
|
||||
type NotificationData = CorePushNotificationsNotificationBasicData & {
|
||||
courseid: number;
|
||||
discussionid: number;
|
||||
contexturl: string;
|
||||
};
|
|
@ -0,0 +1,51 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CoreCronHandler } from '@services/cron';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { AddonModForumSync } from '../sync';
|
||||
|
||||
/**
|
||||
* Synchronization cron handler.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonModForumSyncCronHandlerService implements CoreCronHandler {
|
||||
|
||||
name = 'AddonModForumSyncCronHandler';
|
||||
|
||||
/**
|
||||
* Execute the process.
|
||||
* Receives the ID of the site affected, undefined for all sites.
|
||||
*
|
||||
* @param siteId ID of the site affected, undefined for all sites.
|
||||
* @param force Wether the execution is forced (manual sync).
|
||||
* @return Promise resolved when done, rejected if failure.
|
||||
*/
|
||||
execute(siteId?: string, force?: boolean): Promise<void> {
|
||||
return AddonModForumSync.instance.syncAllForums(siteId, force);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the time between consecutive executions.
|
||||
*
|
||||
* @return Time between consecutive executions (in ms).
|
||||
*/
|
||||
getInterval(): number {
|
||||
return AddonModForumSync.instance.syncInterval;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonModForumSyncCronHandler extends makeSingleton(AddonModForumSyncCronHandlerService) {}
|
|
@ -0,0 +1,62 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Injectable, Type } from '@angular/core';
|
||||
|
||||
import { CoreTagAreaHandler } from '@features/tag/services/tag-area-delegate';
|
||||
import { CoreTagFeedComponent } from '@features/tag/components/feed/feed';
|
||||
import { CoreTagHelper, CoreTagFeedElement } from '@features/tag/services/tag-helper';
|
||||
import { makeSingleton } from '@singletons';
|
||||
|
||||
/**
|
||||
* Handler to support tags.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonModForumTagAreaHandlerService implements CoreTagAreaHandler {
|
||||
|
||||
name = 'AddonModForumTagAreaHandler';
|
||||
type = 'mod_forum/forum_posts';
|
||||
|
||||
/**
|
||||
* Whether or not the handler is enabled on a site level.
|
||||
*
|
||||
* @return Whether or not the handler is enabled on a site level.
|
||||
*/
|
||||
async isEnabled(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the rendered content of a tag index and returns the items.
|
||||
*
|
||||
* @param content Rendered content.
|
||||
* @return Area items (or promise resolved with the items).
|
||||
*/
|
||||
parseContent(content: string): CoreTagFeedElement[] {
|
||||
return CoreTagHelper.instance.parseFeedContent(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the component to use to display items.
|
||||
*
|
||||
* @param injector Injector.
|
||||
* @return The component (or promise resolved with component) to use, undefined if not found.
|
||||
*/
|
||||
getComponent(): Type<unknown> | Promise<Type<unknown>> {
|
||||
return CoreTagFeedComponent;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonModForumTagAreaHandler extends makeSingleton(AddonModForumTagAreaHandlerService) {}
|
|
@ -0,0 +1,520 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { FileEntry } from '@ionic-native/file/ngx';
|
||||
import { CoreFileEntry, CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
|
||||
import { CoreUser } from '@features/user/services/user';
|
||||
import { CoreApp } from '@services/app';
|
||||
import { CoreFile } from '@services/file';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreTimeUtils } from '@services/utils/time';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { makeSingleton, Translate } from '@singletons';
|
||||
import {
|
||||
AddonModForum,
|
||||
AddonModForumAddDiscussionWSOptionsObject,
|
||||
AddonModForumData,
|
||||
AddonModForumDiscussion,
|
||||
AddonModForumPost,
|
||||
AddonModForumProvider,
|
||||
} from './forum';
|
||||
import { AddonModForumDiscussionOptions, AddonModForumOffline, AddonModForumOfflineReply } from './offline';
|
||||
|
||||
/**
|
||||
* Service that provides some features for forums.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonModForumHelperProvider {
|
||||
|
||||
/**
|
||||
* Add a new discussion.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param name Forum name.
|
||||
* @param courseId Course ID the forum belongs to.
|
||||
* @param subject New discussion's subject.
|
||||
* @param message New discussion's message.
|
||||
* @param attachments New discussion's attachments.
|
||||
* @param options Options (subscribe, pin, ...).
|
||||
* @param groupIds Groups this discussion belongs to.
|
||||
* @param timeCreated The time the discussion was created. Only used when editing discussion.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with ids of the created discussions or null if stored offline
|
||||
*/
|
||||
async addNewDiscussion(
|
||||
forumId: number,
|
||||
name: string,
|
||||
courseId: number,
|
||||
subject: string,
|
||||
message: string,
|
||||
attachments?: CoreFileEntry[],
|
||||
options?: AddonModForumDiscussionOptions,
|
||||
groupIds?: number[],
|
||||
timeCreated?: number,
|
||||
siteId?: string,
|
||||
): Promise<number[] | null> {
|
||||
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||
groupIds = (groupIds && groupIds.length > 0) ? groupIds : [0];
|
||||
|
||||
let saveOffline = false;
|
||||
const attachmentsIds: number[] = [];
|
||||
let offlineAttachments: CoreFileUploaderStoreFilesResult;
|
||||
|
||||
// Convenience function to store a message to be synchronized later.
|
||||
const storeOffline = async (): Promise<void> => {
|
||||
// Multiple groups, the discussion is being posted to all groups.
|
||||
const groupId = groupIds!.length > 1 ? AddonModForumProvider.ALL_GROUPS : groupIds![0];
|
||||
|
||||
if (offlineAttachments && options) {
|
||||
options.attachmentsid = offlineAttachments;
|
||||
}
|
||||
|
||||
await AddonModForumOffline.instance.addNewDiscussion(
|
||||
forumId,
|
||||
name,
|
||||
courseId,
|
||||
subject,
|
||||
message,
|
||||
options,
|
||||
groupId,
|
||||
timeCreated,
|
||||
siteId,
|
||||
);
|
||||
};
|
||||
|
||||
// First try to upload attachments, once per group.
|
||||
if (attachments && attachments.length > 0) {
|
||||
const promises = groupIds.map(
|
||||
() => this
|
||||
.uploadOrStoreNewDiscussionFiles(forumId, timeCreated || 0, attachments, false)
|
||||
.then(attach => attachmentsIds.push(attach)),
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
// Cannot upload them in online, save them in offline.
|
||||
saveOffline = true;
|
||||
|
||||
const attach = await this.uploadOrStoreNewDiscussionFiles(forumId, timeCreated || 0, attachments, true);
|
||||
|
||||
offlineAttachments = attach;
|
||||
}
|
||||
}
|
||||
|
||||
// If we are editing an offline discussion, discard previous first.
|
||||
if (timeCreated) {
|
||||
await AddonModForumOffline.instance.deleteNewDiscussion(forumId, timeCreated, siteId);
|
||||
}
|
||||
|
||||
if (saveOffline || !CoreApp.instance.isOnline()) {
|
||||
await storeOffline();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const errors: Error[] = [];
|
||||
const discussionIds: number[] = [];
|
||||
const promises = groupIds.map(async (groupId, index) => {
|
||||
const groupOptions = CoreUtils.instance.clone(options);
|
||||
|
||||
if (groupOptions && attachmentsIds[index]) {
|
||||
groupOptions.attachmentsid = attachmentsIds[index];
|
||||
}
|
||||
|
||||
try {
|
||||
const discussionId = await AddonModForum.instance.addNewDiscussionOnline(
|
||||
forumId,
|
||||
subject,
|
||||
message,
|
||||
groupOptions as unknown as AddonModForumAddDiscussionWSOptionsObject,
|
||||
groupId,
|
||||
siteId,
|
||||
);
|
||||
|
||||
discussionIds.push(discussionId);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
if (errors.length == groupIds.length) {
|
||||
// All requests have failed.
|
||||
for (let i = 0; i < errors.length; i++) {
|
||||
if (CoreUtils.instance.isWebServiceError(errors[i]) || (attachments && attachments.length > 0)) {
|
||||
// The WebService has thrown an error or offline not supported, reject.
|
||||
throw errors[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Couldn't connect to server, store offline.
|
||||
await storeOffline();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return discussionIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert offline reply to online format in order to be compatible with them.
|
||||
*
|
||||
* @param offlineReply Offline version of the reply.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with the object converted to Online.
|
||||
*/
|
||||
convertOfflineReplyToOnline(offlineReply: AddonModForumOfflineReply, siteId?: string): Promise<AddonModForumPost> {
|
||||
const reply: AddonModForumPost = {
|
||||
id: -offlineReply.timecreated,
|
||||
discussionid: offlineReply.discussionid,
|
||||
parentid: offlineReply.postid,
|
||||
hasparent: !!offlineReply.postid,
|
||||
author: {
|
||||
id: offlineReply.userid,
|
||||
},
|
||||
timecreated: false,
|
||||
subject: offlineReply.subject,
|
||||
message: offlineReply.message,
|
||||
attachments: [],
|
||||
capabilities: {
|
||||
reply: false,
|
||||
},
|
||||
unread: false,
|
||||
isprivatereply: !!offlineReply.options?.private,
|
||||
};
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
// Treat attachments if any.
|
||||
if (offlineReply.options && offlineReply.options.attachmentsid) {
|
||||
const attachments = offlineReply.options.attachmentsid;
|
||||
|
||||
reply.attachments = typeof attachments === 'object' && 'online' in attachments ? attachments.online : [];
|
||||
|
||||
if (typeof attachments === 'object' && attachments.offline) {
|
||||
promises.push(
|
||||
this
|
||||
.getReplyStoredFiles(offlineReply.forumid, reply.parentid!, siteId, offlineReply.userid)
|
||||
.then(files => {
|
||||
reply.attachments = reply.attachments!.concat(files as unknown as []);
|
||||
|
||||
return;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get user data.
|
||||
promises.push(
|
||||
CoreUtils.instance.ignoreErrors(
|
||||
CoreUser.instance
|
||||
.getProfile(offlineReply.userid, offlineReply.courseid, true)
|
||||
.then(user => {
|
||||
reply.author.fullname = user.fullname;
|
||||
reply.author.urls = { profileimage: user.profileimageurl };
|
||||
|
||||
return;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return Promise.all(promises).then(() => {
|
||||
reply.attachment = reply.attachments!.length > 0 ? 1 : 0;
|
||||
|
||||
return reply;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stored attachment files for a new discussion.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param timecreated The time the discussion was created.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved when deleted.
|
||||
*/
|
||||
async deleteNewDiscussionStoredFiles(forumId: number, timecreated: number, siteId?: string): Promise<void> {
|
||||
const folderPath = await AddonModForumOffline.instance.getNewDiscussionFolder(forumId, timecreated, siteId);
|
||||
|
||||
// Ignore any errors, CoreFileProvider.removeDir fails if folder doesn't exist.
|
||||
await CoreUtils.instance.ignoreErrors(CoreFile.instance.removeDir(folderPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stored attachment files for a reply.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param postId ID of the post being replied.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the reply belongs to. If not defined, current user in site.
|
||||
* @return Promise resolved when deleted.
|
||||
*/
|
||||
async deleteReplyStoredFiles(forumId: number, postId: number, siteId?: string, userId?: number): Promise<void> {
|
||||
const folderPath = await AddonModForumOffline.instance.getReplyFolder(forumId, postId, siteId, userId);
|
||||
|
||||
// Ignore any errors, CoreFileProvider.removeDir fails if folder doesn't exist.
|
||||
await CoreUtils.instance.ignoreErrors(CoreFile.instance.removeDir(folderPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the availability message of the given forum.
|
||||
*
|
||||
* @param forum Forum instance.
|
||||
* @return Message or null if the forum has no cut-off or due date.
|
||||
*/
|
||||
getAvailabilityMessage(forum: AddonModForumData): string | null {
|
||||
if (this.isCutoffDateReached(forum)) {
|
||||
return Translate.instance.instant('addon.mod_forum.cutoffdatereached');
|
||||
}
|
||||
|
||||
if (this.isDueDateReached(forum)) {
|
||||
const dueDate = CoreTimeUtils.instance.userDate(forum.duedate * 1000);
|
||||
|
||||
return Translate.instance.instant('addon.mod_forum.thisforumisdue', { $a: dueDate });
|
||||
}
|
||||
|
||||
if ((forum.duedate ?? 0) > 0) {
|
||||
const dueDate = CoreTimeUtils.instance.userDate(forum.duedate! * 1000);
|
||||
|
||||
return Translate.instance.instant('addon.mod_forum.thisforumhasduedate', { $a: dueDate });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a forum discussion by id.
|
||||
*
|
||||
* This function is inefficient because it needs to fetch all discussion pages in the worst case.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param cmId Forum cmid
|
||||
* @param discussionId Discussion ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with the discussion data.
|
||||
*/
|
||||
getDiscussionById(forumId: number, cmId: number, discussionId: number, siteId?: string): Promise<AddonModForumDiscussion> {
|
||||
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||
|
||||
const findDiscussion = async (page: number): Promise<AddonModForumDiscussion> => {
|
||||
const response = await AddonModForum.instance.getDiscussions(forumId, {
|
||||
cmId,
|
||||
page,
|
||||
siteId,
|
||||
});
|
||||
|
||||
if (response.discussions && response.discussions.length > 0) {
|
||||
// Note that discussion.id is the main post ID but discussion ID is discussion.discussion.
|
||||
const discussion = response.discussions.find((discussion) => discussion.discussion == discussionId);
|
||||
|
||||
if (discussion) {
|
||||
return discussion;
|
||||
}
|
||||
|
||||
if (response.canLoadMore) {
|
||||
return findDiscussion(page + 1);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Discussion not found');
|
||||
};
|
||||
|
||||
return findDiscussion(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of stored attachment files for a new discussion. See AddonModForumHelper#storeNewDiscussionFiles.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param timecreated The time the discussion was created.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with the files.
|
||||
*/
|
||||
async getNewDiscussionStoredFiles(forumId: number, timecreated: number, siteId?: string): Promise<FileEntry[]> {
|
||||
const folderPath = await AddonModForumOffline.instance.getNewDiscussionFolder(forumId, timecreated, siteId);
|
||||
|
||||
return CoreFileUploader.instance.getStoredFiles(folderPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of stored attachment files for a reply. See AddonModForumHelper#storeReplyFiles.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param postId ID of the post being replied.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the reply belongs to. If not defined, current user in site.
|
||||
* @return Promise resolved with the files.
|
||||
*/
|
||||
async getReplyStoredFiles(forumId: number, postId: number, siteId?: string, userId?: number): Promise<FileEntry[]> {
|
||||
const folderPath = await AddonModForumOffline.instance.getReplyFolder(forumId, postId, siteId, userId);
|
||||
|
||||
return CoreFileUploader.instance.getStoredFiles(folderPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the data of a post/discussion has changed.
|
||||
*
|
||||
* @param post Current data.
|
||||
* @param original Original ata.
|
||||
* @return True if data has changed, false otherwise.
|
||||
*/
|
||||
hasPostDataChanged(post: any, original?: any): boolean {
|
||||
if (!original || original.subject == null) {
|
||||
// There is no original data, assume it hasn't changed.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (post.subject != original.subject || post.message != original.message) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (post.isprivatereply != original.isprivatereply) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return CoreFileUploader.instance.areFileListDifferent(post.files, original.files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the cutoff date for the forum reached?
|
||||
*
|
||||
* @param forum Forum instance.
|
||||
*/
|
||||
isCutoffDateReached(forum: AddonModForumData): boolean {
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
return !!forum.cutoffdate && forum.cutoffdate > 0 && forum.cutoffdate < now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the due date for the forum reached?
|
||||
*
|
||||
* @param forum Forum instance.
|
||||
*/
|
||||
isDueDateReached(forum: AddonModForumData): forum is AddonModForumData & { duedate: number } {
|
||||
const now = Date.now() / 1000;
|
||||
const duedate = forum.duedate ?? 0;
|
||||
|
||||
return duedate > 0 && duedate < now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of files (either online files or local files), store the local files in a local folder
|
||||
* to be submitted later.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param timecreated The time the discussion was created.
|
||||
* @param files List of files.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved if success, rejected otherwise.
|
||||
*/
|
||||
async storeNewDiscussionFiles(
|
||||
forumId: number,
|
||||
timecreated: number,
|
||||
files: CoreFileEntry[],
|
||||
siteId?: string,
|
||||
): Promise<CoreFileUploaderStoreFilesResult> {
|
||||
// Get the folder where to store the files.
|
||||
const folderPath = await AddonModForumOffline.instance.getNewDiscussionFolder(forumId, timecreated, siteId);
|
||||
|
||||
return CoreFileUploader.instance.storeFilesToUpload(folderPath, files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of files (either online files or local files), store the local files in a local folder
|
||||
* to be submitted later.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param postId ID of the post being replied.
|
||||
* @param files List of files.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the reply belongs to. If not defined, current user in site.
|
||||
* @return Promise resolved if success, rejected otherwise.
|
||||
*/
|
||||
async storeReplyFiles(forumId: number, postId: number, files: any[], siteId?: string, userId?: number): Promise<void> {
|
||||
// Get the folder where to store the files.
|
||||
const folderPath = await AddonModForumOffline.instance.getReplyFolder(forumId, postId, siteId, userId);
|
||||
|
||||
await CoreFileUploader.instance.storeFilesToUpload(folderPath, files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload or store some files for a new discussion, depending if the user is offline or not.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param timecreated The time the discussion was created.
|
||||
* @param files List of files.
|
||||
* @param offline True if files sould be stored for offline, false to upload them.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved if success.
|
||||
*/
|
||||
uploadOrStoreNewDiscussionFiles(
|
||||
forumId: number,
|
||||
timecreated: number,
|
||||
files: CoreFileEntry[],
|
||||
offline: true,
|
||||
siteId?: string,
|
||||
): Promise<CoreFileUploaderStoreFilesResult>;
|
||||
uploadOrStoreNewDiscussionFiles(
|
||||
forumId: number,
|
||||
timecreated: number,
|
||||
files: CoreFileEntry[],
|
||||
offline: false,
|
||||
siteId?: string,
|
||||
): Promise<number>;
|
||||
uploadOrStoreNewDiscussionFiles(
|
||||
forumId: number,
|
||||
timecreated: number,
|
||||
files: CoreFileEntry[],
|
||||
offline: boolean,
|
||||
siteId?: string,
|
||||
): Promise<CoreFileUploaderStoreFilesResult | number> {
|
||||
if (offline) {
|
||||
return this.storeNewDiscussionFiles(forumId, timecreated, files, siteId);
|
||||
} else {
|
||||
return CoreFileUploader.instance.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload or store some files for a reply, depending if the user is offline or not.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param postId ID of the post being replied.
|
||||
* @param files List of files.
|
||||
* @param offline True if files sould be stored for offline, false to upload them.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the reply belongs to. If not defined, current user in site.
|
||||
* @return Promise resolved if success.
|
||||
*/
|
||||
uploadOrStoreReplyFiles(
|
||||
forumId: number,
|
||||
postId: number,
|
||||
files: any[],
|
||||
offline: boolean,
|
||||
siteId?: string,
|
||||
userId?: number,
|
||||
): Promise<any> {
|
||||
if (offline) {
|
||||
return this.storeReplyFiles(forumId, postId, files, siteId, userId);
|
||||
} else {
|
||||
return CoreFileUploader.instance.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonModForumHelper extends makeSingleton(AddonModForumHelperProvider) {}
|
|
@ -0,0 +1,444 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
|
||||
import { CoreFile } from '@services/file';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreTextUtils } from '@services/utils/text';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { AddonModForumProvider } from './forum';
|
||||
import {
|
||||
AddonModForumOfflineDiscussionDBRecord,
|
||||
AddonModForumOfflineReplyDBRecord,
|
||||
DISCUSSIONS_TABLE,
|
||||
REPLIES_TABLE,
|
||||
} from './database/offline';
|
||||
|
||||
/**
|
||||
* Service to handle offline forum.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonModForumOfflineProvider {
|
||||
|
||||
/**
|
||||
* Delete a forum offline discussion.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param timeCreated The time the discussion was created.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the discussion belongs to. If not defined, current user in site.
|
||||
* @return Promise resolved if stored, rejected if failure.
|
||||
*/
|
||||
async deleteNewDiscussion(forumId: number, timeCreated: number, siteId?: string, userId?: number): Promise<void> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
const conditions = {
|
||||
forumid: forumId,
|
||||
userid: userId || site.getUserId(),
|
||||
timecreated: timeCreated,
|
||||
};
|
||||
|
||||
await site.getDb().deleteRecords(DISCUSSIONS_TABLE, conditions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a forum offline discussion.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param timeCreated The time the discussion was created.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the discussion belongs to. If not defined, current user in site.
|
||||
* @return Promise resolved if stored, rejected if failure.
|
||||
*/
|
||||
async getNewDiscussion(
|
||||
forumId: number,
|
||||
timeCreated: number,
|
||||
siteId?: string,
|
||||
userId?: number,
|
||||
): Promise<AddonModForumOfflineDiscussion> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
const conditions = {
|
||||
forumid: forumId,
|
||||
userid: userId || site.getUserId(),
|
||||
timecreated: timeCreated,
|
||||
};
|
||||
|
||||
const record = await site.getDb().getRecord<AddonModForumOfflineDiscussionDBRecord>(DISCUSSIONS_TABLE, conditions);
|
||||
|
||||
return this.parseRecordOptions(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all offline new discussions.
|
||||
*
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with discussions.
|
||||
*/
|
||||
async getAllNewDiscussions(siteId?: string): Promise<AddonModForumOfflineDiscussion[]> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
const records = await site.getDb().getRecords<AddonModForumOfflineDiscussionDBRecord>(DISCUSSIONS_TABLE);
|
||||
|
||||
return this.parseRecordsOptions(records);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are offline new discussions to send.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the discussions belong to. If not defined, current user in site.
|
||||
* @return Promise resolved with boolean: true if has offline answers, false otherwise.
|
||||
*/
|
||||
async hasNewDiscussions(forumId: number, siteId?: string, userId?: number): Promise<boolean> {
|
||||
try {
|
||||
const discussions = await this.getNewDiscussions(forumId, siteId, userId);
|
||||
|
||||
return !!discussions.length;
|
||||
} catch (error) {
|
||||
// No offline data found, return false.
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get new discussions to be synced.
|
||||
*
|
||||
* @param forumId Forum ID to get.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the discussions belong to. If not defined, current user in site.
|
||||
* @return Promise resolved with the object to be synced.
|
||||
*/
|
||||
async getNewDiscussions(forumId: number, siteId?: string, userId?: number): Promise<AddonModForumOfflineDiscussion[]> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
const conditions = {
|
||||
forumid: forumId,
|
||||
userid: userId || site.getUserId(),
|
||||
};
|
||||
|
||||
const records = await site.getDb().getRecords<AddonModForumOfflineDiscussionDBRecord>(DISCUSSIONS_TABLE, conditions);
|
||||
|
||||
return this.parseRecordsOptions(records);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offline version for adding a new discussion to a forum.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param name Forum name.
|
||||
* @param courseId Course ID the forum belongs to.
|
||||
* @param subject New discussion's subject.
|
||||
* @param message New discussion's message.
|
||||
* @param options Options (subscribe, pin, ...).
|
||||
* @param groupId Group this discussion belongs to.
|
||||
* @param timeCreated The time the discussion was created. If not defined, current time.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the discussion belong to. If not defined, current user in site.
|
||||
* @return Promise resolved when new discussion is successfully saved.
|
||||
*/
|
||||
async addNewDiscussion(
|
||||
forumId: number,
|
||||
name: string,
|
||||
courseId: number,
|
||||
subject: string,
|
||||
message: string,
|
||||
options?: AddonModForumDiscussionOptions,
|
||||
groupId?: number,
|
||||
timeCreated?: number,
|
||||
siteId?: string,
|
||||
userId?: number,
|
||||
): Promise<void> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
const data: AddonModForumOfflineDiscussionDBRecord = {
|
||||
forumid: forumId,
|
||||
name: name,
|
||||
courseid: courseId,
|
||||
subject: subject,
|
||||
message: message,
|
||||
options: JSON.stringify(options || {}),
|
||||
groupid: groupId || AddonModForumProvider.ALL_PARTICIPANTS,
|
||||
userid: userId || site.getUserId(),
|
||||
timecreated: timeCreated || new Date().getTime(),
|
||||
};
|
||||
|
||||
await site.getDb().insertRecord(DISCUSSIONS_TABLE, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete forum offline replies.
|
||||
*
|
||||
* @param postId ID of the post being replied.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the reply belongs to. If not defined, current user in site.
|
||||
* @return Promise resolved if stored, rejected if failure.
|
||||
*/
|
||||
async deleteReply(postId: number, siteId?: string, userId?: number): Promise<void> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
const conditions = {
|
||||
postid: postId,
|
||||
userid: userId || site.getUserId(),
|
||||
};
|
||||
|
||||
await site.getDb().deleteRecords(REPLIES_TABLE, conditions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all offline replies.
|
||||
*
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with replies.
|
||||
*/
|
||||
async getAllReplies(siteId?: string): Promise<AddonModForumOfflineReply[]> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
const records = await site.getDb().getRecords<AddonModForumOfflineReplyDBRecord>(REPLIES_TABLE);
|
||||
|
||||
return this.parseRecordsOptions(records);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is an offline reply for a forum to be synced.
|
||||
*
|
||||
* @param forumId ID of the forum being replied.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the replies belong to. If not defined, current user in site.
|
||||
* @return Promise resolved with boolean: true if has offline answers, false otherwise.
|
||||
*/
|
||||
async hasForumReplies(forumId: number, siteId?: string, userId?: number): Promise<boolean> {
|
||||
try {
|
||||
const replies = await this.getForumReplies(forumId, siteId, userId);
|
||||
|
||||
return !!replies.length;
|
||||
} catch (error) {
|
||||
// No offline data found, return false.
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the replies of a forum to be synced.
|
||||
*
|
||||
* @param forumId ID of the forum being replied.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the replies belong to. If not defined, current user in site.
|
||||
* @return Promise resolved with replies.
|
||||
*/
|
||||
async getForumReplies(forumId: number, siteId?: string, userId?: number): Promise<AddonModForumOfflineReply[]> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
const conditions = {
|
||||
forumid: forumId,
|
||||
userid: userId || site.getUserId(),
|
||||
};
|
||||
|
||||
const records = await site.getDb().getRecords<AddonModForumOfflineReplyDBRecord>(REPLIES_TABLE, conditions);
|
||||
|
||||
return this.parseRecordsOptions(records);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is an offline reply to be synced.
|
||||
*
|
||||
* @param discussionId ID of the discussion the user is replying to.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the replies belong to. If not defined, current user in site.
|
||||
* @return Promise resolved with boolean: true if has offline answers, false otherwise.
|
||||
*/
|
||||
async hasDiscussionReplies(discussionId: number, siteId?: string, userId?: number): Promise<boolean> {
|
||||
try {
|
||||
const replies = await this.getDiscussionReplies(discussionId, siteId, userId);
|
||||
|
||||
return !!replies.length;
|
||||
} catch (error) {
|
||||
// No offline data found, return false.
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the replies of a discussion to be synced.
|
||||
*
|
||||
* @param discussionId ID of the discussion the user is replying to.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the replies belong to. If not defined, current user in site.
|
||||
* @return Promise resolved with discussions.
|
||||
*/
|
||||
async getDiscussionReplies(discussionId: number, siteId?: string, userId?: number): Promise<AddonModForumOfflineReply[]> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
const conditions = {
|
||||
discussionid: discussionId,
|
||||
userid: userId || site.getUserId(),
|
||||
};
|
||||
|
||||
const records = await site.getDb().getRecords<AddonModForumOfflineReplyDBRecord>(REPLIES_TABLE, conditions);
|
||||
|
||||
return this.parseRecordsOptions(records);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offline version for replying to a certain post.
|
||||
*
|
||||
* @param postId ID of the post being replied.
|
||||
* @param discussionId ID of the discussion the user is replying to.
|
||||
* @param forumId ID of the forum the user is replying to.
|
||||
* @param name Forum name.
|
||||
* @param courseId Course ID the forum belongs to.
|
||||
* @param subject New post's subject.
|
||||
* @param message New post's message.
|
||||
* @param options Options (subscribe, attachments, ...).
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the post belong to. If not defined, current user in site.
|
||||
* @return Promise resolved when the post is created.
|
||||
*/
|
||||
async replyPost(
|
||||
postId: number,
|
||||
discussionId: number,
|
||||
forumId: number,
|
||||
name: string,
|
||||
courseId: number,
|
||||
subject: string,
|
||||
message: string,
|
||||
options?: AddonModForumReplyOptions,
|
||||
siteId?: string,
|
||||
userId?: number,
|
||||
): Promise<void> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
const data: AddonModForumOfflineReplyDBRecord = {
|
||||
postid: postId,
|
||||
discussionid: discussionId,
|
||||
forumid: forumId,
|
||||
name: name,
|
||||
courseid: courseId,
|
||||
subject: subject,
|
||||
message: message,
|
||||
options: JSON.stringify(options || {}),
|
||||
userid: userId || site.getUserId(),
|
||||
timecreated: new Date().getTime(),
|
||||
};
|
||||
|
||||
await site.getDb().insertRecord(REPLIES_TABLE, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the folder where to store files for offline attachments in a forum.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with the path.
|
||||
*/
|
||||
async getForumFolder(forumId: number, siteId?: string): Promise<string> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
const siteFolderPath = CoreFile.instance.getSiteFolder(site.getId());
|
||||
|
||||
return CoreTextUtils.instance.concatenatePaths(siteFolderPath, 'offlineforum/' + forumId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the folder where to store files for a new offline discussion.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param timeCreated The time the discussion was created.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with the path.
|
||||
*/
|
||||
async getNewDiscussionFolder(forumId: number, timeCreated: number, siteId?: string): Promise<string> {
|
||||
const folderPath = await this.getForumFolder(forumId, siteId);
|
||||
|
||||
return CoreTextUtils.instance.concatenatePaths(folderPath, 'newdisc_' + timeCreated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the folder where to store files for a new offline reply.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param postId ID of the post being replied.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the replies belong to. If not defined, current user in site.
|
||||
* @return Promise resolved with the path.
|
||||
*/
|
||||
async getReplyFolder(forumId: number, postId: number, siteId?: string, userId?: number): Promise<string> {
|
||||
const folderPath = await this.getForumFolder(forumId, siteId);
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
userId = userId || site.getUserId();
|
||||
|
||||
return CoreTextUtils.instance.concatenatePaths(folderPath, 'reply_' + postId + '_' + userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse "options" column of fetched record.
|
||||
*
|
||||
* @param records List of records.
|
||||
* @return List of records with options parsed.
|
||||
*/
|
||||
protected parseRecordsOptions<
|
||||
R extends { options: string },
|
||||
O extends Record<string, unknown> = Record<string, unknown>
|
||||
>(records: R[]): (Omit<R, 'options'> & { options: O })[] {
|
||||
return records.map(record => this.parseRecordOptions(record));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse "options" column of fetched record.
|
||||
*
|
||||
* @param record Record.
|
||||
* @return Record with options parsed.
|
||||
*/
|
||||
protected parseRecordOptions<
|
||||
R extends { options: string },
|
||||
O extends Record<string, unknown> = Record<string, unknown>
|
||||
>(record: R): Omit<R, 'options'> & { options: O } {
|
||||
record.options = CoreTextUtils.instance.parseJSON(record.options);
|
||||
|
||||
return record as unknown as Omit<R, 'options'> & { options: O };
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonModForumOffline extends makeSingleton(AddonModForumOfflineProvider) {}
|
||||
|
||||
export type AddonModForumDiscussionOptions = {
|
||||
attachmentsid?: number | CoreFileUploaderStoreFilesResult;
|
||||
discussionsubscribe?: boolean;
|
||||
discussionpinned?: boolean;
|
||||
};
|
||||
|
||||
export type AddonModForumReplyOptions = {
|
||||
private?: boolean;
|
||||
attachmentsid?: number | CoreFileUploaderStoreFilesResult;
|
||||
};
|
||||
|
||||
export type AddonModForumOfflineDiscussion = {
|
||||
forumid: number;
|
||||
name: string;
|
||||
courseid: number;
|
||||
subject: string;
|
||||
message: string;
|
||||
options: AddonModForumDiscussionOptions;
|
||||
groupid: number;
|
||||
groupname?: string;
|
||||
userid: number;
|
||||
timecreated: number;
|
||||
};
|
||||
export type AddonModForumOfflineReply = {
|
||||
postid: number;
|
||||
discussionid: number;
|
||||
forumid: number;
|
||||
name: string;
|
||||
courseid: number;
|
||||
subject: string;
|
||||
message: string;
|
||||
options: AddonModForumReplyOptions;
|
||||
userid: number;
|
||||
timecreated: number;
|
||||
};
|
|
@ -0,0 +1,655 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CoreSyncBaseProvider } from '@classes/base-sync';
|
||||
import { CoreCourse } from '@features/course/services/course';
|
||||
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
|
||||
import { CoreFileUploader } from '@features/fileuploader/services/fileuploader';
|
||||
import { CoreApp } from '@services/app';
|
||||
import { CoreGroups } from '@services/groups';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreSync } from '@services/sync';
|
||||
import { CoreTextUtils } from '@services/utils/text';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { makeSingleton, Translate } from '@singletons';
|
||||
import { CoreArray } from '@singletons/array';
|
||||
import { CoreEvents, CoreEventSiteData } from '@singletons/events';
|
||||
import {
|
||||
AddonModForum,
|
||||
AddonModForumAddDiscussionPostWSOptionsObject,
|
||||
AddonModForumAddDiscussionWSOptionsObject,
|
||||
AddonModForumProvider,
|
||||
} from './forum';
|
||||
import { AddonModForumHelper } from './helper';
|
||||
import { AddonModForumOffline, AddonModForumOfflineDiscussion, AddonModForumOfflineReply } from './offline';
|
||||
|
||||
declare module '@singletons/events' {
|
||||
|
||||
/**
|
||||
* Augment CoreEventsData interface with events specific to this service.
|
||||
*
|
||||
* @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
|
||||
*/
|
||||
export interface CoreEventsData {
|
||||
[AddonModForumSyncProvider.AUTO_SYNCED]: AddonModForumAutoSyncData;
|
||||
[AddonModForumSyncProvider.MANUAL_SYNCED]: AddonModForumManualSyncData;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Service to sync forums.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonModForumSyncProvider extends CoreSyncBaseProvider<AddonModForumSyncResult> {
|
||||
|
||||
static readonly AUTO_SYNCED = 'addon_mod_forum_autom_synced';
|
||||
static readonly MANUAL_SYNCED = 'addon_mod_forum_manual_synced';
|
||||
|
||||
private _componentTranslate?: string;
|
||||
|
||||
constructor() {
|
||||
super('AddonModForumSyncProvider');
|
||||
}
|
||||
|
||||
protected get componentTranslate(): string {
|
||||
if (!this._componentTranslate) {
|
||||
this._componentTranslate = CoreCourse.instance.translateModuleName('forum');
|
||||
}
|
||||
|
||||
return this._componentTranslate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to synchronize all the forums in a certain site or in all sites.
|
||||
*
|
||||
* @param siteId Site ID to sync. If not defined, sync all sites.
|
||||
* @param force Wether to force sync not depending on last execution.
|
||||
* @return Promise resolved if sync is successful, rejected if sync fails.
|
||||
*/
|
||||
async syncAllForums(siteId?: string, force?: boolean): Promise<void> {
|
||||
await this.syncOnSites('all forums', this.syncAllForumsFunc.bind(this, !!force), siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all forums on a site.
|
||||
*
|
||||
* @param force Wether to force sync not depending on last execution.
|
||||
* @param siteId Site ID to sync.
|
||||
* @return Promise resolved if sync is successful, rejected if sync fails.
|
||||
*/
|
||||
protected async syncAllForumsFunc(force: boolean, siteId: string): Promise<void> {
|
||||
const sitePromises: Promise<unknown>[] = [];
|
||||
|
||||
// Sync all new discussions.
|
||||
const syncDiscussions = async (discussions: AddonModForumOfflineDiscussion[]) => {
|
||||
// Do not sync same forum twice.
|
||||
const syncedForumIds: number[] = [];
|
||||
const promises = discussions.map(async discussion => {
|
||||
if (CoreArray.contains(syncedForumIds, discussion.forumid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncedForumIds.push(discussion.forumid);
|
||||
const result = force
|
||||
? await this.syncForumDiscussions(discussion.forumid, discussion.userid, siteId)
|
||||
: await this.syncForumDiscussionsIfNeeded(discussion.forumid, discussion.userid, siteId);
|
||||
|
||||
if (result && result.updated) {
|
||||
// Sync successful, send event.
|
||||
CoreEvents.trigger(AddonModForumSyncProvider.AUTO_SYNCED, {
|
||||
forumId: discussion.forumid,
|
||||
userId: discussion.userid,
|
||||
warnings: result.warnings,
|
||||
}, siteId);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(Object.values(promises));
|
||||
};
|
||||
|
||||
sitePromises.push(
|
||||
AddonModForumOffline.instance
|
||||
.getAllNewDiscussions(siteId)
|
||||
.then(discussions => syncDiscussions(discussions)),
|
||||
);
|
||||
|
||||
// Sync all discussion replies.
|
||||
const syncReplies = async (replies: AddonModForumOfflineReply[]) => {
|
||||
// Do not sync same discussion twice.
|
||||
const syncedDiscussionIds: number[] = [];
|
||||
const promises = replies.map(async reply => {
|
||||
if (CoreArray.contains(syncedDiscussionIds, reply.discussionid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = force
|
||||
? await this.syncDiscussionReplies(reply.discussionid, reply.userid, siteId)
|
||||
: await this.syncDiscussionRepliesIfNeeded(reply.discussionid, reply.userid, siteId);
|
||||
|
||||
if (result && result.updated) {
|
||||
// Sync successful, send event.
|
||||
CoreEvents.trigger(AddonModForumSyncProvider.AUTO_SYNCED, {
|
||||
forumId: reply.forumid,
|
||||
discussionId: reply.discussionid,
|
||||
userId: reply.userid,
|
||||
warnings: result.warnings,
|
||||
}, siteId);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
sitePromises.push(
|
||||
AddonModForumOffline.instance
|
||||
.getAllReplies(siteId)
|
||||
.then(replies => syncReplies(replies)),
|
||||
);
|
||||
|
||||
// Sync ratings.
|
||||
sitePromises.push(this.syncRatings(undefined, undefined, force, siteId));
|
||||
|
||||
await Promise.all(sitePromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a forum only if a certain time has passed since the last time.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param userId User the discussion belong to.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved when the forum is synced or if it doesn't need to be synced.
|
||||
*/
|
||||
async syncForumDiscussionsIfNeeded(
|
||||
forumId: number,
|
||||
userId: number,
|
||||
siteId?: string,
|
||||
): Promise<AddonModForumSyncResult | void> {
|
||||
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||
|
||||
const syncId = this.getForumSyncId(forumId, userId);
|
||||
|
||||
const needed = await this.isSyncNeeded(syncId, siteId);
|
||||
|
||||
if (needed) {
|
||||
return this.syncForumDiscussions(forumId, userId, siteId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize all offline discussions of a forum.
|
||||
*
|
||||
* @param forumId Forum ID to be synced.
|
||||
* @param userId User the discussions belong to.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved if sync is successful, rejected otherwise.
|
||||
*/
|
||||
async syncForumDiscussions(
|
||||
forumId: number,
|
||||
userId?: number,
|
||||
siteId?: string,
|
||||
): Promise<AddonModForumSyncResult> {
|
||||
userId = userId || CoreSites.instance.getCurrentSiteUserId();
|
||||
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||
|
||||
const syncId = this.getForumSyncId(forumId, userId);
|
||||
|
||||
if (this.isSyncing(syncId, siteId)) {
|
||||
// There's already a sync ongoing for this discussion, return the promise.
|
||||
return this.getOngoingSync(syncId, siteId)!;
|
||||
}
|
||||
|
||||
// Verify that forum isn't blocked.
|
||||
if (CoreSync.instance.isBlocked(AddonModForumProvider.COMPONENT, syncId, siteId)) {
|
||||
this.logger.debug('Cannot sync forum ' + forumId + ' because it is blocked.');
|
||||
|
||||
return Promise.reject(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
|
||||
}
|
||||
|
||||
this.logger.debug('Try to sync forum ' + forumId + ' for user ' + userId);
|
||||
|
||||
const result: AddonModForumSyncResult = {
|
||||
warnings: [],
|
||||
updated: false,
|
||||
};
|
||||
|
||||
// Sync offline logs.
|
||||
const syncDiscussions = async (): Promise<{ warnings: string[]; updated: boolean }> => {
|
||||
await CoreUtils.instance.ignoreErrors(
|
||||
CoreCourseLogHelper.instance.syncActivity(AddonModForumProvider.COMPONENT, forumId, siteId),
|
||||
);
|
||||
|
||||
// Get offline responses to be sent.
|
||||
const discussions = await CoreUtils.instance.ignoreErrors(
|
||||
AddonModForumOffline.instance.getNewDiscussions(forumId, siteId, userId),
|
||||
[] as AddonModForumOfflineDiscussion[],
|
||||
);
|
||||
|
||||
if (discussions.length !== 0 && !CoreApp.instance.isOnline()) {
|
||||
throw new Error('cannot sync in offline');
|
||||
}
|
||||
|
||||
const promises = discussions.map(async discussion => {
|
||||
const errors: Error[] = [];
|
||||
const groupIds = discussion.groupid === AddonModForumProvider.ALL_GROUPS
|
||||
? await AddonModForum.instance
|
||||
.getForumById(discussion.courseid, discussion.forumid, { siteId })
|
||||
.then(forum => CoreGroups.instance.getActivityAllowedGroups(forum.cmid))
|
||||
.then(result => result.groups.map((group) => group.id))
|
||||
: [discussion.groupid];
|
||||
|
||||
await Promise.all(groupIds.map(async groupId => {
|
||||
try {
|
||||
// First of all upload the attachments (if any).
|
||||
const itemId = await this.uploadAttachments(forumId, discussion, true, siteId, userId);
|
||||
|
||||
// Now try to add the discussion.
|
||||
const options = CoreUtils.instance.clone(discussion.options || {});
|
||||
options.attachmentsid = itemId!;
|
||||
|
||||
await AddonModForum.instance.addNewDiscussionOnline(
|
||||
forumId,
|
||||
discussion.subject,
|
||||
discussion.message,
|
||||
options as unknown as AddonModForumAddDiscussionWSOptionsObject,
|
||||
groupId,
|
||||
siteId,
|
||||
);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}));
|
||||
|
||||
if (errors.length === groupIds.length) {
|
||||
// All requests have failed, reject if errors were not returned by WS.
|
||||
for (const error of errors) {
|
||||
if (!CoreUtils.instance.isWebServiceError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All requests succeeded, some failed or all failed with a WS error.
|
||||
result.updated = true;
|
||||
|
||||
await this.deleteNewDiscussion(forumId, discussion.timecreated, siteId, userId);
|
||||
|
||||
if (errors.length === groupIds.length) {
|
||||
// All requests failed with WS error.
|
||||
result.warnings.push(Translate.instant('core.warningofflinedatadeleted', {
|
||||
component: this.componentTranslate,
|
||||
name: discussion.name,
|
||||
error: CoreTextUtils.instance.getErrorMessageFromError(errors[0]),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
if (result.updated) {
|
||||
// Data has been sent to server. Now invalidate the WS calls.
|
||||
const promises = [
|
||||
AddonModForum.instance.invalidateDiscussionsList(forumId, siteId),
|
||||
AddonModForum.instance.invalidateCanAddDiscussion(forumId, siteId),
|
||||
];
|
||||
|
||||
await CoreUtils.instance.ignoreErrors(Promise.all(promises));
|
||||
}
|
||||
|
||||
// Sync finished, set sync time.
|
||||
await CoreUtils.instance.ignoreErrors(this.setSyncTime(syncId, siteId));
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return this.addOngoingSync(syncId, syncDiscussions(), siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize forum offline ratings.
|
||||
*
|
||||
* @param cmId Course module to be synced. If not defined, sync all forums.
|
||||
* @param discussionId Discussion id to be synced. If not defined, sync all discussions.
|
||||
* @param force Wether to force sync not depending on last execution.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved if sync is successful, rejected otherwise.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async syncRatings(cmId?: number, discussionId?: number, force?: boolean, siteId?: string): Promise<{
|
||||
updated: boolean;
|
||||
warnings: string[];
|
||||
}> {
|
||||
// @todo
|
||||
|
||||
return { updated: true, warnings: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize all offline discussion replies of a forum.
|
||||
*
|
||||
* @param forumId Forum ID to be synced.
|
||||
* @param userId User the discussions belong to.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved if sync is successful, rejected otherwise.
|
||||
*/
|
||||
async syncForumReplies(forumId: number, userId?: number, siteId?: string): Promise<AddonModForumSyncResult> {
|
||||
// Get offline forum replies to be sent.
|
||||
const replies = await CoreUtils.instance.ignoreErrors(
|
||||
AddonModForumOffline.instance.getForumReplies(forumId, siteId, userId),
|
||||
[] as AddonModForumOfflineReply[],
|
||||
);
|
||||
|
||||
if (!replies.length) {
|
||||
// Nothing to sync.
|
||||
return { warnings: [], updated: false };
|
||||
} else if (!CoreApp.instance.isOnline()) {
|
||||
// Cannot sync in offline.
|
||||
return Promise.reject(null);
|
||||
}
|
||||
|
||||
const promises: Record<string, Promise<AddonModForumSyncResult>> = {};
|
||||
|
||||
// Do not sync same discussion twice.
|
||||
replies.forEach((reply) => {
|
||||
if (typeof promises[reply.discussionid] != 'undefined') {
|
||||
return;
|
||||
}
|
||||
promises[reply.discussionid] = this.syncDiscussionReplies(reply.discussionid, userId, siteId);
|
||||
});
|
||||
|
||||
const results = await Promise.all(Object.values(promises));
|
||||
|
||||
return results.reduce((a, b) => ({
|
||||
warnings: a.warnings.concat(b.warnings),
|
||||
updated: a.updated || b.updated,
|
||||
}), { warnings: [], updated: false } as AddonModForumSyncResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a forum discussion replies only if a certain time has passed since the last time.
|
||||
*
|
||||
* @param discussionId Discussion ID to be synced.
|
||||
* @param userId User the posts belong to.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved when the forum discussion is synced or if it doesn't need to be synced.
|
||||
*/
|
||||
async syncDiscussionRepliesIfNeeded(
|
||||
discussionId: number,
|
||||
userId?: number,
|
||||
siteId?: string,
|
||||
): Promise<AddonModForumSyncResult | void> {
|
||||
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||
|
||||
const syncId = this.getDiscussionSyncId(discussionId, userId);
|
||||
|
||||
const needed = await this.isSyncNeeded(syncId, siteId);
|
||||
|
||||
if (needed) {
|
||||
return this.syncDiscussionReplies(discussionId, userId, siteId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize all offline replies from a discussion.
|
||||
*
|
||||
* @param discussionId Discussion ID to be synced.
|
||||
* @param userId User the posts belong to.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved if sync is successful, rejected otherwise.
|
||||
*/
|
||||
async syncDiscussionReplies(discussionId: number, userId?: number, siteId?: string): Promise<AddonModForumSyncResult> {
|
||||
userId = userId || CoreSites.instance.getCurrentSiteUserId();
|
||||
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||
|
||||
const syncId = this.getDiscussionSyncId(discussionId, userId);
|
||||
|
||||
if (this.isSyncing(syncId, siteId)) {
|
||||
// There's already a sync ongoing for this discussion, return the promise.
|
||||
return this.getOngoingSync(syncId, siteId)!;
|
||||
}
|
||||
|
||||
// Verify that forum isn't blocked.
|
||||
if (CoreSync.instance.isBlocked(AddonModForumProvider.COMPONENT, syncId, siteId)) {
|
||||
this.logger.debug('Cannot sync forum discussion ' + discussionId + ' because it is blocked.');
|
||||
|
||||
throw new Error(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
|
||||
}
|
||||
|
||||
this.logger.debug('Try to sync forum discussion ' + discussionId + ' for user ' + userId);
|
||||
|
||||
let forumId;
|
||||
const result: AddonModForumSyncResult = {
|
||||
warnings: [],
|
||||
updated: false,
|
||||
};
|
||||
|
||||
// Get offline responses to be sent.
|
||||
const syncReplies = async () => {
|
||||
const replies = await CoreUtils.instance.ignoreErrors(
|
||||
AddonModForumOffline.instance.getDiscussionReplies(discussionId, siteId, userId),
|
||||
[] as AddonModForumOfflineReply[],
|
||||
);
|
||||
|
||||
if (replies.length !== 0 && !CoreApp.instance.isOnline()) {
|
||||
throw new Error('Cannot sync in offline');
|
||||
}
|
||||
|
||||
const promises = replies.map(async reply => {
|
||||
forumId = reply.forumid;
|
||||
reply.options = reply.options || {};
|
||||
|
||||
try {
|
||||
// First of all upload the attachments (if any).
|
||||
await this.uploadAttachments(forumId, reply, false, siteId, userId).then((itemId) => {
|
||||
// Now try to send the reply.
|
||||
reply.options.attachmentsid = itemId;
|
||||
|
||||
return AddonModForum.instance.replyPostOnline(
|
||||
reply.postid,
|
||||
reply.subject,
|
||||
reply.message,
|
||||
reply.options as unknown as AddonModForumAddDiscussionPostWSOptionsObject,
|
||||
siteId,
|
||||
);
|
||||
});
|
||||
|
||||
result.updated = true;
|
||||
|
||||
await this.deleteReply(forumId, reply.postid, siteId, userId);
|
||||
} catch (error) {
|
||||
if (!CoreUtils.instance.isWebServiceError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// The WebService has thrown an error, this means that responses cannot be submitted. Delete them.
|
||||
result.updated = true;
|
||||
|
||||
await this.deleteReply(forumId, reply.postid, siteId, userId);
|
||||
|
||||
// Responses deleted, add a warning.
|
||||
result.warnings.push(Translate.instant('core.warningofflinedatadeleted', {
|
||||
component: this.componentTranslate,
|
||||
name: reply.name,
|
||||
error: CoreTextUtils.instance.getErrorMessageFromError(error),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Data has been sent to server. Now invalidate the WS calls.
|
||||
const invalidationPromises: Promise<void>[] = [];
|
||||
|
||||
if (forumId) {
|
||||
invalidationPromises.push(AddonModForum.instance.invalidateDiscussionsList(forumId, siteId));
|
||||
}
|
||||
|
||||
invalidationPromises.push(AddonModForum.instance.invalidateDiscussionPosts(discussionId, forumId, siteId));
|
||||
|
||||
await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(invalidationPromises));
|
||||
|
||||
// Sync finished, set sync time.
|
||||
await CoreUtils.instance.ignoreErrors(this.setSyncTime(syncId, siteId));
|
||||
|
||||
// All done, return the warnings.
|
||||
return result;
|
||||
};
|
||||
|
||||
return this.addOngoingSync(syncId, syncReplies(), siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a new discussion.
|
||||
*
|
||||
* @param forumId Forum ID the discussion belongs to.
|
||||
* @param timecreated The timecreated of the discussion.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the discussion belongs to. If not defined, current user in site.
|
||||
* @return Promise resolved when deleted.
|
||||
*/
|
||||
protected async deleteNewDiscussion(forumId: number, timecreated: number, siteId?: string, userId?: number): Promise<void> {
|
||||
await Promise.all([
|
||||
AddonModForumOffline.instance.deleteNewDiscussion(forumId, timecreated, siteId, userId),
|
||||
CoreUtils.instance.ignoreErrors(
|
||||
AddonModForumHelper.instance.deleteNewDiscussionStoredFiles(forumId, timecreated, siteId),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a new discussion.
|
||||
*
|
||||
* @param forumId Forum ID the discussion belongs to.
|
||||
* @param postId ID of the post being replied.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the discussion belongs to. If not defined, current user in site.
|
||||
* @return Promise resolved when deleted.
|
||||
*/
|
||||
protected async deleteReply(forumId: number, postId: number, siteId?: string, userId?: number): Promise<void> {
|
||||
await Promise.all([
|
||||
AddonModForumOffline.instance.deleteReply(postId, siteId, userId),
|
||||
CoreUtils.instance.ignoreErrors(AddonModForumHelper.instance.deleteReplyStoredFiles(forumId, postId, siteId, userId)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload attachments of an offline post/discussion.
|
||||
*
|
||||
* @param forumId Forum ID the post belongs to.
|
||||
* @param post Offline post or discussion.
|
||||
* @param isDiscussion True if it's a new discussion, false if it's a reply.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @param userId User the reply belongs to. If not defined, current user in site.
|
||||
* @return Promise resolved with draftid if uploaded, resolved with undefined if nothing to upload.
|
||||
*/
|
||||
protected async uploadAttachments(
|
||||
forumId: number,
|
||||
post: AddonModForumOfflineDiscussion | AddonModForumOfflineReply,
|
||||
isDiscussion: boolean,
|
||||
siteId?: string,
|
||||
userId?: number,
|
||||
): Promise<number | undefined> {
|
||||
const attachments = post && post.options && post.options.attachmentsid;
|
||||
|
||||
if (!attachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Has some attachments to sync.
|
||||
let files = typeof attachments === 'object' && attachments.online ? attachments.online : [];
|
||||
|
||||
if (typeof attachments === 'object' && attachments.offline) {
|
||||
// Has offline files.
|
||||
try {
|
||||
const postAttachments = isDiscussion
|
||||
? await AddonModForumHelper.instance.getNewDiscussionStoredFiles(
|
||||
forumId,
|
||||
(post as AddonModForumOfflineDiscussion).timecreated,
|
||||
siteId,
|
||||
)
|
||||
: await AddonModForumHelper.instance.getReplyStoredFiles(
|
||||
forumId,
|
||||
(post as AddonModForumOfflineReply).postid,
|
||||
siteId,
|
||||
userId,
|
||||
);
|
||||
|
||||
files = files.concat(postAttachments as unknown as []);
|
||||
} catch (error) {
|
||||
// Folder not found, no files to add.
|
||||
}
|
||||
}
|
||||
|
||||
return CoreFileUploader.instance.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of a forum sync.
|
||||
*
|
||||
* @param forumId Forum ID.
|
||||
* @param userId User the responses belong to.. If not defined, current user.
|
||||
* @return Sync ID.
|
||||
*/
|
||||
getForumSyncId(forumId: number, userId?: number): string {
|
||||
userId = userId || CoreSites.instance.getCurrentSiteUserId();
|
||||
|
||||
return 'forum#' + forumId + '#' + userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of a discussion sync.
|
||||
*
|
||||
* @param discussionId Discussion ID.
|
||||
* @param userId User the responses belong to.. If not defined, current user.
|
||||
* @return Sync ID.
|
||||
*/
|
||||
getDiscussionSyncId(discussionId: number, userId?: number): string {
|
||||
userId = userId || CoreSites.instance.getCurrentSiteUserId();
|
||||
|
||||
return 'discussion#' + discussionId + '#' + userId;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonModForumSync extends makeSingleton(AddonModForumSyncProvider) {}
|
||||
|
||||
/**
|
||||
* Result of forum sync.
|
||||
*/
|
||||
export type AddonModForumSyncResult = {
|
||||
updated: boolean;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Data passed to AUTO_SYNCED event.
|
||||
*/
|
||||
export type AddonModForumAutoSyncData = CoreEventSiteData & {
|
||||
forumId: number;
|
||||
userId: number;
|
||||
warnings: string[];
|
||||
discussionId?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Data passed to MANUAL_SYNCED event.
|
||||
*/
|
||||
export type AddonModForumManualSyncData = CoreEventSiteData & {
|
||||
forumId: number;
|
||||
userId: number;
|
||||
source: string;
|
||||
discussionId?: number;
|
||||
};
|
|
@ -53,7 +53,7 @@
|
|||
|
||||
<!-- Input password for protected lessons. -->
|
||||
<ion-card *ngIf="askPassword">
|
||||
<form ion-list (ngSubmit)="submitPassword($event, passwordinput)" #passwordForm>
|
||||
<form (ngSubmit)="submitPassword($event, passwordinput)" #passwordForm>
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label position="stacked">{{ 'addon.mod_lesson.enterpassword' | translate }}</ion-label>
|
||||
<core-show-password name="password">
|
||||
|
|
|
@ -53,7 +53,7 @@
|
|||
|
||||
<!-- Question page. -->
|
||||
<!-- We need to set ngIf loaded to make formGroup directive restart every time a page changes, see MOBILE-2540. -->
|
||||
<form *ngIf="question && loaded" ion-list [formGroup]="questionForm" #questionFormEl
|
||||
<form *ngIf="question && loaded" [formGroup]="questionForm" #questionFormEl
|
||||
(ngSubmit)="submitQuestion($event)">
|
||||
|
||||
<ion-item-divider class="ion-text-wrap" *ngIf="pageContent">
|
||||
|
|
|
@ -3110,7 +3110,7 @@ export class AddonModLessonProvider {
|
|||
const params: AddonModLessonProcessPageWSParams = {
|
||||
lessonid: lessonId,
|
||||
pageid: pageId,
|
||||
data: <ProcessPageData[]> CoreUtils.instance.objectToArrayOfObjects(data, 'name', 'value', true),
|
||||
data: CoreUtils.instance.objectToArrayOfObjects<ProcessPageData>(data, 'name', 'value', true),
|
||||
review: !!options.review,
|
||||
};
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ import { NgModule } from '@angular/core';
|
|||
|
||||
import { AddonModAssignModule } from './assign/assign.module';
|
||||
import { AddonModBookModule } from './book/book.module';
|
||||
import { AddonModForumModule } from './forum/forum.module';
|
||||
import { AddonModLessonModule } from './lesson/lesson.module';
|
||||
import { AddonModPageModule } from './page/page.module';
|
||||
import { AddonModQuizModule } from './quiz/quiz.module';
|
||||
|
@ -25,6 +26,7 @@ import { AddonModQuizModule } from './quiz/quiz.module';
|
|||
imports: [
|
||||
AddonModAssignModule,
|
||||
AddonModBookModule,
|
||||
AddonModForumModule,
|
||||
AddonModLessonModule,
|
||||
AddonModPageModule,
|
||||
AddonModQuizModule,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<section ion-list class="addon-qtype-match-container" *ngIf="matchQuestion && matchQuestion.loaded">
|
||||
<section class="addon-qtype-match-container" *ngIf="matchQuestion && matchQuestion.loaded">
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<core-format-text [component]="component" [componentId]="componentId" [text]="matchQuestion.text"
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { ActivatedRouteSnapshot, Params } from '@angular/router';
|
||||
import { ActivatedRoute, ActivatedRouteSnapshot, Params } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
import { CoreSplitViewComponent } from '@components/split-view/split-view';
|
||||
|
@ -135,17 +135,22 @@ export abstract class CorePageItemsListManager<Item> {
|
|||
}
|
||||
|
||||
// If this item is already selected, do nothing.
|
||||
const itemRoute = this.getItemRoute(route);
|
||||
const itemPath = this.getItemPath(item);
|
||||
const selectedItemPath = itemRoute ? this.getSelectedItemPath(itemRoute.snapshot) : null;
|
||||
|
||||
if (route.firstChild?.routeConfig?.path === itemPath) {
|
||||
if (selectedItemPath === itemPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to item.
|
||||
const path = route.firstChild ? `../${itemPath}` : itemPath;
|
||||
const params = this.getItemQueryParams(item);
|
||||
const pathPrefix = selectedItemPath ? selectedItemPath.split('/').fill('../').join('') : '';
|
||||
|
||||
await CoreNavigator.instance.navigate(path, { params });
|
||||
await CoreNavigator.instance.navigate(pathPrefix + itemPath, {
|
||||
params,
|
||||
reset: CoreScreen.instance.isTablet,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -220,4 +225,20 @@ export abstract class CorePageItemsListManager<Item> {
|
|||
*/
|
||||
protected abstract getSelectedItemPath(route: ActivatedRouteSnapshot): string | null;
|
||||
|
||||
/**
|
||||
* Get the active item route, if any.
|
||||
*
|
||||
* @param pageRoute Page route.
|
||||
* @return Item route.
|
||||
*/
|
||||
private getItemRoute(pageRoute: ActivatedRoute): ActivatedRoute | null {
|
||||
let itemRoute = pageRoute.firstChild;
|
||||
|
||||
while (itemRoute && !itemRoute.component) {
|
||||
itemRoute = itemRoute.firstChild;
|
||||
}
|
||||
|
||||
return itemRoute;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { AfterViewInit, Component, ElementRef, HostBinding, Input, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot } from '@angular/router';
|
||||
import { IonContent, IonRouterOutlet } from '@ionic/angular';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
|
@ -33,7 +33,6 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy {
|
|||
|
||||
@ViewChild(IonContent) menuContent!: IonContent;
|
||||
@ViewChild(IonRouterOutlet) contentOutlet!: IonRouterOutlet;
|
||||
@HostBinding('class') classes = '';
|
||||
@Input() placeholderText = 'core.emptysplit';
|
||||
@Input() mode?: CoreSplitViewMode;
|
||||
isNested = false;
|
||||
|
@ -47,6 +46,10 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy {
|
|||
return this.outletRouteSubject.value;
|
||||
}
|
||||
|
||||
get outletActivated(): boolean {
|
||||
return this.contentOutlet.isActivated;
|
||||
}
|
||||
|
||||
get outletRouteObservable(): Observable<ActivatedRouteSnapshot | null> {
|
||||
return this.outletRouteSubject.asObservable();
|
||||
}
|
||||
|
@ -92,7 +95,7 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy {
|
|||
classes.push('nested');
|
||||
}
|
||||
|
||||
this.classes = classes.join(' ');
|
||||
this.element.nativeElement.setAttribute('class', classes.join(' '));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -119,13 +122,4 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy {
|
|||
return CoreSplitViewMode.MenuAndContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if both panels are shown. It depends on screen width.
|
||||
*
|
||||
* @return If split view is enabled.
|
||||
*/
|
||||
isOn(): boolean {
|
||||
return this.contentOutlet.isActivated;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -68,7 +68,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR
|
|||
* Component being initialized.
|
||||
*/
|
||||
async ngOnInit(): Promise<void> {
|
||||
super.ngOnInit();
|
||||
await super.ngOnInit();
|
||||
|
||||
this.hasOffline = false;
|
||||
this.syncIcon = CoreConstants.ICON_LOADING;
|
||||
|
|
|
@ -115,7 +115,7 @@
|
|||
|
||||
<!-- Template to render a section. -->
|
||||
<ng-template #sectionTemplate let-section="section">
|
||||
<section ion-list *ngIf="!section.hiddenbynumsections && section.id != allSectionsId && section.id != stealthModulesSectionId">
|
||||
<section *ngIf="!section.hiddenbynumsections && section.id != allSectionsId && section.id != stealthModulesSectionId">
|
||||
<!-- Title is only displayed when viewing all sections. -->
|
||||
<ion-item-divider *ngIf="selectedSection?.id == allSectionsId && section.name" class="ion-text-wrap" color="light"
|
||||
[class.core-section-download]="downloadEnabled"
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { InjectionToken, ModuleWithProviders, NgModule } from '@angular/core';
|
||||
|
||||
import { ModuleRoutesConfig } from '@/app/app-routing.module';
|
||||
|
||||
export const COURSE_CONTENTS_ROUTES = new InjectionToken('COURSE_CONTENTS_ROUTES');
|
||||
|
||||
@NgModule()
|
||||
export class CoreCourseContentsRoutingModule {
|
||||
|
||||
static forChild(routes: ModuleRoutesConfig): ModuleWithProviders<CoreCourseContentsRoutingModule> {
|
||||
return {
|
||||
ngModule: CoreCourseContentsRoutingModule,
|
||||
providers: [
|
||||
{ provide: COURSE_CONTENTS_ROUTES, multi: true, useValue: routes },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
}
|
|
@ -12,23 +12,34 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { Injector, NgModule } from '@angular/core';
|
||||
import { RouterModule, ROUTES, Routes } from '@angular/router';
|
||||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
import { CoreCourseContentsPage } from './contents';
|
||||
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
import { resolveModuleRoutes } from '@/app/app-routing.module';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: CoreCourseContentsPage,
|
||||
},
|
||||
];
|
||||
import { CoreCourseContentsPage } from './contents';
|
||||
import { COURSE_CONTENTS_ROUTES } from './contents-routing.module';
|
||||
|
||||
function buildRoutes(injector: Injector): Routes {
|
||||
const routes = resolveModuleRoutes(injector, COURSE_CONTENTS_ROUTES);
|
||||
|
||||
return [
|
||||
{
|
||||
path: '',
|
||||
component: CoreCourseContentsPage,
|
||||
children: routes.children,
|
||||
},
|
||||
...routes.siblings,
|
||||
];
|
||||
}
|
||||
|
||||
@NgModule({
|
||||
providers: [
|
||||
{ provide: ROUTES, multi: true, useFactory: buildRoutes, deps: [Injector] },
|
||||
],
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CoreSharedModule,
|
||||
CoreCourseComponentsModule,
|
||||
],
|
||||
|
|
|
@ -779,3 +779,5 @@ export type CoreFileUploaderTypeListInfoEntry = {
|
|||
name?: string;
|
||||
extlist: string;
|
||||
};
|
||||
|
||||
export type CoreFileEntry = CoreWSExternalFile | FileEntry;
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
<p class="core-siteurl">{{siteUrl}}</p>
|
||||
</div>
|
||||
|
||||
<form ion-list [formGroup]="credForm" (ngSubmit)="login($event)" class="core-login-form" #credentialsForm>
|
||||
<form [formGroup]="credForm" (ngSubmit)="login($event)" class="core-login-form" #credentialsForm>
|
||||
<ion-item *ngIf="siteChecked && !isBrowserSSO">
|
||||
<ion-label>
|
||||
<ion-input type="text" name="username" placeholder="{{ 'core.login.username' | translate }}"
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
</ion-list>
|
||||
|
||||
<!-- Age verification. -->
|
||||
<form ion-list *ngIf="allRequiredSupported && settingsLoaded && settings && ageDigitalConsentVerification"
|
||||
<form *ngIf="allRequiredSupported && settingsLoaded && settings && ageDigitalConsentVerification"
|
||||
[formGroup]="ageVerificationForm" (ngSubmit)="verifyAge($event)" #ageForm>
|
||||
|
||||
<ion-item-divider class="ion-text-wrap">
|
||||
|
@ -74,7 +74,7 @@
|
|||
</form>
|
||||
|
||||
<!-- Signup form. -->
|
||||
<form ion-list *ngIf="allRequiredSupported && settingsLoaded && settings && !ageDigitalConsentVerification"
|
||||
<form *ngIf="allRequiredSupported && settingsLoaded && settings && !ageDigitalConsentVerification"
|
||||
[formGroup]="signupForm" (ngSubmit)="create($event)" #signupFormEl>
|
||||
|
||||
<ion-item class="ion-text-wrap ion-text-center">
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
</ion-item>
|
||||
</ion-list>
|
||||
<ion-card>
|
||||
<form ion-list [formGroup]="myForm" (ngSubmit)="resetPassword($event)" #resetPasswordForm>
|
||||
<form [formGroup]="myForm" (ngSubmit)="resetPassword($event)" #resetPasswordForm>
|
||||
<ion-item-divider class="ion-text-wrap">
|
||||
<ion-label>{{ 'core.login.searchby' | translate }}</ion-label>
|
||||
</ion-item-divider>
|
||||
|
|
|
@ -19,7 +19,7 @@ import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
|
|||
import { makeSingleton } from '@singletons';
|
||||
import { CoreCourse } from '../../course/services/course';
|
||||
import { CoreCourses } from '../../courses/services/courses';
|
||||
import { AddonModForum, AddonModForumData } from '@/addons/mod/forum/services/forum';
|
||||
import { AddonModForum, AddonModForumData } from '@addons/mod/forum/services/forum';
|
||||
|
||||
/**
|
||||
* Items with index 1 and 3 were removed on 2.5 and not being supported in the app.
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Params } from '@angular/router';
|
||||
import { Connection } from '@ionic-native/network/ngx';
|
||||
|
||||
import { CoreDB } from '@services/db';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
|
@ -341,8 +340,9 @@ export class CoreAppProvider {
|
|||
return false;
|
||||
}
|
||||
|
||||
let online = Network.instance.type !== null && Number(Network.instance.type) != Connection.NONE &&
|
||||
Number(Network.instance.type) != Connection.UNKNOWN;
|
||||
let online = Network.instance.type !== null && Network.instance.type != Network.instance.Connection.NONE &&
|
||||
Network.instance.type != Network.instance.Connection.UNKNOWN;
|
||||
|
||||
// Double check we are not online because we cannot rely 100% in Cordova APIs. Also, check it in browser.
|
||||
if (!online && navigator.onLine) {
|
||||
online = true;
|
||||
|
@ -363,9 +363,14 @@ export class CoreAppProvider {
|
|||
return false;
|
||||
}
|
||||
|
||||
const limited = [Connection.CELL_2G, Connection.CELL_3G, Connection.CELL_4G, Connection.CELL];
|
||||
const limited = [
|
||||
Network.instance.Connection.CELL_2G,
|
||||
Network.instance.Connection.CELL_3G,
|
||||
Network.instance.Connection.CELL_4G,
|
||||
Network.instance.Connection.CELL,
|
||||
];
|
||||
|
||||
return limited.indexOf(Number(type)) > -1;
|
||||
return limited.indexOf(type) > -1;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -743,7 +743,7 @@ export class CoreDomUtilsProvider {
|
|||
* @param error Error to check.
|
||||
* @return Whether it's a canceled error.
|
||||
*/
|
||||
isCanceledError(error: CoreError | CoreTextErrorObject | string): boolean {
|
||||
isCanceledError(error: CoreError | CoreTextErrorObject | string | null): boolean {
|
||||
return error instanceof CoreCanceledError;
|
||||
}
|
||||
|
||||
|
@ -1393,7 +1393,7 @@ export class CoreDomUtilsProvider {
|
|||
* @return Promise resolved with the alert modal.
|
||||
*/
|
||||
async showErrorModalDefault(
|
||||
error: CoreError | CoreTextErrorObject | string,
|
||||
error: CoreError | CoreTextErrorObject | string | null,
|
||||
defaultError: string,
|
||||
needsTranslate?: boolean,
|
||||
autocloseTime?: number,
|
||||
|
@ -1409,7 +1409,7 @@ export class CoreDomUtilsProvider {
|
|||
errorMessage = CoreTextUtils.instance.getErrorMessageFromError(error);
|
||||
}
|
||||
|
||||
return this.showErrorModal(typeof errorMessage == 'string' ? error : defaultError, needsTranslate, autocloseTime);
|
||||
return this.showErrorModal(typeof errorMessage == 'string' ? error! : defaultError, needsTranslate, autocloseTime);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -219,7 +219,7 @@ export class CoreUtilsProvider {
|
|||
try {
|
||||
const response = await this.timeoutPromise(window.fetch(url, initOptions), CoreWS.instance.getRequestTimeout());
|
||||
|
||||
return response.redirected;
|
||||
return !!response && response.redirected;
|
||||
} catch (error) {
|
||||
if (error.timeout && controller) {
|
||||
// Timeout, abort the request.
|
||||
|
@ -1072,13 +1072,16 @@ export class CoreUtilsProvider {
|
|||
* @param sortByValue True to sort values alphabetically, false otherwise.
|
||||
* @return Array of objects with the name & value of each property.
|
||||
*/
|
||||
objectToArrayOfObjects<T = Record<string, unknown>>(
|
||||
obj: Record<string, unknown>,
|
||||
objectToArrayOfObjects<
|
||||
A extends Record<string,unknown> = Record<string, unknown>,
|
||||
O extends Record<string, unknown> = Record<string, unknown>
|
||||
>(
|
||||
obj: O,
|
||||
keyName: string,
|
||||
valueName: string,
|
||||
sortByKey?: boolean,
|
||||
sortByValue?: boolean,
|
||||
): T[] {
|
||||
): A[] {
|
||||
// Get the entries from an object or primitive value.
|
||||
const getEntries = (elKey: string, value: unknown): Record<string, unknown>[] | unknown => {
|
||||
if (typeof value == 'undefined' || value == null) {
|
||||
|
@ -1114,7 +1117,7 @@ export class CoreUtilsProvider {
|
|||
}
|
||||
|
||||
// "obj" will always be an object, so "entries" will always be an array.
|
||||
const entries = getEntries('', obj) as T[];
|
||||
const entries = getEntries('', obj) as A[];
|
||||
if (sortByKey || sortByValue) {
|
||||
return entries.sort((a, b) => {
|
||||
if (sortByKey) {
|
||||
|
@ -1223,7 +1226,7 @@ export class CoreUtilsProvider {
|
|||
promiseDefer<T>(): PromiseDefer<T> {
|
||||
const deferred: Partial<PromiseDefer<T>> = {};
|
||||
deferred.promise = new Promise((resolve, reject): void => {
|
||||
deferred.resolve = resolve;
|
||||
deferred.resolve = resolve as (value?: T | undefined) => void;
|
||||
deferred.reject = reject;
|
||||
});
|
||||
|
||||
|
@ -1371,7 +1374,7 @@ export class CoreUtilsProvider {
|
|||
* @param time Number of milliseconds of the timeout.
|
||||
* @return Promise with the timeout.
|
||||
*/
|
||||
timeoutPromise<T>(promise: Promise<T>, time: number): Promise<T> {
|
||||
timeoutPromise<T>(promise: Promise<T>, time: number): Promise<T | void> {
|
||||
return new Promise((resolve, reject): void => {
|
||||
let timedOut = false;
|
||||
const resolveBeforeTimeout = () => {
|
||||
|
|
|
@ -28,6 +28,25 @@ export interface CoreEventObserver {
|
|||
off: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event payloads.
|
||||
*/
|
||||
export interface CoreEventsData {
|
||||
[CoreEvents.SITE_UPDATED]: CoreEventSiteUpdatedData;
|
||||
[CoreEvents.SITE_ADDED]: CoreEventSiteAddedData;
|
||||
[CoreEvents.SESSION_EXPIRED]: CoreEventSessionExpiredData;
|
||||
[CoreEvents.CORE_LOADING_CHANGED]: CoreEventLoadingChangedData;
|
||||
[CoreEvents.COURSE_STATUS_CHANGED]: CoreEventCourseStatusChanged;
|
||||
[CoreEvents.PACKAGE_STATUS_CHANGED]: CoreEventPackageStatusChanged;
|
||||
[CoreEvents.USER_DELETED]: CoreEventUserDeletedData;
|
||||
[CoreEvents.FORM_ACTION]: CoreEventFormActionData;
|
||||
[CoreEvents.NOTIFICATION_SOUND_CHANGED]: CoreEventNotificationSoundChangedData;
|
||||
[CoreEvents.SELECT_COURSE_TAB]: CoreEventSelectCourseTabData;
|
||||
[CoreEvents.COMPLETION_MODULE_VIEWED]: CoreEventCompletionModuleViewedData;
|
||||
[CoreEvents.SECTION_STATUS_CHANGED]: CoreEventSectionStatusChangedData;
|
||||
[CoreEvents.ACTIVITY_DATA_SENT]: CoreEventActivityDataSentData;
|
||||
};
|
||||
|
||||
/*
|
||||
* Service to send and listen to events.
|
||||
*/
|
||||
|
@ -84,15 +103,15 @@ export class CoreEvents {
|
|||
* @param siteId Site where to trigger the event. Undefined won't check the site.
|
||||
* @return Observer to stop listening.
|
||||
*/
|
||||
static on<T = unknown>(
|
||||
eventName: string,
|
||||
callBack: (value: T & { siteId?: string }) => void,
|
||||
static on<Fallback = unknown, Event extends string = string>(
|
||||
eventName: Event,
|
||||
callBack: (value: CoreEventData<Event, Fallback> & { siteId?: string }) => void,
|
||||
siteId?: string,
|
||||
): CoreEventObserver {
|
||||
// If it's a unique event and has been triggered already, call the callBack.
|
||||
// We don't need to create an observer because the event won't be triggered again.
|
||||
if (this.uniqueEvents[eventName]) {
|
||||
callBack(<T> this.uniqueEvents[eventName].data);
|
||||
callBack(this.uniqueEvents[eventName].data as CoreEventData<Event, Fallback> & { siteId?: string });
|
||||
|
||||
// Return a fake observer to prevent errors.
|
||||
return {
|
||||
|
@ -106,14 +125,16 @@ export class CoreEvents {
|
|||
|
||||
if (typeof this.observables[eventName] == 'undefined') {
|
||||
// No observable for this event, create a new one.
|
||||
this.observables[eventName] = new Subject<T>();
|
||||
this.observables[eventName] = new Subject();
|
||||
}
|
||||
|
||||
const subscription = this.observables[eventName].subscribe((value: T & {siteId?: string}) => {
|
||||
if (!siteId || value.siteId == siteId) {
|
||||
callBack(value);
|
||||
}
|
||||
});
|
||||
const subscription = this.observables[eventName].subscribe(
|
||||
(value: CoreEventData<Event, Fallback> & { siteId?: string }) => {
|
||||
if (!siteId || value.siteId == siteId) {
|
||||
callBack(value);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Create and return a CoreEventObserver.
|
||||
return {
|
||||
|
@ -155,7 +176,11 @@ export class CoreEvents {
|
|||
* @param data Data to pass to the observers.
|
||||
* @param siteId Site where to trigger the event. Undefined means no Site.
|
||||
*/
|
||||
static trigger<T = unknown>(eventName: string, data?: T, siteId?: string): void {
|
||||
static trigger<Fallback = unknown, Event extends string = string>(
|
||||
eventName: Event,
|
||||
data?: CoreEventData<Event, Fallback>,
|
||||
siteId?: string,
|
||||
): void {
|
||||
this.logger.debug(`Event '${eventName}' triggered.`);
|
||||
if (this.observables[eventName]) {
|
||||
if (siteId) {
|
||||
|
@ -172,7 +197,11 @@ export class CoreEvents {
|
|||
* @param data Data to pass to the observers.
|
||||
* @param siteId Site where to trigger the event. Undefined means no Site.
|
||||
*/
|
||||
static triggerUnique<T = unknown>(eventName: string, data: T, siteId?: string): void {
|
||||
static triggerUnique<Fallback = unknown, Event extends string = string>(
|
||||
eventName: Event,
|
||||
data: CoreEventData<Event, Fallback>,
|
||||
siteId?: string,
|
||||
): void {
|
||||
if (this.uniqueEvents[eventName]) {
|
||||
this.logger.debug(`Unique event '${eventName}' ignored because it was already triggered.`);
|
||||
} else {
|
||||
|
@ -196,6 +225,11 @@ export class CoreEvents {
|
|||
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve payload type for a given event.
|
||||
*/
|
||||
export type CoreEventData<Event, Fallback> = Event extends keyof CoreEventsData ? CoreEventsData[Event] : Fallback;
|
||||
|
||||
/**
|
||||
* Some events contains siteId added by the trigger function. This type is intended to be combined with others.
|
||||
*/
|
||||
|
|
|
@ -158,4 +158,10 @@ export class NavController extends makeSingleton(NavControllerService) {}
|
|||
export class Router extends makeSingleton(RouterService) {}
|
||||
|
||||
// Convert external libraries injectables.
|
||||
export class Translate extends makeSingleton(TranslateService) {}
|
||||
export class Translate extends makeSingleton(TranslateService) {
|
||||
|
||||
static instant(key: string | Array<string>, interpolateParams?: Record<string, unknown>): string | any {
|
||||
return this.instance.instant(key, interpolateParams);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -93,4 +93,7 @@
|
|||
--core-question-feedback-background-color: var(--yellow-dark);
|
||||
|
||||
--core-dd-question-selected-shadow: 2px 2px 4px var(--gray-light);
|
||||
|
||||
--addon-forum-border-color: var(--custom-forum-border-color, var(--gray-darker));
|
||||
--addon-forum-highlight-color: var(--custom-forum-highlight-color, var(--gray-dark));
|
||||
}
|
||||
|
|
|
@ -183,6 +183,10 @@
|
|||
--addon-messages-discussion-badge: var(--custom-messages-discussion-badge, var(--core-color));
|
||||
--addon-messages-discussion-badge-text: var(--custom-messages-discussion-badge-text, var(--white));
|
||||
|
||||
--addon-forum-avatar-size: var(--custom-forum-avatar-size, 28px);
|
||||
--addon-forum-border-color: var(--custom-forum-border-color, var(--gray));
|
||||
--addon-forum-highlight-color: var(--custom-forum-highlight-color, var(--gray-lighter));
|
||||
|
||||
--drop-shadow: var(--custom-drop-shadow, 0, 0, 0, 0.2);
|
||||
|
||||
--core-menu-box-shadow-end: var(--custom-menu-box-shadow-end, -4px 0px 16px rgba(0, 0, 0, 0.18));
|
||||
|
|
Loading…
Reference in New Issue