From ed22889840b662310ce44edc2c6ff71ea1e9418b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Tue, 16 Feb 2021 13:47:07 +0100 Subject: [PATCH] MOBILE-3658 comments: Comments module --- .../block/comments/services/block-handler.ts | 8 +- .../features/comments/comments-lazy.module.ts | 28 + src/core/features/comments/comments.module.ts | 54 ++ .../comments/components/add/add-modal.ts | 94 +++ .../features/comments/components/add/add.html | 29 + .../components/comments/comments.scss | 4 + .../comments/components/comments/comments.ts | 220 ++++++ .../components/comments/core-comments.html | 8 + .../comments/components/components.module.ts | 36 + src/core/features/comments/lang.json | 12 + .../comments/pages/viewer/viewer.html | 108 +++ .../comments/pages/viewer/viewer.module.ts | 38 ++ .../comments/pages/viewer/viewer.page.ts | 527 +++++++++++++++ .../comments/services/comments-offline.ts | 304 +++++++++ .../comments/services/comments-sync.ts | 336 ++++++++++ .../features/comments/services/comments.ts | 625 ++++++++++++++++++ .../comments/services/database/comments.ts | 115 ++++ .../comments/services/handlers/sync-cron.ts | 49 ++ src/core/features/features.module.ts | 2 + src/core/features/login/pages/site/site.html | 2 +- .../features/login/pages/sites/sites.html | 2 +- src/core/services/utils/dom.ts | 2 +- 22 files changed, 2593 insertions(+), 10 deletions(-) create mode 100644 src/core/features/comments/comments-lazy.module.ts create mode 100644 src/core/features/comments/comments.module.ts create mode 100644 src/core/features/comments/components/add/add-modal.ts create mode 100644 src/core/features/comments/components/add/add.html create mode 100644 src/core/features/comments/components/comments/comments.scss create mode 100644 src/core/features/comments/components/comments/comments.ts create mode 100644 src/core/features/comments/components/comments/core-comments.html create mode 100644 src/core/features/comments/components/components.module.ts create mode 100644 src/core/features/comments/lang.json create mode 100644 src/core/features/comments/pages/viewer/viewer.html create mode 100644 src/core/features/comments/pages/viewer/viewer.module.ts create mode 100644 src/core/features/comments/pages/viewer/viewer.page.ts create mode 100644 src/core/features/comments/services/comments-offline.ts create mode 100644 src/core/features/comments/services/comments-sync.ts create mode 100644 src/core/features/comments/services/comments.ts create mode 100644 src/core/features/comments/services/database/comments.ts create mode 100644 src/core/features/comments/services/handlers/sync-cron.ts diff --git a/src/addons/block/comments/services/block-handler.ts b/src/addons/block/comments/services/block-handler.ts index 0a72b3360..eb99fe5f9 100644 --- a/src/addons/block/comments/services/block-handler.ts +++ b/src/addons/block/comments/services/block-handler.ts @@ -37,19 +37,13 @@ export class AddonBlockCommentsHandlerService extends CoreBlockBaseHandler { * @return Data or promise resolved with the data. */ getDisplayData(block: CoreCourseBlock, contextLevel: string, instanceId: number): CoreBlockHandlerData { - // @todo - return { title: 'addon.block_comments.pluginname', class: 'addon-block-comments', component: CoreBlockOnlyTitleComponent, - link: 'CoreCommentsViewerPage', + link: 'comments/' + contextLevel + '/' + instanceId + '/block_comments/0', linkParams: { - contextLevel: contextLevel, - instanceId: instanceId, - componentName: 'block_comments', area: 'page_comments', - itemId: 0, }, }; } diff --git a/src/core/features/comments/comments-lazy.module.ts b/src/core/features/comments/comments-lazy.module.ts new file mode 100644 index 000000000..252225814 --- /dev/null +++ b/src/core/features/comments/comments-lazy.module.ts @@ -0,0 +1,28 @@ +// (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'; + +const routes: Routes = [ + { + path: ':contextLevel/:instanceId/:componentName/:itemId', + loadChildren: () => import('./pages/viewer/viewer.module').then( m => m.CoreCommentsViewerPageModule), + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], +}) +export class CoreCommentsLazyModule {} diff --git a/src/core/features/comments/comments.module.ts b/src/core/features/comments/comments.module.ts new file mode 100644 index 000000000..23ed1bbfc --- /dev/null +++ b/src/core/features/comments/comments.module.ts @@ -0,0 +1,54 @@ +// (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 { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreCronDelegate } from '@services/cron'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { CoreCommentsComponentsModule } from './components/components.module'; +import { CoreComments } from './services/comments'; +import { COMMENTS_OFFLINE_SITE_SCHEMA } from './services/database/comments'; +import { CoreCommentsSyncCronHandler } from './services/handlers/sync-cron'; + +const routes: Routes = [ + { + path: 'comments', + loadChildren: () => import('@features/comments/comments-lazy.module').then(m => m.CoreCommentsLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreCommentsComponentsModule, + CoreMainMenuTabRoutingModule.forChild(routes), + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [COMMENTS_OFFLINE_SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + useValue: () => { + CoreCronDelegate.instance.register(CoreCommentsSyncCronHandler.instance); + + CoreComments.instance.initialize(); + }, + }, + ], +}) +export class CoreCommentsModule {} diff --git a/src/core/features/comments/components/add/add-modal.ts b/src/core/features/comments/components/add/add-modal.ts new file mode 100644 index 000000000..e4fbcba09 --- /dev/null +++ b/src/core/features/comments/components/add/add-modal.ts @@ -0,0 +1,94 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, ViewChild, ElementRef, Input } from '@angular/core'; +import { CoreComments } from '@features/comments/services/comments'; +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { ModalController } from '@singletons'; + +/** + * Component that displays a text area for composing a comment. + */ +@Component({ + selector: 'core-comments-add', + templateUrl: 'add.html', +}) +export class CoreCommentsAddComponent { + + @ViewChild('commentForm') formElement?: ElementRef; + + @Input() protected contextLevel!: string; + @Input() protected instanceId!: number; + @Input() protected componentName!: string; + @Input() protected itemId!: number; + @Input() protected area = ''; + @Input() content = ''; + + processing = false; + + /** + * Send the comment or store it offline. + * + * @param e Event. + */ + async addComment(e: Event): Promise { + e.preventDefault(); + e.stopPropagation(); + + CoreApp.instance.closeKeyboard(); + const loadingModal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + // Freeze the add comment button. + this.processing = true; + try { + const commentsResponse = await CoreComments.instance.addComment( + this.content, + this.contextLevel, + this.instanceId, + this.componentName, + this.itemId, + this.area, + ); + + CoreDomUtils.instance.triggerFormSubmittedEvent( + this.formElement, + !!commentsResponse, + CoreSites.instance.getCurrentSiteId(), + ); + + ModalController.instance.dismiss({ comment: commentsResponse }).finally(() => { + CoreDomUtils.instance.showToast( + commentsResponse ? 'core.comments.eventcommentcreated' : 'core.datastoredoffline', + true, + 3000, + ); + }); + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + this.processing = false; + } finally { + loadingModal.dismiss(); + } + } + + /** + * Close modal. + */ + closeModal(): void { + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId()); + ModalController.instance.dismiss(); + } + +} diff --git a/src/core/features/comments/components/add/add.html b/src/core/features/comments/components/add/add.html new file mode 100644 index 000000000..1c7c3d011 --- /dev/null +++ b/src/core/features/comments/components/add/add.html @@ -0,0 +1,29 @@ + + + + + + {{ 'core.comments.addcomment' | translate }} + + + + + + + + +
+ + + + + + +
+ + {{ 'core.comments.savecomment' | translate }} + +
+
+
diff --git a/src/core/features/comments/components/comments/comments.scss b/src/core/features/comments/components/comments/comments.scss new file mode 100644 index 000000000..e78db5e81 --- /dev/null +++ b/src/core/features/comments/components/comments/comments.scss @@ -0,0 +1,4 @@ +.core-comments-clickable { + pointer-events: auto; + cursor: pointer; +} diff --git a/src/core/features/comments/components/comments/comments.ts b/src/core/features/comments/components/comments/comments.ts new file mode 100644 index 000000000..d18cc34b1 --- /dev/null +++ b/src/core/features/comments/components/comments/comments.ts @@ -0,0 +1,220 @@ +// (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, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChange, OnInit } from '@angular/core'; +import { + CoreComments, + CoreCommentsCountChangedEventData, + CoreCommentsProvider, + CoreCommentsRefreshCommentsEventData, +} from '../../services/comments'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { CoreNavigator } from '@services/navigator'; +import { CoreUtils } from '@services/utils/utils'; +import { ContextLevel } from '@/core/constants'; + +/** + * Component that displays the count of comments. + */ +@Component({ + selector: 'core-comments', + templateUrl: 'core-comments.html', + styleUrls: ['comments.scss'], +}) +export class CoreCommentsCommentsComponent implements OnInit, OnChanges, OnDestroy { + + @Input() contextLevel!: ContextLevel; + @Input() instanceId!: number; + @Input() component!: string; + @Input() itemId!: number; + @Input() area = ''; + @Input() title?: string; + @Input() displaySpinner = true; // Whether to display the loading spinner. + @Output() onLoading: EventEmitter; // Eevent that indicates whether the component is loading data. + @Input() courseId?: number; // Course ID the comments belong to. It can be used to improve performance with filters. + + commentsLoaded = false; + commentsCount = ''; + countError = false; + disabled = false; + + protected updateSiteObserver?: CoreEventObserver; + protected refreshCommentsObserver?: CoreEventObserver; + protected commentsCountObserver?: CoreEventObserver; + + constructor() { + + this.onLoading = new EventEmitter(); + + this.disabled = CoreComments.instance.areCommentsDisabledInSite(); + + // Update visibility if current site info is updated. + this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { + const wasDisabled = this.disabled; + + this.disabled = CoreComments.instance.areCommentsDisabledInSite(); + + if (wasDisabled && !this.disabled) { + this.fetchData(); + } + }, CoreSites.instance.getCurrentSiteId()); + + // Refresh comments if event received. + this.refreshCommentsObserver = CoreEvents.on( + CoreCommentsProvider.REFRESH_COMMENTS_EVENT, + (data) => { + // Verify these comments need to be updated. + if (this.undefinedOrEqual(data, 'contextLevel') && this.undefinedOrEqual(data, 'instanceId') && + this.undefinedOrEqual(data, 'component') && this.undefinedOrEqual(data, 'itemId') && + this.undefinedOrEqual(data, 'area')) { + + CoreUtils.instance.ignoreErrors(this.doRefresh()); + } + }, + CoreSites.instance.getCurrentSiteId(), + ); + + // Refresh comments count if event received. + this.commentsCountObserver = CoreEvents.on( + CoreCommentsProvider.COMMENTS_COUNT_CHANGED_EVENT, + (data) => { + // Verify these comments need to be updated. + if (!this.commentsCount.endsWith('+') && this.undefinedOrEqual(data, 'contextLevel') && + this.undefinedOrEqual(data, 'instanceId') && this.undefinedOrEqual(data, 'component') && + this.undefinedOrEqual(data, 'itemId') && this.undefinedOrEqual(data, 'area') && !this.countError) { + let newNumber = parseInt(this.commentsCount, 10) + data.countChange; + newNumber = newNumber >= 0 ? newNumber : 0; + + // Parse and unparse string. + this.commentsCount = newNumber + ''; + } + }, + CoreSites.instance.getCurrentSiteId(), + ); + } + + /** + * View loaded. + */ + ngOnInit(): void { + this.fetchData(); + } + + /** + * Listen to changes. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + // If something change, update the fields. + if (changes && this.commentsLoaded) { + this.fetchData(); + } + } + + /** + * Fetch comments data. + */ + async fetchData(): Promise { + if (this.disabled) { + return; + } + + this.commentsLoaded = false; + this.onLoading.emit(true); + + const commentsCount = await CoreComments.instance.getCommentsCount( + this.contextLevel, + this.instanceId, + this.component, + this.itemId, + this.area, + ); + this.commentsCount = commentsCount; + this.countError = parseInt(this.commentsCount, 10) < 0; + this.commentsLoaded = true; + this.onLoading.emit(false); + } + + /** + * Refresh comments. + * + * @return Promise resolved when done. + */ + async doRefresh(): Promise { + await this.invalidateComments(); + + await this.fetchData(); + } + + /** + * Invalidate comments data. + * + * @return Promise resolved when done. + */ + async invalidateComments(): Promise { + await CoreComments.instance.invalidateCommentsData( + this.contextLevel, + this.instanceId, + this.component, + this.itemId, + this.area, + ); + } + + /** + * Opens the comments page. + */ + openComments(e?: Event): void { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + if (this.disabled || this.countError) { + return; + } + + CoreNavigator.instance.navigateToSitePath( + 'comments/' + this.contextLevel + '/' + this.instanceId + '/' + this.component + '/' + this.itemId + '/', + { + params: { + area: this.area, + title: this.title, + courseId: this.courseId, + }, + }, + ); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.updateSiteObserver?.off(); + this.refreshCommentsObserver?.off(); + this.commentsCountObserver?.off(); + } + + /** + * Check if a certain value in data is undefined or equal to this instance value. + * + * @param data Data object. + * @param name Name of the property to check. + * @return Whether it's undefined or equal. + */ + protected undefinedOrEqual(data: Record, name: string): boolean { + return typeof data[name] == 'undefined' || data[name] == this[name]; + } + +} diff --git a/src/core/features/comments/components/comments/core-comments.html b/src/core/features/comments/components/comments/core-comments.html new file mode 100644 index 000000000..b5ac94cb1 --- /dev/null +++ b/src/core/features/comments/components/comments/core-comments.html @@ -0,0 +1,8 @@ + +
+ {{ 'core.comments.commentscount' | translate : {'$a': commentsCount} }} +
+
+ {{ 'core.comments.commentsnotworking' | translate }} +
+
diff --git a/src/core/features/comments/components/components.module.ts b/src/core/features/comments/components/components.module.ts new file mode 100644 index 000000000..cf6d3e093 --- /dev/null +++ b/src/core/features/comments/components/components.module.ts @@ -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 { CoreSharedModule } from '@/core/shared.module'; +import { NgModule } from '@angular/core'; +import { CoreCommentsAddComponent } from './add/add-modal'; +import { CoreCommentsCommentsComponent } from './comments/comments'; + +@NgModule({ + declarations: [ + CoreCommentsCommentsComponent, + CoreCommentsAddComponent, + ], + imports: [ + CoreSharedModule, + ], + exports: [ + CoreCommentsCommentsComponent, + CoreCommentsAddComponent, + ], + entryComponents: [ + CoreCommentsCommentsComponent, + ], +}) +export class CoreCommentsComponentsModule {} diff --git a/src/core/features/comments/lang.json b/src/core/features/comments/lang.json new file mode 100644 index 000000000..c48dcce17 --- /dev/null +++ b/src/core/features/comments/lang.json @@ -0,0 +1,12 @@ +{ + "addcomment": "Add a comment...", + "comments": "Comments", + "commentscount": "Comments ({{$a}})", + "commentsnotworking": "Comments cannot be retrieved", + "deletecommentbyon": "Delete comment posted by {{$a.user}} on {{$a.time}}", + "eventcommentcreated": "Comment created", + "eventcommentdeleted": "Comment deleted", + "nocomments": "No comments", + "savecomment": "Save comment", + "warningcommentsnotsent": "Couldn't sync comments. {{error}}" +} \ No newline at end of file diff --git a/src/core/features/comments/pages/viewer/viewer.html b/src/core/features/comments/pages/viewer/viewer.html new file mode 100644 index 000000000..0ab6da568 --- /dev/null +++ b/src/core/features/comments/pages/viewer/viewer.html @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.thereisdatatosync' | translate:{$a: 'core.comments.comments' | translate | lowercase } }} + + + + + + + + +

{{ offlineComment.fullname }}

+

+ {{ 'core.notsent' | translate }} +

+
+ + + +
+ + + + + + +
+ + + + + +

{{ comment.fullname }}

+

{{ comment.timecreated * 1000 | coreFormatDate: 'strftimerecentfull' }}

+

+ + {{ 'core.deletedoffline' | translate }} + +

+
+ + + + + + +
+ + + + + + +
+ + +
+ + + + + + +
diff --git a/src/core/features/comments/pages/viewer/viewer.module.ts b/src/core/features/comments/pages/viewer/viewer.module.ts new file mode 100644 index 000000000..f4da73230 --- /dev/null +++ b/src/core/features/comments/pages/viewer/viewer.module.ts @@ -0,0 +1,38 @@ +// (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 { CoreCommentsViewerPage } from './viewer.page'; + +const routes: Routes = [ + { + path: '', + component: CoreCommentsViewerPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + ], + declarations: [ + CoreCommentsViewerPage, + ], + exports: [RouterModule], +}) +export class CoreCommentsViewerPageModule {} diff --git a/src/core/features/comments/pages/viewer/viewer.page.ts b/src/core/features/comments/pages/viewer/viewer.page.ts new file mode 100644 index 000000000..d3d089270 --- /dev/null +++ b/src/core/features/comments/pages/viewer/viewer.page.ts @@ -0,0 +1,527 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreAnimations } from '@components/animations'; +import { ActivatedRoute, Params } from '@angular/router'; +import { CoreSites } from '@services/sites'; +import { + CoreComments, + CoreCommentsCommentBasicData, + CoreCommentsCountChangedEventData, + CoreCommentsData, + CoreCommentsProvider, +} from '@features/comments/services/comments'; +import { + CoreCommentsSync, + CoreCommentsSyncAutoSyncData, + CoreCommentsSyncProvider, +} from '@features/comments/services/comments-sync'; +import { IonContent, IonRefresher } from '@ionic/angular'; +import { ContextLevel, CoreConstants } from '@/core/constants'; +import { CoreNavigator } from '@services/navigator'; +import { ModalController, Translate } from '@singletons'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUser, CoreUserProfile } from '@features/user/services/user'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreError } from '@classes/errors/error'; +import { CoreCommentsOffline } from '@features/comments/services/comments-offline'; +import { CoreCommentsDBRecord } from '@features/comments/services/database/comments'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreCommentsAddComponent } from '@features/comments/components/add/add-modal'; + +/** + * Page that displays comments. + */ +@Component({ + selector: 'page-core-comments-viewer', + templateUrl: 'viewer.html', + animations: [CoreAnimations.SLIDE_IN_OUT], +}) +export class CoreCommentsViewerPage implements OnInit, OnDestroy { + + @ViewChild(IonContent) content?: IonContent; + + comments: CoreCommentsDataWithUser[] = []; + commentsLoaded = false; + contextLevel!: ContextLevel; + instanceId!: number; + componentName!: string; + itemId = 0; + area = ''; + page = 0; + title = ''; + courseId?: number; + canLoadMore = false; + loadMoreError = false; + canAddComments = false; + canDeleteComments = false; + showDelete = false; + hasOffline = false; + refreshIcon = CoreConstants.ICON_LOADING; + syncIcon = CoreConstants.ICON_LOADING; + offlineComment?: CoreCommentsOfflineWithUser; + currentUserId: number; + + protected addDeleteCommentsAvailable = false; + protected syncObserver?: CoreEventObserver; + protected currentUser?: CoreUserProfile; + + constructor( + protected route: ActivatedRoute, + ) { + this.currentUserId = CoreSites.instance.getCurrentSiteUserId(); + + // Refresh data if comments are synchronized automatically. + this.syncObserver = CoreEvents.on(CoreCommentsSyncProvider.AUTO_SYNCED, (data) => { + if (data.contextLevel == this.contextLevel && data.instanceId == this.instanceId && + data.componentName == this.componentName && data.itemId == this.itemId && data.area == this.area) { + // Show the sync warnings. + this.showSyncWarnings(data.warnings); + + // Refresh the data. + this.commentsLoaded = false; + this.refreshIcon = CoreConstants.ICON_LOADING; + this.syncIcon = CoreConstants.ICON_LOADING; + + this.content?.scrollToTop(); + + this.page = 0; + this.comments = []; + this.fetchComments(false); + } + }, CoreSites.instance.getCurrentSiteId()); + } + + /** + * View loaded. + */ + async ngOnInit(): Promise { + // Is implicit the user can delete if he can add. + this.addDeleteCommentsAvailable = await CoreComments.instance.isAddCommentsAvailable(); + this.currentUserId = CoreSites.instance.getCurrentSiteUserId(); + + this.commentsLoaded = false; + this.contextLevel = CoreNavigator.instance.getRouteParam('contextLevel')!; + this.instanceId = CoreNavigator.instance.getRouteNumberParam('instanceId')!; + this.componentName = CoreNavigator.instance.getRouteParam('componentName')!; + this.itemId = CoreNavigator.instance.getRouteNumberParam('itemId')!; + this.area = CoreNavigator.instance.getRouteParam('area') || ''; + this.title = CoreNavigator.instance.getRouteNumberParam('title') || + Translate.instance.instant('core.comments.comments'); + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId'); + + await this.fetchComments(true); + } + + /** + * Fetches the comments. + * + * @param sync When to resync comments. + * @param showErrors When to display errors or not. + * @return Resolved when done. + */ + protected async fetchComments(sync: boolean, showErrors = false): Promise { + this.loadMoreError = false; + + if (sync) { + await CoreUtils.instance.ignoreErrors(this.syncComments(showErrors)); + } + + try { + // Get comments data. + const commentsResponse = await CoreComments.instance.getComments( + this.contextLevel, + this.instanceId, + this.componentName, + this.itemId, + this.area, + this.page, + ); + this.canAddComments = this.addDeleteCommentsAvailable && !!commentsResponse.canpost; + + let comments = commentsResponse.comments.sort((a, b) => b.timecreated - a.timecreated); + if (typeof commentsResponse.count != 'undefined') { + this.canLoadMore = (this.comments.length + comments.length) > commentsResponse.count; + } else { + // Old style. + this.canLoadMore = commentsResponse.comments.length > 0 && + commentsResponse.comments.length >= CoreCommentsProvider.pageSize; + } + + comments = await Promise.all(comments.map((comment) => this.loadCommentProfile(comment))); + + this.comments = this.comments.concat(comments); + + this.canDeleteComments = this.addDeleteCommentsAvailable && + (this.hasOffline || this.comments.some((comment) => !!comment.delete)); + + await this.loadOfflineData(); + } catch (error) { + this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. + if (error && this.componentName == 'assignsubmission_comments') { + CoreDomUtils.instance.showAlertTranslated('core.notice', 'core.comments.commentsnotworking'); + } else { + CoreDomUtils.instance.showErrorModalDefault(error, Translate.instance.instant('core.error') + ': get_comments'); + } + } finally { + this.commentsLoaded = true; + this.refreshIcon = CoreConstants.ICON_REFRESH; + this.syncIcon = CoreConstants.ICON_SYNC; + } + + } + + /** + * Function to load more commemts. + * + * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. + * @return Resolved when done. + */ + loadMore(infiniteComplete?: () => void): Promise { + this.page++; + this.canLoadMore = false; + + return this.fetchComments(true).finally(() => { + infiniteComplete && infiniteComplete(); + }); + } + + /** + * Refresh the comments. + * + * @param showErrors Whether to display errors or not. + * @param refresher Refresher. + * @return Resolved when done. + */ + async refreshComments(showErrors: boolean, refresher?: CustomEvent): Promise { + this.commentsLoaded = false; + this.refreshIcon = CoreConstants.ICON_LOADING; + this.syncIcon = CoreConstants.ICON_LOADING; + + try { + await this.invalidateComments(); + } finally { + this.page = 0; + this.comments = []; + + try { + await this.fetchComments(true, showErrors); + } finally { + refresher?.detail.complete(); + } + } + } + + /** + * Show sync warnings if any. + * + * @param warnings the warnings + */ + private showSyncWarnings(warnings: string[]): void { + const message = CoreTextUtils.instance.buildMessage(warnings); + if (message) { + CoreDomUtils.instance.showErrorModal(message); + } + } + + /** + * Tries to synchronize comments. + * + * @param showErrors Whether to display errors or not. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + private async syncComments(showErrors: boolean): Promise { + try { + const result = await CoreCommentsSync.instance.syncComments( + this.contextLevel, + this.instanceId, + this.componentName, + this.itemId, + this.area, + ); + this.showSyncWarnings(result?.warnings || []); + } catch (error) { + if (showErrors) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errorsync', true); + } + + throw new CoreError(error); + } + } + + /** + * Add a new comment to the list. + * + * @param e Event. + */ + async addComment(e: Event): Promise { + e.preventDefault(); + e.stopPropagation(); + + const params: Params = { + contextLevel: this.contextLevel, + instanceId: this.instanceId, + componentName: this.componentName, + itemId: this.itemId, + area: this.area, + content: this.offlineComment ? this.offlineComment!.content : '', + }; + + const modal = await ModalController.instance.create({ + component: CoreCommentsAddComponent, + componentProps: params, + }); + + await modal.present(); + + const result = await modal.onDidDismiss(); + + if (result?.data?.comment) { + this.invalidateComments(); + + const addedComments = await this.loadCommentProfile(result.data.comment); + // Add the comment to the top. + this.comments = [addedComments].concat(this.comments); + this.canDeleteComments = this.addDeleteCommentsAvailable; + + CoreEvents.trigger(CoreCommentsProvider.COMMENTS_COUNT_CHANGED_EVENT, { + contextLevel: this.contextLevel, + instanceId: this.instanceId, + component: this.componentName, + itemId: this.itemId, + area: this.area, + countChange: 1, + }, CoreSites.instance.getCurrentSiteId()); + + } else if (result?.data?.comment === false) { + // Comments added in offline mode. + return this.loadOfflineData(); + } + } + + /** + * Delete a comment. + * + * @param e Click event. + * @param comment Comment to delete. + */ + async deleteComment(e: Event, comment: CoreCommentsDataWithUser | CoreCommentsOfflineWithUser): Promise { + e.preventDefault(); + e.stopPropagation(); + + const modified = 'lastmodified' in comment + ? comment.lastmodified + : comment.timecreated; + const time = CoreTimeUtils.instance.userDate( + modified * 1000, + 'core.strftimerecentfull', + ); + + const deleteComment: CoreCommentsCommentBasicData = { + contextlevel: this.contextLevel, + instanceid: this.instanceId, + component: this.componentName, + itemid: this.itemId, + area: this.area, + content: comment.content, + id: 'id' in comment ? comment.id : undefined, + }; + + try { + await CoreDomUtils.instance.showDeleteConfirm('core.comments.deletecommentbyon', { + $a: + { user: comment.fullname || '', time: time }, + }); + } catch { + // User cancelled, nothing to do. + return; + } + + try { + const deletedOnline = await CoreComments.instance.deleteComment(deleteComment); + this.showDelete = false; + + if (deletedOnline) { + const index = this.comments.findIndex((comment) => comment.id == comment.id); + if (index >= 0) { + this.comments.splice(index, 1); + + CoreEvents.trigger(CoreCommentsProvider.COMMENTS_COUNT_CHANGED_EVENT, { + contextLevel: this.contextLevel, + instanceId: this.instanceId, + component: this.componentName, + itemId: this.itemId, + area: this.area, + countChange: -1, + }, CoreSites.instance.getCurrentSiteId()); + } + } else { + this.loadOfflineData(); + } + + this.invalidateComments(); + + CoreDomUtils.instance.showToast('core.comments.eventcommentdeleted', true, 3000); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Delete comment failed.'); + } + } + + /** + * Invalidate comments. + * + * @return Resolved when done. + */ + protected invalidateComments(): Promise { + return CoreComments.instance.invalidateCommentsData( + this.contextLevel, + this.instanceId, + this.componentName, + this.itemId, + this.area, + ); + } + + /** + * Loads the profile info onto the comment object. + * + * @param comment Comment object. + * @return Promise resolved with modified comment when done. + */ + protected async loadCommentProfile(comment: CoreCommentsDataWithUser): Promise { + // Get the user profile image. + try { + const user = await CoreUser.instance.getProfile(comment.userid!, undefined, true); + comment.profileimageurl = user.profileimageurl; + comment.fullname = user.fullname; + } catch { + // Ignore errors. + } + + return comment; + + } + + /** + * Load offline comments. + * + * @return Promise resolved when done. + */ + protected async loadOfflineData(): Promise { + const promises: Promise[] = []; + let hasDeletedComments = false; + + // Load the only offline comment allowed if any. + promises.push(CoreCommentsOffline.instance.getComment( + this.contextLevel, + this.instanceId, + this.componentName, + this.itemId, + this.area, + ).then(async (offlineComment) => { + this.offlineComment = offlineComment; + + if (!offlineComment) { + return; + } + + if (!this.currentUser) { + this.currentUser = await CoreUser.instance.getProfile(this.currentUserId, undefined, true); + } + + if (this.currentUser) { + this.offlineComment!.profileimageurl = this.currentUser.profileimageurl; + this.offlineComment!.fullname = this.currentUser.fullname; + } + this.offlineComment!.userid = this.currentUserId; + + return; + })); + + // Load deleted comments offline. + promises.push(CoreCommentsOffline.instance.getDeletedComments( + this.contextLevel, + this.instanceId, + this.componentName, + this.itemId, + this.area, + ).then((deletedComments) => { + hasDeletedComments = deletedComments && deletedComments.length > 0; + + if (hasDeletedComments) { + deletedComments.forEach((deletedComment) => { + const comment = this.comments.find((comment) => comment.id == deletedComment.commentid); + + if (comment) { + comment.deleted = !!deletedComment.deleted; + } + }); + } + + return; + })); + + await Promise.all(promises); + + this.hasOffline = !!this.offlineComment || hasDeletedComments; + } + + /** + * Restore a comment. + * + * @param e Click event. + * @param comment Comment to delete. + */ + async undoDeleteComment(e: Event, comment: CoreCommentsDataWithUser): Promise { + e.preventDefault(); + e.stopPropagation(); + + await CoreCommentsOffline.instance.undoDeleteComment(comment.id); + + comment.deleted = false; + this.showDelete = false; + } + + /** + * Toggle delete. + */ + toggleDelete(): void { + this.showDelete = !this.showDelete; + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.syncObserver && this.syncObserver.off(); + } + +} + +export type CoreCommentsDataWithUser = CoreCommentsData & { + profileimageurl?: string; + fullname?: string; + deleted?: boolean; +}; + + +export type CoreCommentsOfflineWithUser = CoreCommentsDBRecord & { + profileimageurl?: string; + fullname?: string; + userid?: number; + deleted?: boolean; +}; diff --git a/src/core/features/comments/services/comments-offline.ts b/src/core/features/comments/services/comments-offline.ts new file mode 100644 index 000000000..8936940cf --- /dev/null +++ b/src/core/features/comments/services/comments-offline.ts @@ -0,0 +1,304 @@ +// (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 { CoreSites } from '@services/sites'; +import { CoreTimeUtils } from '@services/utils/time'; +import { makeSingleton } from '@singletons'; +import { COMMENTS_TABLE, COMMENTS_DELETED_TABLE, CoreCommentsDBRecord, CoreCommentsDeletedDBRecord } from './database/comments'; + +/** + * Service to handle offline comments. + */ +@Injectable( { providedIn: 'root' }) +export class CoreCommentsOfflineProvider { + + /** + * Get all offline comments. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with comments. + */ + async getAllComments(siteId?: string): Promise<(CoreCommentsDBRecord | CoreCommentsDeletedDBRecord)[]> { + const site = await CoreSites.instance.getSite(siteId); + const results = await Promise.all([ + site.getDb().getRecords(COMMENTS_TABLE), + site.getDb().getRecords(COMMENTS_DELETED_TABLE), + ]); + + return [].concat.apply([], results); + } + + /** + * Get an offline comment. + * + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the comments. + */ + async getComment( + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + siteId?: string, + ): Promise { + try { + const site = await CoreSites.instance.getSite(siteId); + + return await site.getDb().getRecord(COMMENTS_TABLE, { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area, + }); + } catch { + return; + } + } + + /** + * Get all offline comments added or deleted of a special area. + * + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the comments. + */ + async getComments( + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + siteId?: string, + ): Promise<(CoreCommentsDBRecord | CoreCommentsDeletedDBRecord)[]> { + let comments: (CoreCommentsDBRecord | CoreCommentsDeletedDBRecord)[] = []; + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const comment = await this.getComment(contextLevel, instanceId, component, itemId, area, siteId); + + comments = comment ? [comment] : []; + + const deletedComments = await this.getDeletedComments(contextLevel, instanceId, component, itemId, area, siteId); + comments = comments.concat(deletedComments); + + return comments; + } + + /** + * Get all offline deleted comments. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with comments. + */ + async getAllDeletedComments(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return await site.getDb().getRecords(COMMENTS_DELETED_TABLE); + } + + /** + * Get an offline comment. + * + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the comments. + */ + async getDeletedComments( + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + siteId?: string, + ): Promise { + try { + const site = await CoreSites.instance.getSite(siteId); + + return await site.getDb().getRecords(COMMENTS_DELETED_TABLE, { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area, + }); + } catch { + return []; + } + } + + /** + * Remove an offline comment. + * + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + async removeComment( + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(COMMENTS_TABLE, { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area, + }); + } + + /** + * Remove an offline deleted comment. + * + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + async removeDeletedComments( + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(COMMENTS_DELETED_TABLE, { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area, + }); + } + + /** + * Save a comment to be sent later. + * + * @param content Comment text. + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async saveComment( + content: string, + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + const now = CoreTimeUtils.instance.timestamp(); + const data: CoreCommentsDBRecord = { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area, + content: content, + lastmodified: now, + }; + + await site.getDb().insertRecord(COMMENTS_TABLE, data); + + return data; + } + + /** + * Delete a comment offline to be sent later. + * + * @param commentId Comment ID. + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteComment( + commentId: number, + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + const now = CoreTimeUtils.instance.timestamp(); + const data: CoreCommentsDeletedDBRecord = { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area, + commentid: commentId, + deleted: now, + }; + + await site.getDb().insertRecord(COMMENTS_DELETED_TABLE, data); + } + + /** + * Undo delete a comment. + * + * @param commentId Comment ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + async undoDeleteComment(commentId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(COMMENTS_DELETED_TABLE, { commentid: commentId }); + } + +} +export const CoreCommentsOffline = makeSingleton(CoreCommentsOfflineProvider); diff --git a/src/core/features/comments/services/comments-sync.ts b/src/core/features/comments/services/comments-sync.ts new file mode 100644 index 000000000..2231fde65 --- /dev/null +++ b/src/core/features/comments/services/comments-sync.ts @@ -0,0 +1,336 @@ +// (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 { CoreComments, CoreCommentsCountChangedEventData, CoreCommentsProvider } from './comments'; +import { CoreEvents } from '@singletons/events'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreCommentsOffline } from './comments-offline'; +import { CoreSites } from '@services/sites'; +import { CoreApp } from '@services/app'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreCommentsDBRecord, CoreCommentsDeletedDBRecord } from './database/comments'; + +/** + * Service to sync omments. + */ +@Injectable( { providedIn: 'root' }) +export class CoreCommentsSyncProvider extends CoreSyncBaseProvider { + + static readonly AUTO_SYNCED = 'core_comments_autom_synced'; + + constructor() { + super('CoreCommentsSync'); + } + + /** + * Try to synchronize all the comments 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. + */ + syncAllComments(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all comments', this.syncAllCommentsFunc.bind(this, siteId, force), siteId); + } + + /** + * Synchronize all the comments in a certain site + * + * @param siteId Site ID to sync. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + private async syncAllCommentsFunc(siteId: string, force: boolean): Promise { + const comments = await CoreCommentsOffline.instance.getAllComments(siteId); + + const commentsUnique: { [syncId: string]: (CoreCommentsDBRecord | CoreCommentsDeletedDBRecord) } = {}; + // Get Unique array. + comments.forEach((comment) => { + const syncId = this.getSyncId( + comment.contextlevel, + comment.instanceid, + comment.component, + comment.itemid, + comment.area, + ); + commentsUnique[syncId] = comment; + }); + + // Sync all courses. + const promises = Object.keys(commentsUnique).map(async (key) => { + const comment = commentsUnique[key]; + + const result = await (force + ? this.syncComments( + comment.contextlevel, + comment.instanceid, + comment.component, + comment.itemid, + comment.area, + siteId, + ) + : this.syncCommentsIfNeeded( + comment.contextlevel, + comment.instanceid, + comment.component, + comment.itemid, + comment.area, + siteId, + )); + + if (typeof result != 'undefined') { + // Sync successful, send event. + CoreEvents.trigger(CoreCommentsSyncProvider.AUTO_SYNCED, { + contextLevel: comment.contextlevel, + instanceId: comment.instanceid, + componentName: comment.component, + itemId: comment.itemid, + area: comment.area, + warnings: result.warnings, + }, siteId); + } + }); + + await Promise.all(promises); + } + + /** + * Sync course comments only if a certain time has passed since the last time. + * + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the comments are synced or if they don't need to be synced. + */ + private async syncCommentsIfNeeded( + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + siteId?: string, + ): Promise { + const syncId = this.getSyncId(contextLevel, instanceId, component, itemId, area); + + const needed = await this.isSyncNeeded(syncId, siteId); + + if (needed) { + return this.syncComments(contextLevel, instanceId, component, itemId, area, siteId); + } + } + + /** + * Synchronize comments in a particular area. + * + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + syncComments( + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const syncId = this.getSyncId(contextLevel, instanceId, component, itemId, area); + + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for comments, return the promise. + return this.getOngoingSync(syncId, siteId)!; + } + + this.logger.debug('Try to sync comments ' + syncId + ' in site ' + siteId); + + const syncPromise = this.performSyncComments(contextLevel, instanceId, component, itemId, area, siteId); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } + + /** + * Performs the syncronization of comments in a particular area. + * + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + private async performSyncComments( + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + siteId: string, + ): Promise { + + const result: CoreCommentsSyncResult = { + warnings: [], + updated: false, + }; + + // Get offline comments to be sent. + const comments = await CoreCommentsOffline.instance.getComments(contextLevel, instanceId, component, itemId, area, siteId); + if (!comments.length) { + // Nothing to sync. + return result; + } + + if (!CoreApp.instance.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + const errors: string[] = []; + const promises: Promise[] = []; + const deleteCommentIds: number[] = []; + let countChange = 0; + + comments.forEach((comment) => { + if ('deleted' in comment) { + deleteCommentIds.push(comment.commentid); + } else { + promises.push(CoreComments.instance.addCommentOnline( + comment.content, + contextLevel, + instanceId, + component, + itemId, + area, + siteId, + ).then(() => { + countChange++; + + return CoreCommentsOffline.instance.removeComment(contextLevel, instanceId, component, itemId, area, siteId); + })); + } + }); + + if (deleteCommentIds.length > 0) { + promises.push(CoreComments.instance.deleteCommentsOnline( + deleteCommentIds, + contextLevel, + instanceId, + component, + itemId, + area, + siteId, + ).then(() => { + countChange--; + + return CoreCommentsOffline.instance.removeDeletedComments( + contextLevel, + instanceId, + component, + itemId, + area, + siteId, + ); + })); + } + + // Send the comments. + try { + await Promise.all(promises); + + result.updated = true; + + CoreEvents.trigger(CoreCommentsProvider.COMMENTS_COUNT_CHANGED_EVENT, { + contextLevel: contextLevel, + instanceId: instanceId, + component, + itemId: itemId, + area: area, + countChange: countChange, + }, CoreSites.instance.getCurrentSiteId()); + + // Fetch the comments from server to be sure they're up to date. + await CoreUtils.instance.ignoreErrors( + CoreComments.instance.invalidateCommentsData(contextLevel, instanceId, component, itemId, area, siteId), + ); + await CoreUtils.instance.ignoreErrors( + CoreComments.instance.getComments(contextLevel, instanceId, component, itemId, area, 0, siteId), + ); + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // It's a WebService error, this means the user cannot send comments. + errors.push(error.message); + } else { + // Not a WebService error, reject the synchronization to try again. + throw error; + } + } + + if (errors && errors.length) { + errors.forEach((error) => { + result.warnings.push(Translate.instance.instant('core.comments.warningcommentsnotsent', { + error: error, + })); + }); + } + + // All done, return the warnings. + return result; + } + + /** + * Get the ID of a comments sync. + * + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @return Sync ID. + */ + protected getSyncId(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = ''): string { + return contextLevel + '#' + instanceId + '#' + component + '#' + itemId + '#' + area; + } + +} +export const CoreCommentsSync = makeSingleton(CoreCommentsSyncProvider); + +export type CoreCommentsSyncResult = { + warnings: string[]; // List of warnings. + updated: boolean; // Whether some data was sent to the server or offline data was updated. +}; + + +/** + * Data passed to AUTO_SYNCED event. + */ +export type CoreCommentsSyncAutoSyncData = { + contextLevel: string; + instanceId: number; + componentName: string; + itemId: number; + area: string; + warnings: string[]; +}; diff --git a/src/core/features/comments/services/comments.ts b/src/core/features/comments/services/comments.ts new file mode 100644 index 000000000..a8a3b8b37 --- /dev/null +++ b/src/core/features/comments/services/comments.ts @@ -0,0 +1,625 @@ +// (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 { CoreError } from '@classes/errors/error'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { CoreCommentsOffline } from './comments-offline'; + +const ROOT_CACHE_KEY = 'mmComments:'; + +/** + * Service that provides some features regarding comments. + */ +@Injectable( { providedIn: 'root' }) +export class CoreCommentsProvider { + + static readonly REFRESH_COMMENTS_EVENT = 'core_comments_refresh_comments'; + static readonly COMMENTS_COUNT_CHANGED_EVENT = 'core_comments_count_changed'; + + static pageSize = 1; // At least it will be one. + static pageSizeOK = false; // If true, the pageSize is definitive. If not, it's a temporal value to reduce WS calls. + + /** + * Initialize the module service. + */ + initialize(): void { + // Reset comments page size. + CoreEvents.on(CoreEvents.LOGIN, () => { + CoreCommentsProvider.pageSize = 1; + CoreCommentsProvider.pageSizeOK = false; + }); + + } + + /** + * Add a comment. + * + * @param content Comment text. + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if comment was sent to server, false if stored in device. + */ + async addComment( + content: string, + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Convenience function to store a comment to be synchronized later. + const storeOffline = async (): Promise => { + await CoreCommentsOffline.instance.saveComment(content, contextLevel, instanceId, component, itemId, area, siteId); + + return false; + }; + + if (!CoreApp.instance.isOnline()) { + // App is offline, store the comment. + return storeOffline(); + } + + // Send comment to server. + try { + return await this.addCommentOnline(content, contextLevel, instanceId, component, itemId, area, siteId); + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // It's a WebService error, the user cannot send the message so don't store it. + throw error; + } + + return storeOffline(); + } + } + + /** + * Add a comment. It will fail if offline or cannot connect. + * + * @param content Comment text. + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when added, rejected otherwise. + */ + async addCommentOnline( + content: string, + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + siteId?: string, + ): Promise { + const comments: CoreCommentsCommentBasicData[] = [ + { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area, + content: content, + }, + ]; + + const commentsResponse = await this.addCommentsOnline(comments, siteId); + + // A comment was added, invalidate them. + await CoreUtils.instance.ignoreErrors( + this.invalidateCommentsData(contextLevel, instanceId, component, itemId, area, siteId), + ); + + return commentsResponse![0]; + } + + /** + * Add several comments. It will fail if offline or cannot connect. + * + * @param comments Comments to save. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when added, rejected otherwise. Promise resolved doesn't mean that comments + * have been added, the resolve param can contain errors for comments not sent. + */ + async addCommentsOnline( + comments: CoreCommentsCommentBasicData[], + siteId?: string, + ): Promise { + if (!comments || !comments.length) { + return; + } + + const site = await CoreSites.instance.getSite(siteId); + const data: CoreCommentsAddCommentsWSParams = { + comments: comments, + }; + + return await site.write('core_comment_add_comments', data); + } + + /** + * Check if Calendar is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + areCommentsDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.isFeatureDisabled('NoDelegate_CoreComments'); + } + + /** + * Check if comments are disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async areCommentsDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.areCommentsDisabledInSite(site); + } + + /** + * Delete a comment. + * + * @param comment Comment object to delete. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when deleted (with true if deleted in online, false otherwise), rejected otherwise. Promise resolved + * doesn't mean that comments have been deleted, the resolve param can contain errors for comments not deleted. + */ + async deleteComment(comment: CoreCommentsCommentBasicData, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Offline comment, just delete it. + if (!comment.id) { + await CoreCommentsOffline.instance.removeComment( + comment.contextlevel, + comment.instanceid, + comment.component, + comment.itemid, + comment.area, + siteId, + ); + + return false; + } + + // Convenience function to store the action to be synchronized later. + const storeOffline = async (): Promise => { + await CoreCommentsOffline.instance.deleteComment( + comment.id!, + comment.contextlevel, + comment.instanceid, + comment.component, + comment.itemid, + comment.area, + siteId, + ); + + return false; + }; + + if (!CoreApp.instance.isOnline()) { + // App is offline, store the comment. + return storeOffline(); + } + + // Send comment to server. + try { + await this.deleteCommentsOnline( + [comment.id], + comment.contextlevel, + comment.instanceid, + comment.component, + comment.itemid, + comment.area, + siteId, + ); + + return true; + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // It's a WebService error, the user cannot send the comment so don't store it. + throw error; + } + + return storeOffline(); + } + } + + /** + * Delete a comment. It will fail if offline or cannot connect. + * + * @param commentIds Comment IDs to delete. + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when deleted, rejected otherwise. Promise resolved doesn't mean that comments + * have been deleted, the resolve param can contain errors for comments not deleted. + */ + async deleteCommentsOnline( + commentIds: number[], + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const data: CoreCommentsDeleteCommentsWSParams = { + comments: commentIds, + }; + + await site.write('core_comment_delete_comments', data); + + await CoreUtils.instance.ignoreErrors( + this.invalidateCommentsData(contextLevel, instanceId, component, itemId, area, siteId), + ); + } + + /** + * Returns whether WS to add/delete comments are available in site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if available, resolved with false or rejected otherwise. + * @since 3.8 + */ + async isAddCommentsAvailable(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + // First check if it's disabled. + if (this.areCommentsDisabledInSite(site)) { + return false; + } + + return site.wsAvailable('core_comment_add_comments'); + } + + /** + * Get cache key for get comments data WS calls. + * + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @return Cache key. + */ + protected getCommentsCacheKey( + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + ): string { + return this.getCommentsPrefixCacheKey(contextLevel, instanceId) + ':' + component + ':' + itemId + ':' + area; + } + + /** + * Get cache key for get comments instance data WS calls. + * + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @return Cache key. + */ + protected getCommentsPrefixCacheKey(contextLevel: string, instanceId: number): string { + return ROOT_CACHE_KEY + 'comments:' + contextLevel + ':' + instanceId; + } + + /** + * Retrieve a list of comments. + * + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param page Page number (0 based). Default 0. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the comments. + */ + async getComments( + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + page: number = 0, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreCommentsGetCommentsWSParams = { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area, + page: page, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCommentsCacheKey(contextLevel, instanceId, component, itemId, area), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + }; + const response = await site.read('core_comment_get_comments', params, preSets); + + if (response.comments) { + // Update pageSize with the greatest count at the moment. + if (typeof response.count == 'undefined' && response.comments.length > CoreCommentsProvider.pageSize) { + CoreCommentsProvider.pageSize = response.comments.length; + } + + return response; + } + + throw new CoreError('No comments returned'); + } + + /** + * Get comments count number to show on the comments component. + * + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param siteId Site ID. If not defined, current site. + * @return Comments count with plus sign if needed. + */ + async getCommentsCount( + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + siteId?: string, + ): Promise { + + siteId = siteId ? siteId : CoreSites.instance.getCurrentSiteId(); + let trueCount = false; + + // Convenience function to get comments number on a page. + const getCommentsPageCount = async (page: number): Promise => { + try { + const response = await this.getComments(contextLevel, instanceId, component, itemId, area, page, siteId); + // Count is only available in 3.8 onwards. + + if (typeof response.count != 'undefined') { + trueCount = true; + + return response.count; + } + + if (response.comments) { + return response.comments.length || 0; + } + + return -1; + } catch { + return -1; + } + }; + + const count = await getCommentsPageCount(0); + + if (trueCount || count < CoreCommentsProvider.pageSize) { + return count + ''; + } else if (CoreCommentsProvider.pageSizeOK && count >= CoreCommentsProvider.pageSize) { + // Page Size is ok, show + in case it reached the limit. + return (CoreCommentsProvider.pageSize - 1) + '+'; + } + + const countMore = await getCommentsPageCount(1); + // Page limit was reached on the previous call. + if (countMore > 0) { + CoreCommentsProvider.pageSizeOK = true; + + return (CoreCommentsProvider.pageSize - 1) + '+'; + } + + return count + ''; + } + + /** + * Invalidates comments data. + * + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param component Component name. + * @param itemId Associated id. + * @param area String comment area. Default empty. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCommentsData( + contextLevel: string, + instanceId: number, + component: string, + itemId: number, + area: string = '', + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await CoreUtils.instance.allPromises([ + // This is done with starting with to avoid conflicts with previous keys that were including page. + site.invalidateWsCacheForKeyStartingWith(this.getCommentsCacheKey( + contextLevel, + instanceId, + component, + itemId, + area, + ) + ':'), + + site.invalidateWsCacheForKey(this.getCommentsCacheKey(contextLevel, instanceId, component, itemId, area)), + ]); + } + + /** + * Invalidates all comments data for an instance. + * + * @param contextLevel Contextlevel system, course, user... + * @param instanceId The Instance id of item associated with the context level. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCommentsByInstance(contextLevel: string, instanceId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getCommentsPrefixCacheKey(contextLevel, instanceId)); + } + +} +export const CoreComments = makeSingleton(CoreCommentsProvider); + +/** + * Data returned by comment_area_exporter. + */ +export type CoreCommentsArea = { + component: string; // Component. + commentarea: string; // Commentarea. + itemid: number; // Itemid. + courseid: number; // Courseid. + contextid: number; // Contextid. + cid: string; // Cid. + autostart: boolean; // Autostart. + canpost: boolean; // Canpost. + canview: boolean; // Canview. + count: number; // Count. + collapsediconkey: string; // @since 3.3. Collapsediconkey. + displaytotalcount: boolean; // Displaytotalcount. + displaycancel: boolean; // Displaycancel. + fullwidth: boolean; // Fullwidth. + linktext: string; // Linktext. + notoggle: boolean; // Notoggle. + template: string; // Template. + canpostorhascomments: boolean; // Canpostorhascomments. +}; + + +/** + * Params of core_comment_add_comments WS. + */ +type CoreCommentsAddCommentsWSParams = { + comments: CoreCommentsCommentBasicData[]; +}; + +export type CoreCommentsCommentBasicData = { + id?: number; // Comment ID. + contextlevel: string; // Contextlevel system, course, user... + instanceid: number; // The id of item associated with the contextlevel. + component: string; // Component. + content: string; // Component. + itemid: number; // Associated id. + area?: string; // String comment area. +}; + +/** + * Comments Data returned by WS. + */ +export type CoreCommentsData = { + id: number; // Comment ID. + content: string; // The content text formatted. + format: number; // Content format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + timecreated: number; // Time created (timestamp). + strftimeformat: string; // Time format. + profileurl: string; // URL profile. + fullname: string; // Fullname. + time: string; // Time in human format. + avatar: string; // HTML user picture. + userid: number; // User ID. + delete?: boolean; // Permission to delete=true/false. +}; + +/** + * Data returned by core_comment_add_comments WS. + */ +export type CoreCommentsAddCommentsWSResponse = CoreCommentsData[]; + +/** + * Params of core_comment_delete_comments WS. + */ +type CoreCommentsDeleteCommentsWSParams = { + comments: number[]; +}; + +/** + * Params of core_comment_get_comments WS. + */ +type CoreCommentsGetCommentsWSParams = { + contextlevel: string; // Contextlevel system, course, user... + instanceid: number; // The Instance id of item associated with the context level. + component: string; // Component. + itemid: number; // Associated id. + area?: string; // String comment area. + page?: number; // Page number (0 based). + sortdirection?: string; // Sort direction: ASC or DESC. +}; + +/** + * Data returned by core_comment_get_comments WS. + */ +export type CoreCommentsGetCommentsWSResponse = { + comments: CoreCommentsData[]; // List of comments. + count?: number; // @since 3.8. Total number of comments. + perpage?: number; // @since 3.8. Number of comments per page. + canpost?: boolean; // Whether the user can post in this comment area. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data sent by COMMENTS_COUNT_CHANGED_EVENT event. + */ +export type CoreCommentsCountChangedEventData = { + contextLevel: string; + instanceId: number; + component: string; + itemId: number; + area: string; + countChange: number; +}; + +/** + * Data sent by REFRESH_COMMENTS_EVENT event. + */ +export type CoreCommentsRefreshCommentsEventData = { + contextLevel?: string; + instanceId?: number; + component?: string; + itemId?: number; + area?: string; +}; diff --git a/src/core/features/comments/services/database/comments.ts b/src/core/features/comments/services/database/comments.ts new file mode 100644 index 000000000..38473d69c --- /dev/null +++ b/src/core/features/comments/services/database/comments.ts @@ -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 CoreCommentsOfflineProvider. + */ +export const COMMENTS_TABLE = 'core_comments_offline_comments'; +export const COMMENTS_DELETED_TABLE = 'core_comments_deleted_offline_comments'; +export const COMMENTS_OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreCommentsOfflineProvider', + version: 1, + tables: [ + { + name: COMMENTS_TABLE, + columns: [ + { + name: 'contextlevel', + type: 'TEXT', + }, + { + name: 'instanceid', + type: 'INTEGER', + }, + { + name: 'component', + type: 'TEXT', + }, + { + name: 'itemid', + type: 'INTEGER', + }, + { + name: 'area', + type: 'TEXT', + }, + { + name: 'content', + type: 'TEXT', + }, + { + name: 'lastmodified', + type: 'INTEGER', + }, + ], + primaryKeys: ['contextlevel', 'instanceid', 'component', 'itemid', 'area'], + }, + { + name: COMMENTS_DELETED_TABLE, + columns: [ + { + name: 'commentid', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'contextlevel', + type: 'TEXT', + }, + { + name: 'instanceid', + type: 'INTEGER', + }, + { + name: 'component', + type: 'TEXT', + }, + { + name: 'itemid', + type: 'INTEGER', + }, + { + name: 'area', + type: 'TEXT', + }, + { + name: 'deleted', + type: 'INTEGER', + }, + ], + }, + ], +}; + +export type CoreCommentsDBRecord = { + contextlevel: string; // Primary key. + instanceid: number; // Primary key. + component: string; // Primary key. + itemid: number; // Primary key. + area: string; // Primary key. + content: string; + lastmodified: number; +}; + +export type CoreCommentsDeletedDBRecord = { + commentid: number; // Primary key. + contextlevel: string; + instanceid: number; + component: string; + itemid: number; + area: string; + deleted: number; +}; diff --git a/src/core/features/comments/services/handlers/sync-cron.ts b/src/core/features/comments/services/handlers/sync-cron.ts new file mode 100644 index 000000000..f4285afda --- /dev/null +++ b/src/core/features/comments/services/handlers/sync-cron.ts @@ -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 { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { CoreCommentsSync } from '../comments-sync'; +/** + * Synchronization cron handler. + */ +@Injectable( { providedIn: 'root' }) +export class CoreCommentsSyncCronHandlerService implements CoreCronHandler { + + name = 'CoreCommentsSyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return CoreCommentsSync.instance.syncAllComments(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return 300000; // 5 minutes. + } + +} +export const CoreCommentsSyncCronHandler = makeSingleton(CoreCommentsSyncCronHandlerService); diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index d3692a017..9be76ae02 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -30,6 +30,7 @@ import { CorePushNotificationsModule } from './pushnotifications/pushnotificatio import { CoreXAPIModule } from './xapi/xapi.module'; import { CoreViewerModule } from './viewer/viewer.module'; import { CoreSearchModule } from './search/search.module'; +import { CoreCommentsModule } from './comments/comments.module'; @NgModule({ imports: [ @@ -49,6 +50,7 @@ import { CoreSearchModule } from './search/search.module'; CoreXAPIModule, CoreH5PModule, CoreViewerModule, + CoreCommentsModule, ], }) export class CoreFeaturesModule {} diff --git a/src/core/features/login/pages/site/site.html b/src/core/features/login/pages/site/site.html index 51d15e67d..0421cc8df 100644 --- a/src/core/features/login/pages/site/site.html +++ b/src/core/features/login/pages/site/site.html @@ -50,7 +50,7 @@