MOBILE-3643 forum: Migrate discussion page

main
Noel De Martin 2021-02-18 11:49:15 +01:00
parent b318b0e4a5
commit 2dd0aa4815
14 changed files with 1947 additions and 203 deletions

View File

@ -14,23 +14,28 @@
import { NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
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 { AddonModForumIndexComponent } from './index/index';
import { AddonModForumPostComponent } from './post/post';
@NgModule({
declarations: [
AddonModForumIndexComponent,
AddonModForumPostComponent,
],
imports: [
CoreSharedModule,
CoreCourseComponentsModule,
CoreTagComponentsModule,
CoreEditorComponentsModule,
],
exports: [
AddonModForumIndexComponent,
AddonModForumPostComponent,
],
})
export class AddonModForumComponentsModule {}

View File

@ -0,0 +1,136 @@
<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" (click)="showOptionsMenu($event)" [attr.aria-label]="('core.displayoptions' | translate)">
<ion-icon name="more" 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" (click)="showOptionsMenu($event)" [attr.aria-label]="('core.displayoptions' | translate)">
<ion-icon name="more" 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>
<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" (click)="showReplyForm()"
[attr.aria-controls]="'addon-forum-reply-edit-form-' + uniqueId" [attr.aria-expanded]="replyData.replyingTo === post.id">
<ion-icon name="fa-reply" slot="start">
</ion-icon> {{ 'addon.mod_forum.reply' | translate }}
</ion-button>
</ion-label>
</ion-item>
</div>
<form *ngIf="(post.id > 0 && !replyData.isEditing && replyData.replyingTo == post.id) || (post.id <=0 && replyData.isEditing && replyData.replyingTo == post.parentid)"
ion-list [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.scss";
: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,510 @@
// (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,
} from '../../services/forum.service';
import { CoreTag } from '@features/tag/services/tag';
import { Translate } from '@singletons';
import { CoreFileUploader } from '@features/fileuploader/services/fileuploader';
import { IonContent } from '@ionic/angular';
import { AddonModForumSync } from '../../services/sync.service';
import { CoreSync } from '@services/sync';
import { CoreTextUtils } from '@services/utils/text';
import { AddonModForumHelper } from '../../services/helper.service';
import { AddonModForumOffline, AddonModForumReplyOptions } from '../../services/offline.service';
import { CoreUtils } from '@services/utils/utils';
/**
* 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,
) {}
/**
* 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?: any[],
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 e Click Event.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
showOptionsMenu(e: Event): void {
alert('Options menu not implemented');
// @todo
}
/**
* Shows a form modal to edit an online post.
*/
editPost(): void {
alert('Edit post not implemented');
// @todo
}
/**
* 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

@ -19,12 +19,37 @@ import { CoreSharedModule } from '@/core/shared.module';
import { AddonModForumComponentsModule } from './components/components.module';
import { AddonModForumIndexPage } from './pages/index';
import { AddonModForumDiscussionPage } from './pages/discussion/discussion';
import { conditionalRoutes } from '@/app/app-routing.module';
import { CoreScreen } from '@services/screen';
const routes: Routes = [
const mobileRoutes: Routes = [
{
path: ':courseId/:cmId',
component: AddonModForumIndexPage,
},
{
path: ':courseId/:cmId/:discussionId',
component: AddonModForumDiscussionPage,
},
];
const tabletRoutes: Routes = [
{
path: ':courseId/:cmId',
component: AddonModForumIndexPage,
children: [
{
path: ':discussionId',
component: AddonModForumDiscussionPage,
},
],
},
];
const routes: Routes = [
...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile),
...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet),
];
@NgModule({
@ -35,6 +60,7 @@ const routes: Routes = [
],
declarations: [
AddonModForumIndexPage,
AddonModForumDiscussionPage,
],
})
export class AddonModForumLazyModule {}

View File

@ -0,0 +1,106 @@
<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 [priority]="650" *ngIf="discussionLoaded && !postHasOffline && isOnline" [content]="'addon.mod_forum.refreshposts' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item [priority]="550" *ngIf="discussionLoaded && !isSplitViewOn && postHasOffline && isOnline" [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(null, $event, true)" [iconAction]="syncIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item [hidden]="sort == 'flat-oldest'" [priority]="500" [content]="'addon.mod_forum.modeflatoldestfirst' | translate" (action)="changeSort('flat-oldest')" iconAction="arrow-round-down"></core-context-menu-item>
<core-context-menu-item [hidden]="sort == 'flat-newest'" [priority]="450" [content]="'addon.mod_forum.modeflatnewestfirst' | translate" (action)="changeSort('flat-newest')" iconAction="arrow-round-up"></core-context-menu-item>
<core-context-menu-item [hidden]="sort == 'nested'" [priority]="400" [content]="'addon.mod_forum.modenested' | translate" (action)="changeSort('nested')" iconAction="swap"></core-context-menu-item>
<core-context-menu-item [hidden]="!discussion || !discussion.canlock || discussion.locked" [priority]="300" [content]="'addon.mod_forum.lockdiscussion' | translate" (action)="setLockState(true)" iconAction="fa-lock"></core-context-menu-item>
<core-context-menu-item [hidden]="!discussion || !discussion.canlock || !discussion.locked" [priority]="300" [content]="'addon.mod_forum.unlockdiscussion' | translate" (action)="setLockState(false)" iconAction="fa-unlock"></core-context-menu-item>
<core-context-menu-item [hidden]="!discussion || !canPin || discussion.pinned" [priority]="250" [content]="'addon.mod_forum.pindiscussion' | translate" (action)="setPinState(true)" iconAction="fa-map-pin"></core-context-menu-item>
<core-context-menu-item [hidden]="!discussion || !canPin || !discussion.pinned" [priority]="250" [content]="'addon.mod_forum.unpindiscussion' | translate" (action)="setPinState(false)" iconAction="fa-map-pin" [iconSlash]="true"></core-context-menu-item>
<core-context-menu-item [hidden]="!discussion || !discussion.canfavourite || discussion.starred" [priority]="200" [content]="'addon.mod_forum.addtofavourites' | translate" (action)="toggleFavouriteState(true)" iconAction="fa-star"></core-context-menu-item>
<core-context-menu-item [hidden]="!discussion || !discussion.canfavourite || !discussion.starred" [priority]="200" [content]="'addon.mod_forum.removefromfavourites' | translate" (action)="toggleFavouriteState(false)" iconAction="fa-star" [iconSlash]="true"></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,7 @@
:host {
.addon-forum-reply-button .label {
margin: 0;
}
}

View File

@ -0,0 +1,758 @@
// (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 } from '@angular/core';
import { CoreUser } from '@features/user/services/user';
import { IonContent } from '@ionic/angular';
import { CoreApp } from '@services/app';
import { CoreNavigator } from '@services/navigator';
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.service';
import { AddonModForumHelper } from '../../services/helper.service';
import { AddonModForumOffline } from '../../services/offline.service';
import { AddonModForumSync, AddonModForumSyncProvider } from '../../services/sync.service';
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 {
@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(protected elementRef: ElementRef) {}
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,
);
});
}
}
/**
* 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';
}
/**
* 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: any) => {
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: any) => {
if (data.source != 'discussion' && data.forumId == this.forumId &&
data.userId == CoreSites.instance.getCurrentSiteUserId()) {
// Refresh the data.
this.discussionLoaded = false;
this.refreshPosts();
}
}, CoreSites.instance.getCurrentSiteId());
// Trigger view event, to highlight the current opened discussion in the split view.
CoreEvents.trigger(AddonModForumProvider.VIEW_DISCUSSION_EVENT, {
forumId: this.forumId,
discussion: this.discussionId,
}, CoreSites.instance.getCurrentSiteId());
this.changeDiscObserver = CoreEvents.on(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, (data: any) => {
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) {
// @todo
// if (this.svComponent && this.svComponent.isOn()) {
// this.svComponent.emptyDetails();
// } else {
// this.navCtrl.pop();
// }
} else {
this.discussionLoaded = false;
this.refreshPosts();
}
}
});
}
});
}
// @todo
// /**
// * Check if we can leave the page or not.
// *
// * @return Resolved if we can leave it, rejected if not.
// */
// async ionViewCanLeave(): Promise<void> {
// if (AddonModForumHelper.instance.hasPostDataChanged(this.replyData, this.originalData)) {
// // Show confirmation if some data has been modified.
// await CoreDomUtils.instance.showConfirm(this.translate.instant('core.confirmcanceledit'));
// }
// // Delete the local files from the tmp folder.
// this.uploaderProvider.clearTmpFiles(this.replyData.files);
// this.leavingPage = true;
// }
/**
* 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. Should not happen.
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;
}
} 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;
});
}
/**
* 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 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

@ -16,6 +16,7 @@ import { Injectable } from '@angular/core';
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
import { CoreCourseCommonModWSOptions } from '@features/course/services/course';
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
import { CoreFileEntry } from '@features/fileuploader/services/fileuploader';
import { CoreUser } from '@features/user/services/user';
import { CoreApp } from '@services/app';
import { CoreFilepool } from '@services/filepool';
@ -25,7 +26,7 @@ import { CoreUrlUtils } from '@services/utils/url';
import { CoreUtils } from '@services/utils/utils';
import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws';
import { makeSingleton, Translate } from '@singletons';
import { AddonModForumOffline, AddonModForumReplyOptions } from './offline.service';
import { AddonModForumOffline, AddonModForumOfflineDiscussion, AddonModForumReplyOptions } from './offline.service';
const ROOT_CACHE_KEY = 'mmaModForum:';
@ -275,13 +276,13 @@ export class AddonModForumProvider {
* @return Promise resolved when done.
* @since 3.8
*/
async deletePost(postId: number, siteId?: string): Promise<void> {
async deletePost(postId: number, siteId?: string): Promise<AddonModForumDeletePostWSResponse> {
const site = await CoreSites.instance.getSite(siteId);
const params: AddonModForumDeletePostWSParams = {
postid: postId,
};
await site.write<AddonModForumDeletePostWSResponse>('mod_forum_delete_post', params);
return site.write<AddonModForumDeletePostWSResponse>('mod_forum_delete_post', params);
}
/**
@ -355,7 +356,12 @@ export class AddonModForumProvider {
* @param discussions List of discussions to format.
* @return Promise resolved with the formatted discussions.
*/
formatDiscussionsGroups(cmId: number, discussions: any[]): Promise<any[]> {
formatDiscussionsGroups(cmId: number, discussions: AddonModForumDiscussion[]): Promise<AddonModForumDiscussion[]>;
formatDiscussionsGroups(cmId: number, discussions: AddonModForumOfflineDiscussion[]): Promise<AddonModForumOfflineDiscussion[]>;
formatDiscussionsGroups(
cmId: number,
discussions: AddonModForumDiscussion[] | AddonModForumOfflineDiscussion[],
): Promise<AddonModForumDiscussion[] | AddonModForumOfflineDiscussion[]> {
discussions = CoreUtils.instance.clone(discussions);
return CoreGroups.instance.getActivityAllowedGroups(cmId).then((result) => {
@ -447,7 +453,7 @@ export class AddonModForumProvider {
throw new Error('Post not found');
}
return response.post;
return this.translateWSPost(response.post);
}
/**
@ -539,9 +545,9 @@ export class AddonModForumProvider {
ratinginfo?: AddonModForumRatingInfo;
}> {
// Convenience function to translate legacy data to new format.
const translateLegacyPostsFormat = (posts: any[]): any[] => posts.map((post) => {
const newPost = {
id: post.id ,
const translateLegacyPostsFormat = (posts: AddonModForumLegacyPost[]): AddonModForumPost[] => posts.map((post) => {
const newPost: AddonModForumPost = {
id: post.id,
discussionid: post.discussion,
parentid: post.parent,
hasparent: !!post.parent,
@ -563,8 +569,8 @@ export class AddonModForumProvider {
tags: post.tags,
};
if (post.groupname) {
newPost.author['groups'] = [{ name: post.groupname }];
if ('groupname' in post && typeof post['groupname'] === 'string') {
newPost.author['groups'] = [{ name: post['groupname'] }];
}
return newPost;
@ -572,26 +578,10 @@ export class AddonModForumProvider {
// For some reason, the new WS doesn't use the tags exporter so it returns a different format than other WebServices.
// Convert the new format to the exporter one so it's the same as in other WebServices.
const translateTagsFormatToLegacy = (posts: any[]): any[] => {
posts.forEach((post) => {
post.tags = post.tags.map((tag) => {
const viewUrl = (tag.urls && tag.urls.view) || '';
const params = CoreUrlUtils.instance.extractUrlParams(viewUrl);
const translateTagsFormatToLegacy = (posts: AddonModForumWSPost[]): AddonModForumPost[] => {
posts.forEach(post => this.translateWSPost(post));
return {
id: tag.tagid,
taginstanceid: tag.id,
flag: tag.flag ? 1 : 0,
isstandard: tag.isstandard,
rawname: tag.displayname,
name: tag.displayname,
tagcollid: params.tc ? Number(params.tc) : undefined,
taginstancecontextid: params.from ? Number(params.from) : undefined,
};
});
});
return posts;
return posts as unknown as AddonModForumPost[];
};
const params: AddonModForumGetDiscussionPostsWSParams | AddonModForumGetForumDiscussionPostsWSParams = {
@ -619,15 +609,16 @@ export class AddonModForumProvider {
throw new Error('Could not get forum posts');
}
if (isGetDiscussionPostsAvailable) {
response.posts = translateTagsFormatToLegacy((response as AddonModForumGetDiscussionPostsWSResponse).posts);
} else {
response.posts = translateLegacyPostsFormat((response as AddonModForumGetForumDiscussionPostsWSResponse).posts);
}
const posts = isGetDiscussionPostsAvailable
? translateTagsFormatToLegacy((response as AddonModForumGetDiscussionPostsWSResponse).posts)
: translateLegacyPostsFormat((response as AddonModForumGetForumDiscussionPostsWSResponse).posts);
this.storeUserData(response.posts);
this.storeUserData(posts);
return response as AddonModForumGetDiscussionPostsWSResponse;
return {
...response,
posts,
};
}
/**
@ -790,7 +781,7 @@ export class AddonModForumProvider {
throw new Error('Could not get discussions');
}
await this.storeUserData(response.discussions);
this.storeUserData(response.discussions);
return {
discussions: response.discussions,
@ -1093,7 +1084,13 @@ export class AddonModForumProvider {
// If there's already a reply to be sent to the server, discard it first.
try {
await AddonModForumOffline.instance.deleteReply(postId, siteId);
await this.replyPostOnline(postId, subject, message, options, siteId);
await this.replyPostOnline(
postId,
subject,
message,
options as unknown as AddonModForumAddDiscussionPostWSOptionsObject,
siteId,
);
return true;
} catch (error) {
@ -1157,7 +1154,12 @@ export class AddonModForumProvider {
* @return Promise resolved when done.
* @since 3.7
*/
async setLockState(forumId: number, discussionId: number, locked: boolean, siteId?: string): Promise<void> {
async setLockState(
forumId: number,
discussionId: number,
locked: boolean,
siteId?: string,
): Promise<AddonModForumSetLockStateWSResponse> {
const site = await CoreSites.instance.getSite(siteId);
const params: AddonModForumSetLockStateWSParams = {
forumid: forumId,
@ -1165,7 +1167,7 @@ export class AddonModForumProvider {
targetstate: locked ? 0 : 1,
};
await site.write<AddonModForumSetLockStateWSResponse>('mod_forum_set_lock_state', params);
return site.write<AddonModForumSetLockStateWSResponse>('mod_forum_set_lock_state', params);
}
/**
@ -1211,7 +1213,7 @@ export class AddonModForumProvider {
const site = await CoreSites.instance.getSite(siteId);
const params: AddonModForumToggleFavouriteStateWSParams = {
discussionid: discussionId,
targetstate: starred ? 1 : 0 as any,
targetstate: starred,
};
await site.write<AddonModForumToggleFavouriteStateWSResponse>('mod_forum_toggle_favourite_state', params);
@ -1222,30 +1224,30 @@ export class AddonModForumProvider {
*
* @param list Array of posts or discussions.
*/
protected storeUserData(list: any[]): void {
protected storeUserData(list: AddonModForumPost[] | AddonModForumDiscussion[]): void {
const users = {};
list.forEach((entry) => {
if (entry.author) {
list.forEach((entry: AddonModForumPost | AddonModForumDiscussion) => {
if ('author' in entry) {
const authorId = Number(entry.author.id);
if (!isNaN(authorId) && !users[authorId]) {
users[authorId] = {
id: entry.author.id,
fullname: entry.author.fullname,
profileimageurl: entry.author.urls.profileimage,
profileimageurl: entry.author.urls?.profileimage,
};
}
}
const userId = parseInt(entry.userid);
if (!isNaN(userId) && !users[userId]) {
const userId = parseInt(entry['userid']);
if ('userid' in entry && !isNaN(userId) && !users[userId]) {
users[userId] = {
id: userId,
fullname: entry.userfullname,
profileimageurl: entry.userpictureurl,
};
}
const userModified = parseInt(entry.usermodified);
if (!isNaN(userModified) && !users[userModified]) {
const userModified = parseInt(entry['usermodified']);
if ('usermodified' in entry && !isNaN(userModified) && !users[userModified]) {
users[userModified] = {
id: userModified,
fullname: entry.usermodifiedfullname,
@ -1293,6 +1295,33 @@ export class AddonModForumProvider {
return response && response.status;
}
/**
* For some reason, the new WS doesn't use the tags exporter so it returns a different format than other WebServices.
* Convert the new format to the exporter one so it's the same as in other WebServices.
*
* @param post Post returned by the new WS.
* @return Post using the same format as other WebServices.
*/
protected translateWSPost(post: AddonModForumWSPost): AddonModForumPost {
(post as unknown as AddonModForumPost).tags = (post.tags || []).map((tag) => {
const viewUrl = (tag.urls && tag.urls.view) || '';
const params = CoreUrlUtils.instance.extractUrlParams(viewUrl);
return {
id: tag.tagid,
taginstanceid: tag.id,
flag: tag.flag ? 1 : 0,
isstandard: tag.isstandard,
rawname: tag.displayname,
name: tag.displayname,
tagcollid: params.tc ? Number(params.tc) : undefined,
taginstancecontextid: params.from ? Number(params.from) : undefined,
};
});
return post as unknown as AddonModForumPost;
}
}
export class AddonModForum extends makeSingleton(AddonModForumProvider) {}
@ -1353,6 +1382,7 @@ export type AddonModForumDiscussion = {
id: number; // Post id.
name: string; // Discussion name.
groupid: number; // Group id.
groupname?: string; // Group name (not returned by WS).
timemodified: number; // Time modified.
usermodified: number; // The id of the user who last modified.
timestart: number; // Time discussion can start.
@ -1386,6 +1416,49 @@ export type AddonModForumDiscussion = {
canfavourite?: boolean; // Can the user star the discussion.
};
/**
* Forum post data returned by web services.
*/
export type AddonModForumPost = {
id: number; // Id.
subject: string; // Subject.
replysubject?: string; // Replysubject.
message: string; // Message.
author: {
id?: number; // Id.
fullname?: string; // Fullname.
urls?: {
profileimage?: string; // The URL for the use profile image.
};
groups?: { // Groups.
name: string; // Name.
}[];
};
discussionid: number; // Discussionid.
hasparent: boolean; // Hasparent.
parentid?: number; // Parentid.
timecreated: number | false; // Timecreated.
unread?: boolean; // Unread.
isprivatereply: boolean; // Isprivatereply.
capabilities: {
reply: boolean; // Whether the user can reply to the post.
};
attachment?: 0 | 1;
attachments?: (CoreFileEntry | AddonModForumWSPostAttachment)[];
tags?: { // Tags.
id: number; // Tag id.
name: string; // Tag name.
rawname: string; // The raw, unnormalised name for the tag as entered by users.
// isstandard: boolean; // Whether this tag is standard.
tagcollid?: number; // Tag collection id.
taginstanceid: number; // Tag instance id.
taginstancecontextid?: number; // Context the tag instance belongs to.
// itemid: number; // Id of the record tagged.
// ordering: number; // Tag ordering.
flag: number; // Whether the tag is flagged as inappropriate.
}[];
};
/**
* Legacy forum post data.
*/
@ -1427,112 +1500,6 @@ export type AddonModForumLegacyPost = {
}[];
};
/**
* Forum post data.
*/
export type AddonModForumPost = {
id: number; // Id.
subject: string; // Subject.
replysubject: string; // Replysubject.
message: string; // Message.
messageformat: number; // Message format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
author: {
id?: number; // Id.
fullname?: string; // Fullname.
isdeleted?: boolean; // Isdeleted.
groups?: { // Groups.
id: number; // Id.
name: string; // Name.
urls: {
image?: string; // Image.
};
}[];
urls: {
profile?: string; // The URL for the use profile page.
profileimage?: string; // The URL for the use profile image.
};
};
discussionid: number; // Discussionid.
hasparent: boolean; // Hasparent.
parentid?: number; // Parentid.
timecreated: number; // Timecreated.
unread?: boolean; // Unread.
isdeleted: boolean; // Isdeleted.
isprivatereply: boolean; // Isprivatereply.
haswordcount: boolean; // Haswordcount.
wordcount?: number; // Wordcount.
charcount?: number; // Charcount.
capabilities: {
view: boolean; // Whether the user can view the post.
edit: boolean; // Whether the user can edit the post.
delete: boolean; // Whether the user can delete the post.
split: boolean; // Whether the user can split the post.
reply: boolean; // Whether the user can reply to the post.
selfenrol: boolean; // Whether the user can self enrol into the course.
export: boolean; // Whether the user can export the post.
controlreadstatus: boolean; // Whether the user can control the read status of the post.
canreplyprivately: boolean; // Whether the user can post a private reply.
};
urls?: {
view?: string; // The URL used to view the post.
viewisolated?: string; // The URL used to view the post in isolation.
viewparent?: string; // The URL used to view the parent of the post.
edit?: string; // The URL used to edit the post.
delete?: string; // The URL used to delete the post.
// The URL used to split the discussion with the selected post being the first post in the new discussion.
split?: string;
reply?: string; // The URL used to reply to the post.
export?: string; // The URL used to export the post.
markasread?: string; // The URL used to mark the post as read.
markasunread?: string; // The URL used to mark the post as unread.
discuss?: string; // Discuss.
};
attachments: { // Attachments.
contextid: number; // Contextid.
component: string; // Component.
filearea: string; // Filearea.
itemid: number; // Itemid.
filepath: string; // Filepath.
filename: string; // Filename.
isdir: boolean; // Isdir.
isimage: boolean; // Isimage.
timemodified: number; // Timemodified.
timecreated: number; // Timecreated.
filesize: number; // Filesize.
author: string; // Author.
license: string; // License.
filenameshort: string; // Filenameshort.
filesizeformatted: string; // Filesizeformatted.
icon: string; // Icon.
timecreatedformatted: string; // Timecreatedformatted.
timemodifiedformatted: string; // Timemodifiedformatted.
url: string; // Url.
urls: {
export?: string; // The URL used to export the attachment.
};
html: {
plagiarism?: string; // The HTML source for the Plagiarism Response.
};
}[];
tags?: { // Tags.
id: number; // The ID of the Tag.
tagid: number; // The tagid.
isstandard: boolean; // Whether this is a standard tag.
displayname: string; // The display name of the tag.
flag: boolean; // Wehther this tag is flagged.
urls: {
view: string; // The URL to view the tag.
};
}[];
html?: {
rating?: string; // The HTML source to rate the post.
taglist?: string; // The HTML source to view the list of tags.
authorsubheading?: string; // The HTML source to view the author details.
};
};
/**
* Forum rating info.
*/
@ -1640,6 +1607,117 @@ export type AddonModForumSortOrder = {
value: number;
};
/**
* Forum post attachement data returned by web services.
*/
export type AddonModForumWSPostAttachment = {
contextid: number; // Contextid.
component: string; // Component.
filearea: string; // Filearea.
itemid: number; // Itemid.
filepath: string; // Filepath.
filename: string; // Filename.
isdir: boolean; // Isdir.
isimage: boolean; // Isimage.
timemodified: number; // Timemodified.
timecreated: number; // Timecreated.
filesize: number; // Filesize.
author: string; // Author.
license: string; // License.
filenameshort: string; // Filenameshort.
filesizeformatted: string; // Filesizeformatted.
icon: string; // Icon.
timecreatedformatted: string; // Timecreatedformatted.
timemodifiedformatted: string; // Timemodifiedformatted.
url: string; // Url.
urls: {
export?: string; // The URL used to export the attachment.
};
html: {
plagiarism?: string; // The HTML source for the Plagiarism Response.
};
};
/**
* Forum post data returned by web services.
*/
export type AddonModForumWSPost = {
id: number; // Id.
subject: string; // Subject.
replysubject: string; // Replysubject.
message: string; // Message.
messageformat: number; // Message format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
author: {
id?: number; // Id.
fullname?: string; // Fullname.
isdeleted?: boolean; // Isdeleted.
groups?: { // Groups.
id: number; // Id.
name: string; // Name.
urls: {
image?: string; // Image.
};
}[];
urls: {
profile?: string; // The URL for the use profile page.
profileimage?: string; // The URL for the use profile image.
};
};
discussionid: number; // Discussionid.
hasparent: boolean; // Hasparent.
parentid?: number; // Parentid.
timecreated: number; // Timecreated.
unread?: boolean; // Unread.
isdeleted: boolean; // Isdeleted.
isprivatereply: boolean; // Isprivatereply.
haswordcount: boolean; // Haswordcount.
wordcount?: number; // Wordcount.
charcount?: number; // Charcount.
capabilities: {
view: boolean; // Whether the user can view the post.
edit: boolean; // Whether the user can edit the post.
delete: boolean; // Whether the user can delete the post.
split: boolean; // Whether the user can split the post.
reply: boolean; // Whether the user can reply to the post.
selfenrol: boolean; // Whether the user can self enrol into the course.
export: boolean; // Whether the user can export the post.
controlreadstatus: boolean; // Whether the user can control the read status of the post.
canreplyprivately: boolean; // Whether the user can post a private reply.
};
urls?: {
view?: string; // The URL used to view the post.
viewisolated?: string; // The URL used to view the post in isolation.
viewparent?: string; // The URL used to view the parent of the post.
edit?: string; // The URL used to edit the post.
delete?: string; // The URL used to delete the post.
// The URL used to split the discussion with the selected post being the first post in the new discussion.
split?: string;
reply?: string; // The URL used to reply to the post.
export?: string; // The URL used to export the post.
markasread?: string; // The URL used to mark the post as read.
markasunread?: string; // The URL used to mark the post as unread.
discuss?: string; // Discuss.
};
attachments: AddonModForumWSPostAttachment[]; // Attachments.
tags?: { // Tags.
id: number; // The ID of the Tag.
tagid: number; // The tagid.
isstandard: boolean; // Whether this is a standard tag.
displayname: string; // The display name of the tag.
flag: boolean; // Wehther this tag is flagged.
urls: {
view: string; // The URL to view the tag.
};
}[];
html?: {
rating?: string; // The HTML source to rate the post.
taglist?: string; // The HTML source to view the list of tags.
authorsubheading?: string; // The HTML source to view the author details.
};
};
/**
* Params of mod_forum_get_forum_discussions WS.
*/
@ -1799,7 +1877,7 @@ export type AddonModForumAddDiscussionPostWSParams = {
export type AddonModForumAddDiscussionPostWSResponse = {
postid: number; // New post id.
warnings?: CoreWSExternalWarning[];
post: AddonModForumPost;
post: AddonModForumWSPost;
messages?: { // List of warnings.
type: string; // The classification to be used in the client side.
message: string; // Untranslated english message to explain the warning.
@ -1859,7 +1937,7 @@ export type AddonModForumGetDiscussionPostWSParams = {
* Data returned by mod_forum_get_discussion_post WS.
*/
export type AddonModForumGetDiscussionPostWSResponse = {
post: AddonModForumPost;
post: AddonModForumWSPost;
warnings?: CoreWSExternalWarning[];
};
@ -1877,7 +1955,7 @@ export type AddonModForumGetDiscussionPostsWSParams = {
* Data returned by mod_forum_get_discussion_posts WS.
*/
export type AddonModForumGetDiscussionPostsWSResponse = {
posts: AddonModForumPost[];
posts: AddonModForumWSPost[];
forumid: number; // The forum id.
courseid: number; // The forum course id.
ratinginfo?: AddonModForumRatingInfo; // Rating information.

View File

@ -21,8 +21,15 @@ import { CoreSites } from '@services/sites';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton, Translate } from '@singletons';
import { AddonModForum, AddonModForumData, AddonModForumProvider } from './forum.service';
import { AddonModForumOffline } from './offline.service';
import {
AddonModForum,
AddonModForumAddDiscussionWSOptionsObject,
AddonModForumData,
AddonModForumDiscussion,
AddonModForumPost,
AddonModForumProvider,
} from './forum.service';
import { AddonModForumDiscussionOptions, AddonModForumOffline, AddonModForumOfflineReply } from './offline.service';
/**
* Service that provides some features for forums.
@ -51,8 +58,8 @@ export class AddonModForumHelperProvider {
courseId: number,
subject: string,
message: string,
attachments?: any[],
options?: any,
attachments?: CoreFileEntry[],
options?: AddonModForumDiscussionOptions,
groupIds?: number[],
timeCreated?: number,
siteId?: string,
@ -62,14 +69,14 @@ export class AddonModForumHelperProvider {
let saveOffline = false;
const attachmentsIds: number[] = [];
let offlineAttachments: any;
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) {
if (offlineAttachments && options) {
options.attachmentsid = offlineAttachments;
}
@ -122,7 +129,7 @@ export class AddonModForumHelperProvider {
const promises = groupIds.map(async (groupId, index) => {
const groupOptions = CoreUtils.instance.clone(options);
if (attachmentsIds[index]) {
if (groupOptions && attachmentsIds[index]) {
groupOptions.attachmentsid = attachmentsIds[index];
}
@ -131,7 +138,7 @@ export class AddonModForumHelperProvider {
forumId,
subject,
message,
groupOptions,
groupOptions as unknown as AddonModForumAddDiscussionWSOptionsObject,
groupId,
siteId,
);
@ -169,8 +176,8 @@ export class AddonModForumHelperProvider {
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the object converted to Online.
*/
convertOfflineReplyToOnline(offlineReply: any, siteId?: string): Promise<any> {
const reply: any = {
convertOfflineReplyToOnline(offlineReply: AddonModForumOfflineReply, siteId?: string): Promise<AddonModForumPost> {
const reply: AddonModForumPost = {
id: -offlineReply.timecreated,
discussionid: offlineReply.discussionid,
parentid: offlineReply.postid,
@ -186,20 +193,25 @@ export class AddonModForumHelperProvider {
reply: false,
},
unread: false,
isprivatereply: offlineReply.options && offlineReply.options.private,
tags: null,
isprivatereply: !!offlineReply.options?.private,
};
const promises: Promise<void>[] = [];
// Treat attachments if any.
if (offlineReply.options && offlineReply.options.attachmentsid) {
reply.attachments = offlineReply.options.attachmentsid.online || [];
const attachments = offlineReply.options.attachmentsid;
if (offlineReply.options.attachmentsid.offline) {
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)),
.getReplyStoredFiles(offlineReply.forumid, reply.parentid!, siteId, offlineReply.userid)
.then(files => {
reply.attachments = reply.attachments!.concat(files as unknown as []);
return;
}),
);
}
}
@ -219,7 +231,7 @@ export class AddonModForumHelperProvider {
);
return Promise.all(promises).then(() => {
reply.attachment = reply.attachments.length > 0 ? 1 : 0;
reply.attachment = reply.attachments!.length > 0 ? 1 : 0;
return reply;
});
@ -293,10 +305,10 @@ export class AddonModForumHelperProvider {
* @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<any> {
getDiscussionById(forumId: number, cmId: number, discussionId: number, siteId?: string): Promise<AddonModForumDiscussion> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
const findDiscussion = async (page: number): Promise<any> => {
const findDiscussion = async (page: number): Promise<AddonModForumDiscussion> => {
const response = await AddonModForum.instance.getDiscussions(forumId, {
cmId,
page,
@ -330,7 +342,7 @@ export class AddonModForumHelperProvider {
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the files.
*/
async getNewDiscussionStoredFiles(forumId: number, timecreated: number, siteId?: string): Promise<any[]> {
async getNewDiscussionStoredFiles(forumId: number, timecreated: number, siteId?: string): Promise<FileEntry[]> {
const folderPath = await AddonModForumOffline.instance.getNewDiscussionFolder(forumId, timecreated, siteId);
return CoreFileUploader.instance.getStoredFiles(folderPath);
@ -345,7 +357,7 @@ export class AddonModForumHelperProvider {
* @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<any[]> {
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);
@ -380,10 +392,10 @@ export class AddonModForumHelperProvider {
*
* @param forum Forum instance.
*/
isCutoffDateReached(forum: any): boolean {
isCutoffDateReached(forum: AddonModForumData): boolean {
const now = Date.now() / 1000;
return forum.cutoffdate > 0 && forum.cutoffdate < now;
return !!forum.cutoffdate && forum.cutoffdate > 0 && forum.cutoffdate < now;
}
/**

View File

@ -13,6 +13,7 @@
// 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';
@ -401,8 +402,14 @@ export class AddonModForumOfflineProvider {
export class AddonModForumOffline extends makeSingleton(AddonModForumOfflineProvider) {}
export type AddonModForumDiscussionOptions = Record<string, unknown>;
export type AddonModForumReplyOptions = Record<string, unknown>;
export type AddonModForumDiscussionOptions = {
attachmentsid: number | CoreFileUploaderStoreFilesResult;
};
export type AddonModForumReplyOptions = {
private?: boolean;
attachmentsid?: number | CoreFileUploaderStoreFilesResult;
};
export type AddonModForumOfflineDiscussion = {
forumid: number;
@ -412,6 +419,7 @@ export type AddonModForumOfflineDiscussion = {
message: string;
options: AddonModForumDiscussionOptions;
groupid: number;
groupname?: string;
userid: number;
timecreated: number;
};

View File

@ -23,10 +23,15 @@ import { CoreSites } from '@services/sites';
import { CoreSync } from '@services/sync';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons';
import { makeSingleton, Translate } from '@singletons';
import { CoreArray } from '@singletons/array';
import { CoreEvents } from '@singletons/events';
import { AddonModForum, AddonModForumProvider } from './forum.service';
import {
AddonModForum,
AddonModForumAddDiscussionPostWSOptionsObject,
AddonModForumAddDiscussionWSOptionsObject,
AddonModForumProvider,
} from './forum.service';
import { AddonModForumHelper } from './helper.service';
import { AddonModForumOffline, AddonModForumOfflineDiscussion, AddonModForumOfflineReply } from './offline.service';
@ -72,7 +77,7 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider<AddonModForu
* @return Promise resolved if sync is successful, rejected if sync fails.
*/
protected async syncAllForumsFunc(force: boolean, siteId: string): Promise<void> {
const sitePromises: Promise<void>[] = [];
const sitePromises: Promise<unknown>[] = [];
// Sync all new discussions.
const syncDiscussions = async (discussions: AddonModForumOfflineDiscussion[]) => {
@ -239,13 +244,13 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider<AddonModForu
// Now try to add the discussion.
const options = CoreUtils.instance.clone(discussion.options || {});
options.attachmentsid = itemId;
options.attachmentsid = itemId!;
await AddonModForum.instance.addNewDiscussionOnline(
forumId,
discussion.subject,
discussion.message,
options,
options as unknown as AddonModForumAddDiscussionWSOptionsObject,
groupId,
siteId,
);
@ -309,8 +314,13 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider<AddonModForu
* @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<void> {
async syncRatings(cmId?: number, discussionId?: number, force?: boolean, siteId?: string): Promise<{
updated: boolean;
warnings: string[];
}> {
// @todo
return { updated: true, warnings: [] };
}
/**
@ -437,7 +447,7 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider<AddonModForu
reply.postid,
reply.subject,
reply.message,
reply.options,
reply.options as unknown as AddonModForumAddDiscussionPostWSOptionsObject,
siteId,
);
});
@ -526,18 +536,18 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider<AddonModForu
*
* @param forumId Forum ID the post belongs to.
* @param post Offline post or discussion.
* @param isDisc True if it's a new discussion, false if it's a reply.
* @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: any,
isDisc: boolean,
post: AddonModForumOfflineDiscussion | AddonModForumOfflineReply,
isDiscussion: boolean,
siteId?: string,
userId?: number,
): Promise<void> {
): Promise<number | undefined> {
const attachments = post && post.options && post.options.attachmentsid;
if (!attachments) {
@ -545,22 +555,31 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider<AddonModForu
}
// Has some attachments to sync.
let files = attachments.online || [];
let files = typeof attachments === 'object' && attachments.online ? attachments.online : [];
if (attachments.offline) {
if (typeof attachments === 'object' && attachments.offline) {
// Has offline files.
try {
const atts = isDisc
? await AddonModForumHelper.instance.getNewDiscussionStoredFiles(forumId, post.timecreated, siteId)
: await AddonModForumHelper.instance.getReplyStoredFiles(forumId, post.postid, siteId, userId);
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(atts);
files = files.concat(postAttachments as unknown as []);
} catch (error) {
// Folder not found, no files to add.
}
}
await CoreFileUploader.instance.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId);
return CoreFileUploader.instance.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId);
}
/**
@ -591,6 +610,8 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider<AddonModForu
}
export class AddonModForumSync extends makeSingleton(AddonModForumSyncProvider) {}
/**
* Result of forum sync.
*/

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

@ -184,6 +184,8 @@
--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);