MOBILE-3643 forum: Migrate index page

main
Noel De Martin 2021-02-16 11:18:12 +01:00
parent 549735bf98
commit b318b0e4a5
25 changed files with 4928 additions and 144 deletions

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,36 @@
// (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 { CoreSharedModule } from '@/core/shared.module';
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
import { CoreTagComponentsModule } from '@features/tag/components/components.module';
import { AddonModForumIndexComponent } from './index/index';
@NgModule({
declarations: [
AddonModForumIndexComponent,
],
imports: [
CoreSharedModule,
CoreCourseComponentsModule,
CoreTagComponentsModule,
],
exports: [
AddonModForumIndexComponent,
],
})
export class AddonModForumComponentsModule {}

View File

@ -0,0 +1,127 @@
<!-- Content. -->
<ion-content>
<core-split-view>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded" class="core-loading-center">
<core-course-module-description *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.length == 0 && offlineDiscussions.length == 0" icon="chatbubbles" [message]="'addon.mod_forum.forumnodiscussionsyet' | translate">
</core-empty-box>
<div *ngIf="discussions.length > 0 && 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($event)">
<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 offlineDiscussions"
class="ion-text-wrap addon-mod-forum-discussion" detail="true"
[attr.lines="none"]="discussion.groupname" [class.core-item-selected]="discussion.timecreated == -selectedDiscussion"
(click)="openNewDiscussion(discussion.timecreated)">
<ion-label>
<div class="addon-mod-forum-discussion-title">
<h2>
<core-format-text [text]="discussion.subject" contextLevel="module" [contextInstanceId]="module && module.id" [courseId]="courseId"></core-format-text>
</h2>
</div>
<div class="addon-mod-forum-discussion-info">
<core-user-avatar [user]="discussion" slot="start" [courseId]="courseId" *ngIf="discussion.userfullname">
</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><ion-icon name="time"></ion-icon> {{ 'core.notsent' | translate }}</p>
</div>
</div>
</ion-label>
</ion-item>
<ion-item *ngFor="let discussion of discussions"
class="addon-mod-forum-discussion" detail="true"
[class.core-split-item-selected]="discussion.discussion == selectedDiscussion"
(click)="openDiscussion(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="more" 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>{{discussion.created * 1000 | coreFormatDate: "strftimerecentfull"}}</p>
</div>
</div>
<ion-row 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>
</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>
</ion-content>

View File

