Merge pull request #2685 from NoelDeMartin/MOBILE-3643

Mobile 3643
main
Dani Palou 2021-03-02 11:42:49 +01:00 committed by GitHub
commit 25a592e0b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 9551 additions and 80 deletions

View File

@ -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);

View File

@ -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();

View File

@ -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);

View File

@ -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>

View File

@ -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 {}

View File

@ -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>

View File

@ -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();
}
}
}

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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' });
}
}
}

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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();
}
}
}

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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"
}

View File

@ -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>

View File

@ -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 {}

View File

@ -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;
}
}

View File

@ -0,0 +1,7 @@
:host {
.addon-forum-reply-button .label {
margin: 0;
}
}

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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 {}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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.
};

View File

@ -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;
};

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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">

View File

@ -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">

View File

@ -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,
};

View File

@ -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,

View File

@ -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"

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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"

View File

@ -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 },
],
};
}
}

View File

@ -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,
],

View File

@ -779,3 +779,5 @@ export type CoreFileUploaderTypeListInfoEntry = {
name?: string;
extlist: string;
};
export type CoreFileEntry = CoreWSExternalFile | FileEntry;

View File

@ -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 }}"

View File

@ -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">

View File

@ -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>

View File

@ -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.

View File

@ -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;
}
/**

View File

@ -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);
}
/**

View File

@ -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 = () => {

View File

@ -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.
*/

View File

@ -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);
}
}

View File

@ -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));
}

View File

@ -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));