@ -0,0 +1,65 @@
@import '../../../../../theme/globals.scss';
: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,563 @@
// (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 } from '@angular/core';
import { IonContent } from '@ionic/angular';
import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component';
import {
AddonModForum,
AddonModForumData,
AddonModForumProvider,
AddonModForumSortOrder,
AddonModForumDiscussion,
} from '@addons/mod/forum/services/forum.service';
import { AddonModForumOffline, AddonModForumOfflineDiscussion } from '@addons/mod/forum/services/offline.service';
import { Translate } from '@singletons';
import { CoreCourseContentsPage } from '@features/course/pages/contents/contents';
import { AddonModForumHelper } from '@addons/mod/forum/services/helper.service';
import { CoreGroups, CoreGroupsProvider } from '@services/groups';
import { CoreEvents } from '@singletons/events';
import { AddonModForumSyncProvider } from '@addons/mod/forum/services/sync.service';
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';
/**
* 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, OnDestroy {
component = AddonModForumProvider.COMPONENT;
moduleName = 'forum';
descriptionNote?: string;
forum?: AddonModForumData;
canLoadMore = false;
loadMoreError = false;
discussions: AddonModForumDiscussion[] = [];
offlineDiscussions: AddonModForumOfflineDiscussion[] = [];
selectedDiscussion = 0; // Disucssion ID or negative timecreated if it's an offline discussion.
canAddDiscussion = false;
addDiscussionText!: string;
availabilityMessage: string | null = null;
sortingAvailable!: boolean;
sortOrders: AddonModForumSortOrder[] = [];
selectedSortOrder: AddonModForumSortOrder | null = null;
sortOrderSelectorExpanded = false;
protected syncEventName = AddonModForumSyncProvider.AUTO_SYNCED;
protected page = 0;
protected trackPosts = false;
protected usesGroups = false;
protected canPin = false;
protected syncManualObserver: any; // It will observe the sync manual event.
protected replyObserver: any;
protected newDiscObserver: any;
protected viewDiscObserver: any;
protected changeDiscObserver: any;
hasOfflineRatings?: boolean;
protected ratingOfflineObserver: any;
protected ratingSyncObserver: any;
constructor(
@Optional() protected content?: IonContent,
@Optional() courseContentsPage?: CoreCourseContentsPage,
) {
super('AddonModForumIndexComponent', content, courseContentsPage);
}
/**
* 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();
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;
}),
);
}
/**
* 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): Promise<void> {
this.loadMoreError = false;
const promises: Promise<void>[] = [];
promises.push(this.fetchForum());
promises.push(this.fetchSortOrderPreference());
try {
await Promise.all(promises);
await Promise.all([
this.fetchOfflineDiscussions(),
this.fetchDiscussions(refresh),
]);
} catch (error) {
if (refresh) {
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true);
this.loadMoreError = 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(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> {
if (!this.courseId || !this.module) {
return;
}
this.loadMoreError = false;
const promises: Promise<void>[] = [];
promises.push(
AddonModForum.instance
.getForum(this.courseId, this.module.id)
.then(async (forum) => {
this.forum = forum;
this.description = forum.intro || this.description;
this.descriptionNote = Translate.instant('addon.mod_forum.numdiscussions', {
numdiscussions: forum.numdiscussions,
});
if (typeof forum.istracked != 'undefined') {
this.trackPosts = forum.istracked;
}
this.availabilityMessage = AddonModForumHelper.instance.getAvailabilityMessage(forum);
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) {
return;
}
// Try to synchronize the forum.
const updated = await this.syncActivity(showErrors);
if (!updated) {
return;
}
// 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(
// eslint-disable-next-line promise/no-nesting
CoreGroups.instance
.getActivityGroupMode(this.forum.cmid)
.then(async mode => {
this.usesGroups = mode === CoreGroupsProvider.SEPARATEGROUPS
|| mode === CoreGroupsProvider.VISIBLEGROUPS;
return;
}),
);
promises.push(
// eslint-disable-next-line promise/no-nesting
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(
// eslint-disable-next-line promise/no-nesting
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);
return;
}),
);
promises.push(this.fetchSortOrderPreference());
try {
await Promise.all(promises);
await Promise.all([
this.fetchOfflineDiscussions(),
this.fetchDiscussions(refresh),
]);
} catch (message) {
if (!refresh) {
// Get forum failed, retry without using cache since it might be a new activity.
return this.refreshContent(sync);
}
CoreDomUtils.instance.showErrorModalDefault(message, 'addon.mod_forum.errorgetforum', true);
this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading.
}
this.fillContextMenu(refresh);
}
/**
* 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.offlineDiscussions = [];
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.offlineDiscussions = 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.loadMoreError = 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 as any).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;
}
}
}
this.discussions = this.page === 0
? discussions
: this.discussions.concat(discussions);
this.canLoadMore = 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: any) => {
// 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.
*/
fetchMoreDiscussions(infiniteComplete?: any): Promise<any> {
return this.fetchDiscussions(false).catch((message) => {
CoreDomUtils.instance.showErrorModalDefault(message, 'addon.mod_forum.errorgetforum', true);
this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading.
}).finally(() => {
infiniteComplete && infiniteComplete();
});
}
/**
* 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 invalidateContent(): Promise<any> {
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));
}
return Promise.all(promises);
}
/**
* 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: any): boolean {
return result.updated;
}
/**
* Opens a discussion.
*
* @param discussion Discussion object.
*/
openDiscussion(discussion: AddonModForumDiscussion): void {
alert(`Open discussion ${discussion.id}: Not implemented!`);
// @todo
// const params = {
// courseId: this.courseId,
// cmId: this.module.id,
// forumId: this.forum.id,
// discussion: discussion,
// trackPosts: this.trackPosts,
// };
// this.splitviewCtrl.push('AddonModForumDiscussionPage', params);
}
/**
* Opens the new discussion form.
*
* @param timeCreated Creation time of the offline discussion.
*/
openNewDiscussion(timeCreated: number = 0): void {
alert(`Open new discussion at ${timeCreated} not implemented!`);
// @todo
// const params = {
// courseId: this.courseId,
// cmId: this.module.id,
// forumId: this.forum.id,
// timeCreated: timeCreated,
// };
// this.splitviewCtrl.push('AddonModForumNewDiscussionPage', params);
this.selectedDiscussion = 0;
}
/**
* Display the sort order selector modal.
*
* @param event Event.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
showSortOrderSelector(event: MouseEvent): void {
if (!this.sortingAvailable) {
return;
}
alert('Show sort order selector not implemented');
// @todo
// const params = { sortOrders: this.sortOrders, selected: this.selectedSortOrder.value };
// const modal = this.modalCtrl.create('AddonModForumSortOrderSelectorPage', params);
// modal.onDidDismiss((sortOrder) => {
// this.sortOrderSelectorExpanded = false;
// if (sortOrder && sortOrder.value != this.selectedSortOrder.value) {
// this.selectedSortOrder = sortOrder;
// this.page = 0;
// this.userProvider.setUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER, sortOrder.value.toFixed(0))
// .then(() => {
// this.showLoadingAndFetch();
// }).catch((error) => {
// this.domUtils.showErrorModalDefault(error, 'Error updating preference.');
// });
// }
// });
// modal.present({ ev: event });
// this.sortOrderSelectorExpanded = true;
}
}

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 { CoreSharedModule } from '@/core/shared.module';
import { AddonModForumComponentsModule } from './components/components.module';
import { AddonModForumIndexPage } from './pages/index';
const routes: Routes = [
{
path: ':courseId/:cmId',
component: AddonModForumIndexPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CoreSharedModule,
AddonModForumComponentsModule,
],
declarations: [
AddonModForumIndexPage,
],
})
export class AddonModForumLazyModule {}

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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { Routes } from '@angular/router';
import { CORE_SITE_SCHEMAS } from '@services/sites';
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module';
import { AddonModForumComponentsModule } from './components/components.module';
import { AddonModForumModuleHandler, AddonModForumModuleHandlerService } from './services/handlers/module';
import { SITE_SCHEMA } from './services/offline-db';
const routes: Routes = [
{
path: AddonModForumModuleHandlerService.PAGE_NAME,
loadChildren: () => import('./forum-lazy.module').then(m => m.AddonModForumLazyModule),
},
];
@NgModule({
imports: [
CoreMainMenuTabRoutingModule.forChild(routes),
AddonModForumComponentsModule,
],
providers: [
{
provide: CORE_SITE_SCHEMAS,
useValue: [SITE_SCHEMA],
multi: true,
},
{
provide: APP_INITIALIZER,
multi: true,
useValue: () => CoreCourseModuleDelegate.instance.registerHandler(AddonModForumModuleHandler.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,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.service';
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,132 +0,0 @@
// (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 { CoreSite, CoreSiteWSPreSets } from '@classes/site';
import { CoreSitesCommonWSOptions, CoreSites } from '@services/sites';
import { CoreWSExternalFile } from '@services/ws';
import { makeSingleton } from '@singletons';
const ROOT_CACHE_KEY = 'mmaModForum:';
/**
* Service that provides some features for forums.
*
* @todo Add all content.
*/
@Injectable({ providedIn: 'root' })
export class AddonModForumProvider {
static readonly COMPONENT = 'mmaModForum';
/**
* Get cache key for forum data WS calls.
*
* @param courseId Course ID.
* @return Cache key.
*/
protected getForumDataCacheKey(courseId: number): string {
return ROOT_CACHE_KEY + 'forum:' + courseId;
}
/**
* Get all course forums.
*
* @param courseId Course ID.
* @param options Other options.
* @return Promise resolved when the forums are retrieved.
*/
async getCourseForums(courseId: number, options: CoreSitesCommonWSOptions = {}): Promise<AddonModForumData[]> {
const site = await CoreSites.instance.getSite(options.siteId);
const params: AddonModForumGetForumsByCoursesWSParams = {
courseids: [courseId],
};
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getForumDataCacheKey(courseId),
updateFrequency: CoreSite.FREQUENCY_RARELY,
component: AddonModForumProvider.COMPONENT,
...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy),
};
return site.read('mod_forum_get_forums_by_courses', params, preSets);
}
/**
* Invalidates forum data.
*
* @param courseId Course ID.
* @return Promise resolved when the data is invalidated.
*/
async invalidateForumData(courseId: number): Promise<void> {
await CoreSites.instance.getCurrentSite()?.invalidateWsCacheForKey(this.getForumDataCacheKey(courseId));
}
}
export class AddonModForum extends makeSingleton(AddonModForumProvider) {}
/**
* Params of mod_forum_get_forums_by_courses WS.
*/
type AddonModForumGetForumsByCoursesWSParams = {
courseids?: number[]; // Array of Course IDs.
};
/**
* General forum activity data.
*/
export type AddonModForumData = {
id: number; // Forum id.
course: number; // Course id.
type: string; // The forum type.
name: string; // Forum name.
intro: string; // The forum intro.
introformat: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
introfiles?: CoreWSExternalFile[];
duedate?: number; // Duedate for the user.
cutoffdate?: number; // Cutoffdate for the user.
assessed: number; // Aggregate type.
assesstimestart: number; // Assess start time.
assesstimefinish: number; // Assess finish time.
scale: number; // Scale.
// eslint-disable-next-line @typescript-eslint/naming-convention
grade_forum: number; // Whole forum grade.
// eslint-disable-next-line @typescript-eslint/naming-convention
grade_forum_notify: number; // Whether to send notifications to students upon grading by default.
maxbytes: number; // Maximum attachment size.
maxattachments: number; // Maximum number of attachments.
forcesubscribe: number; // Force users to subscribe.
trackingtype: number; // Subscription mode.
rsstype: number; // RSS feed for this activity.
rssarticles: number; // Number of RSS recent articles.
timemodified: number; // Time modified.
warnafter: number; // Post threshold for warning.
blockafter: number; // Post threshold for blocking.
blockperiod: number; // Time period for blocking.
completiondiscussions: number; // Student must create discussions.
completionreplies: number; // Student must post replies.
completionposts: number; // Student must post discussions or replies.
cmid: number; // Course module id.
numdiscussions?: number; // Number of discussions in the forum.
cancreatediscussions?: boolean; // If the user can create discussions.
lockdiscussionafter?: number; // After what period a discussion is locked.
istracked?: boolean; // If the user is tracking the forum.
unreadpostscount?: number; // The number of unread posts for tracked forums.
};
/**
* Data returned by mod_forum_get_forums_by_courses WS.
*/
export type AddonModForumGetForumsByCoursesWSResponse = AddonModForumData[];

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.service';
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, 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: { courseId?: number; moduleId?: number; siteId?: string }) => {
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,507 @@
// (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 { 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, AddonModForumData, AddonModForumProvider } from './forum.service';
import { AddonModForumOffline } from './offline.service';
/**
* 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?: any[],
options?: any,
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: any;
// 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.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 (attachmentsIds[index]) {
groupOptions.attachmentsid = attachmentsIds[index];
}
try {
const discussionId = await AddonModForum.instance.addNewDiscussionOnline(
forumId,
subject,
message,
groupOptions,
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: any, siteId?: string): Promise<any> {
const reply: any = {
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 && offlineReply.options.private,
tags: null,
};
const promises: Promise<void>[] = [];
// Treat attachments if any.
if (offlineReply.options && offlineReply.options.attachmentsid) {
reply.attachments = offlineReply.options.attachmentsid.online || [];
if (offlineReply.options.attachmentsid.offline) {
promises.push(
this
.getReplyStoredFiles(offlineReply.forumid, reply.parentid, siteId, offlineReply.userid)
.then(files => reply.attachments = reply.attachments.concat(files)),
);
}
}
// 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<any> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
const findDiscussion = async (page: number): Promise<any> => {
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<any[]> {
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<any[]> {
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: any): boolean {
const now = Date.now() / 1000;
return 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,115 @@
// (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';
/**
* 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'],
},
],
};

View File

@ -0,0 +1,436 @@
// (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 { CoreFile } from '@services/file';
import { CoreSites } from '@services/sites';
import { CoreTextUtils } from '@services/utils/text';
import { makeSingleton } from '@singletons';
import { AddonModForumProvider } from './forum.service';
import { DISCUSSIONS_TABLE, REPLIES_TABLE } from './offline-db';
/**
* 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<AddonModForumOfflineDiscussionRecord>(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<AddonModForumOfflineDiscussionRecord>(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<AddonModForumOfflineDiscussionRecord>(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: AddonModForumOfflineDiscussionRecord = {
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<AddonModForumOfflineReplyRecord>(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<AddonModForumOfflineReplyRecord>(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<AddonModForumOfflineReplyRecord>(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: AddonModForumOfflineReplyRecord = {
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 = Record<string, unknown>;
export type AddonModForumReplyOptions = Record<string, unknown>;
export type AddonModForumOfflineDiscussion = {
forumid: number;
name: string;
courseid: number;
subject: string;
message: string;
options: AddonModForumDiscussionOptions;
groupid: number;
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;
};
export type AddonModForumOfflineDiscussionRecord = Omit<AddonModForumOfflineDiscussion, 'options'> & {
options: string;
};
export type AddonModForumOfflineReplyRecord = Omit<AddonModForumOfflineReply, 'options'> & {
options: string;
};

View File

@ -0,0 +1,600 @@
// (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 { Translate } from '@singletons';
import { CoreArray } from '@singletons/array';
import { CoreEvents } from '@singletons/events';
import { AddonModForum, AddonModForumProvider } from './forum.service';
import { AddonModForumHelper } from './helper.service';
import { AddonModForumOffline, AddonModForumOfflineDiscussion, AddonModForumOfflineReply } from './offline.service';
/**
* 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<void>[] = [];
// 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,
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<void> {
// @todo
}
/**
* 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,
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 isDisc 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,
siteId?: string,
userId?: number,
): Promise<void> {
const attachments = post && post.options && post.options.attachmentsid;
if (!attachments) {
return;
}
// Has some attachments to sync.
let files = attachments.online || [];
if (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);
files = files.concat(atts);
} catch (error) {
// Folder not found, no files to add.
}
}
await 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;
}
}
/**
* Result of forum sync.
*/
export type AddonModForumSyncResult = {
updated: boolean;
warnings: string[];
};

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

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

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

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.service';
/**
* Items with index 1 and 3 were removed on 2.5 and not being supported in the app.

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

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

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