diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index bcabc9893..50b42d8f4 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -18,3 +18,4 @@ jobs: - run: npx tslint -c ionic-migration.json -p tsconfig.json - run: npm run test:ci - run: npm run build:prod + - run: result=$(find src -type f -iname '*.html' -exec grep -E 'class="[^"]+"[^>]+class="' {} \; | wc -l); test $result -eq 0 diff --git a/angular.json b/angular.json index 7caa291e1..bccd90c01 100644 --- a/angular.json +++ b/angular.json @@ -33,7 +33,7 @@ ], "styles": [ { - "input": "src/theme/global.scss" + "input": "src/theme/theme.scss" } ], "scripts": [], diff --git a/src/addons/addons.module.ts b/src/addons/addons.module.ts index 7cedffb7f..fd704ef20 100644 --- a/src/addons/addons.module.ts +++ b/src/addons/addons.module.ts @@ -22,12 +22,14 @@ import { AddonBadgesModule } from './badges/badges.module'; import { AddonCalendarModule } from './calendar/calendar.module'; import { AddonNotificationsModule } from './notifications/notifications.module'; import { AddonMessageOutputModule } from './messageoutput/messageoutput.module'; +import { AddonMessagesModule } from './messages/messages.module'; @NgModule({ imports: [ AddonBlockModule, AddonBadgesModule, AddonCalendarModule, + AddonMessagesModule, AddonPrivateFilesModule, AddonFilterModule, AddonUserProfileFieldModule, diff --git a/src/addons/badges/badges-lazy.module.ts b/src/addons/badges/badges-lazy.module.ts index e1aee5b89..f88a84958 100644 --- a/src/addons/badges/badges-lazy.module.ts +++ b/src/addons/badges/badges-lazy.module.ts @@ -13,7 +13,13 @@ // limitations under the License. import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; +import { Route, RouterModule, Routes } from '@angular/router'; + + +export const AddonBadgesIssueRoute: Route = { + path: 'issue', + loadChildren: () => import('./pages/issued-badge/issued-badge.module').then( m => m.AddonBadgesIssuedBadgePageModule), +}; const routes: Routes = [ { @@ -21,10 +27,7 @@ const routes: Routes = [ redirectTo: 'user', pathMatch: 'full', }, - { - path: 'issue', - loadChildren: () => import('./pages/issued-badge/issued-badge.module').then( m => m.AddonBadgesIssuedBadgePageModule), - }, + AddonBadgesIssueRoute, { path: 'user', loadChildren: () => import('./pages/user-badges/user-badges.module').then( m => m.AddonBadgesUserBadgesPageModule), diff --git a/src/addons/badges/pages/issued-badge/issued-badge.page.ts b/src/addons/badges/pages/issued-badge/issued-badge.page.ts index 058267aa6..d146376e7 100644 --- a/src/addons/badges/pages/issued-badge/issued-badge.page.ts +++ b/src/addons/badges/pages/issued-badge/issued-badge.page.ts @@ -22,6 +22,7 @@ import { AddonBadges, AddonBadgesUserBadge } from '../../services/badges'; import { CoreUtils } from '@services/utils/utils'; import { CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/courses'; import { CoreNavigator } from '@services/navigator'; +import { ActivatedRoute } from '@angular/router'; /** * Page that displays the list of calendar events. @@ -42,16 +43,24 @@ export class AddonBadgesIssuedBadgePage implements OnInit { badgeLoaded = false; currentTime = 0; + constructor( + protected route: ActivatedRoute, + ) { } + /** * View loaded. */ ngOnInit(): void { - this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || this.courseId; // Use 0 for site badges. - this.userId = CoreNavigator.instance.getRouteNumberParam('userId') || CoreSites.instance.getCurrentSite()!.getUserId(); - this.badgeHash = CoreNavigator.instance.getRouteParam('badgeHash') || ''; + this.route.queryParams.subscribe(() => { + this.badgeLoaded = false; - this.fetchIssuedBadge().finally(() => { - this.badgeLoaded = true; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || this.courseId; // Use 0 for site badges. + this.userId = CoreNavigator.instance.getRouteNumberParam('userId') || CoreSites.instance.getCurrentSite()!.getUserId(); + this.badgeHash = CoreNavigator.instance.getRouteParam('badgeHash') || ''; + + this.fetchIssuedBadge().finally(() => { + this.badgeLoaded = true; + }); }); } diff --git a/src/addons/badges/pages/user-badges/user-badges.html b/src/addons/badges/pages/user-badges/user-badges.html index 355c64698..684182843 100644 --- a/src/addons/badges/pages/user-badges/user-badges.html +++ b/src/addons/badges/pages/user-badges/user-badges.html @@ -6,29 +6,31 @@ {{ 'addon.badges.badges' | translate }} - - - - - - - + + + + + + + - - - - - - -

{{ badge.name }}

-

{{ badge.dateissued * 1000 | coreFormatDate :'strftimedatetimeshort' }}

-
- - {{ 'addon.badges.expired' | translate }} - -
-
-
+ + + + + + +

{{ badge.name }}

+

{{ badge.dateissued * 1000 | coreFormatDate :'strftimedatetimeshort' }}

+
+ + {{ 'addon.badges.expired' | translate }} + +
+
+
+
diff --git a/src/addons/badges/pages/user-badges/user-badges.module.ts b/src/addons/badges/pages/user-badges/user-badges.module.ts index 11da23507..10d3a6e86 100644 --- a/src/addons/badges/pages/user-badges/user-badges.module.ts +++ b/src/addons/badges/pages/user-badges/user-badges.module.ts @@ -17,15 +17,34 @@ import { IonicModule } from '@ionic/angular'; import { TranslateModule } from '@ngx-translate/core'; import { RouterModule, Routes } from '@angular/router'; import { CommonModule } from '@angular/common'; +import { conditionalRoutes } from '@/app/app-routing.module'; +import { CoreScreen } from '@services/screen'; import { CoreSharedModule } from '@/core/shared.module'; import { AddonBadgesUserBadgesPage } from './user-badges.page'; +import { AddonBadgesIssueRoute } from '@addons/badges/badges-lazy.module'; -const routes: Routes = [ +const mobileRoutes: Routes = [ { path: '', component: AddonBadgesUserBadgesPage, }, + AddonBadgesIssueRoute, +]; + +const tabletRoutes: Routes = [ + { + path: '', + component: AddonBadgesUserBadgesPage, + children: [ + AddonBadgesIssueRoute, + ], + }, +]; + +const routes: Routes = [ + ...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile), + ...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet), ]; @NgModule({ diff --git a/src/addons/badges/pages/user-badges/user-badges.page.ts b/src/addons/badges/pages/user-badges/user-badges.page.ts index dae15b5e1..75087bfc4 100644 --- a/src/addons/badges/pages/user-badges/user-badges.page.ts +++ b/src/addons/badges/pages/user-badges/user-badges.page.ts @@ -20,7 +20,7 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreSites } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; import { CoreNavigator } from '@services/navigator'; -// @todo import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreScreen } from '@services/screen'; /** * Page that displays the list of calendar events. @@ -31,8 +31,6 @@ import { CoreNavigator } from '@services/navigator'; }) export class AddonBadgesUserBadgesPage implements OnInit { - // @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; - courseId = 0; userId!: number; @@ -50,11 +48,10 @@ export class AddonBadgesUserBadgesPage implements OnInit { this.userId = CoreNavigator.instance.getRouteNumberParam('userId') || CoreSites.instance.getCurrentSite()!.getUserId(); this.fetchBadges().finally(() => { - // @todo splitview - /* if (!this.badgeHash && this.splitviewCtrl.isOn() && this.badges.length > 0) { + if (!this.badgeHash && CoreScreen.instance.isTablet && this.badges.length > 0) { // Take first and load it. this.loadIssuedBadge(this.badges[0].uniquehash); - }*/ + } this.badgesLoaded = true; }); } @@ -99,9 +96,11 @@ export class AddonBadgesUserBadgesPage implements OnInit { loadIssuedBadge(badgeHash: string): void { this.badgeHash = badgeHash; const params = { courseId: this.courseId, userId: this.userId, badgeHash: badgeHash }; - // @todo use splitview. - // this.splitviewCtrl.push('AddonBadgesIssuedBadgePage', params); - CoreNavigator.instance.navigateToSitePath('/badges/issue', { params }); + + const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/badges/user/issue'); + const path = (splitViewLoaded ? '../' : '') + 'issue'; + + CoreNavigator.instance.navigate(path, { params }); } } diff --git a/src/addons/block/myoverview/components/myoverview/addon-block-myoverview.html b/src/addons/block/myoverview/components/myoverview/addon-block-myoverview.html index 1532ecda2..acf647cf3 100644 --- a/src/addons/block/myoverview/components/myoverview/addon-block-myoverview.html +++ b/src/addons/block/myoverview/components/myoverview/addon-block-myoverview.html @@ -33,10 +33,10 @@ -
+
- + {{ 'addon.block_myoverview.allincludinghidden' | translate }} diff --git a/src/addons/block/recentlyaccessedcourses/components/recentlyaccessedcourses/addon-block-recentlyaccessedcourses.html b/src/addons/block/recentlyaccessedcourses/components/recentlyaccessedcourses/addon-block-recentlyaccessedcourses.html index b4fe5b2e1..821e6874b 100644 --- a/src/addons/block/recentlyaccessedcourses/components/recentlyaccessedcourses/addon-block-recentlyaccessedcourses.html +++ b/src/addons/block/recentlyaccessedcourses/components/recentlyaccessedcourses/addon-block-recentlyaccessedcourses.html @@ -1,22 +1,27 @@

{{ 'addon.block_recentlyaccessedcourses.pluginname' | translate }}

-
+
- + - {{prefetchCoursesData.badge}} + + {{prefetchCoursesData.badge}} +
- +
- +
diff --git a/src/addons/block/sitemainmenu/components/sitemainmenu/addon-block-sitemainmenu.html b/src/addons/block/sitemainmenu/components/sitemainmenu/addon-block-sitemainmenu.html index a9e637d33..fa047f050 100644 --- a/src/addons/block/sitemainmenu/components/sitemainmenu/addon-block-sitemainmenu.html +++ b/src/addons/block/sitemainmenu/components/sitemainmenu/addon-block-sitemainmenu.html @@ -12,6 +12,7 @@ - + diff --git a/src/addons/block/starredcourses/components/starredcourses/addon-block-starredcourses.html b/src/addons/block/starredcourses/components/starredcourses/addon-block-starredcourses.html index 09db419a6..b8c52e1c2 100644 --- a/src/addons/block/starredcourses/components/starredcourses/addon-block-starredcourses.html +++ b/src/addons/block/starredcourses/components/starredcourses/addon-block-starredcourses.html @@ -3,20 +3,25 @@

{{ 'addon.block_starredcourses.pluginname' | translate }}

- + - {{prefetchCoursesData.badge}} + + {{prefetchCoursesData.badge}} +
- +
- +
diff --git a/src/addons/calendar/calendar-lazy.module.ts b/src/addons/calendar/calendar-lazy.module.ts index 396831edf..8b7c40fc1 100644 --- a/src/addons/calendar/calendar-lazy.module.ts +++ b/src/addons/calendar/calendar-lazy.module.ts @@ -13,10 +13,21 @@ // limitations under the License. import { Injector, NgModule } from '@angular/core'; -import { RouterModule, ROUTES, Routes } from '@angular/router'; +import { Route, RouterModule, ROUTES, Routes } from '@angular/router'; import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module'; +export const AddonCalendarEditRoute: Route = { + path: 'edit', + loadChildren: () => + import('@/addons/calendar/pages/edit-event/edit-event.module').then(m => m.AddonCalendarEditEventPageModule), +}; + +export const AddonCalendarEventRoute: Route ={ + path: 'event', + loadChildren: () => import('@/addons/calendar/pages/event/event.module').then(m => m.AddonCalendarEventPageModule), +}; + function buildRoutes(injector: Injector): Routes { return [ { @@ -37,16 +48,8 @@ function buildRoutes(injector: Injector): Routes { loadChildren: () => import('@/addons/calendar/pages/day/day.module').then(m => m.AddonCalendarDayPageModule), }, - { - path: 'event', - loadChildren: () => - import('@/addons/calendar/pages/event/event.module').then(m => m.AddonCalendarEventPageModule), - }, - { - path: 'edit', - loadChildren: () => - import('@/addons/calendar/pages/edit-event/edit-event.module').then(m => m.AddonCalendarEditEventPageModule), - }, + AddonCalendarEventRoute, + AddonCalendarEditRoute, ...buildTabMainRoutes(injector, { redirectTo: 'index', pathMatch: 'full', diff --git a/src/addons/calendar/components/calendar/addon-calendar-calendar.html b/src/addons/calendar/components/calendar/addon-calendar-calendar.html index 6b697a330..d4eb63e83 100644 --- a/src/addons/calendar/components/calendar/addon-calendar-calendar.html +++ b/src/addons/calendar/components/calendar/addon-calendar-calendar.html @@ -32,7 +32,7 @@ - + {{ day.shortname | translate }} {{ day.fullname | translate }} @@ -42,10 +42,10 @@ - + [class.addon-calendar-event-past-day]="isPastMonth || day.ispast">

{{ day.mday }}

diff --git a/src/addons/calendar/components/upcoming-events/addon-calendar-upcoming-events.html b/src/addons/calendar/components/upcoming-events/addon-calendar-upcoming-events.html index f86b22a2c..0f91e2f50 100644 --- a/src/addons/calendar/components/upcoming-events/addon-calendar-upcoming-events.html +++ b/src/addons/calendar/components/upcoming-events/addon-calendar-upcoming-events.html @@ -4,7 +4,7 @@ - diff --git a/src/addons/calendar/pages/day/day.html b/src/addons/calendar/pages/day/day.html index 378fe8dcc..33ea1792f 100644 --- a/src/addons/calendar/pages/day/day.html +++ b/src/addons/calendar/pages/day/day.html @@ -59,9 +59,8 @@ - + diff --git a/src/addons/calendar/pages/edit-event/edit-event.html b/src/addons/calendar/pages/edit-event/edit-event.html index 27ce1cfe5..09b1bdf45 100644 --- a/src/addons/calendar/pages/edit-event/edit-event.html +++ b/src/addons/calendar/pages/edit-event/edit-event.html @@ -97,7 +97,7 @@
- +

{{ 'core.coursenogroups' | translate }}

@@ -119,7 +119,7 @@ - + @@ -147,7 +147,7 @@
-
+
@@ -192,7 +192,7 @@ -
+
diff --git a/src/addons/calendar/pages/edit-event/edit-event.page.ts b/src/addons/calendar/pages/edit-event/edit-event.page.ts index c757fc200..71ee3feb1 100644 --- a/src/addons/calendar/pages/edit-event/edit-event.page.ts +++ b/src/addons/calendar/pages/edit-event/edit-event.page.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; +import { Component, OnInit, OnDestroy, ViewChild, ElementRef, Optional } from '@angular/core'; import { FormControl, FormGroup, FormBuilder, Validators } from '@angular/forms'; import { IonRefresher } from '@ionic/angular'; import { CoreEvents } from '@singletons/events'; @@ -23,7 +23,7 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreTimeUtils } from '@services/utils/time'; import { CoreUtils } from '@services/utils/utils'; import { CoreCategoryData, CoreCourses, CoreCourseSearchedData, CoreEnrolledCourseData } from '@features/courses/services/courses'; -// @todo import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreEditorRichTextEditorComponent } from '@features/editor/components/rich-text-editor/rich-text-editor.ts'; import { AddonCalendarProvider, @@ -91,6 +91,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy { constructor( protected fb: FormBuilder, + @Optional() protected svComponent: CoreSplitViewComponent, ) { this.currentSite = CoreSites.instance.getCurrentSite()!; @@ -569,14 +570,15 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy { } } - /* if (this.svComponent && this.svComponent.isOn()) { + if (this.svComponent?.isOn()) { // Empty form. this.hasOffline = false; this.form.reset(this.originalData); this.originalData = CoreUtils.instance.clone(this.form.value); - } else {*/ - this.originalData = undefined; // Avoid asking for confirmation. - CoreNavigator.instance.back(); + } else { + this.originalData = undefined; // Avoid asking for confirmation. + CoreNavigator.instance.back(); + } } /** diff --git a/src/addons/calendar/pages/event/event.page.ts b/src/addons/calendar/pages/event/event.page.ts index 225fd8275..3d3c9e7a2 100644 --- a/src/addons/calendar/pages/event/event.page.ts +++ b/src/addons/calendar/pages/event/event.page.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit, Optional } from '@angular/core'; import { IonRefresher } from '@ionic/angular'; import { AlertOptions } from '@ionic/core'; import { @@ -37,12 +37,14 @@ import { CoreLocalNotifications } from '@services/local-notifications'; import { CoreCourse } from '@features/course/services/course'; import { CoreTimeUtils } from '@services/utils/time'; import { CoreGroups } from '@services/groups'; -// @todo import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { Network, NgZone, Translate } from '@singletons'; import { Subscription } from 'rxjs'; import { CoreNavigator } from '@services/navigator'; import { CoreUtils } from '@services/utils/utils'; import { AddonCalendarReminderDBRecord } from '../../services/database/calendar'; +import { ActivatedRoute } from '@angular/router'; +import { CoreScreen } from '@services/screen'; /** * Page that displays a single calendar event. @@ -85,11 +87,15 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { syncIcon = 'spinner'; // Sync icon. isSplitViewOn = false; - constructor() { + constructor( + @Optional() protected svComponent: CoreSplitViewComponent, + protected route: ActivatedRoute, + ) { + this.notificationsEnabled = CoreLocalNotifications.instance.isAvailable(); this.siteHomeId = CoreSites.instance.getCurrentSiteHomeId(); this.currentSiteId = CoreSites.instance.getCurrentSiteId(); - // this.isSplitViewOn = this.svComponent && this.svComponent.isOn(); + this.isSplitViewOn = this.svComponent?.isOn(); // Check if site supports editing and deleting. No need to check allowed types, event.canedit already does it. this.canEdit = AddonCalendar.instance.canEditEventsInSite(); @@ -145,18 +151,22 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { * View loaded. */ ngOnInit(): void { - const eventId = CoreNavigator.instance.getRouteNumberParam('id'); - if (!eventId) { - CoreDomUtils.instance.showErrorModal('Event ID not supplied.'); - CoreNavigator.instance.back(); + this.route.queryParams.subscribe(() => { + this.eventLoaded = false; - return; - } + const eventId = CoreNavigator.instance.getRouteNumberParam('id'); + if (!eventId) { + CoreDomUtils.instance.showErrorModal('Event ID not supplied.'); + CoreNavigator.instance.back(); - this.eventId = eventId; - this.syncIcon = 'spinner'; + return; + } - this.fetchEvent(); + this.eventId = eventId; + this.syncIcon = 'spinner'; + + this.fetchEvent(); + }); } /** @@ -501,9 +511,9 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { CoreDomUtils.instance.showToast('addon.calendar.eventcalendareventdeleted', true, 3000); // Event deleted, close the view. - /* if (!this.svComponent || !this.svComponent.isOn()) { - this.navCtrl.pop(); - }*/ + if (CoreScreen.instance.isMobile) { + CoreNavigator.instance.back(); + } } else { // Event deleted in offline, just mark it as deleted. this.event.deleted = true; @@ -558,9 +568,9 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { CoreDomUtils.instance.showToast('addon.calendar.eventcalendareventdeleted', true, 3000); // Event was deleted, close the view. - /* if (!this.svComponent || !this.svComponent.isOn()) { - this.navCtrl.pop(); - }*/ + if (CoreScreen.instance.isMobile) { + CoreNavigator.instance.back(); + } } else if (data.events && (!isManual || data.source != 'event')) { const event = data.events.find((ev) => ev.id == this.eventId); diff --git a/src/addons/calendar/pages/index/index.page.ts b/src/addons/calendar/pages/index/index.page.ts index 60a7eb3bc..fbe1e8319 100644 --- a/src/addons/calendar/pages/index/index.page.ts +++ b/src/addons/calendar/pages/index/index.page.ts @@ -89,9 +89,9 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy { this.currentSiteId = CoreSites.instance.getCurrentSiteId(); // Listen for events added. When an event is added, reload the data. - this.newEventObserver = CoreEvents.on( + this.newEventObserver = CoreEvents.on( AddonCalendarProvider.NEW_EVENT_EVENT, - (data: AddonCalendarUpdatedEventEvent) => { + (data) => { if (data && data.eventId) { this.loaded = false; this.refreshData(true, false); @@ -107,9 +107,9 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy { }, this.currentSiteId); // Listen for events edited. When an event is edited, reload the data. - this.editEventObserver = CoreEvents.on( + this.editEventObserver = CoreEvents.on( AddonCalendarProvider.EDIT_EVENT_EVENT, - (data: AddonCalendarUpdatedEventEvent) => { + (data) => { if (data && data.eventId) { this.loaded = false; this.refreshData(true, false); @@ -125,7 +125,7 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy { }, this.currentSiteId); // Refresh data if calendar events are synchronized manually but not by this page. - this.manualSyncObserver = CoreEvents.on(AddonCalendarSyncProvider.MANUAL_SYNCED, (data: AddonCalendarSyncEvents) => { + this.manualSyncObserver = CoreEvents.on(AddonCalendarSyncProvider.MANUAL_SYNCED, (data) => { if (data && data.source != 'index') { this.loaded = false; this.refreshData(false, false); @@ -143,9 +143,9 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy { this.hasOffline = await AddonCalendarOffline.instance.hasOfflineData(); }, this.currentSiteId); - this.filterChangedObserver = CoreEvents.on( + this.filterChangedObserver = CoreEvents.on( AddonCalendarProvider.FILTER_CHANGED_EVENT, - async (filterData: AddonCalendarFilter) => { + async (filterData) => { this.filter = filterData; // Course viewed has changed, check if the user can create events for this course calendar. diff --git a/src/addons/calendar/pages/list/list.html b/src/addons/calendar/pages/list/list.html index 1c11b3950..65fbc523a 100644 --- a/src/addons/calendar/pages/list/list.html +++ b/src/addons/calendar/pages/list/list.html @@ -19,8 +19,8 @@ - - + + @@ -42,9 +42,8 @@ {{ event.timestart * 1000 | coreFormatDate: "strftimedayshort" }} - + @@ -86,5 +85,5 @@ - - + + diff --git a/src/addons/calendar/pages/list/list.module.ts b/src/addons/calendar/pages/list/list.module.ts index 18d32c117..a8653f719 100644 --- a/src/addons/calendar/pages/list/list.module.ts +++ b/src/addons/calendar/pages/list/list.module.ts @@ -17,18 +17,40 @@ import { IonicModule } from '@ionic/angular'; import { TranslateModule } from '@ngx-translate/core'; import { RouterModule, Routes } from '@angular/router'; import { CommonModule } from '@angular/common'; +import { AddonCalendarEventRoute, AddonCalendarEditRoute } from '@addons/calendar/calendar-lazy.module'; +import { conditionalRoutes } from '@/app/app-routing.module'; +import { CoreScreen } from '@services/screen'; import { CoreSharedModule } from '@/core/shared.module'; import { AddonCalendarListPage } from './list.page'; -const routes: Routes = [ +const splitviewRoutes = [AddonCalendarEditRoute, AddonCalendarEventRoute]; + +const mobileRoutes: Routes = [ { path: '', component: AddonCalendarListPage, }, + ...splitviewRoutes, ]; +const tabletRoutes: Routes = [ + { + path: '', + component: AddonCalendarListPage, + children: [ + ...splitviewRoutes, + ], + }, +]; + +const routes: Routes = [ + ...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile), + ...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet), +]; + + @NgModule({ imports: [ RouterModule.forChild(routes), diff --git a/src/addons/calendar/pages/list/list.page.ts b/src/addons/calendar/pages/list/list.page.ts index 93451166f..a0142b4a0 100644 --- a/src/addons/calendar/pages/list/list.page.ts +++ b/src/addons/calendar/pages/list/list.page.ts @@ -30,7 +30,7 @@ import { CoreSites } from '@services/sites'; import { CoreLocalNotifications } from '@services/local-notifications'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreApp } from '@services/app'; -// @todo import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; import moment from 'moment'; import { CoreConstants } from '@/core/constants'; import { AddonCalendarFilterPopoverComponent } from '../../components/filter/filter'; @@ -52,7 +52,7 @@ import { CoreNavigator } from '@services/navigator'; export class AddonCalendarListPage implements OnInit, OnDestroy { @ViewChild(IonContent) content?: IonContent; - // @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; + @ViewChild(CoreSplitViewComponent) splitviewCtrl?: CoreSplitViewComponent; protected initialTime = 0; protected daysLoaded = 0; @@ -117,30 +117,28 @@ export class AddonCalendarListPage implements OnInit, OnDestroy { } // Listen for events added. When an event is added, reload the data. - this.newEventObserver = CoreEvents.on(AddonCalendarProvider.NEW_EVENT_EVENT, (data: AddonCalendarUpdatedEventEvent) => { + this.newEventObserver = CoreEvents.on(AddonCalendarProvider.NEW_EVENT_EVENT, (data) => { if (data && data.eventId) { - /* if (this.splitviewCtrl.isOn()) { - // Discussion added, clear details page. - this.splitviewCtrl.emptyDetails(); - }*/ - this.eventsLoaded = false; this.refreshEvents(true, false).finally(() => { // In tablet mode try to open the event (only if it's an online event). - /* if (this.splitviewCtrl.isOn() && data.event.id > 0) { - this.gotoEvent(data.event.id); - }*/ + if (this.splitviewCtrl?.isOn() && data.eventId > 0) { + this.gotoEvent(data.eventId); + } else if (this.splitviewCtrl?.isOn()) { + // Discussion added, clear details page. + this.emptySplitView(); + } }); } }, this.currentSiteId); // Listen for new event discarded event. When it does, reload the data. this.discardedObserver = CoreEvents.on(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, () => { - /* if (this.splitviewCtrl.isOn()) { + if (this.splitviewCtrl?.isOn()) { // Discussion added, clear details page. - this.splitviewCtrl.emptyDetails(); - }*/ + this.emptySplitView(); + } this.eventsLoaded = false; this.refreshEvents(true, false); @@ -155,14 +153,14 @@ export class AddonCalendarListPage implements OnInit, OnDestroy { }, this.currentSiteId); // Refresh data if calendar events are synchronized automatically. - this.syncObserver = CoreEvents.on(AddonCalendarSyncProvider.AUTO_SYNCED, () => { + this.syncObserver = CoreEvents.on(AddonCalendarSyncProvider.AUTO_SYNCED, (data) => { this.eventsLoaded = false; this.refreshEvents(); - /* if (this.splitviewCtrl.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) { + if (this.splitviewCtrl?.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) { // Current selected event was deleted. Clear details. - this.splitviewCtrl.emptyDetails(); - } */ + this.emptySplitView(); + } }, this.currentSiteId); // Refresh data if calendar events are synchronized manually but not by this page. @@ -172,10 +170,10 @@ export class AddonCalendarListPage implements OnInit, OnDestroy { this.refreshEvents(); } - /* if (this.splitviewCtrl.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) { + if (this.splitviewCtrl?.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) { // Current selected event was deleted. Clear details. - this.splitviewCtrl.emptyDetails(); - }*/ + this.emptySplitView(); + } }, this.currentSiteId); // Update the list when an event is deleted. @@ -183,15 +181,15 @@ export class AddonCalendarListPage implements OnInit, OnDestroy { AddonCalendarProvider.DELETED_EVENT_EVENT, (data: AddonCalendarUpdatedEventEvent) => { if (data && !data.sent) { - // Event was deleted in offline. Just mark it as deleted, no need to refresh. + // Event was deleted in offline. Just mark it as deleted, no need to refresh. this.markAsDeleted(data.eventId, true); this.deletedEvents.push(data.eventId); this.hasOffline = true; } else { - // Event deleted, clear the details if needed and refresh the view. - /* if (this.splitviewCtrl.isOn()) { - this.splitviewCtrl.emptyDetails(); - } */ + // Event deleted, clear the details if needed and refresh the view. + if (this.splitviewCtrl?.isOn()) { + this.emptySplitView(); + } this.eventsLoaded = false; this.refreshEvents(); @@ -259,14 +257,26 @@ export class AddonCalendarListPage implements OnInit, OnDestroy { await this.fetchData(false, true, false); - /* if (!this.eventId && this.splitviewCtrl.isOn() && this.events.length > 0) { + if (!this.eventId && this.splitviewCtrl?.isOn() && this.events.length > 0) { // Take first online event and load it. If no online event, load the first offline. if (this.onlineEvents[0]) { this.gotoEvent(this.onlineEvents[0].id); } else { this.gotoEvent(this.offlineEvents[0].id); } - }*/ + } + } + + /** + * Convenience function to clear detail view of the split view. + */ + protected emptySplitView(): void { + // Empty details. + const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/calendar/list/event') || + CoreNavigator.instance.isCurrentPathInTablet('**/calendar/list/edit'); + if (splitViewLoaded) { + CoreNavigator.instance.navigate('../'); + } } /** @@ -642,7 +652,10 @@ export class AddonCalendarListPage implements OnInit, OnDestroy { params.courseId = this.filter.courseId; } - CoreNavigator.instance.navigateToSitePath('/calendar/edit', { params }); // @todo , this.splitviewCtrl); + const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/calendar/list/event') || + CoreNavigator.instance.isCurrentPathInTablet('**/calendar/list/edit'); + const path = (splitViewLoaded ? '../' : '') + 'edit'; + CoreNavigator.instance.navigate(path, { params }); } /** @@ -664,9 +677,12 @@ export class AddonCalendarListPage implements OnInit, OnDestroy { // It's an offline event, go to the edit page. this.openEdit(eventId); } else { - /* this.splitviewCtrl.push('/calendar/event', { + const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/calendar/list/event') || + CoreNavigator.instance.isCurrentPathInTablet('**/calendar/list/edit'); + const path = (splitViewLoaded ? '../' : '') + 'event'; + CoreNavigator.instance.navigate(path, { params: { id: eventId, - });*/ + } }); } } diff --git a/src/addons/coursecompletion/services/coursecompletion.ts b/src/addons/coursecompletion/services/coursecompletion.ts index 6ae4d2b92..dd6f7205e 100644 --- a/src/addons/coursecompletion/services/coursecompletion.ts +++ b/src/addons/coursecompletion/services/coursecompletion.ts @@ -20,6 +20,7 @@ import { CoreCourses } from '@features/courses/services/courses'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@services/ws'; import { makeSingleton } from '@singletons'; +import { CoreError } from '@classes/errors/error'; const ROOT_CACHE_KEY = 'mmaCourseCompletion:'; @@ -118,7 +119,7 @@ export class AddonCourseCompletionProvider { return result.completionstatus; } - throw null; + throw new CoreError('Cannot fetch course completion status'); } /** @@ -165,7 +166,7 @@ export class AddonCourseCompletionProvider { */ async isPluginViewEnabledForCourse(courseId: number, preferCache: boolean = true): Promise { if (!courseId) { - throw null; + throw new CoreError('No courseId provided'); } const course = await CoreCourses.instance.getUserCourse(courseId, preferCache); @@ -260,7 +261,7 @@ export class AddonCourseCompletionProvider { const response = await site.write('core_completion_mark_course_self_completed', params); if (!response.status) { - throw null; + throw new CoreError('Cannot mark course as self completed'); } } diff --git a/src/addons/filter/mathjaxloader/services/handlers/mathjaxloader.ts b/src/addons/filter/mathjaxloader/services/handlers/mathjaxloader.ts index 5056dc1c5..c8dc95b46 100644 --- a/src/addons/filter/mathjaxloader/services/handlers/mathjaxloader.ts +++ b/src/addons/filter/mathjaxloader/services/handlers/mathjaxloader.ts @@ -216,26 +216,26 @@ export class AddonFilterMathJaxLoaderHandlerService extends CoreFilterDefaultHan document.head.appendChild(script); // Save the lang config until MathJax is actually loaded. - this._lang = params.lang; // eslint-disable-line no-underscore-dangle + this._lang = params.lang; }, // Set the correct language for the MathJax menus. _setLocale: function (): void { - if (!this._configured) { // eslint-disable-line no-underscore-dangle - const lang = this._lang; // eslint-disable-line no-underscore-dangle + if (!this._configured) { + const lang = this._lang; if (typeof that.window.MathJax != 'undefined') { that.window.MathJax.Hub.Queue(() => { that.window.MathJax.Localization.setLocale(lang); }); that.window.MathJax.Hub.Configured(); - this._configured = true; // eslint-disable-line no-underscore-dangle + this._configured = true; } } }, // Called by the filter when an equation is found while rendering the page. typeset: function (container: HTMLElement): void { - if (!this._configured) { // eslint-disable-line no-underscore-dangle - this._setLocale(); // eslint-disable-line no-underscore-dangle + if (!this._configured) { + this._setLocale(); } if (typeof that.window.MathJax != 'undefined') { diff --git a/src/addons/messages/components/components.module.ts b/src/addons/messages/components/components.module.ts new file mode 100644 index 000000000..ec68cc07b --- /dev/null +++ b/src/addons/messages/components/components.module.ts @@ -0,0 +1,39 @@ +// (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 { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreSharedModule } from '@/core/shared.module'; + +import { AddonMessagesConversationInfoComponent } from './conversation-info/conversation-info'; + + +@NgModule({ + declarations: [ + AddonMessagesConversationInfoComponent, + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + ], + entryComponents: [ + AddonMessagesConversationInfoComponent, + ], +}) +export class AddonMessagesComponentsModule {} diff --git a/src/addons/messages/components/conversation-info/conversation-info.html b/src/addons/messages/components/conversation-info/conversation-info.html new file mode 100644 index 000000000..a07fe4cd0 --- /dev/null +++ b/src/addons/messages/components/conversation-info/conversation-info.html @@ -0,0 +1,54 @@ + + + + + + {{ 'addon.messages.groupinfo' | translate }} + + + + + + + + + + + + + + + +
+ +
+

+ +

+

+ + +

+

{{ 'addon.messages.numparticipants' | translate:{$a: conversation!.membercount} }}

+
+
+ + + + + +

+ {{ member.fullname }} + + +

+
+
+ + + +
+
diff --git a/src/addons/messages/components/conversation-info/conversation-info.ts b/src/addons/messages/components/conversation-info/conversation-info.ts new file mode 100644 index 000000000..42cc442f5 --- /dev/null +++ b/src/addons/messages/components/conversation-info/conversation-info.ts @@ -0,0 +1,147 @@ +// (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 { IonRefresher } from '@ionic/angular'; +import { + AddonMessagesConversationFormatted, + AddonMessagesConversationMember, + AddonMessages, +} from '../../services/messages'; +import { CoreDomUtils } from '@services/utils/dom'; +import { ActivatedRoute } from '@angular/router'; +import { ModalController } from '@singletons'; +import { CoreNavigator } from '@services/navigator'; + +/** + * Component that displays the list of conversations, including group conversations. + */ +@Component({ + selector: 'page-addon-messages-conversation-info', + templateUrl: 'conversation-info.html', +}) +export class AddonMessagesConversationInfoComponent implements OnInit { + + loaded = false; + conversation?: AddonMessagesConversationFormatted; + members: AddonMessagesConversationMember[] = []; + canLoadMore = false; + loadMoreError = false; + + protected conversationId!: number; + + constructor( + protected route: ActivatedRoute, + ) { + } + + /** + * Component loaded. + */ + ngOnInit(): void { + this.route.queryParams.subscribe(async () => { + this.conversationId = CoreNavigator.instance.getRouteNumberParam('conversationId') || 0; + + this.loaded = false; + this.fetchData().finally(() => { + this.loaded = true; + }); + }); + } + + /** + * Fetch the required data. + * + * @return Promise resolved when done. + */ + protected async fetchData(): Promise { + // Get the conversation data first. + try { + const conversation = await AddonMessages.instance.getConversation(this.conversationId, false, true, 0, 0); + this.conversation = conversation; + + // Now get the members. + await this.fetchMembers(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting members.'); + } + } + + /** + * Get conversation members. + * + * @param loadingMore Whether we are loading more data or just the first ones. + * @return Promise resolved when done. + */ + protected async fetchMembers(loadingMore?: boolean): Promise { + this.loadMoreError = false; + + const limitFrom = loadingMore ? this.members.length : 0; + + const data = await AddonMessages.instance.getConversationMembers(this.conversationId, limitFrom); + if (loadingMore) { + this.members = this.members.concat(data.members); + } else { + this.members = data.members; + } + + this.canLoadMore = data.canLoadMore; + } + + /** + * Function to load more members. + * + * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. + * @return Resolved when done. + */ + async loadMoreMembers(infiniteComplete?: () => void): Promise { + try { + await this.fetchMembers(true); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting members.'); + this.loadMoreError = true; + } finally { + infiniteComplete && infiniteComplete(); + } + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @return Promise resolved when done. + */ + async refreshData(refresher?: CustomEvent): Promise { + const promises: Promise[] = []; + + promises.push(AddonMessages.instance.invalidateConversation(this.conversationId)); + promises.push(AddonMessages.instance.invalidateConversationMembers(this.conversationId)); + + await Promise.all(promises); + + await this.fetchData().finally(() => { + refresher?.detail.complete(); + }); + } + + /** + * Close modal. + * + * @param userId User conversation to load. + */ + closeModal(userId?: number): void { + ModalController.instance.dismiss(userId); + } + +} diff --git a/src/addons/messages/lang.json b/src/addons/messages/lang.json new file mode 100644 index 000000000..0b728d3e9 --- /dev/null +++ b/src/addons/messages/lang.json @@ -0,0 +1,84 @@ +{ + "acceptandaddcontact": "Accept and add to contacts", + "addcontact": "Add contact", + "addcontactconfirm": "Are you sure you want to add {{$a}} to your contacts?", + "addtofavourites": "Star conversation", + "addtoyourcontacts": "Add to contacts", + "blocknoncontacts": "Prevent non-contacts from messaging me", + "blockuser": "Block user", + "blockuserconfirm": "Are you sure you want to block {{$a}}?", + "contactableprivacy": "Accept messages from:", + "contactableprivacy_coursemember": "My contacts and anyone in my courses", + "contactableprivacy_onlycontacts": "My contacts only", + "contactableprivacy_site": "Anyone on the site", + "contactblocked": "Contact blocked", + "contactlistempty": "The contact list is empty", + "contactname": "Contact name", + "contactrequestsent": "Contact request sent", + "contacts": "Contacts", + "conversationactions": "Conversation actions menu", + "decline": "Decline", + "deleteallconfirm": "Are you sure you would like to delete this entire conversation? This will not delete it for other conversation participants.", + "deleteallselfconfirm": "Are you sure you would like to delete this entire personal conversation?", + "deleteconversation": "Delete conversation", + "deleteforeveryone": "Delete for me and for everyone else", + "deletemessage": "Delete message", + "deletemessageconfirmation": "Are you sure you want to delete this message? It will only be deleted from your messaging history and will still be viewable by the user who sent or received the message.", + "errordeletemessage": "Error while deleting the message.", + "errorwhileretrievingcontacts": "Error while retrieving contacts from the server.", + "errorwhileretrievingdiscussions": "Error while retrieving discussions from the server.", + "errorwhileretrievingmessages": "Error while retrieving messages from the server.", + "errorwhileretrievingusers": "Error while retrieving users from the server.", + "groupconversations": "Group", + "groupinfo": "Group info", + "individualconversations": "Private", + "info": "User info", + "isnotinyourcontacts": "{{$a}} is not in your contacts", + "message": "Message", + "messagenotsent": "The message was not sent. Please try again later.", + "messagepreferences": "Message preferences", + "messages": "Messages", + "muteconversation": "Mute", + "mutedconversation": "Muted conversation", + "newmessage": "New message", + "newmessages": "New messages", + "nocontactrequests": "No contact requests", + "nocontactsgetstarted": "No contacts", + "nofavourites": "No starred conversations", + "nogroupconversations": "No group conversations", + "noindividualconversations": "No private conversations", + "nomessagesfound": "No messages were found", + "noncontacts": "Non-contacts", + "nousersfound": "No users found", + "numparticipants": "{{$a}} participants", + "removecontact": "Remove contact", + "removecontactconfirm": "Are you sure you want to remove {{$a}} from your contacts?", + "removefromfavourites": "Unstar conversation", + "removefromyourcontacts": "Remove from contacts", + "requests": "Requests", + "requirecontacttomessage": "You need to request {{$a}} to add you as a contact to be able to message them.", + "searchcombined": "Search people and messages", + "selfconversation": "Personal space", + "selfconversationdefaultmessage": "Save draft messages, links, notes etc. to access later.", + "sendcontactrequest": "Send contact request", + "showdeletemessages": "Show delete messages", + "type_blocked": "Blocked", + "type_offline": "Offline", + "type_online": "Online", + "type_search": "Search results", + "type_strangers": "Others", + "unabletomessage": "You are unable to message this user", + "unblockuser": "Unblock user", + "unblockuserconfirm": "Are you sure you want to unblock {{$a}}?", + "unmuteconversation": "Unmute", + "useentertosend": "Use enter to send", + "useentertosenddescdesktop": "If disabled, you can use Ctrl+Enter to send the message.", + "useentertosenddescmac": "If disabled, you can use Cmd+Enter to send the message.", + "userwouldliketocontactyou": "{{$a}} would like to contact you", + "warningconversationmessagenotsent": "Couldn't send message(s) to conversation {{conversation}}. {{error}}", + "warningmessagenotsent": "Couldn't send message(s) to user {{user}}. {{error}}", + "wouldliketocontactyou": "Would like to contact you", + "you": "You:", + "youhaveblockeduser": "You have blocked this user.", + "yourcontactrequestpending": "Your contact request is pending with {{$a}}" +} \ No newline at end of file diff --git a/src/addons/messages/messages-common.scss b/src/addons/messages/messages-common.scss new file mode 100644 index 000000000..42917afcf --- /dev/null +++ b/src/addons/messages/messages-common.scss @@ -0,0 +1,66 @@ +:host { + .addon-messages-conversation-item, + .addon-message-discussion { + h2 { + core-format-text { + font-weight: bold; + } + + ion-icon { + margin-left: 2px; + } + } + + .note { + position: absolute; + top: 0; + right: 0; + margin: 4px 8px; + font-size: 1.3rem; + } + + .addon-message-last-message { + display: flex; + justify-content: flex-start; + } + + .addon-message-last-message-user { + white-space: nowrap; + color: var(--ion-text-color); + margin-right: 2px; + } + + .addon-message-last-message-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 1; + } + } + + .addon-message-discussion { + h2 { + margin-top: 10px; + } + } +} + +:host-context([dir=rtl]) { + .addon-messages-conversation-item, + .addon-message-discussion { + h2 ion-icon { + margin-right: 2px; + margin-left: 0; + } + + .note { + left: 0; + right: unset; + } + + .addon-message-last-message-user { + margin-left: 2px; + margin-right: 0; + } + } +} diff --git a/src/addons/messages/messages-lazy.module.ts b/src/addons/messages/messages-lazy.module.ts new file mode 100644 index 000000000..35e2a2431 --- /dev/null +++ b/src/addons/messages/messages-lazy.module.ts @@ -0,0 +1,77 @@ +// (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 { Injector, NgModule } from '@angular/core'; +import { Route, RouterModule, ROUTES, Routes } from '@angular/router'; + +import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { AddonMessagesSettingsHandlerService } from './services/handlers/settings'; + +export const AddonMessagesDiscussionRoute: Route = { + path: 'discussion', + loadChildren: () => import('./pages/discussion/discussion.module') + .then(m => m.AddonMessagesDiscussionPageModule), +}; + +function buildRoutes(injector: Injector): Routes { + return [ + { + path: 'index', // 3.5 or lower. + loadChildren: () => + import('./pages/discussions-35/discussions.module').then(m => m.AddonMessagesDiscussions35PageModule), + }, + { + path: 'contacts-35', // 3.5 or lower. + loadChildren: () => import('./pages/contacts-35/contacts.module').then(m => m.AddonMessagesContacts35PageModule), + }, + { + path: 'group-conversations', // 3.6 or greater. + loadChildren: () => import('./pages/group-conversations/group-conversations.module') + .then(m => m.AddonMessagesGroupConversationsPageModule), + }, + AddonMessagesDiscussionRoute, + { + path: 'search', + loadChildren: () => import('./pages/search/search.module') + .then(m => m.AddonMessagesSearchPageModule), + }, + { + path: AddonMessagesSettingsHandlerService.PAGE_NAME, + loadChildren: () => import('./pages/settings/settings.module') + .then(m => m.AddonMessagesSettingsPageModule), + }, + { + path: 'contacts', // 3.6 or greater. + loadChildren: () => import('./pages/contacts/contacts.module') + .then(m => m.AddonMessagesContactsPageModule), + }, + ...buildTabMainRoutes(injector, { + redirectTo: 'index', + pathMatch: 'full', + }), + ]; +} + +@NgModule({ + exports: [RouterModule], + providers: [ + { + provide: ROUTES, + multi: true, + deps: [Injector], + useFactory: buildRoutes, + }, + ], +}) +export class AddonMessagesLazyModule { } diff --git a/src/addons/messages/messages.module.ts b/src/addons/messages/messages.module.ts new file mode 100644 index 000000000..37480435d --- /dev/null +++ b/src/addons/messages/messages.module.ts @@ -0,0 +1,84 @@ +// (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 { CoreMainMenuRoutingModule } from '@features/mainmenu/mainmenu-routing.module'; +import { MESSAGES_OFFLINE_SITE_SCHEMA } from './services/database/messages'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { CoreMainMenuDelegate } from '@features/mainmenu/services/mainmenu-delegate'; +import { AddonMessagesMainMenuHandler, AddonMessagesMainMenuHandlerService } from './services/handlers/mainmenu'; +import { CoreCronDelegate } from '@services/cron'; +import { CoreSettingsDelegate } from '@features/settings/services/settings-delegate'; +import { AddonMessagesSettingsHandler } from './services/handlers/settings'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; +import { AddonMessagesIndexLinkHandler } from './services/handlers/index-link'; +import { AddonMessagesDiscussionLinkHandler } from './services/handlers/discussion-link'; +import { AddonMessagesContactRequestLinkHandler } from './services/handlers/contact-request-link'; +import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate'; +import { AddonMessagesPushClickHandler } from './services/handlers/push-click'; +import { CoreUserDelegate } from '@features/user/services/user-delegate'; +import { AddonMessagesSendMessageUserHandler } from './services/handlers/user-send-message'; +import { Network, NgZone } from '@singletons'; +import { AddonMessagesSync } from './services/messages-sync'; + +const mainMenuChildrenRoutes: Routes = [ + { + path: AddonMessagesMainMenuHandlerService.PAGE_NAME, + loadChildren: () => import('./messages-lazy.module').then(m => m.AddonMessagesLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuRoutingModule.forChild({ children: mainMenuChildrenRoutes }), + CoreMainMenuTabRoutingModule.forChild( mainMenuChildrenRoutes), + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [MESSAGES_OFFLINE_SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + // Register handlers. + CoreMainMenuDelegate.instance.registerHandler(AddonMessagesMainMenuHandler.instance); + CoreCronDelegate.instance.register(AddonMessagesMainMenuHandler.instance); + CoreCronDelegate.instance.register(AddonMessagesPushClickHandler.instance); + CoreSettingsDelegate.instance.registerHandler(AddonMessagesSettingsHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonMessagesIndexLinkHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonMessagesDiscussionLinkHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonMessagesContactRequestLinkHandler.instance); + CorePushNotificationsDelegate.instance.registerClickHandler(AddonMessagesPushClickHandler.instance); + CoreUserDelegate.instance.registerHandler(AddonMessagesSendMessageUserHandler.instance); + + // Sync some discussions when device goes online. + Network.instance.onConnect().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(() => { + AddonMessagesSync.instance.syncAllDiscussions(undefined, true); + }); + }); + }, + }, + + ], +}) +export class AddonMessagesModule {} diff --git a/src/addons/messages/pages/contacts-35/contacts.html b/src/addons/messages/pages/contacts-35/contacts.html new file mode 100644 index 000000000..a91b3af6b --- /dev/null +++ b/src/addons/messages/pages/contacts-35/contacts.html @@ -0,0 +1,51 @@ + + + + + + {{ 'addon.messages.contacts' | translate }} + + + + + + + + + + + + + + + + + + + + + + +

{{ 'addon.messages.type_' + contactType | translate }}

+ {{ contacts[contactType].length }} +
+ + + + +

{{ contact.fullname }}

+
+
+
+
+
+
+
diff --git a/src/addons/messages/pages/contacts-35/contacts.module.ts b/src/addons/messages/pages/contacts-35/contacts.module.ts new file mode 100644 index 000000000..de85dcb1d --- /dev/null +++ b/src/addons/messages/pages/contacts-35/contacts.module.ts @@ -0,0 +1,66 @@ +// (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 { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { conditionalRoutes } from '@/app/app-routing.module'; +import { AddonMessagesDiscussionRoute } from '@addons/messages/messages-lazy.module'; +import { CoreScreen } from '@services/screen'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreSearchComponentsModule } from '@features/search/components/components.module'; + +import { AddonMessagesContacts35Page } from './contacts.page'; + +const mobileRoutes: Routes = [ + { + path: '', + component: AddonMessagesContacts35Page, + }, + AddonMessagesDiscussionRoute, +]; + +const tabletRoutes: Routes = [ + { + path: '', + component: AddonMessagesContacts35Page, + children: [ + AddonMessagesDiscussionRoute, + ], + }, +]; + +const routes: Routes = [ + ...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile), + ...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet), +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + CoreSearchComponentsModule, + ], + declarations: [ + AddonMessagesContacts35Page, + ], + exports: [RouterModule], +}) +export class AddonMessagesContacts35PageModule {} diff --git a/src/addons/messages/pages/contacts-35/contacts.page.ts b/src/addons/messages/pages/contacts-35/contacts.page.ts new file mode 100644 index 000000000..cb291043d --- /dev/null +++ b/src/addons/messages/pages/contacts-35/contacts.page.ts @@ -0,0 +1,273 @@ +// (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 } from '@angular/core'; +import { IonRefresher } from '@ionic/angular'; +import { CoreSites } from '@services/sites'; +import { + AddonMessagesProvider, + AddonMessagesGetContactsWSResponse, + AddonMessagesSearchContactsContact, + AddonMessagesGetContactsContact, + AddonMessages, + AddonMessagesMemberInfoChangedEventData, +} from '../../services/messages'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreApp } from '@services/app'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { ActivatedRoute, Params } from '@angular/router'; +import { Translate } from '@singletons'; +import { CoreScreen } from '@services/screen'; +import { CoreNavigator } from '@services/navigator'; + +/** + * Page that displays the list of contacts. + */ +@Component({ + selector: 'addon-messages-contacts', + templateUrl: 'contacts.html', + styleUrls: ['../../messages-common.scss'], +}) +export class AddonMessagesContacts35Page implements OnInit, OnDestroy { + + protected searchingMessages: string; + protected loadingMessages: string; + protected siteId: string; + protected noSearchTypes = ['online', 'offline', 'blocked', 'strangers']; + protected memberInfoObserver: CoreEventObserver; + + loaded = false; + discussionUserId?: number; + contactTypes = ['online', 'offline', 'blocked', 'strangers']; + searchType = 'search'; + loadingMessage = ''; + hasContacts = false; + contacts: AddonMessagesGetContactsFormatted = { + online: [], + offline: [], + strangers: [], + search: [], + }; + + searchString = ''; + + + constructor( + protected route: ActivatedRoute, + ) { + this.siteId = CoreSites.instance.getCurrentSiteId(); + this.searchingMessages = Translate.instance.instant('core.searching'); + this.loadingMessages = Translate.instance.instant('core.loading'); + this.loadingMessage = this.loadingMessages; + + // Refresh the list when a contact request is confirmed. + this.memberInfoObserver = CoreEvents.on( + AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT, + (data) => { + if (data.contactRequestConfirmed) { + this.refreshData(); + } + }, + CoreSites.instance.getCurrentSiteId(), + ); + } + + /** + * Component loaded. + */ + ngOnInit(): void { + this.route.queryParams.subscribe(async () => { + const discussionUserId = CoreNavigator.instance.getRouteNumberParam('discussionUserId') || + CoreNavigator.instance.getRouteNumberParam('userId') || undefined; + + if (this.loaded && this.discussionUserId == discussionUserId) { + return; + } + + this.discussionUserId = discussionUserId; + + if (this.discussionUserId) { + // There is a discussion to load, open the discussion in a new state. + this.gotoDiscussion(this.discussionUserId); + } + + try { + await this.fetchData(); + if (!this.discussionUserId && this.hasContacts && CoreScreen.instance.isTablet) { + let contact: AddonMessagesGetContactsContact | undefined; + for (const x in this.contacts) { + if (this.contacts[x].length > 0) { + contact = this.contacts[x][0]; + break; + } + } + + if (contact) { + // Take first and load it. + this.gotoDiscussion(contact.id); + } + } + } finally { + this.loaded = true; + } + }); + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @return Promise resolved when done. + */ + async refreshData(refresher?: CustomEvent): Promise { + try { + if (this.searchString) { + // User has searched, update the search. + await this.performSearch(this.searchString); + } else { + // Update contacts. + await AddonMessages.instance.invalidateAllContactsCache(); + await this.fetchData(); + } + } finally { + refresher?.detail.complete(); + } + } + + /** + * Fetch contacts. + * + * @return Promise resolved when done. + */ + protected async fetchData(): Promise { + this.loadingMessage = this.loadingMessages; + + try { + const contacts = await AddonMessages.instance.getAllContacts(); + for (const x in contacts) { + if (contacts[x].length > 0) { + this.contacts[x] = this.sortUsers(contacts[x]); + } else { + this.contacts[x] = []; + } + } + + this.clearSearch(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingcontacts', true); + } + } + + /** + * Sort user list by fullname + * + * @param list List to sort. + * @return Sorted list. + */ + protected sortUsers(list: AddonMessagesSearchContactsContact[]): AddonMessagesSearchContactsContact[] { + return list.sort((a, b) => { + const compareA = a.fullname.toLowerCase(); + const compareB = b.fullname.toLowerCase(); + + return compareA.localeCompare(compareB); + }); + } + + /** + * Clear search and show all contacts again. + */ + clearSearch(): void { + this.searchString = ''; // Reset searched string. + this.contactTypes = this.noSearchTypes; + + this.hasContacts = false; + for (const x in this.contacts) { + if (this.contacts[x].length > 0) { + this.hasContacts = true; + + return; + } + } + } + + /** + * Search users from the UI. + * + * @param query Text to search for. + * @return Resolved when done. + */ + search(query: string): Promise { + CoreApp.instance.closeKeyboard(); + + this.loaded = false; + this.loadingMessage = this.searchingMessages; + + return this.performSearch(query).finally(() => { + this.loaded = true; + }); + } + + /** + * Perform the search of users. + * + * @param query Text to search for. + * @return Resolved when done. + */ + protected async performSearch(query: string): Promise { + try { + const result = await AddonMessages.instance.searchContacts(query); + this.hasContacts = result.length > 0; + this.searchString = query; + this.contactTypes = ['search']; + + this.contacts.search = this.sortUsers(result); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingcontacts', true); + } + } + + /** + * Navigate to a particular discussion. + * + * @param discussionUserId Discussion Id to load. + */ + gotoDiscussion(discussionUserId: number): void { + this.discussionUserId = discussionUserId; + + const params: Params = { + userId: discussionUserId, + }; + + const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/messages/contacts-35/discussion'); + const path = (splitViewLoaded ? '../' : '') + 'discussion'; + + // @todo Check why this is failing on ngInit. + CoreNavigator.instance.navigate(path, { params }); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.memberInfoObserver?.off(); + } + +} + +/** + * Contacts with some calculated data. + */ +export type AddonMessagesGetContactsFormatted = AddonMessagesGetContactsWSResponse & { + search?: AddonMessagesSearchContactsContact[]; // Calculated in the app. Result of searching users. +}; diff --git a/src/addons/messages/pages/contacts/contacts.html b/src/addons/messages/pages/contacts/contacts.html new file mode 100644 index 000000000..0beaf5f76 --- /dev/null +++ b/src/addons/messages/pages/contacts/contacts.html @@ -0,0 +1,92 @@ + + + + + + {{ 'addon.messages.contacts' | translate }} + + + + + + + + + + + + + + + {{ 'addon.messages.contacts' | translate}} + + + + {{ 'addon.messages.requests' | translate}} + {{ requestsBadge }} + + + + +
+ + + + + + + + +

+ + + + +

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

+ {{ 'addon.messages.wouldliketocontactyou' | translate }} +

+
+
+
+ + + + +
+
+ +
+
diff --git a/src/addons/messages/pages/contacts/contacts.module.ts b/src/addons/messages/pages/contacts/contacts.module.ts new file mode 100644 index 000000000..33dee8bfd --- /dev/null +++ b/src/addons/messages/pages/contacts/contacts.module.ts @@ -0,0 +1,65 @@ +// (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 { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { conditionalRoutes } from '@/app/app-routing.module'; +import { AddonMessagesDiscussionRoute } from '@addons/messages/messages-lazy.module'; +import { CoreScreen } from '@services/screen'; + +import { CoreSharedModule } from '@/core/shared.module'; + +import { AddonMessagesContactsPage } from './contacts.page'; + +const mobileRoutes: Routes = [ + { + path: '', + component: AddonMessagesContactsPage, + }, + AddonMessagesDiscussionRoute, +]; + +const tabletRoutes: Routes = [ + { + path: '', + component: AddonMessagesContactsPage, + children: [ + AddonMessagesDiscussionRoute, + ], + }, +]; + +const routes: Routes = [ + ...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile), + ...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet), +]; + + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + ], + declarations: [ + AddonMessagesContactsPage, + ], + exports: [RouterModule], +}) +export class AddonMessagesContactsPageModule {} diff --git a/src/addons/messages/pages/contacts/contacts.page.ts b/src/addons/messages/pages/contacts/contacts.page.ts new file mode 100644 index 000000000..583156ba1 --- /dev/null +++ b/src/addons/messages/pages/contacts/contacts.page.ts @@ -0,0 +1,274 @@ +// (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 } from '@angular/core'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { + AddonMessages, + AddonMessagesContactRequestCountEventData, + AddonMessagesConversationMember, + AddonMessagesMemberInfoChangedEventData, + AddonMessagesProvider, +} from '../../services/messages'; +import { CoreNavigator } from '@services/navigator'; +import { CoreScreen } from '@services/screen'; +import { CoreDomUtils } from '@services/utils/dom'; +import { IonRefresher } from '@ionic/angular'; + +/** + * Page that displays contacts and contact requests. + */ +@Component({ + selector: 'page-addon-messages-contacts', + templateUrl: 'contacts.html', + styleUrls: [ + 'tabs.scss', + '../../messages-common.scss', + ], +}) +export class AddonMessagesContactsPage implements OnInit, OnDestroy { + + selected = 'confirmed'; + requestsBadge = ''; + selectedUserId?: number; // User id of the conversation opened in the split view. + + confirmedLoaded = false; + confirmedCanLoadMore = false; + confirmedLoadMoreError = false; + confirmedContacts: AddonMessagesConversationMember[] = []; + + requestsLoaded = false; + requestsCanLoadMore = false; + requestsLoadMoreError = false; + requests: AddonMessagesConversationMember[] = []; + + protected siteId: string; + protected contactRequestsCountObserver: CoreEventObserver; + protected memberInfoObserver: CoreEventObserver; + + + constructor() { + + this.siteId = CoreSites.instance.getCurrentSiteId(); + + // Update the contact requests badge. + this.contactRequestsCountObserver = CoreEvents.on( + AddonMessagesProvider.CONTACT_REQUESTS_COUNT_EVENT, + (data) => { + this.requestsBadge = data.count > 0 ? String(data.count) : ''; + }, + this.siteId, + ); + + // Update block status of a user. + this.memberInfoObserver = CoreEvents.on( + AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT, + (data) => { + if (data.userBlocked || data.userUnblocked) { + const user = this.confirmedContacts.find((user) => user.id == data.userId); + if (user) { + user.isblocked = !!data.userBlocked; + } + } else if (data.contactRemoved) { + const index = this.confirmedContacts.findIndex((contact) => contact.id == data.userId); + if (index >= 0) { + this.confirmedContacts.splice(index, 1); + } + } else if (data.contactRequestConfirmed) { + this.refreshData(); + } + + if (data.contactRequestConfirmed || data.contactRequestDeclined) { + const index = this.requests.findIndex((request) => request.id == data.userId); + if (index >= 0) { + this.requests.splice(index, 1); + } + } + }, + CoreSites.instance.getCurrentSiteId(), + ); + + } + + /** + * Page being initialized. + */ + async ngOnInit(): Promise { + AddonMessages.instance.getContactRequestsCount(this.siteId); // Badge already updated by the observer. + + if (this.selected == 'confirmed') { + try { + + await this.confirmedFetchData(); + if (this.confirmedContacts.length && CoreScreen.instance.isTablet) { + this.selectUser(this.confirmedContacts[0].id, true); + } + } finally { + this.confirmedLoaded = true; + } + } else { + try { + await this.requestsFetchData(); + if (this.requests.length && CoreScreen.instance.isTablet) { + this.selectUser(this.requests[0].id, true); + } + } finally { + this.requestsLoaded = true; + } + } + } + + /** + * Fetch contacts. + * + * @param refresh True if we are refreshing contacts, false if we are loading more. + * @return Promise resolved when done. + */ + async confirmedFetchData(refresh: boolean = false): Promise { + this.confirmedLoadMoreError = false; + + const limitFrom = refresh ? 0 : this.confirmedContacts.length; + + if (limitFrom === 0) { + // Always try to get latest data from server. + await AddonMessages.instance.invalidateUserContacts(); + } + + try { + const result = await AddonMessages.instance.getUserContacts(limitFrom); + this.confirmedContacts = refresh ? result.contacts : this.confirmedContacts.concat(result.contacts); + this.confirmedCanLoadMore = result.canLoadMore; + } catch (error) { + this.confirmedLoadMoreError = true; + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingcontacts', true); + } + } + + /** + * Fetch contact requests. + * + * @param refresh True if we are refreshing contact requests, false if we are loading more. + * @return Promise resolved when done. + */ + async requestsFetchData(refresh: boolean = false): Promise { + this.requestsLoadMoreError = false; + + const limitFrom = refresh ? 0 : this.requests.length; + + if (limitFrom === 0) { + // Always try to get latest data from server. + await AddonMessages.instance.invalidateContactRequestsCache(); + } + + try { + const result = await AddonMessages.instance.getContactRequests(limitFrom); + this.requests = refresh ? result.requests : this.requests.concat(result.requests); + this.requestsCanLoadMore = result.canLoadMore; + } catch (error) { + this.requestsLoadMoreError = true; + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingcontacts', true); + } + } + + /** + * Refresh contacts or requests. + * + * @param refresher Refresher. + * @return Promise resolved when done. + */ + async refreshData(refresher?: CustomEvent): Promise { + try { + if (this.selected == 'confirmed') { + // No need to invalidate contacts, we always try to get the latest. + await this.confirmedFetchData(true); + } else { + // Refresh the number of contacts requests to update badges. + AddonMessages.instance.refreshContactRequestsCount(); + + // No need to invalidate contact requests, we always try to get the latest. + await this.requestsFetchData(true); + } + } finally { + refresher?.detail.complete(); + } + } + + /** + * Load more contacts or requests. + * + * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. + * @return Resolved when done. + */ + async loadMore(infiniteComplete?: () => void): Promise { + try { + if (this.selected == 'confirmed') { + // No need to invalidate contacts, we always try to get the latest. + await this.confirmedFetchData(); + } else { + await this.requestsFetchData(); + } + } finally { + infiniteComplete && infiniteComplete(); + } + } + + /** + * Navigate to the search page. + */ + gotoSearch(): void { + CoreNavigator.instance.navigateToSitePath('search'); + } + + selectTab(selected: string): void { + this.selected = selected; + + if ((this.selected == 'confirmed' && !this.confirmedLoaded) || (this.selected != 'confirmed' && !this.requestsLoaded)) { + this.ngOnInit(); + } + } + + /** + * Set the selected user and open the conversation in the split view if needed. + * + * @param userId Id of the selected user, undefined to use the last selected user in the tab. + * @param onInit Whether the contact was selected on initial load. + */ + selectUser(userId: number, onInit = false): void { + if (userId == this.selectedUserId && CoreScreen.instance.isTablet) { + // No user conversation to open or it is already opened. + return; + } + + if (onInit && CoreScreen.instance.isMobile) { + // Do not open a conversation by default when split view is not visible. + return; + } + + this.selectedUserId = userId; + + const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/messages/contacts/discussion'); + const path = (splitViewLoaded ? '../' : '') + 'discussion'; + + CoreNavigator.instance.navigate(path, { params : { userId } }); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.contactRequestsCountObserver?.off(); + } + +} diff --git a/src/addons/messages/pages/contacts/tabs.scss b/src/addons/messages/pages/contacts/tabs.scss new file mode 100644 index 000000000..8bb0948a5 --- /dev/null +++ b/src/addons/messages/pages/contacts/tabs.scss @@ -0,0 +1,48 @@ +:host { + ion-tab-bar.core-tabs-bar { + position: relative; + width: 100%; + background: var(--core-tabs-background); + color: var(--core-tab-color); + -webkit-filter: drop-shadow(0px 3px 3px rgba(var(--drop-shadow))); + filter: drop-shadow(0px 3px 3px rgba(var(--drop-shadow))); + border: 0; + + ion-row { + width: 100%; + } + + .tab-slide { + border-bottom: 2px solid transparent; + min-width: 100px; + min-height: 56px; + cursor: pointer; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + margin-bottom: 1px; + + ion-label { + font-size: 16px; + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + word-wrap: break-word; + max-width: 100%; + line-height: 1.2em; + margin: 16px auto; + } + + &[aria-selected=true] { + color: var(--core-tab-border-color-active); + border-bottom-color: var(--core-tab-color-active); + ion-tab-button { + color: var(--core-tab-border-color-active); + } + } + } + } +} diff --git a/src/addons/messages/pages/discussion/discussion.html b/src/addons/messages/pages/discussion/discussion.html new file mode 100644 index 000000000..5188bb66b --- /dev/null +++ b/src/addons/messages/pages/discussion/discussion.html @@ -0,0 +1,172 @@ + + + + + + +
+ + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + +
+ + + + + + + +

{{ 'addon.messages.selfconversation' | translate }}

+

{{ 'addon.messages.selfconversationdefaultmessage' | translate }}

+
+ + + +
+ {{ message.timecreated | coreFormatDate: "strftimedayshort" }} +
+ + + {{ 'addon.messages.newmessages' | translate }} + + + + + + +

+ + +
{{ members[message.useridfrom].fullname }}
+ + {{ message.timecreated | coreFormatDate: "strftimetime" }} + +

+ + +

+ +

+
+ + + + +
+
+
+
+ + +
+ + + + + {{ newMessages }} + + +
+ + +

+ {{ 'addon.messages.unabletomessage' | translate }} +

+
+

{{ 'addon.messages.youhaveblockeduser' | translate }}

+ + {{ 'addon.messages.unblockuser' | translate }} + +
+
+

+ {{ 'addon.messages.isnotinyourcontacts' | translate: {$a: otherMember.fullname} }} +

+

{{ 'addon.messages.requirecontacttomessage' | translate: {$a: otherMember.fullname} }}

+ + {{ 'addon.messages.sendcontactrequest' | translate }} + +
+
+

{{ 'addon.messages.userwouldliketocontactyou' | translate: {$a: otherMember.fullname} }}

+ + {{ 'addon.messages.acceptandaddcontact' | translate }} + + + {{ 'addon.messages.decline' | translate }} + +
+
+

{{ 'addon.messages.contactrequestsent' | translate }}

+

+ {{ 'addon.messages.yourcontactrequestpending' | translate: {$a: otherMember.fullname} }} +

+
+ +
+
diff --git a/src/addons/messages/pages/discussion/discussion.module.ts b/src/addons/messages/pages/discussion/discussion.module.ts new file mode 100644 index 000000000..5147cdb35 --- /dev/null +++ b/src/addons/messages/pages/discussion/discussion.module.ts @@ -0,0 +1,48 @@ +// (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 { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; + +import { CoreSharedModule } from '@/core/shared.module'; + +import { AddonMessagesDiscussionPage } from './discussion.page'; +import { AddonMessagesComponentsModule } from '@addons/messages/components/components.module'; + +const routes: Routes = [ + { + path: '', + component: AddonMessagesDiscussionPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + AddonMessagesComponentsModule, + ], + declarations: [ + AddonMessagesDiscussionPage, + ], + exports: [RouterModule], +}) +export class AddonMessagesDiscussionPageModule {} + diff --git a/src/addons/messages/pages/discussion/discussion.page.ts b/src/addons/messages/pages/discussion/discussion.page.ts new file mode 100644 index 000000000..ecbe47ef8 --- /dev/null +++ b/src/addons/messages/pages/discussion/discussion.page.ts @@ -0,0 +1,1711 @@ +// (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 { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { IonContent } from '@ionic/angular'; +import { AlertOptions } from '@ionic/core'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { + AddonMessagesProvider, + AddonMessagesConversationFormatted, + AddonMessagesConversationMember, + AddonMessagesGetMessagesMessage, + AddonMessages, + AddonMessagesMemberInfoChangedEventData, + AddonMessagesReadChangedEventData, + AddonMessagesNewMessagedEventData, + AddonMessagesUpdateConversationListEventData, + AddonMessagesConversationMessageFormatted, + AddonMessagesOpenConversationEventData, + AddonMessagesSendMessageResults, +} from '../../services/messages'; +import { AddonMessagesOffline, AddonMessagesOfflineMessagesDBRecordFormatted } from '../../services/messages-offline'; +import { AddonMessagesSync, AddonMessagesSyncEvents, AddonMessagesSyncProvider } from '../../services/messages-sync'; +import { CoreUser } from '@features/user/services/user'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreLogger } from '@singletons/logger'; +import { CoreApp } from '@services/app'; +import { CoreInfiniteLoadingComponent } from '@components/infinite-loading/infinite-loading'; +import { Md5 } from 'ts-md5/dist/md5'; +import moment from 'moment'; +import { CoreAnimations } from '@components/animations'; +import { CoreError } from '@classes/errors/error'; +import { ModalController, Translate } from '@singletons'; +import { CoreNavigator } from '@services/navigator'; +import { CoreIonLoadingElement } from '@classes/ion-loading'; +import { ActivatedRoute } from '@angular/router'; +import { AddonMessagesConversationInfoComponent } from '../../components/conversation-info/conversation-info'; + +/** + * Page that displays a message discussion page. + */ +@Component({ + selector: 'page-addon-messages-discussion', + templateUrl: 'discussion.html', + animations: [CoreAnimations.SLIDE_IN_OUT], + styleUrls: ['discussion.scss'], +}) +export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterViewInit { + + @ViewChild(IonContent) content?: IonContent; + @ViewChild(CoreInfiniteLoadingComponent) infinite?: CoreInfiniteLoadingComponent; + + siteId: string; + protected fetching = false; + protected polling?: number; + protected logger: CoreLogger; + + protected messagesBeingSent = 0; + protected pagesLoaded = 1; + protected lastMessage = { text: '', timecreated: 0 }; + protected keepMessageMap: {[hash: string]: boolean} = {}; + protected syncObserver: CoreEventObserver; + protected oldContentHeight = 0; + protected keyboardObserver: CoreEventObserver; + protected scrollBottom = true; + protected viewDestroyed = false; + protected memberInfoObserver: CoreEventObserver; + protected showLoadingModal = false; // Whether to show a loading modal while fetching data. + + conversationId?: number; // Conversation ID. Undefined if it's a new individual conversation. + conversation?: AddonMessagesConversationFormatted; // The conversation object (if it exists). + userId?: number; // User ID you're talking to (only if group messaging not enabled or it's a new individual conversation). + currentUserId: number; + title?: string; + showInfo = false; + conversationImage?: string; + loaded = false; + showKeyboard = false; + canLoadMore = false; + loadMoreError = false; + messages: AddonMessagesConversationMessageFormatted[] = []; + showDelete = false; + canDelete = false; + groupMessagingEnabled: boolean; + isGroup = false; + members: {[id: number]: AddonMessagesConversationMember} = {}; // Members that wrote a message, indexed by ID. + favouriteIcon = 'fa-star'; + deleteIcon = 'fas-trash'; + blockIcon = 'fas-user-lock'; + addRemoveIcon = 'fas-user-plus'; + muteIcon = 'fas-bell-slash'; + favouriteIconSlash = false; + muteEnabled = false; + otherMember?: AddonMessagesConversationMember; // Other member information (individual conversations only). + footerType: 'message' | 'blocked' | 'requiresContact' | 'requestSent' | 'requestReceived' | 'unable' = 'unable'; + requestContactSent = false; + requestContactReceived = false; + isSelf = false; + newMessages = 0; + scrollElement?: HTMLElement; + unreadMessageFrom = 0; + + constructor( + protected route: ActivatedRoute, + ) { + this.siteId = CoreSites.instance.getCurrentSiteId(); + this.currentUserId = CoreSites.instance.getCurrentSiteUserId(); + this.groupMessagingEnabled = AddonMessages.instance.isGroupMessagingEnabled(); + this.muteEnabled = AddonMessages.instance.isMuteConversationEnabled(); + + this.logger = CoreLogger.getInstance('AddonMessagesDiscussionPage'); + + // Refresh data if this discussion is synchronized automatically. + this.syncObserver = CoreEvents.on(AddonMessagesSyncProvider.AUTO_SYNCED, (data) => { + if ((data.userId && data.userId == this.userId) || + (data.conversationId && data.conversationId == this.conversationId)) { + // Fetch messages. + this.fetchMessages(); + + // Show first warning if any. + if (data.warnings && data.warnings[0]) { + CoreDomUtils.instance.showErrorModal(data.warnings[0]); + } + } + }, this.siteId); + + // Refresh data if info of a mamber of the conversation have changed. + this.memberInfoObserver = CoreEvents.on( + AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT, + (data) => { + if (data.userId && (this.members[data.userId] || this.otherMember && data.userId == this.otherMember.id)) { + this.fetchData(); + } + }, + this.siteId, + ); + + // Recalculate footer position when keyboard is shown or hidden. + this.keyboardObserver = CoreEvents.on(CoreEvents.KEYBOARD_CHANGE, () => { + // @todo probably not needed. + // this.content.resize(); + }); + } + + /** + * Runs when the page has loaded. This event only happens once per page being created. + * If a page leaves but is cached, then this event will not fire again on a subsequent viewing. + * Setup code for the page. + */ + async ngOnInit(): Promise { + // Disable the profile button if we're already coming from a profile. + const backViewPage = CoreNavigator.instance.getPreviousPath(); + this.showInfo = !backViewPage || !CoreTextUtils.instance.matchesGlob(backViewPage, '**/user/profile'); + + this.route.queryParams.subscribe(async (params) => { + this.loaded = false; + this.conversationId = CoreNavigator.instance.getRouteNumberParam('conversationId', params) || undefined; + this.userId = CoreNavigator.instance.getRouteNumberParam('userId', params) || undefined; + this.showKeyboard = CoreNavigator.instance.getRouteBooleanParam('showKeyboard', params) || false; + + await this.fetchData(); + + this.scrollToBottom(); + }); + } + + /** + * View has been initialized. + */ + async ngAfterViewInit(): Promise { + this.scrollElement = await this.content?.getScrollElement(); + } + + /** + * Adds a new message to the message list. + * + * @param message Message to be added. + * @param keep If set the keep flag or not. + * @return If message is not mine and was recently added. + */ + protected addMessage( + message: AddonMessagesConversationMessageFormatted, + keep: boolean = true, + ): boolean { + + /* Create a hash to identify the message. The text of online messages isn't reliable because it can have random data + like VideoJS ID. Try to use id and fallback to text for offline messages. */ + const id = 'id' in message ? message.id : ''; + message.hash = Md5.hashAsciiStr(String(id || message.text || '')) + '#' + message.timecreated + '#' + + message.useridfrom; + + let added = false; + if (typeof this.keepMessageMap[message.hash] === 'undefined') { + // Message not added to the list. Add it now. + this.messages.push(message); + added = message.useridfrom != this.currentUserId; + } + // Message needs to be kept in the list. + this.keepMessageMap[message.hash] = keep; + + return added; + } + + /** + * Remove a message if it shouldn't be in the list anymore. + * + * @param hash Hash of the message to be removed. + */ + protected removeMessage(hash: string): void { + if (this.keepMessageMap[hash]) { + // Selected to keep it, clear the flag. + this.keepMessageMap[hash] = false; + + return; + } + + delete this.keepMessageMap[hash]; + + const position = this.messages.findIndex((message) => message.hash == hash); + if (position >= 0) { + this.messages.splice(position, 1); + } + } + + /** + * Convenience function to fetch the conversation data. + * + * @return Resolved when done. + */ + protected async fetchData(): Promise { + let loader: CoreIonLoadingElement | undefined; + if (this.showLoadingModal) { + loader = await CoreDomUtils.instance.showModalLoading(); + } + + if (!this.groupMessagingEnabled && this.userId) { + // Get the user profile to retrieve the user fullname and image. + CoreUser.instance.getProfile(this.userId, undefined, true).then((user) => { + if (!this.title) { + this.title = user.fullname; + } + this.conversationImage = user.profileimageurl; + + return; + }).catch(() => { + // Ignore errors. + }); + } + + // Synchronize messages if needed. + try { + const syncResult = await AddonMessagesSync.instance.syncDiscussion(this.conversationId, this.userId); + if (syncResult.warnings && syncResult.warnings[0]) { + CoreDomUtils.instance.showErrorModal(syncResult.warnings[0]); + } + } catch { + // Ignore errors; + } + + try { + const promises: Promise[] = []; + if (this.groupMessagingEnabled) { + // Get the conversation ID if it exists and we don't have it yet. + const exists = await this.getConversation(this.conversationId, this.userId); + + if (exists) { + // Fetch the messages for the first time. + promises.push(this.fetchMessages()); + } + + if (this.userId) { + // Get the member info. Invalidate first to make sure we get the latest status. + promises.push(AddonMessages.instance.invalidateMemberInfo(this.userId).then(async () => { + this.otherMember = await AddonMessages.instance.getMemberInfo(this.userId!); + + if (!exists && this.otherMember) { + this.conversationImage = this.otherMember.profileimageurl; + this.title = this.otherMember.fullname; + } + this.blockIcon = this.otherMember.isblocked ? 'fas-user-lock' : 'fas-user-check'; + + return; + })); + } else { + this.otherMember = undefined; + } + + } else { + if (this.userId) { + // Fake the user member info. + promises.push(CoreUser.instance.getProfile(this.userId!).then(async (user) => { + this.otherMember = { + id: user.id, + fullname: user.fullname, + profileurl: '', + profileimageurl: user.profileimageurl || '', + profileimageurlsmall: user.profileimageurlsmall || '', + isonline: false, + showonlinestatus: false, + isblocked: false, + iscontact: false, + isdeleted: false, + canmessageevenifblocked: true, + canmessage: true, + requirescontact: false, + }; + this.otherMember.isblocked = await AddonMessages.instance.isBlocked(this.userId!); + this.otherMember.iscontact = await AddonMessages.instance.isContact(this.userId!); + this.blockIcon = this.otherMember.isblocked ? 'fas-user-lock' : 'fas-user-check'; + + return; + })); + + + } + + // Fetch the messages for the first time. + promises.push(this.fetchMessages().then(() => { + if (!this.title && this.messages.length) { + // Didn't receive the fullname via argument. Try to get it from messages. + // It's possible that name cannot be resolved when no messages were yet exchanged. + const firstMessage = this.messages[0]; + if ('usertofullname' in firstMessage) { + if (firstMessage.useridto != this.currentUserId) { + this.title = firstMessage.usertofullname || ''; + } else { + this.title = firstMessage.userfromfullname || ''; + } + } + } + + return; + })); + } + + await Promise.all(promises); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingmessages', true); + } finally { + this.checkCanDelete(); + this.resizeContent(); + this.loaded = true; + this.setPolling(); // Make sure we're polling messages. + this.setContactRequestInfo(); + this.setFooterType(); + loader && loader.dismiss(); + } + } + + /** + * Runs when the page has fully entered and is now the active page. + * This event will fire, whether it was the first load or a cached page. + */ + ionViewDidEnter(): void { + this.setPolling(); + } + + /** + * Runs when the page is about to leave and no longer be the active page. + */ + ionViewWillLeave(): void { + this.unsetPolling(); + } + + /** + * Convenience function to fetch messages. + * + * @param messagesAreNew If messages loaded are new messages. + * @return Resolved when done. + */ + protected async fetchMessages(messagesAreNew: boolean = true): Promise { + this.loadMoreError = false; + + if (this.messagesBeingSent > 0) { + // We do not poll while a message is being sent or we could confuse the user. + // Otherwise, his message would disappear from the list, and he'd have to wait for the interval to check for messages. + return; + } else if (this.fetching) { + // Already fetching. + return; + } else if (this.groupMessagingEnabled && !this.conversationId) { + // Don't have enough data to fetch messages. + throw new CoreError('No enough data provided to fetch messages'); + } + + if (this.conversationId) { + this.logger.debug(`Polling new messages for conversation '${this.conversationId}'`); + } else if (this.userId) { + this.logger.debug(`Polling new messages for discussion with user '${this.userId}'`); + } else { + // Should not happen. + throw new CoreError('No enough data provided to fetch messages'); + } + + this.fetching = true; + + try { + // Wait for synchronization process to finish. + await AddonMessagesSync.instance.waitForSyncConversation(this.conversationId, this.userId); + + let messages: AddonMessagesConversationMessageFormatted[] = []; + // Fetch messages. Invalidate the cache before fetching. + if (this.groupMessagingEnabled) { + await AddonMessages.instance.invalidateConversationMessages(this.conversationId!); + messages = await this.getConversationMessages(this.pagesLoaded); + } else { + await AddonMessages.instance.invalidateDiscussionCache(this.userId!); + messages = await this.getDiscussionMessages(this.pagesLoaded); + } + + this.loadMessages(messages, messagesAreNew); + + } finally { + this.fetching = false; + } + } + + /** + * Format and load a list of messages into the view. + * + * @param messagesAreNew If messages loaded are new messages. + * @param messages Messages to load. + */ + protected loadMessages( + messages: AddonMessagesConversationMessageFormatted[], + messagesAreNew: boolean = true, + ): void { + + if (this.viewDestroyed) { + return; + } + + // Don't use domUtils.getScrollHeight because it gives an outdated value after receiving a new message. + const scrollHeight = this.scrollElement ? this.scrollElement.scrollHeight : 0; + + // Check if we are at the bottom to scroll it after render. + // Use a 5px error margin because in iOS there is 1px difference for some reason. + this.scrollBottom = Math.abs(scrollHeight - (this.scrollElement?.scrollTop || 0) - + (this.scrollElement?.clientHeight || 0)) < 5; + + if (this.messagesBeingSent > 0) { + // Ignore polling due to a race condition. + return; + } + + // Add new messages to the list and mark the messages that should still be displayed. + const newMessages = messages.reduce((val, message) => val + (this.addMessage(message) ? 1 : 0), 0); + + // Set the new badges message if we're loading new messages. + if (messagesAreNew) { + this.setNewMessagesBadge(this.newMessages + newMessages); + } + + // Remove messages that shouldn't be in the list anymore. + for (const hash in this.keepMessageMap) { + this.removeMessage(hash); + } + + // Sort the messages. + AddonMessages.instance.sortMessages(this.messages); + + // Calculate which messages need to display the date or user data. + this.messages.forEach((message, index) => { + message.showDate = this.showDate(message, this.messages[index - 1]); + message.showUserData = this.showUserData(message, this.messages[index - 1]); + message.showTail = this.showTail(message, this.messages[index + 1]); + }); + + // Call resize to recalculate the dimensions. + // @todo probably not needed. + // this.content!.resize(); + + // If we received a new message while using group messaging, force mark messages as read. + const last = this.messages[this.messages.length - 1]; + const forceMark = this.groupMessagingEnabled && last && last.useridfrom != this.currentUserId && this.lastMessage.text != '' + && (last.text !== this.lastMessage.text || last.timecreated !== this.lastMessage.timecreated); + + // Notify that there can be a new message. + this.notifyNewMessage(); + + // Mark retrieved messages as read if they are not. + this.markMessagesAsRead(forceMark); + } + + /** + * Set the new message badge number and set scroll listener if needed. + * + * @param addMessages NUmber of messages still to be read. + */ + protected setNewMessagesBadge(addMessages: number): void { + if (this.newMessages == 0 && addMessages > 0) { + // Setup scrolling. + this.content!.scrollEvents = true; + + this.scrollFunction(); + } else if (this.newMessages > 0 && addMessages == 0) { + // Remove scrolling. + this.content!.scrollEvents = false; + } + + this.newMessages = addMessages; + } + + /** + * The scroll was moved. Update new messages count. + */ + scrollFunction(): void { + if (this.newMessages > 0) { + const scrollBottom = (this.scrollElement?.scrollTop || 0) + (this.scrollElement?.clientHeight || 0); + const scrollHeight = (this.scrollElement?.scrollHeight || 0); + if (scrollBottom > scrollHeight - 40) { + // At the bottom, reset. + this.setNewMessagesBadge(0); + + return; + } + + const scrollElRect = this.scrollElement?.getBoundingClientRect(); + const scrollBottomPos = (scrollElRect && scrollElRect.bottom) || 0; + + if (scrollBottomPos == 0) { + return; + } + + const messages = Array.from(document.querySelectorAll('.addon-message-not-mine')).slice(-this.newMessages).reverse(); + + const newMessagesUnread = messages.findIndex((message) => { + const elementRect = message.getBoundingClientRect(); + if (!elementRect) { + return false; + } + + return elementRect.bottom <= scrollBottomPos; + }); + + if (newMessagesUnread > 0 && newMessagesUnread < this.newMessages) { + this.setNewMessagesBadge(newMessagesUnread); + } + } + } + + /** + * Get the conversation. + * + * @param conversationId Conversation ID. + * @param userId User ID. + * @return Promise resolved with a boolean: whether the conversation exists or not. + */ + protected async getConversation(conversationId?: number, userId?: number): Promise { + let fallbackConversation: AddonMessagesConversationFormatted | undefined; + + // Try to get the conversationId if we don't have it. + if (!conversationId && userId) { + try { + if (userId == this.currentUserId && AddonMessages.instance.isSelfConversationEnabled()) { + fallbackConversation = await AddonMessages.instance.getSelfConversation(); + } else { + fallbackConversation = await AddonMessages.instance.getConversationBetweenUsers(userId, undefined, true); + } + conversationId = fallbackConversation.id; + } catch (error) { + // Probably conversation does not exist or user is offline. Try to load offline messages. + this.isSelf = userId == this.currentUserId; + + const messages = await AddonMessagesOffline.instance.getMessages(userId); + + if (messages && messages.length) { + // We have offline messages, this probably means that the conversation didn't exist. Don't display error. + messages.forEach((message) => { + message.pending = true; + message.text = message.smallmessage; + }); + + this.loadMessages(messages); + } else if (error.errorcode != 'errorconversationdoesnotexist') { + // Display the error. + throw error; + } + + return false; + } + } + + + // Retrieve the conversation. Invalidate data first to get the right unreadcount. + await AddonMessages.instance.invalidateConversation(conversationId!); + + try { + this.conversation = await AddonMessages.instance.getConversation(conversationId!, undefined, true); + } catch (error) { + // Get conversation failed, use the fallback one if we have it. + if (fallbackConversation) { + this.conversation = fallbackConversation; + } else { + throw error; + } + } + + if (this.conversation) { + this.conversationId = this.conversation.id; + this.title = this.conversation.name; + this.conversationImage = this.conversation.imageurl; + this.isGroup = this.conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP; + this.favouriteIcon = 'fas-star'; + this.favouriteIconSlash = this.conversation.isfavourite; + this.muteIcon = this.conversation.ismuted ? 'fas-bell' : 'fas-bell-slash'; + if (!this.isGroup) { + this.userId = this.conversation.userid; + } + this.isSelf = this.conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_SELF; + + return true; + } else { + return false; + } + + } + + /** + * Get the messages of the conversation. Used if group messaging is supported. + * + * @param pagesToLoad Number of "pages" to load. + * @param offset Offset for message list. + * @return Promise resolved with the list of messages. + */ + protected async getConversationMessages( + pagesToLoad: number, + offset: number = 0, + ): Promise { + + if (!this.conversationId) { + return []; + } + + const excludePending = offset > 0; + + const result = await AddonMessages.instance.getConversationMessages(this.conversationId, { + excludePending: excludePending, + limitFrom: offset, + }); + + pagesToLoad--; + + // Treat members. Don't use CoreUtilsProvider.arrayToObject because we don't want to override the existing object. + if (result.members) { + result.members.forEach((member) => { + this.members[member.id] = member; + }); + } + + const messages: AddonMessagesConversationMessageFormatted[] = result.messages; + + if (pagesToLoad > 0 && result.canLoadMore) { + offset += AddonMessagesProvider.LIMIT_MESSAGES; + + // Get more messages. + const nextMessages = await this.getConversationMessages(pagesToLoad, offset); + + return messages.concat(nextMessages); + } + + // No more messages to load, return them. + this.canLoadMore = !!result.canLoadMore; + + return messages; + + } + + /** + * Get a discussion. Can load several "pages". + * + * @param pagesToLoad Number of pages to load. + * @param lfReceivedUnread Number of unread received messages already fetched, so fetch will be done from this. + * @param lfReceivedRead Number of read received messages already fetched, so fetch will be done from this. + * @param lfSentUnread Number of unread sent messages already fetched, so fetch will be done from this. + * @param lfSentRead Number of read sent messages already fetched, so fetch will be done from this. + * @return Resolved when done. + */ + protected async getDiscussionMessages( + pagesToLoad: number, + lfReceivedUnread: number = 0, + lfReceivedRead: number = 0, + lfSentUnread: number = 0, + lfSentRead: number = 0, + ): Promise<(AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[]> { + + // Only get offline messages if we're loading the first "page". + const excludePending = lfReceivedUnread > 0 || lfReceivedRead > 0 || lfSentUnread > 0 || lfSentRead > 0; + + // Get next messages. + const result = await AddonMessages.instance.getDiscussion( + this.userId!, + excludePending, + lfReceivedUnread, + lfReceivedRead, + lfSentUnread, + lfSentRead, + ); + + pagesToLoad--; + if (pagesToLoad > 0 && result.canLoadMore) { + // More pages to load. Calculate new limit froms. + result.messages.forEach((message) => { + if (!message.pending && 'read' in message) { + if (message.useridfrom == this.userId) { + if (message.read) { + lfReceivedRead++; + } else { + lfReceivedUnread++; + } + } else { + if (message.read) { + lfSentRead++; + } else { + lfSentUnread++; + } + } + } + }); + + // Get next messages. + const nextMessages = + await this.getDiscussionMessages(pagesToLoad, lfReceivedUnread, lfReceivedRead, lfSentUnread, lfSentRead); + + return result.messages.concat(nextMessages); + } else { + // No more messages to load, return them. + this.canLoadMore = result.canLoadMore; + + return result.messages; + } + } + + /** + * Mark messages as read. + */ + protected async markMessagesAsRead(forceMark: boolean): Promise { + let readChanged = false; + + if (AddonMessages.instance.isMarkAllMessagesReadEnabled()) { + let messageUnreadFound = false; + + // Mark all messages at a time if there is any unread message. + if (forceMark) { + messageUnreadFound = true; + } else if (this.groupMessagingEnabled) { + messageUnreadFound = !!((this.conversation?.unreadcount && this.conversation?.unreadcount > 0) && + (this.conversationId && this.conversationId > 0)); + } else { + // If an unread message is found, mark all messages as read. + messageUnreadFound = this.messages.some((message) => + message.useridfrom != this.currentUserId && ('read' in message && !message.read)); + } + + if (messageUnreadFound) { + this.setUnreadLabelPosition(); + + if (this.groupMessagingEnabled) { + await AddonMessages.instance.markAllConversationMessagesRead(this.conversationId!); + } else { + await AddonMessages.instance.markAllMessagesRead(this.userId); + + // Mark all messages as read. + this.messages.forEach((message) => { + if ('read' in message) { + message.read = true; + } + }); + } + + readChanged = true; + } + } else { + this.setUnreadLabelPosition(); + const promises: Promise[] = []; + + // Mark each message as read one by one. + this.messages.forEach((message) => { + // If the message is unread, call AddonMessages.instance.markMessageRead. + if (message.useridfrom != this.currentUserId && 'read' in message && !message.read) { + promises.push(AddonMessages.instance.markMessageRead(message.id).then(() => { + readChanged = true; + message.read = true; + + return; + })); + } + }); + + await Promise.all(promises); + } + + if (readChanged) { + CoreEvents.trigger(AddonMessagesProvider.READ_CHANGED_EVENT, { + conversationId: this.conversationId, + userId: this.userId, + }, this.siteId); + } + } + + /** + * Notify the last message found so discussions list controller can tell if last message should be updated. + */ + protected notifyNewMessage(): void { + const last = this.messages[this.messages.length - 1]; + + let trigger = false; + + if (!last) { + this.lastMessage = { text: '', timecreated: 0 }; + trigger = true; + } else if (last.text !== this.lastMessage.text || last.timecreated !== this.lastMessage.timecreated) { + this.lastMessage = { text: last.text || '', timecreated: last.timecreated }; + trigger = true; + } + + if (trigger) { + // Update discussions last message. + CoreEvents.trigger(AddonMessagesProvider.NEW_MESSAGE_EVENT, { + conversationId: this.conversationId, + userId: this.userId, + message: this.lastMessage.text, + timecreated: this.lastMessage.timecreated, + isfavourite: !!this.conversation?.isfavourite, + type: this.conversation?.type, + }, this.siteId); + + // Update navBar links and buttons. + const newCanDelete = (last && 'id' in last && last.id && this.messages.length == 1) || this.messages.length > 1; + if (this.canDelete != newCanDelete) { + this.checkCanDelete(); + } + } + } + + /** + * Set the place where the unread label position has to be. + */ + protected setUnreadLabelPosition(): void { + if (this.unreadMessageFrom != 0) { + return; + } + + if (this.groupMessagingEnabled) { + // Use the unreadcount from the conversation to calculate where should the label be placed. + if (this.conversation && (this.conversation?.unreadcount && this.conversation?.unreadcount > 0) && this.messages) { + // Iterate over messages to find the right message using the unreadcount. Skip offline messages and own messages. + let found = 0; + + for (let i = this.messages.length - 1; i >= 0; i--) { + const message = this.messages[i]; + if (!message.pending && message.useridfrom != this.currentUserId && 'id' in message) { + found++; + if (found == this.conversation.unreadcount) { + this.unreadMessageFrom = Number(message.id); + break; + } + } + } + } + } else { + let previousMessageRead = false; + + for (const x in this.messages) { + const message = this.messages[x]; + if (message.useridfrom != this.currentUserId && 'read' in message) { + const unreadFrom = !message.read && previousMessageRead; + + if (unreadFrom) { + // Save where the label is placed. + this.unreadMessageFrom = Number(message.id); + break; + } + + previousMessageRead = !!message.read; + } + } + } + + // Do not update the message unread from label on next refresh. + if (this.unreadMessageFrom == 0) { + // Using negative to indicate the label is not placed but should not be placed. + this.unreadMessageFrom = -1; + } + } + + /** + * Check if there's any message in the list that can be deleted. + */ + protected checkCanDelete(): void { + // All messages being sent should be at the end of the list. + const first = this.messages[0]; + this.canDelete = first && !first.sending; + } + + /** + * Hide unread label when sending messages. + */ + protected hideUnreadLabel(): void { + if (this.unreadMessageFrom > 0) { + this.unreadMessageFrom = -1; + } + } + + /** + * Wait until fetching is false. + * + * @return Resolved when done. + */ + protected waitForFetch(): Promise { + if (!this.fetching) { + return Promise.resolve(); + } + + const deferred = CoreUtils.instance.promiseDefer(); + + setTimeout(() => this.waitForFetch().finally(() => { + deferred.resolve(); + }), 400); + + return deferred.promise; + } + + /** + * Set a polling to get new messages every certain time. + */ + protected setPolling(): void { + if (this.groupMessagingEnabled && !this.conversationId) { + // Don't have enough data to poll messages. + return; + } + + if (!this.polling) { + // Start polling. + this.polling = window.setInterval(() => { + this.fetchMessages().catch(() => { + // Ignore errors. + }); + }, AddonMessagesProvider.POLL_INTERVAL); + } + } + + /** + * Unset polling. + */ + protected unsetPolling(): void { + if (this.polling) { + this.logger.debug(`Cancelling polling for conversation with user '${this.userId}'`); + clearInterval(this.polling); + this.polling = undefined; + } + } + + /** + * Copy message to clipboard. + * + * @param message Message to be copied. + */ + copyMessage(message: AddonMessagesConversationMessageFormatted): void { + const text = 'smallmessage' in message ? message.smallmessage || message.text || '' : message.text || ''; + CoreUtils.instance.copyToClipboard(CoreTextUtils.instance.decodeHTMLEntities(text)); + } + + /** + * Function to delete a message. + * + * @param message Message object to delete. + * @param index Index where the message is to delete it from the view. + */ + async deleteMessage( + message: AddonMessagesConversationMessageFormatted, + index: number, + ): Promise { + + const canDeleteAll = this.conversation && this.conversation.candeletemessagesforallusers; + const langKey = message.pending || canDeleteAll || this.isSelf ? 'core.areyousure' : + 'addon.messages.deletemessageconfirmation'; + const options: AlertOptions = {}; + + if (canDeleteAll && !message.pending) { + // Show delete for all checkbox. + options.inputs = [{ + type: 'checkbox', + name: 'deleteforall', + checked: false, + value: true, + label: Translate.instance.instant('addon.messages.deleteforeveryone'), + }]; + } + + try { + const data: boolean[] = await CoreDomUtils.instance.showConfirm( + Translate.instance.instant(langKey), + undefined, + undefined, + undefined, + options, + ); + + const modal = await CoreDomUtils.instance.showModalLoading('core.deleting', true); + + try { + await AddonMessages.instance.deleteMessage(message, data && data[0]); + // Remove message from the list without having to wait for re-fetch. + this.messages.splice(index, 1); + this.removeMessage(message.hash!); + this.notifyNewMessage(); + + this.fetchMessages(); // Re-fetch messages to update cached data. + } finally { + modal.dismiss(); + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errordeletemessage', true); + } + } + + /** + * Function to load previous messages. + * + * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. + * @return Resolved when done. + */ + async loadPrevious(infiniteComplete?: () => void): Promise { + let infiniteHeight = this.infinite?.infiniteEl?.nativeElement.getBoundingClientRect().height || 0; + const scrollHeight = (this.scrollElement?.scrollHeight || 0); + + // If there is an ongoing fetch, wait for it to finish. + try { + await this.waitForFetch(); + } finally { + this.pagesLoaded++; + + try { + await this.fetchMessages(false); + + // Try to keep the scroll position. + const scrollBottom = scrollHeight - (this.scrollElement?.scrollTop || 0); + + const height = this.infinite?.infiniteEl?.nativeElement.getBoundingClientRect().height || 0; + if (this.canLoadMore && infiniteHeight && this.infinite) { + // The height of the infinite is different while spinner is shown. Add that difference. + infiniteHeight = infiniteHeight - height; + } else if (!this.canLoadMore) { + // Can't load more, take into account the full height of the infinite loading since it will disappear now. + infiniteHeight = infiniteHeight || height; + } + + this.keepScroll(scrollHeight, scrollBottom, infiniteHeight); + } catch (error) { + this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. + this.pagesLoaded--; + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingmessages', true); + } finally { + infiniteComplete && infiniteComplete(); + } + } + } + + /** + * Keep scroll position after loading previous messages. + * We don't use resizeContent because the approach used is different and it isn't easy to calculate these positions. + */ + protected keepScroll(oldScrollHeight: number, oldScrollBottom: number, infiniteHeight: number, retries = 0): void { + + setTimeout(() => { + const newScrollHeight = (this.scrollElement?.scrollHeight || 0); + + if (newScrollHeight == oldScrollHeight) { + // Height hasn't changed yet. Retry if max retries haven't been reached. + if (retries <= 10) { + this.keepScroll(oldScrollHeight, oldScrollBottom, infiniteHeight, retries + 1); + } + + return; + } + + const scrollTo = newScrollHeight - oldScrollBottom + infiniteHeight; + + this.content!.scrollToPoint(0, scrollTo, 0); + }, 30); + } + + /** + * Content or scroll has been resized. For content, only call it if it's been added on top. + */ + resizeContent(): void { + /* @todo probably not needed. + let top = this.content!.getContentDimensions().scrollTop; + // @todo this.content.resize(); + + // Wait for new content height to be calculated. + setTimeout(() => { + // Visible content size changed, maintain the bottom position. + if (!this.viewDestroyed && (this.scrollElement?.clientHeight || 0) != this.oldContentHeight) { + if (!top) { + top = this.content!.getContentDimensions().scrollTop; + } + + top += this.oldContentHeight - (this.scrollElement?.clientHeight || 0); + this.oldContentHeight = (this.scrollElement?.clientHeight || 0); + + this.content!.scrollToPoint(0, top, 0); + } + }); + */ + } + + /** + * Scroll bottom when render has finished. + */ + scrollToBottom(): void { + // Check if scroll is at bottom. If so, scroll bottom after rendering since there might be something new. + if (this.scrollBottom) { + // Need a timeout to leave time to the view to be rendered. + setTimeout(() => { + if (!this.viewDestroyed) { + this.content!.scrollToBottom(0); + } + }); + this.scrollBottom = false; + + // Reset the badge. + this.setNewMessagesBadge(0); + } + } + + /** + * Scroll to the first new unread message. + */ + scrollToFirstUnreadMessage(): void { + if (this.newMessages > 0) { + const messages = Array.from(document.querySelectorAll('.addon-message-not-mine')); + + CoreDomUtils.instance.scrollToElement(this.content!, messages[messages.length - this.newMessages]); + } + } + + /** + * Sends a message to the server. + * + * @param text Message text. + */ + async sendMessage(text: string): Promise { + this.hideUnreadLabel(); + + this.showDelete = false; + this.scrollBottom = true; + this.setNewMessagesBadge(0); + + const message: AddonMessagesConversationMessageFormatted = { + id: -1, + pending: true, + sending: true, + useridfrom: this.currentUserId, + smallmessage: text, + text: text, + timecreated: new Date().getTime(), + }; + message.showDate = this.showDate(message, this.messages[this.messages.length - 1]); + this.addMessage(message, false); + + this.messagesBeingSent++; + + // If there is an ongoing fetch, wait for it to finish. + // Otherwise, if a message is sent while fetching it could disappear until the next fetch. + try { + await this.waitForFetch(); + } finally { + + try { + let data: AddonMessagesSendMessageResults; + if (this.conversationId) { + data = await AddonMessages.instance.sendMessageToConversation(this.conversation!, text); + } else { + data = await AddonMessages.instance.sendMessage(this.userId!, text); + } + + + this.messagesBeingSent--; + let failure = false; + if (data.sent) { + try { + + if (!this.conversationId && data.message && 'conversationid' in data.message) { + // Message sent to a new conversation, try to load the conversation. + await this.getConversation(data.message.conversationid, this.userId); + // Now fetch messages. + try { + await this.fetchMessages(); + } finally { + // Start polling messages now that the conversation exists. + this.setPolling(); + } + } else { + // Message was sent, fetch messages right now. + await this.fetchMessages(); + } + } catch { + failure = true; + } + } + + if (failure || !data.sent) { + // Fetch failed or is offline message, mark the message as sent. + // If fetch is successful there's no need to mark it because the fetch will already show the message received. + message.sending = false; + if (data.sent) { + // Message sent to server, not pending anymore. + message.pending = false; + } else if (data.message) { + message.timecreated = data.message.timecreated || 0; + } + + this.notifyNewMessage(); + } + + } catch (error) { + this.messagesBeingSent--; + + // Only close the keyboard if an error happens. + // We want the user to be able to send multiple messages without the keyboard being closed. + CoreApp.instance.closeKeyboard(); + + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.messagenotsent', true); + this.removeMessage(message.hash!); + } + } + } + + /** + * Check date should be shown on message list for the current message. + * If date has changed from previous to current message it should be shown. + * + * @param message Current message where to show the date. + * @param prevMessage Previous message where to compare the date with. + * @return If date has changed and should be shown. + */ + showDate( + message: AddonMessagesConversationMessageFormatted, + prevMessage?: AddonMessagesConversationMessageFormatted, + ): boolean { + + if (!prevMessage) { + // First message, show it. + return true; + } + + // Check if day has changed. + return !moment(message.timecreated).isSame(prevMessage.timecreated, 'day'); + } + + /** + * Check if the user info should be displayed for the current message. + * User data is only displayed for group conversations if the previous message was from another user. + * + * @param message Current message where to show the user info. + * @param prevMessage Previous message. + * @return Whether user data should be shown. + */ + showUserData( + message: AddonMessagesConversationMessageFormatted, + prevMessage?: AddonMessagesConversationMessageFormatted, + ): boolean { + + return this.isGroup && message.useridfrom != this.currentUserId && this.members[(message.useridfrom || 0)] && + (!prevMessage || prevMessage.useridfrom != message.useridfrom || !!message.showDate); + } + + /** + * Check if a css tail should be shown. + * + * @param message Current message where to show the user info. + * @param nextMessage Next message. + * @return Whether user data should be shown. + */ + showTail( + message: AddonMessagesConversationMessageFormatted, + nextMessage?: AddonMessagesConversationMessageFormatted, + ): boolean { + return !nextMessage || nextMessage.useridfrom != message.useridfrom || !!nextMessage.showDate; + } + + /** + * Toggles delete state. + */ + toggleDelete(): void { + this.showDelete = !this.showDelete; + } + + /** + * View info. If it's an individual conversation, go to the user profile. + * If it's a group conversation, view info about the group. + */ + async viewInfo(): Promise { + if (this.isGroup) { + // Display the group information. + const modal = await ModalController.instance.create({ + component: AddonMessagesConversationInfoComponent, + componentProps: { + conversationId: this.conversationId, + }, + }); + + await modal.present(); + + const result = await modal.onDidDismiss(); + + if (typeof result.data != 'undefined') { + const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/messages/**/discussion'); + + // Open user conversation. + if (splitViewLoaded) { + // Notify the left pane to load it, this way the right conversation will be highlighted. + CoreEvents.trigger( + AddonMessagesProvider.OPEN_CONVERSATION_EVENT, + { userId: result.data }, + this.siteId, + ); + } else { + // Open the discussion in a new view. + CoreNavigator.instance.navigateToSitePath('/messages/discussion', { params: { userId: result.data.userId } }); + } + } + } else { + // Open the user profile. + CoreNavigator.instance.navigateToSitePath('/user/profile', { params: { userId: this.userId } }); + } + } + + /** + * Change the favourite state of the current conversation. + * + * @param done Function to call when done. + */ + async changeFavourite(done?: () => void): Promise { + if (!this.conversation) { + return; + } + + this.favouriteIcon = 'spinner'; + + try { + await AddonMessages.instance.setFavouriteConversation(this.conversation.id, !this.conversation.isfavourite); + + this.conversation.isfavourite = !this.conversation.isfavourite; + + // Get the conversation data so it's cached. Don't block the user for this. + AddonMessages.instance.getConversation(this.conversation.id, undefined, true); + + CoreEvents.trigger(AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, { + conversationId: this.conversation.id, + action: 'favourite', + value: this.conversation.isfavourite, + }, this.siteId); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error changing favourite state.'); + } finally { + this.favouriteIcon = 'fas-star'; + this.favouriteIconSlash = this.conversation.isfavourite; + done && done(); + } + } + + /** + * Change the mute state of the current conversation. + * + * @param done Function to call when done. + */ + async changeMute(done?: () => void): Promise { + if (!this.conversation) { + return; + } + + this.muteIcon = 'spinner'; + + try { + await AddonMessages.instance.muteConversation(this.conversation.id, !this.conversation.ismuted); + this.conversation.ismuted = !this.conversation.ismuted; + + // Get the conversation data so it's cached. Don't block the user for this. + AddonMessages.instance.getConversation(this.conversation.id, undefined, true); + + CoreEvents.trigger(AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, { + conversationId: this.conversation.id, + action: 'mute', + value: this.conversation.ismuted, + }, this.siteId); + + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error changing muted state.'); + } finally { + this.muteIcon = this.conversation.ismuted ? 'fas-bell' : 'fas-bell-slash'; + done && done(); + } + } + + /** + * Calculate whether there are pending contact requests. + */ + protected setContactRequestInfo(): void { + this.requestContactSent = false; + this.requestContactReceived = false; + if (this.otherMember && !this.otherMember.iscontact) { + this.requestContactSent = !!this.otherMember.contactrequests?.some((request) => + request.userid == this.currentUserId && request.requesteduserid == this.otherMember!.id); + this.requestContactReceived = !!this.otherMember.contactrequests?.some((request) => + request.userid == this.otherMember!.id && request.requesteduserid == this.currentUserId); + } + } + + /** + * Calculate what to display in the footer. + */ + protected setFooterType(): void { + if (!this.otherMember) { + // Group conversation or group messaging not available. + this.footerType = 'message'; + } else if (this.otherMember.isblocked) { + this.footerType = 'blocked'; + } else if (this.requestContactReceived) { + this.footerType = 'requestReceived'; + } else if (this.otherMember.canmessage) { + this.footerType = 'message'; + } else if (this.requestContactSent) { + this.footerType = 'requestSent'; + } else if (this.otherMember.requirescontact) { + this.footerType = 'requiresContact'; + } else { + this.footerType = 'unable'; + } + } + + /** + * Displays a confirmation modal to block the user of the individual conversation. + * + * @return Promise resolved when user is blocked or dialog is cancelled. + */ + async blockUser(): Promise { + if (!this.otherMember) { + // Should never happen. + throw new CoreError('No member selected to be blocked.'); + } + + const template = Translate.instance.instant('addon.messages.blockuserconfirm', { $a: this.otherMember.fullname }); + const okText = Translate.instance.instant('addon.messages.blockuser'); + + try { + await CoreDomUtils.instance.showConfirm(template, undefined, okText); + this.blockIcon = 'spinner'; + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + this.showLoadingModal = true; + + try { + try { + await AddonMessages.instance.blockContact(this.otherMember.id); + } finally { + modal.dismiss(); + this.showLoadingModal = false; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } finally { + this.blockIcon = this.otherMember.isblocked ? 'fas-user-lock' : 'fas-user-check'; + } + } catch { + // User cancelled. + } + } + + /** + * Delete the conversation. + * + * @param done Function to call when done. + */ + async deleteConversation(done?: () => void): Promise { + if (!this.conversation) { + return; + } + + const confirmMessage = 'addon.messages.' + (this.isSelf ? 'deleteallselfconfirm' : 'deleteallconfirm'); + + try { + await CoreDomUtils.instance.showDeleteConfirm(confirmMessage); + this.deleteIcon = 'spinner'; + + try { + try { + await AddonMessages.instance.deleteConversation(this.conversation.id); + + CoreEvents.trigger( + AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, + { + conversationId: this.conversation.id, + action: 'delete', + }, + this.siteId, + ); + + this.messages = []; + } finally { + done && done(); + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error deleting conversation.'); + } finally { + this.deleteIcon = 'fas-trash'; + } + } catch { + // User cancelled. + } + } + + /** + * Displays a confirmation modal to unblock the user of the individual conversation. + * + * @return Promise resolved when user is unblocked or dialog is cancelled. + */ + async unblockUser(): Promise { + if (!this.otherMember) { + // Should never happen. + throw new CoreError('No member selected to be unblocked.'); + } + + const template = Translate.instance.instant('addon.messages.unblockuserconfirm', { $a: this.otherMember.fullname }); + const okText = Translate.instance.instant('addon.messages.unblockuser'); + + try { + await CoreDomUtils.instance.showConfirm(template, undefined, okText); + + this.blockIcon = 'spinner'; + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + this.showLoadingModal = true; + + try { + try { + await AddonMessages.instance.unblockContact(this.otherMember.id); + } finally { + modal.dismiss(); + this.showLoadingModal = false; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } finally { + this.blockIcon = this.otherMember.isblocked ? 'fas-user-lock' : 'fas-user-check'; + } + } catch { + // User cancelled. + } + } + + /** + * Displays a confirmation modal to send a contact request to the other user of the individual conversation. + * + * @return Promise resolved when the request is sent or the dialog is cancelled. + */ + async createContactRequest(): Promise { + if (!this.otherMember) { + // Should never happen. + throw new CoreError('No member selected to be requested.'); + } + + const template = Translate.instance.instant('addon.messages.addcontactconfirm', { $a: this.otherMember.fullname }); + const okText = Translate.instance.instant('core.add'); + + try { + await CoreDomUtils.instance.showConfirm(template, undefined, okText); + + this.addRemoveIcon = 'spinner'; + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + this.showLoadingModal = true; + + try { + try { + await AddonMessages.instance.createContactRequest(this.otherMember.id); + } finally { + modal.dismiss(); + this.showLoadingModal = false; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } finally { + this.addRemoveIcon = 'fas-user-plus'; + } + } catch { + // User cancelled. + } + } + + /** + * Confirms the contact request of the other user of the individual conversation. + * + * @return Promise resolved when the request is confirmed. + */ + async confirmContactRequest(): Promise { + if (!this.otherMember) { + // Should never happen. + throw new CoreError('No member selected to be confirmed.'); + } + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + this.showLoadingModal = true; + + try { + try { + await AddonMessages.instance.confirmContactRequest(this.otherMember.id); + } finally { + modal.dismiss(); + this.showLoadingModal = false; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } + } + + /** + * Declines the contact request of the other user of the individual conversation. + * + * @return Promise resolved when the request is confirmed. + */ + async declineContactRequest(): Promise { + if (!this.otherMember) { + // Should never happen. + throw new CoreError('No member selected to be declined.'); + } + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + this.showLoadingModal = true; + + try { + try { + await AddonMessages.instance.declineContactRequest(this.otherMember.id); + } finally { + modal.dismiss(); + this.showLoadingModal = false; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } + } + + /** + * Displays a confirmation modal to remove the other user of the conversation from contacts. + * + * @return Promise resolved when the request is sent or the dialog is cancelled. + */ + async removeContact(): Promise { + if (!this.otherMember) { + // Should never happen. + throw new CoreError('No member selected to be removed.'); + } + + const template = Translate.instance.instant('addon.messages.removecontactconfirm', { $a: this.otherMember.fullname }); + const okText = Translate.instance.instant('core.remove'); + + try { + await CoreDomUtils.instance.showConfirm(template, undefined, okText); + + this.addRemoveIcon = 'spinner'; + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + this.showLoadingModal = true; + + try { + try { + await AddonMessages.instance.removeContact(this.otherMember.id); + } finally { + modal.dismiss(); + this.showLoadingModal = false; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } finally { + this.addRemoveIcon = 'fas-user-plus'; + } + } catch { + // User cancelled. + } + + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + // Unset again, just in case. + this.unsetPolling(); + this.syncObserver?.off(); + this.keyboardObserver?.off(); + this.memberInfoObserver?.off(); + this.viewDestroyed = true; + } + +} diff --git a/src/addons/messages/pages/discussion/discussion.scss b/src/addons/messages/pages/discussion/discussion.scss new file mode 100644 index 000000000..67086643d --- /dev/null +++ b/src/addons/messages/pages/discussion/discussion.scss @@ -0,0 +1,300 @@ +:host { + ion-content { + background-color: var(--background-lighter); + + &::part(scroll) { + padding-bottom: 0 !important; + } + } + + .addon-messages-discussion-container { + display: flex; + flex-direction: column; + padding-bottom: 15px; + background: var(--background-lighter); + } + + .addon-messages-date { + font-weight: normal; + font-size: 0.9rem; + } + + .addon-messages-unreadfrom { + color: var(--core-color); + background-color: transparent; + margin-top: 6px; + ion-icon { + color: var(--core-color); + background-color: transparent; + } + } + + // Message item. + ion-item.addon-message { + border: 0; + border-radius: 4px; + padding: 8px; + margin: 8px 8px 0 8px; + --background: var(--addon-messages-message-bg); + background: var(--background); + align-self: flex-start; + width: 90%; + max-width: 90%; + min-height: 0; + position: relative; + -webkit-transition: width 500ms ease-in-out; + transition: width 500ms ease-in-out; + // This is needed to display bubble tails. + overflow: visible; + + &::part(native) { + --inner-border-width: 0; + --inner-padding-end: 0; + padding: 0; + margin: 0; + } + + core-format-text > p:only-child { + display: inline; + } + + .addon-message-user { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-bottom: .5rem; + margin-top: 0; + color: var(--ion-text-color); + + core-user-avatar { + display: block; + --core-avatar-size: var(--addon-messages-avatar-size); + margin: 0; + } + + div { + font-weight: 500; + flex-grow: 1; + padding-right: .5rem; + padding-left: .5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + ion-note { + text-align: end;; + color: var(--addon-messages-message-note-text); + font-size: var(--addon-messages-message-note-font-size); + } + } + + &.addon-message-no-user .addon-message-user ion-note { + width: 100%; + } + + &:active { + --background: var(--addon-messages-message-activated-bg); + } + + ion-label { + margin: 0; + padding: 0; + } + + .addon-message-text { + display: inline-flex; + * { + color: var(--ion-text-color); + } + } + + .addon-messages-delete-button { + min-height: initial; + line-height: initial; + margin: 0 0 0 10px; + height: 1.6em !important; + -webkit-align-self: flex-end; + align-self: flex-end; + vertical-align: middle; + float: inline-end; + + ion-icon { + font-size: 1.4em; + line-height: initial; + color: var(--ion-color-danger); + } + } + + .tail { + content: ''; + width: 0; + height: 0; + border: 0.5rem solid transparent; + position: absolute; + touch-action: none; + } + + // Defines when an item-message is the user's. + &.addon-message-mine { + --background: var(--addon-messages-message-mine-bg); + align-self: flex-end; + + &:active { + --background: var(--addon-messages-message-mine-activated-bg); + } + + .spinner { + float: inline-end; + margin: 2px, -3px, -2px, 5px; + + svg { + width: 16px; + height: 16px; + } + } + + .tail { + right: -8px; + bottom: -8px; + margin-right: -0.5rem; + border-bottom-color: var(--addon-messages-message-mine-bg); + } + + &:active .tail { + border-bottom-color: var(--addon-messages-message-mine-activated-bg); + } + } + + &.addon-message-not-mine .tail { + border-bottom-color: var(--addon-messages-message-bg); + bottom: -8px; + left: -8px; + margin-left: -0.5rem; + } + + &.addon-message-not-mine.activated .tail { + border-bottom-color: var(--addon-messages-message-activated-bg); + } + } + + ion-item.addon-message.addon-message-mine + ion-item.addon-message.addon-message-no-user.addon-message-mine, + ion-item.addon-message.addon-message-not-mine + ion-item.addon-message.addon-message-no-user.addon-message-not-mine { + h2 { + margin-bottom: 0; + } + margin-top: -8px; + padding-top: 0; + border-top-right-radius: 0; + border-top-left-radius: 0; + } + + .has-fab .scroll-content { + padding-bottom: 0; + } + + ion-fab ion-fab-button { + &::part(native) { + contain: unset; + overflow: visible; + } + + .core-discussion-messages-badge { + position: absolute; + border-radius: 50%; + color: var(--addon-messages-discussion-badge-text); + background-color: var(--addon-messages-discussion-badge); + display: block; + line-height: 20px; + height: 20px; + width: 20px; + right: -6px; + top: -6px; + + } + } + + ion-header ion-toolbar .toolbar-title { + display: flex; + align-items: center; + padding: 0; + + .core-bar-button-image { + margin-right: 6px; + } + + core-format-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 1; + } + + ion-icon { + margin-left: 6px; + } + } +} + +:host-context([dir=rtl]) { + ion-header ion-toolbar .toolbar-title { + .core-bar-button-image { + margin-left: 6px; + margin-right: 0; + } + + ion-icon { + margin-right: 6px; + margin-left: 0; + } + } + + // Message item. + ion-item.addon-message { + + .addon-messages-delete-button { + margin-right: 10px; + margin-left: 0; + } + + &.addon-message-mine { + .spinner { + margin-right: 5px; + margin-left: -3px; + } + + .tail { + right: unset; + left: -8px; + margin-right: unset; + margin-left: 0; + } + } + + &.addon-message-not-mine .tail { + right: -8px; + margin-right: -0.5rem; + left: unset; + margin-left: 0; + } + } + + ion-fab button { + .core-discussion-messages-badge { + left: -6px; + right: unset; + } + } +} + +:host-context(.ios) { + ion-header ion-toolbar .toolbar-title { + justify-content: center; + } + + ion-footer .toolbar:last-child { + padding-bottom: 4px; + min-height: 0; + } +} diff --git a/src/addons/messages/pages/discussions-35/discussions.html b/src/addons/messages/pages/discussions-35/discussions.html new file mode 100644 index 000000000..e5bce97cd --- /dev/null +++ b/src/addons/messages/pages/discussions-35/discussions.html @@ -0,0 +1,82 @@ + + + + + + {{ 'addon.messages.messages' | translate }} + + + + + + + + + + + + + + + + + + + + +

{{ 'addon.messages.contacts' | translate }}

+
+ + + + +

{{ 'core.searchresults' | translate }}

+
+ {{ search.results.length }} +
+ + + +

{{ result.fullname }}

+

+
+
+
+ + + + +

{{ discussion.fullname }}

+ + + + {{discussion.message!.timecreated / 1000 | coreDateDayOrTime}} + + +

+ + +

+
+
+
+
+ + + + +
+
+
diff --git a/src/addons/messages/pages/discussions-35/discussions.module.ts b/src/addons/messages/pages/discussions-35/discussions.module.ts new file mode 100644 index 000000000..7b0329662 --- /dev/null +++ b/src/addons/messages/pages/discussions-35/discussions.module.ts @@ -0,0 +1,66 @@ +// (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 { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { CoreScreen } from '@services/screen'; +import { conditionalRoutes } from '@/app/app-routing.module'; +import { AddonMessagesDiscussionRoute } from '@addons/messages/messages-lazy.module'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreSearchComponentsModule } from '@features/search/components/components.module'; + +import { AddonMessagesDiscussions35Page } from './discussions.page'; + +const mobileRoutes: Routes = [ + { + path: '', + component: AddonMessagesDiscussions35Page, + }, + AddonMessagesDiscussionRoute, +]; + +const tabletRoutes: Routes = [ + { + path: '', + component: AddonMessagesDiscussions35Page, + children: [ + AddonMessagesDiscussionRoute, + ], + }, +]; + +const routes: Routes = [ + ...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile), + ...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet), +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + CoreSearchComponentsModule, + ], + declarations: [ + AddonMessagesDiscussions35Page, + ], + exports: [RouterModule], +}) +export class AddonMessagesDiscussions35PageModule {} diff --git a/src/addons/messages/pages/discussions-35/discussions.page.ts b/src/addons/messages/pages/discussions-35/discussions.page.ts new file mode 100644 index 000000000..b9cfa73a5 --- /dev/null +++ b/src/addons/messages/pages/discussions-35/discussions.page.ts @@ -0,0 +1,306 @@ +// (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 } from '@angular/core'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { + AddonMessages, + AddonMessagesDiscussion, + AddonMessagesMessageAreaContact, + AddonMessagesNewMessagedEventData, + AddonMessagesProvider, + AddonMessagesReadChangedEventData, +} from '../../services/messages'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreApp } from '@services/app'; +import { ActivatedRoute, Params } from '@angular/router'; +import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications'; +import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate'; +import { Subscription } from 'rxjs'; +import { Translate, Platform } from '@singletons'; +import { IonRefresher } from '@ionic/angular'; +import { CoreNavigator } from '@services/navigator'; +import { CoreScreen } from '@services/screen'; + +/** + * Page that displays the list of discussions. + */ +@Component({ + selector: 'addon-messages-discussions', + templateUrl: 'discussions.html', + styleUrls: ['../../messages-common.scss'], +}) +export class AddonMessagesDiscussions35Page implements OnInit, OnDestroy { + + protected newMessagesObserver: CoreEventObserver; + protected readChangedObserver: CoreEventObserver; + protected appResumeSubscription: Subscription; + protected pushObserver: Subscription; + protected loadingMessages: string; + protected siteId: string; + + loaded = false; + loadingMessage = ''; + discussions: AddonMessagesDiscussion[] = []; + discussionUserId?: number; + + search = { + enabled: false, + showResults: false, + results: [], + loading: '', + text: '', + }; + + constructor( + protected route: ActivatedRoute, + ) { + + this.search.loading = Translate.instance.instant('core.searching'); + this.loadingMessages = Translate.instance.instant('core.loading'); + this.siteId = CoreSites.instance.getCurrentSiteId(); + + // Update discussions when new message is received. + this.newMessagesObserver = CoreEvents.on( + AddonMessagesProvider.NEW_MESSAGE_EVENT, + (data) => { + if (data.userId && this.discussions) { + const discussion = this.discussions.find((disc) => disc.message!.user == data.userId); + + if (typeof discussion == 'undefined') { + this.loaded = false; + this.refreshData().finally(() => { + this.loaded = true; + }); + } else { + // An existing discussion has a new message, update the last message. + discussion.message!.message = data.message; + discussion.message!.timecreated = data.timecreated; + } + } + }, + this.siteId, + ); + + // Update discussions when a message is read. + this.readChangedObserver = CoreEvents.on( + AddonMessagesProvider.READ_CHANGED_EVENT, + (data) => { + if (data.userId && this.discussions) { + const discussion = this.discussions.find((disc) => disc.message!.user == data.userId); + + if (typeof discussion != 'undefined') { + // A discussion has been read reset counter. + discussion.unread = false; + + // Conversations changed, invalidate them and refresh unread counts. + AddonMessages.instance.invalidateConversations(this.siteId); + AddonMessages.instance.refreshUnreadConversationCounts(this.siteId); + } + } + }, + this.siteId, + ); + + // Refresh the view when the app is resumed. + this.appResumeSubscription = Platform.instance.resume.subscribe(() => { + if (!this.loaded) { + return; + } + this.loaded = false; + this.refreshData(); + }); + + + // If a message push notification is received, refresh the view. + this.pushObserver = CorePushNotificationsDelegate.instance.on('receive') + .subscribe((notification) => { + // New message received. If it's from current site, refresh the data. + if (CoreUtils.instance.isFalseOrZero(notification.notif) && notification.site == this.siteId) { + // Don't refresh unread counts, it's refreshed from the main menu handler in this case. + this.refreshData(undefined, false); + } + }); + } + + /** + * Component loaded. + */ + ngOnInit(): void { + this.route.queryParams.subscribe(async (params) => { + const discussionUserId = CoreNavigator.instance.getRouteNumberParam('discussionUserId', params) || + CoreNavigator.instance.getRouteNumberParam('userId', params) || undefined; + + if (this.loaded && this.discussionUserId == discussionUserId) { + return; + } + + this.discussionUserId = discussionUserId; + + if (this.discussionUserId) { + // There is a discussion to load, open the discussion in a new state. + this.gotoDiscussion(this.discussionUserId); + } + + await this.fetchData(); + + if (!this.discussionUserId && this.discussions.length > 0 && CoreScreen.instance.isTablet) { + // Take first and load it. + this.gotoDiscussion(this.discussions[0].message!.user); + } + }); + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @param refreshUnreadCounts Whteher to refresh unread counts. + * @return Promise resolved when done. + */ + async refreshData(refresher?: CustomEvent, refreshUnreadCounts: boolean = true): Promise { + const promises: Promise[] = []; + promises.push(AddonMessages.instance.invalidateDiscussionsCache(this.siteId)); + + if (refreshUnreadCounts) { + promises.push(AddonMessages.instance.invalidateUnreadConversationCounts(this.siteId)); + } + + await CoreUtils.instance.allPromises(promises).finally(() => this.fetchData().finally(() => { + if (refresher) { + refresher?.detail.complete(); + } + })); + } + + /** + * Fetch discussions. + * + * @return Promise resolved when done. + */ + protected async fetchData(): Promise { + this.loadingMessage = this.loadingMessages; + this.search.enabled = AddonMessages.instance.isSearchMessagesEnabled(); + + const promises: Promise[] = []; + + promises.push(AddonMessages.instance.getDiscussions(this.siteId).then((discussions) => { + // Convert to an array for sorting. + const discussionsSorted: AddonMessagesDiscussion[] = []; + for (const userId in discussions) { + discussions[userId].unread = !!discussions[userId].unread; + + discussionsSorted.push(discussions[userId]); + } + + this.discussions = discussionsSorted.sort((a, b) => (b.message?.timecreated || 0) - (a.message?.timecreated || 0)); + + return; + })); + + promises.push(AddonMessages.instance.getUnreadConversationCounts(this.siteId)); + + try { + await Promise.all(promises); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true); + } + + this.loaded = true; + } + + /** + * Clear search and show discussions again. + */ + clearSearch(): void { + this.loaded = false; + this.search.showResults = false; + this.search.text = ''; // Reset searched string. + this.fetchData().finally(() => { + this.loaded = true; + }); + } + + /** + * Search messages cotaining text. + * + * @param query Text to search for. + * @return Resolved when done. + */ + async searchMessage(query: string): Promise { + CoreApp.instance.closeKeyboard(); + this.loaded = false; + this.loadingMessage = this.search.loading; + + try { + const searchResults = await AddonMessages.instance.searchMessages(query, undefined, undefined, undefined, this.siteId); + this.search.showResults = true; + this.search.results = searchResults.messages; + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingmessages', true); + } + + this.loaded = true; + } + + /** + * Navigate to a particular discussion. + * + * @param discussionUserId Discussion Id to load. + * @param messageId Message to scroll after loading the discussion. Used when searching. + * @param onlyWithSplitView Only go to Discussion if split view is on. + */ + gotoDiscussion(discussionUserId: number, messageId?: number): void { + this.discussionUserId = discussionUserId; + + const params: Params = { + userId: discussionUserId, + }; + + if (messageId) { + params.message = messageId; + } + + const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/messages/index/discussion'); + const path = (splitViewLoaded ? '../' : '') + 'discussion'; + + CoreNavigator.instance.navigate(path, { params }); + } + + /** + * Navigate to contacts view. + */ + gotoContacts(): void { + const params: Params = {}; + + if (CoreScreen.instance.isTablet && this.discussionUserId) { + params.discussionUserId = this.discussionUserId; + } + + CoreNavigator.instance.navigateToSitePath('contacts-35', { params }); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.newMessagesObserver?.off(); + this.readChangedObserver?.off(); + this.appResumeSubscription?.unsubscribe(); + this.pushObserver?.unsubscribe(); + } + +} diff --git a/src/addons/messages/pages/group-conversations/group-conversations.html b/src/addons/messages/pages/group-conversations/group-conversations.html new file mode 100644 index 000000000..15454d75a --- /dev/null +++ b/src/addons/messages/pages/group-conversations/group-conversations.html @@ -0,0 +1,143 @@ + + + + + + {{ 'addon.messages.messages' | translate }} + + + + + + + + + + + + + + + + + + + + + + +

{{ 'addon.messages.contacts' | translate }}

+ {{contactRequestsCount}} +
+ + + + + {{ 'core.favourites' | translate }} ({{ favourites.count }}) + {{ favourites.unread }} + +
+ + + + + +

{{ 'addon.messages.nofavourites' | translate }}

+
+
+ + + + + + + + + {{ 'addon.messages.groupconversations' | translate }} ({{ group.count }}) + {{ group.unread }} + +
+ + + + + +

{{ 'addon.messages.nogroupconversations' | translate }}

+
+
+ + + + + + + + {{ 'addon.messages.individualconversations' | translate }} ({{ individual.count }}) + {{ individual.unread }} + +
+ + + + + +

{{ 'addon.messages.noindividualconversations' | translate }}

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

+ + + +

+ + {{ conversation.unreadcount }} + {{conversation.lastmessagedate | coreDateDayOrTime}} + +

+

+ + {{ 'addon.messages.you' | translate }} + + {{ conversation.members[0].fullname + ':' }} + +

+
+
+
diff --git a/src/addons/messages/pages/group-conversations/group-conversations.module.ts b/src/addons/messages/pages/group-conversations/group-conversations.module.ts new file mode 100644 index 000000000..87b9da1c9 --- /dev/null +++ b/src/addons/messages/pages/group-conversations/group-conversations.module.ts @@ -0,0 +1,64 @@ +// (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 { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { conditionalRoutes } from '@/app/app-routing.module'; +import { AddonMessagesDiscussionRoute } from '@addons/messages/messages-lazy.module'; +import { CoreScreen } from '@services/screen'; + +import { CoreSharedModule } from '@/core/shared.module'; + +import { AddonMessagesGroupConversationsPage } from './group-conversations.page'; + +const mobileRoutes: Routes = [ + { + path: '', + component: AddonMessagesGroupConversationsPage, + }, + AddonMessagesDiscussionRoute, +]; + +const tabletRoutes: Routes = [ + { + path: '', + component: AddonMessagesGroupConversationsPage, + children: [ + AddonMessagesDiscussionRoute, + ], + }, +]; + +const routes: Routes = [ + ...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile), + ...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet), +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + ], + declarations: [ + AddonMessagesGroupConversationsPage, + ], + exports: [RouterModule], +}) +export class AddonMessagesGroupConversationsPageModule {} diff --git a/src/addons/messages/pages/group-conversations/group-conversations.page.ts b/src/addons/messages/pages/group-conversations/group-conversations.page.ts new file mode 100644 index 000000000..7e965b31b --- /dev/null +++ b/src/addons/messages/pages/group-conversations/group-conversations.page.ts @@ -0,0 +1,833 @@ +// (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, OnDestroy, ViewChild, ElementRef } from '@angular/core'; +import { IonContent, IonRefresher } from '@ionic/angular'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { + AddonMessagesProvider, + AddonMessagesConversationFormatted, + AddonMessages, + AddonMessagesMemberInfoChangedEventData, + AddonMessagesContactRequestCountEventData, + AddonMessagesUnreadConversationCountsEventData, + AddonMessagesReadChangedEventData, + AddonMessagesUpdateConversationListEventData, + AddonMessagesNewMessagedEventData, + AddonMessagesOpenConversationEventData, +} from '../../services/messages'; +import { + AddonMessagesOffline, + AddonMessagesOfflineAnyMessagesFormatted, +} from '../../services/messages-offline'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUser } from '@features/user/services/user'; +import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate'; +import { Platform, Translate } from '@singletons'; +import { Subscription } from 'rxjs'; +import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications'; +import { ActivatedRoute, Params } from '@angular/router'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreNavigator } from '@services/navigator'; +import { AddonMessagesSettingsHandlerService } from '@addons/messages/services/handlers/settings'; +import { CoreScreen } from '@services/screen'; + +/** + * Page that displays the list of conversations, including group conversations. + */ +@Component({ + selector: 'page-addon-messages-group-conversations', + templateUrl: 'group-conversations.html', + styleUrls: ['../../messages-common.scss'], +}) +export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { + + @ViewChild(IonContent) content?: IonContent; + @ViewChild('favlist') favListEl?: ElementRef; + @ViewChild('grouplist') groupListEl?: ElementRef; + @ViewChild('indlist') indListEl?: ElementRef; + + loaded = false; + loadingMessage: string; + selectedConversationId?: number; + selectedUserId?: number; + contactRequestsCount = 0; + favourites: AddonMessagesGroupConversationOption = { + type: undefined, + favourites: true, + count: 0, + unread: 0, + conversations: [], + }; + + group: AddonMessagesGroupConversationOption = { + type: AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP, + favourites: false, + count: 0, + unread: 0, + conversations: [], + }; + + individual: AddonMessagesGroupConversationOption = { + type: AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL, + favourites: false, + count: 0, + unread: 0, + conversations: [], + }; + + typeGroup = AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP; + currentListEl?: HTMLElement; + + protected siteId: string; + protected currentUserId: number; + protected conversationId?: number; + protected discussionUserId?: number; + protected newMessagesObserver: CoreEventObserver; + protected pushObserver: Subscription; + protected appResumeSubscription: Subscription; + protected readChangedObserver: CoreEventObserver; + protected cronObserver: CoreEventObserver; + protected openConversationObserver: CoreEventObserver; + protected updateConversationListObserver: CoreEventObserver; + protected contactRequestsCountObserver: CoreEventObserver; + protected memberInfoObserver: CoreEventObserver; + + constructor( + protected route: ActivatedRoute, + ) { + this.loadingMessage = Translate.instance.instant('core.loading'); + this.siteId = CoreSites.instance.getCurrentSiteId(); + this.currentUserId = CoreSites.instance.getCurrentSiteUserId(); + + // Update conversations when new message is received. + this.newMessagesObserver = CoreEvents.on( + AddonMessagesProvider.NEW_MESSAGE_EVENT, + (data) => { + // Check if the new message belongs to the option that is currently expanded. + const expandedOption = this.getExpandedOption(); + const messageOption = this.getConversationOption(data); + + if (expandedOption != messageOption) { + return; // Message doesn't belong to current list, stop. + } + + // Search the conversation to update. + const conversation = this.findConversation(data.conversationId, data.userId, expandedOption); + + if (typeof conversation == 'undefined') { + // Probably a new conversation, refresh the list. + this.loaded = false; + this.refreshData().finally(() => { + this.loaded = true; + }); + + return; + } + if (conversation.lastmessage != data.message || conversation.lastmessagedate != data.timecreated / 1000) { + const isNewer = data.timecreated / 1000 > (conversation.lastmessagedate || 0); + + // An existing conversation has a new message, update the last message. + conversation.lastmessage = data.message; + conversation.lastmessagedate = data.timecreated / 1000; + + // Sort the affected list. + const option = this.getConversationOption(conversation); + option.conversations = AddonMessages.instance.sortConversations(option.conversations || []); + + if (isNewer) { + // The last message is newer than the previous one, scroll to top to keep viewing the conversation. + this.content?.scrollToTop(); + } + } + }, + this.siteId, + ); + + // Update conversations when a message is read. + this.readChangedObserver = CoreEvents.on(AddonMessagesProvider.READ_CHANGED_EVENT, ( + data, + ) => { + if (data.conversationId) { + const conversation = this.findConversation(data.conversationId); + + if (typeof conversation != 'undefined') { + // A conversation has been read reset counter. + conversation.unreadcount = 0; + + // Conversations changed, invalidate them and refresh unread counts. + AddonMessages.instance.invalidateConversations(this.siteId); + AddonMessages.instance.refreshUnreadConversationCounts(this.siteId); + } + } + }, this.siteId); + + // Load a discussion if we receive an event to do so. + this.openConversationObserver = CoreEvents.on( + AddonMessagesProvider.OPEN_CONVERSATION_EVENT, + (data) => { + if (data.conversationId || data.userId) { + this.gotoConversation(data.conversationId, data.userId); + } + }, + this.siteId, + ); + + // Refresh the view when the app is resumed. + this.appResumeSubscription = Platform.instance.resume.subscribe(() => { + if (!this.loaded) { + return; + } + this.loaded = false; + this.refreshData().finally(() => { + this.loaded = true; + }); + }); + + // Update conversations if we receive an event to do so. + this.updateConversationListObserver = CoreEvents.on( + AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, + (data) => { + if (data && data.action == 'mute') { + // If the conversation is displayed, change its muted value. + const expandedOption = this.getExpandedOption(); + + if (expandedOption && expandedOption.conversations) { + const conversation = this.findConversation(data.conversationId, undefined, expandedOption); + if (conversation) { + conversation.ismuted = !!data.value; + } + } + + return; + } + + this.refreshData(); + + }, + this.siteId, + ); + + // If a message push notification is received, refresh the view. + this.pushObserver = CorePushNotificationsDelegate.instance.on('receive') + .subscribe((notification) => { + // New message received. If it's from current site, refresh the data. + if (CoreUtils.instance.isFalseOrZero(notification.notif) && notification.site == this.siteId) { + // Don't refresh unread counts, it's refreshed from the main menu handler in this case. + this.refreshData(undefined, false); + } + }); + + // Update unread conversation counts. + this.cronObserver = CoreEvents.on( + AddonMessagesProvider.UNREAD_CONVERSATION_COUNTS_EVENT, + (data) => { + this.favourites.unread = data.favourites; + this.individual.unread = data.individual + data.self; // Self is only returned if it's not favourite. + this.group.unread = data.group; + }, + this.siteId, + ); + + // Update the contact requests badge. + this.contactRequestsCountObserver = CoreEvents.on( + AddonMessagesProvider.CONTACT_REQUESTS_COUNT_EVENT, + (data) => { + this.contactRequestsCount = data.count; + }, + this.siteId, + ); + + // Update block status of a user. + this.memberInfoObserver = CoreEvents.on( + AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT, + (data) => { + if (!data.userBlocked && !data.userUnblocked) { + // The block status has not changed, ignore. + return; + } + + const expandedOption = this.getExpandedOption(); + if (expandedOption == this.individual || expandedOption == this.favourites) { + if (!expandedOption.conversations || expandedOption.conversations.length <= 0) { + return; + } + + const conversation = this.findConversation(undefined, data.userId, expandedOption); + if (conversation) { + conversation.isblocked = data.userBlocked; + } + } + }, + this.siteId, + ); + } + + /** + * Component loaded. + */ + ngOnInit(): void { + this.route.queryParams.subscribe(async (params) => { + // Conversation to load. + this.conversationId = CoreNavigator.instance.getRouteNumberParam('conversationId', params) || undefined; + if (!this.conversationId) { + this.discussionUserId = CoreNavigator.instance.getRouteNumberParam('discussionUserId', params) || undefined; + } + + if (this.conversationId || this.discussionUserId) { + // There is a discussion to load, open the discussion in a new state. + this.gotoConversation(this.conversationId, this.discussionUserId); + } + + await this.fetchData(); + if (!this.conversationId && !this.discussionUserId && CoreScreen.instance.isTablet) { + // Load the first conversation. + let conversation: AddonMessagesConversationForList; + const expandedOption = this.getExpandedOption(); + + if (expandedOption && expandedOption.conversations.length) { + conversation = expandedOption.conversations[0]; + + if (conversation) { + this.gotoConversation(conversation.id); + } + } + } + }); + } + + /** + * Fetch conversations. + * + * @param refreshUnreadCounts Whether to refresh unread counts. + * @return Promise resolved when done. + */ + protected async fetchData(refreshUnreadCounts: boolean = true): Promise { + // Load the amount of conversations and contact requests. + const promises: Promise[] = []; + + promises.push(this.fetchConversationCounts()); + + // View updated by the events observers. + promises.push(AddonMessages.instance.getContactRequestsCount(this.siteId)); + if (refreshUnreadCounts) { + promises.push(AddonMessages.instance.refreshUnreadConversationCounts(this.siteId)); + } + + try { + await Promise.all(promises); + + // The expanded status hasn't been initialized. Do it now. + if (typeof this.favourites.expanded == 'undefined' && this.conversationId || this.discussionUserId) { + // A certain conversation should be opened. + // We don't know which option it belongs to, so we need to fetch the data for all of them. + const promises: Promise[] = []; + + promises.push(this.fetchDataForOption(this.favourites, false)); + promises.push(this.fetchDataForOption(this.group, false)); + promises.push(this.fetchDataForOption(this.individual, false)); + + await Promise.all(promises); + // All conversations have been loaded, find the one we need to load and expand its option. + const conversation = this.findConversation(this.conversationId, this.discussionUserId); + if (conversation) { + const option = this.getConversationOption(conversation); + + await this.expandOption(option); + + this.loaded = true; + + return; + } + } + + // Load the data for the expanded option. + await this.fetchDataForExpandedOption(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true); + } + this.loaded = true; + } + + /** + * Fetch data for the expanded option. + * + * @return Promise resolved when done. + */ + protected async fetchDataForExpandedOption(): Promise { + if (typeof this.favourites.expanded == 'undefined') { + // Calculate which option should be expanded initially. + this.favourites.expanded = this.favourites.count != 0 && !this.group.unread && !this.individual.unread; + this.group.expanded = !this.favourites.expanded && this.group.count != 0 && !this.individual.unread; + this.individual.expanded = !this.favourites.expanded && !this.group.expanded; + } + + this.loadCurrentListElement(); + + const expandedOption = this.getExpandedOption(); + + if (expandedOption) { + await this.fetchDataForOption(expandedOption, false); + } + } + + /** + * Fetch data for a certain option. + * + * @param option The option to fetch data for. + * @param loadingMore Whether we are loading more data or just the first ones. + * @param getCounts Whether to get counts data. + * @return Promise resolved when done. + */ + async fetchDataForOption( + option: AddonMessagesGroupConversationOption, + loadingMore = false, + getCounts = false, + ): Promise { + option.loadMoreError = false; + + const limitFrom = loadingMore ? option.conversations.length : 0; + const promises: Promise[] = []; + + let data = { + conversations: [], + canLoadMore: false, + }; + let offlineMessages: + AddonMessagesOfflineAnyMessagesFormatted[] = []; + + // Get the conversations and, if needed, the offline messages. Always try to get the latest data. + promises.push(AddonMessages.instance.invalidateConversations(this.siteId).then(async () => { + data = await AddonMessages.instance.getConversations(option.type, option.favourites, limitFrom, this.siteId); + + return; + })); + + if (!loadingMore) { + promises.push(AddonMessagesOffline.instance.getAllMessages().then((messages) => { + offlineMessages = messages; + + return; + })); + } + + if (getCounts) { + promises.push(this.fetchConversationCounts()); + promises.push(AddonMessages.instance.refreshUnreadConversationCounts(this.siteId)); + } + + await Promise.all(promises); + + if (loadingMore) { + option.conversations = option.conversations.concat(data.conversations); + option.canLoadMore = data.canLoadMore; + } else { + option.conversations = data.conversations; + option.canLoadMore = data.canLoadMore; + + if (offlineMessages && offlineMessages.length) { + await this.loadOfflineMessages(option, offlineMessages); + + // Sort the conversations, the offline messages could affect the order. + option.conversations = AddonMessages.instance.sortConversations(option.conversations); + } + } + } + + /** + * Fetch conversation counts. + * + * @return Promise resolved when done. + */ + protected async fetchConversationCounts(): Promise { + // Always try to get the latest data. + await AddonMessages.instance.invalidateConversationCounts(this.siteId); + + const counts = await AddonMessages.instance.getConversationCounts(this.siteId); + this.favourites.count = counts.favourites; + this.individual.count = counts.individual + counts.self; // Self is only returned if it's not favourite. + this.group.count = counts.group; + } + + /** + * Find a conversation in the list of loaded conversations. + * + * @param conversationId The conversation ID to search. + * @param userId User ID to search (if no conversationId). + * @param option The option to search in. If not defined, search in all options. + * @return Conversation. + */ + protected findConversation( + conversationId?: number, + userId?: number, + option?: AddonMessagesGroupConversationOption, + ): AddonMessagesConversationForList | undefined { + + if (conversationId) { + const conversations: AddonMessagesConversationForList[] = option + ? option.conversations + : (this.favourites.conversations.concat(this.group.conversations).concat(this.individual.conversations)); + + return conversations.find((conv) => conv.id == conversationId); + } + + const conversations = option + ? option.conversations + : this.favourites.conversations.concat(this.individual.conversations); + + return conversations.find((conv) => conv.userid == userId); + } + + /** + * Get the option that is currently expanded, undefined if they are all collapsed. + * + * @return Option currently expanded. + */ + protected getExpandedOption(): AddonMessagesGroupConversationOption | undefined { + if (this.favourites.expanded) { + return this.favourites; + } else if (this.group.expanded) { + return this.group; + } else if (this.individual.expanded) { + return this.individual; + } + } + + /** + * Navigate to contacts view. + */ + gotoContacts(): void { + CoreNavigator.instance.navigateToSitePath('contacts'); + } + + /** + * Navigate to a particular conversation. + * + * @param conversationId Conversation Id to load. + * @param userId User of the conversation. Only if there is no conversationId. + * @param messageId Message to scroll after loading the discussion. Used when searching. + */ + gotoConversation(conversationId?: number, userId?: number, messageId?: number): void { + this.selectedConversationId = conversationId; + this.selectedUserId = userId; + + const params: Params = {}; + if (conversationId) { + params.conversationId = conversationId; + } + if (userId) { + params.userId = userId; + } + if (messageId) { + params.message = messageId; + } + + const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/messages/group-conversations/discussion'); + const path = (splitViewLoaded ? '../' : '') + 'discussion'; + CoreNavigator.instance.navigate(path, { params }); + } + + /** + * Navigate to message settings. + */ + gotoSettings(): void { + CoreNavigator.instance.navigateToSitePath(AddonMessagesSettingsHandlerService.PAGE_NAME); + } + + /** + * Function to load more conversations. + * + * @param option The option to fetch data for. + * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. + * @return Promise resolved when done. + */ + async loadMoreConversations(option: AddonMessagesGroupConversationOption, infiniteComplete?: () => void): Promise { + try { + await this.fetchDataForOption(option, true); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true); + option.loadMoreError = true; + } + + infiniteComplete && infiniteComplete(); + } + + /** + * Load offline messages into the conversations. + * + * @param option The option where the messages should be loaded. + * @param messages Offline messages. + * @return Promise resolved when done. + */ + protected async loadOfflineMessages( + option: AddonMessagesGroupConversationOption, + messages: AddonMessagesOfflineAnyMessagesFormatted[], + ): Promise { + const promises: Promise[] = []; + + messages.forEach((message) => { + if ('conversationid' in message) { + // It's an existing conversation. Search it in the current option. + let conversation = this.findConversation(message.conversationid, undefined, option); + + if (conversation) { + // Check if it's the last message. Offline messages are considered more recent than sent messages. + if (typeof conversation.lastmessage === 'undefined' || conversation.lastmessage === null || + !conversation.lastmessagepending || (conversation.lastmessagedate || 0) <= message.timecreated / 1000) { + + this.addLastOfflineMessage(conversation, message); + } + } else { + // Conversation not found, it could be an old one or the message could belong to another option. + conversation = { + id: message.conversationid, + type: message.conversation?.type || AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL, + membercount: message.conversation?.membercount || 0, + ismuted: message.conversation?.ismuted || false, + isfavourite: message.conversation?.isfavourite || false, + isread: message.conversation?.isread || false, + members: message.conversation?.members || [], + messages: message.conversation?.messages || [], + candeletemessagesforallusers: message.conversation?.candeletemessagesforallusers || false, + userid: 0, // Faked data. + name: message.conversation?.name, + imageurl: message.conversation?.imageurl || '', + }; + + if (this.getConversationOption(conversation) == option) { + // Message belongs to current option, add the conversation. + this.addLastOfflineMessage(conversation, message); + this.addOfflineConversation(conversation); + } + } + } else if (option.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL) { + // It's a new conversation. Check if we already created it (there is more than one message for the same user). + const conversation = this.findConversation(undefined, message.touserid, option); + + message.text = message.smallmessage; + + if (conversation) { + // Check if it's the last message. Offline messages are considered more recent than sent messages. + if ((conversation.lastmessagedate || 0) <= message.timecreated / 1000) { + this.addLastOfflineMessage(conversation, message); + } + } else { + // Get the user data and create a new conversation if it belongs to the current option. + promises.push(CoreUser.instance.getProfile(message.touserid, undefined, true).catch(() => { + // User not found. + }).then((user) => { + const conversation: AddonMessagesConversationForList = { + id: 0, + type: AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL, + membercount: 0, // Faked data. + ismuted: false, // Faked data. + isfavourite: false, // Faked data. + isread: false, // Faked data. + members: [], // Faked data. + messages: [], // Faked data. + candeletemessagesforallusers: false, + userid: message.touserid, + name: user ? user.fullname : String(message.touserid), + imageurl: user ? user.profileimageurl : '', + }; + + this.addLastOfflineMessage(conversation, message); + this.addOfflineConversation(conversation); + + return; + })); + } + } + }); + + await Promise.all(promises); + } + + /** + * Add an offline conversation into the right list of conversations. + * + * @param conversation Offline conversation to add. + */ + protected addOfflineConversation(conversation: AddonMessagesConversationForList): void { + const option = this.getConversationOption(conversation); + option.conversations.unshift(conversation); + } + + /** + * Add a last offline message into a conversation. + * + * @param conversation Conversation where to put the last message. + * @param message Offline message to add. + */ + protected addLastOfflineMessage( + conversation: AddonMessagesConversationForList, + message: AddonMessagesOfflineAnyMessagesFormatted, + ): void { + conversation.lastmessage = message.text; + conversation.lastmessagedate = message.timecreated / 1000; + conversation.lastmessagepending = true; + conversation.sentfromcurrentuser = true; + } + + /** + * Given a conversation, return its option (favourites, group, individual). + * + * @param conversation Conversation to check. + * @return Option object. + */ + protected getConversationOption( + conversation: AddonMessagesConversationForList | AddonMessagesNewMessagedEventData, + ): AddonMessagesGroupConversationOption { + if (conversation.isfavourite) { + return this.favourites; + } + + if (conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP) { + return this.group; + } + + return this.individual; + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @param refreshUnreadCounts Whether to refresh unread counts. + * @return Promise resolved when done. + */ + async refreshData(refresher?: CustomEvent, refreshUnreadCounts: boolean = true): Promise { + // Don't invalidate conversations and so, they always try to get latest data. + try { + await AddonMessages.instance.invalidateContactRequestsCountCache(this.siteId); + } finally { + try { + await this.fetchData(refreshUnreadCounts); + } finally { + if (refresher) { + refresher?.detail.complete(); + } + } + } + } + + /** + * Toogle the visibility of an option (expand/collapse). + * + * @param option The option to expand/collapse. + */ + toggle(option: AddonMessagesGroupConversationOption): void { + if (option.expanded) { + // Already expanded, close it. + option.expanded = false; + this.loadCurrentListElement(); + } else { + // Pass getCounts=true to update the counts everytime the user expands an option. + this.expandOption(option, true).catch((error) => { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true); + }); + } + } + + /** + * Expand a certain option. + * + * @param option The option to expand. + * @param getCounts Whether to get counts data. + * @return Promise resolved when done. + */ + protected async expandOption(option: AddonMessagesGroupConversationOption, getCounts = false): Promise { + // Collapse all and expand the right one. + this.favourites.expanded = false; + this.group.expanded = false; + this.individual.expanded = false; + + option.expanded = true; + option.loading = true; + + try { + await this.fetchDataForOption(option, false, getCounts); + + this.loadCurrentListElement(); + } catch (error) { + option.expanded = false; + + throw error; + } finally { + option.loading = false; + } + + } + + /** + * Load the current list element based on the expanded list. + */ + protected loadCurrentListElement(): void { + if (this.favourites.expanded) { + this.currentListEl = this.favListEl && this.favListEl.nativeElement; + } else if (this.group.expanded) { + this.currentListEl = this.groupListEl && this.groupListEl.nativeElement; + } else if (this.individual.expanded) { + this.currentListEl = this.indListEl && this.indListEl.nativeElement; + } else { + this.currentListEl = undefined; + } + } + + /** + * Navigate to the search page. + */ + gotoSearch(): void { + CoreNavigator.instance.navigateToSitePath('search'); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.newMessagesObserver?.off(); + this.appResumeSubscription?.unsubscribe(); + this.pushObserver?.unsubscribe(); + this.readChangedObserver?.off(); + this.cronObserver?.off(); + this.openConversationObserver?.off(); + this.updateConversationListObserver?.off(); + this.contactRequestsCountObserver?.off(); + this.memberInfoObserver?.off(); + } + +} + +/** + * Conversation options. + */ +export type AddonMessagesGroupConversationOption = { + type?: number; // Option type. + favourites: boolean; // Whether it contains favourites conversations. + count: number; // Number of conversations. + unread?: number; // Number of unread conversations. + expanded?: boolean; // Whether the option is currently expanded. + loading?: boolean; // Whether the option is being loaded. + canLoadMore?: boolean; // Whether it can load more data. + loadMoreError?: boolean; // Whether there was an error loading more conversations. + conversations: AddonMessagesConversationForList[]; // List of conversations. +}; + +/** + * Formatted conversation with some calculated data for the list. + */ +export type AddonMessagesConversationForList = AddonMessagesConversationFormatted & { + lastmessagepending?: boolean; // Calculated in the app. Whether last message is pending to be sent. +}; diff --git a/src/addons/messages/pages/search/search.html b/src/addons/messages/pages/search/search.html new file mode 100644 index 000000000..932271d6a --- /dev/null +++ b/src/addons/messages/pages/search/search.html @@ -0,0 +1,80 @@ + + + + + + {{ 'addon.messages.searchcombined' | translate }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ item.titleString | translate }} + + + + + + +

+ + + +

+ + {{result.lastmessagedate | coreDateDayOrTime}} + +

+ + {{ 'addon.messages.you' | translate }} + + +

+
+
+ + + +
+ + {{ 'core.loadmore' | translate }} + +
+
+ +
+
+
+
diff --git a/src/addons/messages/pages/search/search.module.ts b/src/addons/messages/pages/search/search.module.ts new file mode 100644 index 000000000..e9eb63e61 --- /dev/null +++ b/src/addons/messages/pages/search/search.module.ts @@ -0,0 +1,66 @@ +// (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 { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { CoreScreen } from '@services/screen'; +import { conditionalRoutes } from '@/app/app-routing.module'; +import { AddonMessagesDiscussionRoute } from '@addons/messages/messages-lazy.module'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreSearchComponentsModule } from '@features/search/components/components.module'; + +import { AddonMessagesSearchPage } from './search.page'; + +const mobileRoutes: Routes = [ + { + path: '', + component: AddonMessagesSearchPage, + }, + AddonMessagesDiscussionRoute, +]; + +const tabletRoutes: Routes = [ + { + path: '', + component: AddonMessagesSearchPage, + children: [ + AddonMessagesDiscussionRoute, + ], + }, +]; + +const routes: Routes = [ + ...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile), + ...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet), +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + CoreSearchComponentsModule, + ], + declarations: [ + AddonMessagesSearchPage, + ], + exports: [RouterModule], +}) +export class AddonMessagesSearchPageModule {} diff --git a/src/addons/messages/pages/search/search.page.ts b/src/addons/messages/pages/search/search.page.ts new file mode 100644 index 000000000..7ecef5284 --- /dev/null +++ b/src/addons/messages/pages/search/search.page.ts @@ -0,0 +1,315 @@ +// (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 } from '@angular/core'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { + AddonMessagesProvider, + AddonMessagesConversationMember, + AddonMessagesMessageAreaContact, + AddonMessagesMemberInfoChangedEventData, + AddonMessages, +} from '../../services/messages'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreApp } from '@services/app'; +import { CoreNavigator } from '@services/navigator'; +import { Params } from '@angular/router'; +import { CoreScreen } from '@services/screen'; + +/** + * Page for searching users. + */ +@Component({ + selector: 'page-addon-messages-search', + templateUrl: 'search.html', +}) +export class AddonMessagesSearchPage implements OnDestroy { + + disableSearch = false; + displaySearching = false; + displayResults = false; + query = ''; + contacts: AddonMessagesSearchResults = { + type: 'contacts', + titleString: 'addon.messages.contacts', + results: [], + canLoadMore: false, + loadingMore: false, + }; + + nonContacts: AddonMessagesSearchResults = { + type: 'noncontacts', + titleString: 'addon.messages.noncontacts', + results: [], + canLoadMore: false, + loadingMore: false, + }; + + messages: AddonMessagesSearchMessageResults = { + type: 'messages', + titleString: 'addon.messages.messages', + results: [], + canLoadMore: false, + loadingMore: false, + loadMoreError: false, + }; + + selectedResult?: AddonMessagesConversationMember | AddonMessagesMessageAreaContact; + + protected memberInfoObserver: CoreEventObserver; + + constructor() { + // Update block status of a user. + this.memberInfoObserver = CoreEvents.on( + AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT, + (data) => { + if (!data.userBlocked && !data.userUnblocked) { + // The block status has not changed, ignore. + return; + } + + const contact = this.contacts.results.find((user) => user.id == data.userId); + if (contact) { + contact.isblocked = !!data.userBlocked; + } else { + const nonContact = this.nonContacts.results.find((user) => user.id == data.userId); + if (nonContact) { + nonContact.isblocked = !!data.userBlocked; + } + } + + this.messages.results.forEach((message: AddonMessagesMessageAreaContact): void => { + if (message.userid == data.userId) { + message.isblocked = !!data.userBlocked; + } + }); + }, + CoreSites.instance.getCurrentSiteId(), + ); + } + + /** + * Clear search. + */ + clearSearch(): void { + this.query = ''; + this.displayResults = false; + + // Empty details. + const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/messages/search/discussion'); + if (splitViewLoaded) { + CoreNavigator.instance.navigate('../'); + } + } + + /** + * Start a new search or load more results. + * + * @param query Text to search for. + * @param loadMore Load more contacts, noncontacts or messages. If undefined, start a new search. + * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. + * @return Resolved when done. + */ + async search(query: string, loadMore?: 'contacts' | 'noncontacts' | 'messages', infiniteComplete?: () => void): Promise { + CoreApp.instance.closeKeyboard(); + + this.query = query; + this.disableSearch = true; + this.displaySearching = !loadMore; + + const promises: Promise[] = []; + let newContacts: AddonMessagesConversationMember[] = []; + let newNonContacts: AddonMessagesConversationMember[] = []; + let newMessages: AddonMessagesMessageAreaContact[] = []; + let canLoadMoreContacts = false; + let canLoadMoreNonContacts = false; + let canLoadMoreMessages = false; + + if (!loadMore || loadMore == 'contacts' || loadMore == 'noncontacts') { + const limitNum = loadMore ? AddonMessagesProvider.LIMIT_SEARCH : AddonMessagesProvider.LIMIT_INITIAL_USER_SEARCH; + let limitFrom = 0; + if (loadMore == 'contacts') { + limitFrom = this.contacts.results.length; + this.contacts.loadingMore = true; + } else if (loadMore == 'noncontacts') { + limitFrom = this.nonContacts.results.length; + this.nonContacts.loadingMore = true; + } + + promises.push( + AddonMessages.instance.searchUsers(query, limitFrom, limitNum).then((result) => { + if (!loadMore || loadMore == 'contacts') { + newContacts = result.contacts; + canLoadMoreContacts = result.canLoadMoreContacts; + } + if (!loadMore || loadMore == 'noncontacts') { + newNonContacts = result.nonContacts; + canLoadMoreNonContacts = result.canLoadMoreNonContacts; + } + + return; + }), + ); + } + + if (!loadMore || loadMore == 'messages') { + let limitFrom = 0; + if (loadMore == 'messages') { + limitFrom = this.messages.results.length; + this.messages.loadingMore = true; + } + + promises.push( + AddonMessages.instance.searchMessages(query, undefined, limitFrom).then((result) => { + newMessages = result.messages; + canLoadMoreMessages = result.canLoadMore; + + return; + }), + ); + } + + try { + await Promise.all(promises); + if (!loadMore) { + this.contacts.results = []; + this.nonContacts.results = []; + this.messages.results = []; + } + + this.displayResults = true; + + if (!loadMore || loadMore == 'contacts') { + this.contacts.results.push(...newContacts); + this.contacts.canLoadMore = canLoadMoreContacts; + this.setHighlight(newContacts, true); + } + + if (!loadMore || loadMore == 'noncontacts') { + this.nonContacts.results.push(...newNonContacts); + this.nonContacts.canLoadMore = canLoadMoreNonContacts; + this.setHighlight(newNonContacts, true); + } + + if (!loadMore || loadMore == 'messages') { + this.messages.results.push(...newMessages); + this.messages.canLoadMore = canLoadMoreMessages; + this.messages.loadMoreError = false; + this.setHighlight(newMessages, false); + } + + if (!loadMore) { + if (this.contacts.results.length > 0) { + this.openConversation(this.contacts.results[0], true); + } else if (this.nonContacts.results.length > 0) { + this.openConversation(this.nonContacts.results[0], true); + } else if (this.messages.results.length > 0) { + this.openConversation(this.messages.results[0], true); + } + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingusers', true); + + if (loadMore == 'messages') { + this.messages.loadMoreError = true; + } + } finally { + this.disableSearch = false; + this.displaySearching = false; + + if (loadMore == 'contacts') { + this.contacts.loadingMore = false; + } else if (loadMore == 'noncontacts') { + this.nonContacts.loadingMore = false; + } else if (loadMore == 'messages') { + this.messages.loadingMore = false; + } + + infiniteComplete && infiniteComplete(); + } + } + + /** + * Open a conversation in the split view. + * + * @param result User or message. + * @param onInit Whether the tser was selected on initial load. + */ + openConversation(result: AddonMessagesConversationMember | AddonMessagesMessageAreaContact, onInit: boolean = false): void { + if (!onInit || CoreScreen.instance.isTablet) { + this.selectedResult = result; + + const params: Params = {}; + if ('conversationid' in result) { + params.conversationId = result.conversationid; + } else { + params.userId = result.id; + } + + const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/messages/search/discussion'); + const path = (splitViewLoaded ? '../' : '') + 'discussion'; + CoreNavigator.instance.navigate(path, { params }); + } + } + + /** + * Set the highlight values for each entry. + * + * @param results Results to highlight. + * @param isUser Whether the results are from a user search or from a message search. + */ + setHighlight( + results: (AddonMessagesConversationMemberWithHighlight | AddonMessagesMessageAreaContactWithHighlight)[], + isUser = false, + ): void { + results.forEach((result) => { + result.highlightName = isUser ? this.query : undefined; + result.highlightMessage = !isUser ? this.query : undefined; + }); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.memberInfoObserver?.off(); + } + +} + +type AddonMessagesSearchResults = { + type: string; + titleString: string; + results: AddonMessagesConversationMemberWithHighlight[]; + canLoadMore: boolean; + loadingMore: boolean; +}; + +type AddonMessagesSearchMessageResults = { + type: string; + titleString: string; + results: AddonMessagesMessageAreaContactWithHighlight[]; + canLoadMore: boolean; + loadingMore: boolean; + loadMoreError: boolean; +}; + +type AddonMessagesSearchResultHighlight = { + highlightName?: string; + highlightMessage?: string; +}; + +type AddonMessagesConversationMemberWithHighlight = AddonMessagesConversationMember & AddonMessagesSearchResultHighlight; +type AddonMessagesMessageAreaContactWithHighlight = AddonMessagesMessageAreaContact & AddonMessagesSearchResultHighlight; diff --git a/src/addons/messages/pages/settings/settings.html b/src/addons/messages/pages/settings/settings.html new file mode 100644 index 000000000..f1fa655a1 --- /dev/null +++ b/src/addons/messages/pages/settings/settings.html @@ -0,0 +1,145 @@ + + + + + + {{ 'addon.messages.messages' | translate }} + + + + + + + + + + + {{ 'addon.messages.blocknoncontacts' | translate }} + + + + + + + +

{{ 'addon.messages.contactableprivacy' | translate }}

+
+ + {{ 'addon.messages.contactableprivacy_onlycontacts' | translate }} + + + + {{ 'addon.messages.contactableprivacy_coursemember' | translate }} + + + + {{ 'addon.messages.contactableprivacy_site' | translate }} + + +
+
+
+ + + +
+ + + + + +

{{ notification.displayname }}

+
+ +

{{ 'core.settings.loggedin' | translate }}

+
+ +

{{ 'core.settings.loggedoff' | translate }}

+
+
+

{{ 'addon.notifications.notificationpreferences' | translate }}

+
+
+ + + + + {{ processor.displayname }} + + + + + {{ processor.lockedmessage }} + + + {{ 'core.settings.disabled' | translate }} + + + + + + + + {{ processor.displayname }} + + + + + +
+ {{'core.settings.locked' | translate }} +
+ + {{ 'core.settings.disabled' | translate }} +
+
+ + + {{ processor.displayname }} + + + + {{ 'core.settings.' + state | translate }} + + + + + + {{'core.settings.locked' | translate }} + + + {{ 'core.settings.disabled' | translate }} + + +
+
+
+
+
+ + + + +

{{ 'core.settings.general' | translate }}

+ + +

{{ 'addon.messages.useentertosend' | translate }}

+
+ +
+
+
+
+
diff --git a/src/addons/messages/pages/settings/settings.module.ts b/src/addons/messages/pages/settings/settings.module.ts new file mode 100644 index 000000000..ef42c4058 --- /dev/null +++ b/src/addons/messages/pages/settings/settings.module.ts @@ -0,0 +1,47 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +import { CoreSharedModule } from '@/core/shared.module'; + +import { AddonMessagesSettingsPage } from './settings.page'; + +const routes: Routes = [ + { + path: '', + component: AddonMessagesSettingsPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + FormsModule, + TranslateModule.forChild(), + CoreSharedModule, + ], + declarations: [ + AddonMessagesSettingsPage, + ], + exports: [RouterModule], +}) +export class AddonMessagesSettingsPageModule {} diff --git a/src/addons/messages/pages/settings/settings.page.ts b/src/addons/messages/pages/settings/settings.page.ts new file mode 100644 index 000000000..4e540d70d --- /dev/null +++ b/src/addons/messages/pages/settings/settings.page.ts @@ -0,0 +1,300 @@ +// (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 } from '@angular/core'; +import { + AddonMessagesProvider, AddonMessagesMessagePreferences, + AddonMessagesMessagePreferencesNotification, + AddonMessagesMessagePreferencesNotificationProcessor, + AddonMessages, +} from '../../services/messages'; +import { CoreUser } from '@features/user/services/user'; +import { CoreApp } from '@services/app'; +import { CoreConfig } from '@services/config'; +import { CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreConstants } from '@/core/constants'; +import { IonRefresher } from '@ionic/angular'; + +/** + * Page that displays the messages settings page. + */ +@Component({ + selector: 'page-addon-messages-settings', + templateUrl: 'settings.html', + styleUrls: ['settings.scss'], +}) +export class AddonMessagesSettingsPage implements OnInit, OnDestroy { + + protected updateTimeout?: number; + + preferences?: AddonMessagesMessagePreferences; + preferencesLoaded = false; + contactablePrivacy?: number | boolean; + advancedContactable = false; // Whether the site supports "advanced" contactable privacy. + allowSiteMessaging = false; + onlyContactsValue = AddonMessagesProvider.MESSAGE_PRIVACY_ONLYCONTACTS; + courseMemberValue = AddonMessagesProvider.MESSAGE_PRIVACY_COURSEMEMBER; + siteValue = AddonMessagesProvider.MESSAGE_PRIVACY_SITE; + groupMessagingEnabled = false; + sendOnEnter = false; + + protected previousContactableValue?: number | boolean; + + constructor() { + + const currentSite = CoreSites.instance.getCurrentSite(); + this.advancedContactable = !!currentSite?.isVersionGreaterEqualThan('3.6'); + this.allowSiteMessaging = !!currentSite?.canUseAdvancedFeature('messagingallusers'); + this.groupMessagingEnabled = AddonMessages.instance.isGroupMessagingEnabled(); + + this.asyncInit(); + } + + protected async asyncInit(): Promise { + this.sendOnEnter = !!(await CoreConfig.instance.get(CoreConstants.SETTINGS_SEND_ON_ENTER, !CoreApp.instance.isMobile())); + } + + /** + * Runs when the page has loaded. This event only happens once per page being created. + * If a page leaves but is cached, then this event will not fire again on a subsequent viewing. + * Setup code for the page. + */ + ngOnInit(): void { + this.fetchPreferences(); + } + + /** + * Fetches preference data. + * + * @return Promise resolved when done. + */ + protected async fetchPreferences(): Promise { + try { + const preferences = await AddonMessages.instance.getMessagePreferences(); + if (this.groupMessagingEnabled) { + // Simplify the preferences. + for (const component of preferences.components) { + // Only display get the notification preferences. + component.notifications = component.notifications.filter((notification) => + notification.preferencekey == AddonMessagesProvider.NOTIFICATION_PREFERENCES_KEY); + + component.notifications.forEach((notification) => { + notification.processors.forEach( + (processor: AddonMessagesMessagePreferencesNotificationProcessorFormatted) => { + processor.checked = processor.loggedin.checked || processor.loggedoff.checked; + }, + ); + }); + } + } + + this.preferences = preferences; + this.contactablePrivacy = preferences.blocknoncontacts; + this.previousContactableValue = this.contactablePrivacy; + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + } finally { + this.preferencesLoaded = true; + } + } + + /** + * Update preferences. The purpose is to store the updated data, it won't be reflected in the view. + */ + protected updatePreferences(): void { + AddonMessages.instance.invalidateMessagePreferences().finally(() => { + this.fetchPreferences(); + }); + } + + /** + * Update preferences after a certain time. The purpose is to store the updated data, it won't be reflected in the view. + */ + protected updatePreferencesAfterDelay(): void { + // Cancel pending updates. + clearTimeout(this.updateTimeout); + + this.updateTimeout = window.setTimeout(() => { + this.updateTimeout = undefined; + this.updatePreferences(); + }, 5000); + } + + /** + * Save the contactable privacy setting.. + * + * @param value The value to set. + */ + async saveContactablePrivacy(value?: number | boolean): Promise { + if (this.contactablePrivacy == this.previousContactableValue) { + // Value hasn't changed from previous, it probably means that we just fetched the value from the server. + return; + } + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + if (!this.advancedContactable) { + // Convert from boolean to number. + value = value ? 1 : 0; + } + + try { + await CoreUser.instance.updateUserPreference('message_blocknoncontacts', String(value)); + // Update the preferences since they were modified. + this.updatePreferencesAfterDelay(); + this.previousContactableValue = this.contactablePrivacy; + } catch (message) { + // Show error and revert change. + CoreDomUtils.instance.showErrorModal(message); + this.contactablePrivacy = this.previousContactableValue; + } finally { + modal.dismiss(); + } + } + + /** + * Change the value of a certain preference. + * + * @param notification Notification object. + * @param state State name, ['loggedin', 'loggedoff']. + * @param processor Notification processor. + */ + async changePreference( + notification: AddonMessagesMessagePreferencesNotificationFormatted, + state: string, + processor: AddonMessagesMessagePreferencesNotificationProcessorFormatted, + ): Promise { + + const valueArray: string[] = []; + let value = 'none'; + + if (this.groupMessagingEnabled) { + // Update both states at the same time. + const promises: Promise[] = []; + + notification.processors.forEach((processor: AddonMessagesMessagePreferencesNotificationProcessorFormatted) => { + if (processor.checked) { + valueArray.push(processor.name); + } + }); + + if (value.length > 0) { + value = valueArray.join(','); + } + + notification.updating = true; + + promises.push(CoreUser.instance.updateUserPreference(notification.preferencekey + '_loggedin', value)); + promises.push(CoreUser.instance.updateUserPreference(notification.preferencekey + '_loggedoff', value)); + + try { + await Promise.all(promises); + // Update the preferences since they were modified. + this.updatePreferencesAfterDelay(); + } catch (error) { + // Show error and revert change. + CoreDomUtils.instance.showErrorModal(error); + processor.checked = !processor.checked; + } finally { + notification.updating = false; + } + + return; + } + + // Update only the specified state. + const processorState = processor[state]; + const preferenceName = notification.preferencekey + '_' + processorState.name; + + notification.processors.forEach((processor) => { + if (processor[state].checked) { + valueArray.push(processor.name); + } + }); + + if (value.length > 0) { + value = valueArray.join(','); + } + + if (!notification.updating) { + notification.updating = {}; + } + + notification.updating[state] = true; + try { + await CoreUser.instance.updateUserPreference(preferenceName, value); + // Update the preferences since they were modified. + this.updatePreferencesAfterDelay(); + } catch (error) { + // Show error and revert change. + CoreDomUtils.instance.showErrorModal(error); + processorState.checked = !processorState.checked; + } finally { + notification.updating[state] = false; + } + } + + /** + * Refresh the list of preferences. + * + * @param refresher Refresher. + */ + refreshPreferences(refresher?: CustomEvent): void { + AddonMessages.instance.invalidateMessagePreferences().finally(() => { + this.fetchPreferences().finally(() => { + refresher?.detail.complete(); + }); + }); + } + + sendOnEnterChanged(): void { + // Save the value. + CoreConfig.instance.set(CoreConstants.SETTINGS_SEND_ON_ENTER, this.sendOnEnter ? 1 : 0); + + // Notify the app. + CoreEvents.trigger( + CoreEvents.SEND_ON_ENTER_CHANGED, + { sendOnEnter: !!this.sendOnEnter }, + CoreSites.instance.getCurrentSiteId(), + ); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + // If there is a pending action to update preferences, execute it right now. + if (this.updateTimeout) { + clearTimeout(this.updateTimeout); + this.updatePreferences(); + } + } + +} + +/** + * Message preferences notification with some caclulated data. + */ +type AddonMessagesMessagePreferencesNotificationFormatted = AddonMessagesMessagePreferencesNotification & { + updating?: boolean | {[state: string]: boolean}; // Calculated in the app. Whether the notification is being updated. +}; + +/** + * Message preferences notification processor with some caclulated data. + */ +type AddonMessagesMessagePreferencesNotificationProcessorFormatted = AddonMessagesMessagePreferencesNotificationProcessor & { + checked?: boolean; // Calculated in the app. Whether the processor is checked either for loggedin or loggedoff. +}; diff --git a/src/addons/messages/pages/settings/settings.scss b/src/addons/messages/pages/settings/settings.scss new file mode 100644 index 000000000..b0fc90bb0 --- /dev/null +++ b/src/addons/messages/pages/settings/settings.scss @@ -0,0 +1,10 @@ +:host { + .list-header { + margin-bottom: 0; + border-top: 0; + } + + .toggle { + display: inline-block; + } +} diff --git a/src/addons/messages/services/database/messages.ts b/src/addons/messages/services/database/messages.ts new file mode 100644 index 000000000..75e04d639 --- /dev/null +++ b/src/addons/messages/services/database/messages.ts @@ -0,0 +1,95 @@ +// (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 AddonMessagesOffline service. + */ +export const MESSAGES_TABLE = 'addon_messages_offline_messages'; // When group messaging isn't available or new conversation starts. +export const CONVERSATION_MESSAGES_TABLE = 'addon_messages_offline_conversation_messages'; // Conversation messages. +export const MESSAGES_OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonMessagesOfflineProvider', + version: 1, + tables: [ + { + name: MESSAGES_TABLE, + columns: [ + { + name: 'touserid', + type: 'INTEGER', + }, + { + name: 'useridfrom', + type: 'INTEGER', + }, + { + name: 'smallmessage', + type: 'TEXT', + }, + { + name: 'timecreated', + type: 'INTEGER', + }, + { + name: 'deviceoffline', // If message was stored because device was offline. + type: 'INTEGER', + }, + ], + primaryKeys: ['touserid', 'smallmessage', 'timecreated'], + }, + { + name: CONVERSATION_MESSAGES_TABLE, + columns: [ + { + name: 'conversationid', + type: 'INTEGER', + }, + { + name: 'text', + type: 'TEXT', + }, + { + name: 'timecreated', + type: 'INTEGER', + }, + { + name: 'deviceoffline', // If message was stored because device was offline. + type: 'INTEGER', + }, + { + name: 'conversation', // Data about the conversation. + type: 'TEXT', + }, + ], + primaryKeys: ['conversationid', 'text', 'timecreated'], + }, + ], +}; + +export type AddonMessagesOfflineMessagesDBRecord = { + touserid: number; + useridfrom: number; + smallmessage: string; + timecreated: number; + deviceoffline: number; // If message was stored because device was offline. +}; + +export type AddonMessagesOfflineConversationMessagesDBRecord = { + conversationid: number; + text: string; + timecreated: number; + deviceoffline: number; // If message was stored because device was offline. + conversation: string; // Data about the conversation. +}; diff --git a/src/addons/messages/services/handlers/contact-request-link.ts b/src/addons/messages/services/handlers/contact-request-link.ts new file mode 100644 index 000000000..7c3622647 --- /dev/null +++ b/src/addons/messages/services/handlers/contact-request-link.ts @@ -0,0 +1,62 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { AddonMessages } from '../messages'; + +/** + * Content links handler for a contact requests. + */ +@Injectable({ providedIn: 'root' }) +export class AddonMessagesContactRequestLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonMessagesContactRequestLinkHandler'; + pattern = /\/message\/pendingcontactrequests\.php/; + + /** + * Get the list of actions for a link (url). + * + * @return List of (or promise resolved with list of) actions. + */ + getActions(): CoreContentLinksAction[] | Promise { + return [{ + action: (siteId): void => { + CoreNavigator.instance.navigateToSitePath('/messages/contacts', { siteId }); + }, + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @return Whether the handler is enabled for the URL and site. + */ + async isEnabled(siteId: string): Promise { + const enabled = await AddonMessages.instance.isPluginEnabled(siteId); + if (!enabled) { + return false; + } + + return AddonMessages.instance.isGroupMessagingEnabledInSite(siteId); + } + +} + +export class AddonMessagesContactRequestLinkHandler extends makeSingleton(AddonMessagesContactRequestLinkHandlerService) {} diff --git a/src/addons/messages/services/handlers/discussion-link.ts b/src/addons/messages/services/handlers/discussion-link.ts new file mode 100644 index 000000000..48d3853d8 --- /dev/null +++ b/src/addons/messages/services/handlers/discussion-link.ts @@ -0,0 +1,85 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { makeSingleton } from '@singletons'; +import { AddonMessages } from '../messages'; + +/** + * Content links handler for a discussion. + * Match message index URL with params id, user1 or user2. + */ +@Injectable({ providedIn: 'root' }) +export class AddonMessagesDiscussionLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonMessagesDiscussionLinkHandler'; + pattern = /\/message\/index\.php.*([?&](id|user1|user2)=\d+)/; + + /** + * Get the list of actions for a link (url). + * + * @param siteIds List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @return List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] | Promise { + return [{ + action: (siteId): void => { + const stateParams = { + userId: parseInt(params.id || params.user2, 10), + }; + CoreNavigator.instance.navigateToSitePath('/messages/discussion', { params: stateParams, siteId }); + }, + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @return Whether the handler is enabled for the URL and site. + */ + async isEnabled(siteId: string, url: string, params: Params): Promise { + const enabled = await AddonMessages.instance.isPluginEnabled(siteId); + if (!enabled) { + return false; + } + + if (typeof params.id == 'undefined' && typeof params.user2 == 'undefined') { + // Other user not defined, cannot treat the URL. + return false; + } + + if (typeof params.user1 != 'undefined') { + // Check if user1 is the current user, since the app only supports current user. + const site = await CoreSites.instance.getSite(siteId); + + return parseInt(params.user1, 10) == site.getUserId(); + } + + return true; + } + +} + +export class AddonMessagesDiscussionLinkHandler extends makeSingleton(AddonMessagesDiscussionLinkHandlerService) {} diff --git a/src/addons/messages/services/handlers/index-link.ts b/src/addons/messages/services/handlers/index-link.ts new file mode 100644 index 000000000..33be2367b --- /dev/null +++ b/src/addons/messages/services/handlers/index-link.ts @@ -0,0 +1,61 @@ +// (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 { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { AddonMessages } from '../messages'; + +/** + * Content links handler for messaging index. + * Match message index URL without params id, user1 or user2. + */ +@Injectable({ providedIn: 'root' }) +export class AddonMessagesIndexLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonMessagesIndexLinkHandler'; + pattern = /\/message\/index\.php((?![?&](id|user1|user2)=\d+).)*$/; + + + /** + * Get the list of actions for a link (url). + * + * @return List of (or promise resolved with list of) actions. + */ + getActions(): CoreContentLinksAction[] | Promise { + return [{ + action: async (siteId): Promise => { + const pageName = await AddonMessages.instance.getMainMessagesPagePathInSite(siteId); + + CoreNavigator.instance.navigateToSitePath(pageName, { siteId }); + }, + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @return Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string): Promise { + return AddonMessages.instance.isPluginEnabled(siteId); + } + +} + +export class AddonMessagesIndexLinkHandler extends makeSingleton(AddonMessagesIndexLinkHandlerService) {} diff --git a/src/addons/messages/services/handlers/mainmenu.ts b/src/addons/messages/services/handlers/mainmenu.ts new file mode 100644 index 000000000..0998b4290 --- /dev/null +++ b/src/addons/messages/services/handlers/mainmenu.ts @@ -0,0 +1,223 @@ +// (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 { + AddonMessagesProvider, + AddonMessages, + AddonMessagesUnreadConversationCountsEventData, + AddonMessagesContactRequestCountEventData, +} from '../messages'; +import { CoreMainMenuHandler, CoreMainMenuHandlerToDisplay } from '@features/mainmenu/services/mainmenu-delegate'; +import { CoreCronHandler } from '@services/cron'; +import { CoreSites } from '@services/sites'; +import { CoreEvents } from '@singletons/events'; +import { CoreUtils } from '@services/utils/utils'; +import { + CorePushNotifications, + CorePushNotificationsNotificationBasicData, +} from '@features/pushnotifications/services/pushnotifications'; +import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to inject an option into main menu. + */ +@Injectable({ providedIn: 'root' }) +export class AddonMessagesMainMenuHandlerService implements CoreMainMenuHandler, CoreCronHandler { + + static readonly PAGE_NAME = 'messages'; + + name = 'AddonMessages'; + priority = 800; + + protected handler: CoreMainMenuHandlerToDisplay = { + icon: 'fas-comments', + title: 'addon.messages.messages', + page: AddonMessages.instance.getMainMessagesPagePath(), + class: 'addon-messages-handler', + showBadge: true, // Do not check isMessageCountEnabled because we'll use fallback it not enabled. + badge: '', + loading: true, + }; + + protected unreadCount = 0; + protected contactRequestsCount = 0; + protected orMore = false; + + constructor() { + + CoreEvents.on( + AddonMessagesProvider.UNREAD_CONVERSATION_COUNTS_EVENT, + (data) => { + this.unreadCount = data.favourites + data.individual + data.group + data.self; + this.orMore = !!data.orMore; + this.updateBadge(data.siteId!); + }, + ); + + CoreEvents.on(AddonMessagesProvider.CONTACT_REQUESTS_COUNT_EVENT, (data) => { + this.contactRequestsCount = data.count; + this.updateBadge(data.siteId!); + }); + + // Reset info on logout. + CoreEvents.on(CoreEvents.LOGOUT, () => { + this.unreadCount = 0; + this.contactRequestsCount = 0; + this.orMore = false; + this.handler.badge = ''; + this.handler.loading = true; + }); + + // If a message push notification is received, refresh the count. + CorePushNotificationsDelegate.instance.on('receive').subscribe( + (notification) => { + // New message received. If it's from current site, refresh the data. + const isMessage = CoreUtils.instance.isFalseOrZero(notification.notif) || + notification.name == 'messagecontactrequests'; + if (isMessage && CoreSites.instance.isCurrentSite(notification.site)) { + this.refreshBadge(notification.site); + } + }, + ); + + // Register Badge counter. + CorePushNotificationsDelegate.instance.registerCounterHandler('AddonMessages'); + } + + /** + * Check if the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + isEnabled(): Promise { + return AddonMessages.instance.isPluginEnabled(); + } + + /** + * Returns the data needed to render the handler. + * + * @return Data needed to render the handler. + */ + getDisplayData(): CoreMainMenuHandlerToDisplay { + this.handler.page = AddonMessages.instance.getMainMessagesPagePath(); + + if (this.handler.loading) { + this.refreshBadge(); + } + + return this.handler; + } + + /** + * Refreshes badge number. + * + * @param siteId Site ID or current Site if undefined. + * @param unreadOnly If true only the unread conversations count is refreshed. + * @return Resolve when done. + */ + async refreshBadge(siteId?: string, unreadOnly?: boolean): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + if (!siteId) { + return; + } + + const promises: Promise[] = []; + + promises.push(AddonMessages.instance.refreshUnreadConversationCounts(siteId).catch(() => { + this.unreadCount = 0; + this.orMore = false; + })); + + // Refresh the number of contact requests in 3.6+ sites. + if (!unreadOnly && AddonMessages.instance.isGroupMessagingEnabled()) { + promises.push(AddonMessages.instance.refreshContactRequestsCount(siteId).catch(() => { + this.contactRequestsCount = 0; + })); + } + + await Promise.all(promises).finally(() => { + this.updateBadge(siteId!); + this.handler.loading = false; + }); + } + + /** + * Update badge number and push notifications counter from loaded data. + * + * @param siteId Site ID. + */ + updateBadge(siteId: string): void { + const totalCount = this.unreadCount + (this.contactRequestsCount || 0); + if (totalCount > 0) { + this.handler.badge = totalCount + (this.orMore ? '+' : ''); + } else { + this.handler.badge = ''; + } + + // Update push notifications badge. + CorePushNotifications.instance.updateAddonCounter('AddonMessages', totalCount, siteId); + } + + /** + * 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. + * @return Promise resolved when done, rejected if failure. + */ + async execute(siteId?: string): Promise { + if (!CoreSites.instance.isCurrentSite(siteId)) { + return; + } + + this.refreshBadge(); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + if (!this.isSync()) { + return 300000; // We have a WS to check the number, check it every 5 minutes. + } + + return 600000; // Check it every 10 minutes. + } + + /** + * Whether it's a synchronization process or not. + * + * @return True if is a sync process, false otherwise. + */ + isSync(): boolean { + // This is done to use only wifi if using the fallback function. + return !AddonMessages.instance.isMessageCountEnabled() && !AddonMessages.instance.isGroupMessagingEnabled(); + } + + /** + * Whether the process should be executed during a manual sync. + * + * @return True if is a manual sync process, false otherwise. + */ + canManualSync(): boolean { + return true; + } + +} + +export class AddonMessagesMainMenuHandler extends makeSingleton(AddonMessagesMainMenuHandlerService) {} diff --git a/src/addons/messages/services/handlers/push-click.ts b/src/addons/messages/services/handlers/push-click.ts new file mode 100644 index 000000000..05b215885 --- /dev/null +++ b/src/addons/messages/services/handlers/push-click.ts @@ -0,0 +1,84 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; +import { CorePushNotificationsClickHandler } from '@features/pushnotifications/services/push-delegate'; +import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications'; +import { CoreNavigator } from '@services/navigator'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { AddonMessages } from '../messages'; + +/** + * Handler for messaging push notifications clicks. + */ +@Injectable({ providedIn: 'root' }) +export class AddonMessagesPushClickHandlerService implements CorePushNotificationsClickHandler { + + name = 'AddonMessagesPushClickHandler'; + priority = 200; + featureName = 'CoreMainMenuDelegate_AddonMessages'; + + /** + * Check if a notification click is handled by this handler. + * + * @param notification The notification to check. + * @return Whether the notification click is handled by this handler + */ + async handles(notification: AddonMessagesPushNotificationData): Promise { + if (CoreUtils.instance.isTrueOrOne(notification.notif) && notification.name != 'messagecontactrequests') { + return false; + } + + // Check that messaging is enabled. + return AddonMessages.instance.isPluginEnabled(notification.site); + } + + /** + * Handle the notification click. + * + * @param notification The notification to check. + * @return Promise resolved when done. + */ + async handleClick(notification: AddonMessagesPushNotificationData): Promise { + try { + await AddonMessages.instance.invalidateDiscussionsCache(notification.site); + } catch { + // Ignore errors. + } + + // Check if group messaging is enabled, to determine which page should be loaded. + const enabled = await AddonMessages.instance.isGroupMessagingEnabledInSite(notification.site); + const pageName = await AddonMessages.instance.getMainMessagesPagePathInSite(notification.site); + + const pageParams: Params = {}; + + // Check if we have enough information to open the conversation. + if (notification.convid && enabled) { + pageParams.conversationId = Number(notification.convid); + } else if (notification.userfromid) { + pageParams.discussionUserId = Number(notification.userfromid); + } + + await CoreNavigator.instance.navigateToSitePath(pageName, { params: pageParams, siteId: notification.site }); + } + +} + +export class AddonMessagesPushClickHandler extends makeSingleton(AddonMessagesPushClickHandlerService) {} + +type AddonMessagesPushNotificationData = CorePushNotificationsNotificationBasicData & { + convid?: number; // Conversation Id. +}; diff --git a/src/addons/messages/services/handlers/settings.ts b/src/addons/messages/services/handlers/settings.ts new file mode 100644 index 000000000..35cfd9435 --- /dev/null +++ b/src/addons/messages/services/handlers/settings.ts @@ -0,0 +1,60 @@ +// (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 { CoreSettingsHandler, CoreSettingsHandlerData } from '@features/settings/services/settings-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonMessages } from '../messages'; +import { AddonMessagesMainMenuHandlerService } from './mainmenu'; + +/** + * Message settings handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonMessagesSettingsHandlerService implements CoreSettingsHandler { + + static readonly PAGE_NAME = 'settings'; + + name = 'AddonMessages'; + priority = 600; + + /** + * Check if the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + async isEnabled(): Promise { + const messagingEnabled = await AddonMessages.instance.isPluginEnabled(); + + return messagingEnabled && AddonMessages.instance.isMessagePreferencesEnabled(); + } + + /** + * Returns the data needed to render the handler. + * + * @return Data needed to render the handler. + */ + getDisplayData(): CoreSettingsHandlerData { + return { + icon: 'fas-comments', + title: 'addon.messages.messages', + page: AddonMessagesMainMenuHandlerService.PAGE_NAME + '/' + AddonMessagesSettingsHandlerService.PAGE_NAME, + class: 'addon-messages-settings-handler', + }; + } + +} + +export class AddonMessagesSettingsHandler extends makeSingleton(AddonMessagesSettingsHandlerService) {} + diff --git a/src/addons/messages/services/handlers/sync-cron.ts b/src/addons/messages/services/handlers/sync-cron.ts new file mode 100644 index 000000000..cd64120ae --- /dev/null +++ b/src/addons/messages/services/handlers/sync-cron.ts @@ -0,0 +1,50 @@ +// (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 { AddonMessagesSync } from '../messages-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonMessagesSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonMessagesSyncCronHandler'; + + /** + * 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. + * @return Promise resolved when done, rejected if failure. + */ + execute(siteId?: string): Promise { + return AddonMessagesSync.instance.syncAllDiscussions(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return 300000; // 5 minutes. + } + +} + +export class AddonMessagesSyncCronHandler extends makeSingleton(AddonMessagesSyncCronHandlerService) {} diff --git a/src/addons/messages/services/handlers/user-send-message.ts b/src/addons/messages/services/handlers/user-send-message.ts new file mode 100644 index 000000000..b3c99a5c6 --- /dev/null +++ b/src/addons/messages/services/handlers/user-send-message.ts @@ -0,0 +1,85 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreUserProfile } from '@features/user/services/user'; +import { CoreUserDelegateService, CoreUserProfileHandler, CoreUserProfileHandlerData } from '@features/user/services/user-delegate'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { makeSingleton } from '@singletons'; +import { AddonMessages } from '../messages'; + +/** + * Profile send message handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonMessagesSendMessageUserHandlerService implements CoreUserProfileHandler { + + name = 'AddonMessages:sendMessage'; + priority = 1000; + type = CoreUserDelegateService.TYPE_COMMUNICATION; + + /** + * Check if handler is enabled. + * + * @return Promise resolved with true if enabled, rejected or resolved with false otherwise. + */ + isEnabled(): Promise { + return AddonMessages.instance.isPluginEnabled(); + } + + /** + * Check if handler is enabled for this user in this context. + * + * @param user User to check. + * @return Promise resolved with true if enabled, resolved with false otherwise. + */ + async isEnabledForUser(user: CoreUserProfile): Promise { + const currentSite = CoreSites.instance.getCurrentSite(); + + if (!currentSite) { + return false; + } + + // From 3.7 you can send messages to yourself. + return user.id != currentSite.getUserId() || currentSite.isVersionGreaterEqualThan('3.7'); + } + + /** + * Returns the data needed to render the handler. + * + * @return Data needed to render the handler. + */ + getDisplayData(): CoreUserProfileHandlerData { + return { + icon: 'fas-paper-plane', + title: 'addon.messages.message', + class: 'addon-messages-send-message-handler', + action: (event: Event, user: CoreUserProfile): void => { + event.preventDefault(); + event.stopPropagation(); + + const pageParams: Params = { + showKeyboard: true, + userId: user.id, + }; + CoreNavigator.instance.navigateToSitePath('/messages/discussion', { params: pageParams }); + }, + }; + } + +} + +export class AddonMessagesSendMessageUserHandler extends makeSingleton(AddonMessagesSendMessageUserHandlerService) {} diff --git a/src/addons/messages/services/messages-offline.ts b/src/addons/messages/services/messages-offline.ts new file mode 100644 index 000000000..73cb35d69 --- /dev/null +++ b/src/addons/messages/services/messages-offline.ts @@ -0,0 +1,384 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreTextUtils } from '@services/utils/text'; +import { + AddonMessagesOfflineConversationMessagesDBRecord, + AddonMessagesOfflineMessagesDBRecord, + CONVERSATION_MESSAGES_TABLE, + MESSAGES_TABLE, +} from './database/messages'; +import { makeSingleton } from '@singletons'; +import { AddonMessagesConversation } from './messages'; + +/** + * Service to handle Offline messages. + */ +@Injectable({ providedIn: 'root' }) +export class AddonMessagesOfflineProvider { + + /** + * Delete a message. + * + * @param conversationId Conversation ID. + * @param message The message. + * @param timeCreated The time the message was created. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteConversationMessage(conversationId: number, message: string, timeCreated: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(CONVERSATION_MESSAGES_TABLE, { + conversationid: conversationId, + text: message, + timecreated: timeCreated, + }); + } + + /** + * Delete all the messages in a conversation. + * + * @param conversationId Conversation ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteConversationMessages(conversationId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(CONVERSATION_MESSAGES_TABLE, { + conversationid: conversationId, + }); + } + + /** + * Delete a message. + * + * @param toUserId User ID to send the message to. + * @param message The message. + * @param timeCreated The time the message was created. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteMessage(toUserId: number, message: string, timeCreated: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(MESSAGES_TABLE, { + touserid: toUserId, + smallmessage: message, + timecreated: timeCreated, + }); + } + + /** + * Get all messages where deviceoffline is set to 1. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with messages. + */ + async getAllDeviceOfflineMessages( + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const [ + messages, + conversations, + ] = await Promise.all([ + site.getDb().getRecords(MESSAGES_TABLE, { deviceoffline: 1 }), + site.getDb().getRecords( + CONVERSATION_MESSAGES_TABLE, + { deviceoffline: 1 }, + ), + ]); + + + const messageResult: + AddonMessagesOfflineAnyMessagesFormatted[] = + this.parseMessages(messages); + const formattedConv = this.parseConversationMessages(conversations); + + return messageResult.concat(formattedConv); + } + + /** + * Get all offline messages. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with messages. + */ + async getAllMessages( + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const [ + messages, + conversations, + ] = await Promise.all([ + site.getDb().getAllRecords(MESSAGES_TABLE), + site.getDb().getAllRecords(CONVERSATION_MESSAGES_TABLE), + ]); + + const messageResult: + AddonMessagesOfflineAnyMessagesFormatted[] = + this.parseMessages(messages); + const formattedConv = this.parseConversationMessages(conversations); + + return messageResult.concat(formattedConv); + } + + /** + * Get offline messages to send to a certain user. + * + * @param conversationId Conversation ID. + * @param userIdFrom To add to the conversation messages when parsing. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with messages. + */ + async getConversationMessages( + conversationId: number, + userIdFrom?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const messages: AddonMessagesOfflineConversationMessagesDBRecord[] = await site.getDb().getRecords( + CONVERSATION_MESSAGES_TABLE, + { conversationid: conversationId }, + ); + + return this.parseConversationMessages(messages, userIdFrom); + } + + /** + * Get offline messages to send to a certain user. + * + * @param toUserId User ID to get messages to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with messages. + */ + async getMessages(toUserId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const messages: AddonMessagesOfflineMessagesDBRecord[] = + await site.getDb().getRecords(MESSAGES_TABLE, { touserid: toUserId }); + + return this.parseMessages(messages); + } + + /** + * Check if there are offline messages to send to a conversation. + * + * @param conversationId Conversation ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has offline messages, false otherwise. + */ + async hasConversationMessages(conversationId: number, siteId?: string): Promise { + const messages = await this.getConversationMessages(conversationId, undefined, siteId); + + return !!messages.length; + } + + /** + * Check if there are offline messages to send to a certain user. + * + * @param toUserId User ID to check. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has offline messages, false otherwise. + */ + async hasMessages(toUserId: number, siteId?: string): Promise { + const messages = await this.getMessages(toUserId, siteId); + + return !!messages.length; + } + + /** + * Parse some fields of each offline conversation messages. + * + * @param messages List of messages to parse. + * @param userIdFrom To add to the conversation messages when parsin. + * @return Parsed messages. + */ + protected parseConversationMessages( + messages: AddonMessagesOfflineConversationMessagesDBRecord[], + userIdFrom?: number, + ): AddonMessagesOfflineConversationMessagesDBRecordFormatted[] { + if (!messages) { + return []; + } + + return messages.map((message) => { + const parsedMessage: AddonMessagesOfflineConversationMessagesDBRecordFormatted = { + conversationid: message.conversationid, + text: message.text, + timecreated: message.timecreated, + deviceoffline: message.deviceoffline, + conversation: message.conversation ? CoreTextUtils.instance.parseJSON(message.conversation, undefined) : undefined, + pending: true, + useridfrom: userIdFrom, + }; + + return parsedMessage; + }); + } + + /** + * Parse some fields of each offline messages. + * + * @param messages List of messages to parse. + * @return Parsed messages. + */ + protected parseMessages( + messages: AddonMessagesOfflineMessagesDBRecord[], + ): AddonMessagesOfflineMessagesDBRecordFormatted[] { + if (!messages) { + return []; + } + + return messages.map((message) => { + const parsedMessage: AddonMessagesOfflineMessagesDBRecordFormatted = { + touserid: message.touserid, + useridfrom: message.useridfrom, + smallmessage: message.smallmessage, + timecreated: message.timecreated, + deviceoffline: message.deviceoffline, + pending: true, + text: message.smallmessage, + }; + + return parsedMessage; + }); + } + + /** + * Save a conversation message to be sent later. + * + * @param conversation Conversation. + * @param message The message to send. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async saveConversationMessage( + conversation: AddonMessagesConversation, + message: string, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const entry: AddonMessagesOfflineConversationMessagesDBRecord = { + conversationid: conversation.id, + text: message, + timecreated: Date.now(), + deviceoffline: CoreApp.instance.isOnline() ? 0 : 1, + conversation: JSON.stringify({ + name: conversation.name || '', + subname: conversation.subname || '', + imageurl: conversation.imageurl || '', + isfavourite: conversation.isfavourite ? 1 : 0, + type: conversation.type, + }), + }; + + await site.getDb().insertRecord(CONVERSATION_MESSAGES_TABLE, entry); + + return entry; + } + + /** + * Save a message to be sent later. + * + * @param toUserId User ID recipient of the message. + * @param message The message to send. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async saveMessage(toUserId: number, message: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const entry: AddonMessagesOfflineMessagesDBRecord = { + touserid: toUserId, + useridfrom: site.getUserId(), + smallmessage: message, + timecreated: new Date().getTime(), + deviceoffline: CoreApp.instance.isOnline() ? 0 : 1, + }; + + await site.getDb().insertRecord(MESSAGES_TABLE, entry); + + return entry; + } + + /** + * Set deviceoffline for a group of messages. + * + * @param messages Messages to update. Should be the same entry as retrieved from the DB. + * @param value Value to set. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async setMessagesDeviceOffline( + messages: AddonMessagesOfflineAnyMessagesFormatted[], + value: boolean, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const db = site.getDb(); + + const promises: Promise[] = []; + const data = { deviceoffline: value ? 1 : 0 }; + + messages.forEach((message) => { + if ('conversationid' in message) { + promises.push(db.updateRecords( + CONVERSATION_MESSAGES_TABLE, + data, + { conversationid: message.conversationid, text: message.text, timecreated: message.timecreated }, + )); + } else { + promises.push(db.updateRecords( + MESSAGES_TABLE, + data, + { touserid: message.touserid, smallmessage: message.smallmessage, timecreated: message.timecreated }, + )); + } + }); + + await Promise.all(promises); + } + +} + +export class AddonMessagesOffline extends makeSingleton(AddonMessagesOfflineProvider) {} + +export type AddonMessagesOfflineMessagesDBRecordFormatted = AddonMessagesOfflineMessagesDBRecord & { + pending?: boolean; // Will be likely true. + text?: string; // Copy of smallmessage. +}; + +export type AddonMessagesOfflineConversationMessagesDBRecordFormatted = + Omit & + { + conversation?: AddonMessagesConversation; // Data about the conversation. + pending: boolean; // Will be always true. + useridfrom?: number; // User Id who send the message, will be likely us. + }; + + +export type AddonMessagesOfflineAnyMessagesFormatted = + AddonMessagesOfflineConversationMessagesDBRecordFormatted | AddonMessagesOfflineMessagesDBRecordFormatted; diff --git a/src/addons/messages/services/messages-sync.ts b/src/addons/messages/services/messages-sync.ts new file mode 100644 index 000000000..be3a2b599 --- /dev/null +++ b/src/addons/messages/services/messages-sync.ts @@ -0,0 +1,407 @@ +// (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 { + AddonMessagesOffline, AddonMessagesOfflineAnyMessagesFormatted, +} from './messages-offline'; +import { + AddonMessagesProvider, + AddonMessages, + AddonMessagesGetMessagesWSParams, +} from './messages'; +import { CoreEvents } from '@singletons/events'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreSites } from '@services/sites'; +import { CoreApp } from '@services/app'; +import { CoreConstants } from '@/core/constants'; +import { CoreUser } from '@features/user/services/user'; +import { CoreError } from '@classes/errors/error'; +import { CoreTextErrorObject, CoreTextUtils } from '@services/utils/text'; +import { CoreSiteWSPreSets } from '@classes/site'; + +/** + * Service to sync messages. + */ +@Injectable({ providedIn: 'root' }) +export class AddonMessagesSyncProvider extends CoreSyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_messages_autom_synced'; + + constructor() { + super('AddonMessagesSync'); + } + + /** + * Get the ID of a discussion sync. + * + * @param conversationId Conversation ID. + * @param userId User ID talking to (if no conversation ID). + * @return Sync ID. + */ + protected getSyncId(conversationId?: number, userId?: number): string { + if (conversationId) { + return 'conversationid:' + conversationId; + } else if (userId) { + return 'userid:' + userId; + } else { + // Should not happen. + throw new CoreError('Incorrect messages sync id.'); + } + } + + /** + * Try to synchronize all the discussions in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param onlyDeviceOffline True to only sync discussions that failed because device was offline, + * false to sync all. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllDiscussions(siteId?: string, onlyDeviceOffline: boolean = false): Promise { + const syncFunctionLog = 'all discussions' + (onlyDeviceOffline ? ' (Only offline)' : ''); + + return this.syncOnSites(syncFunctionLog, this.syncAllDiscussionsFunc.bind(this, [onlyDeviceOffline]), siteId); + } + + /** + * Get all messages pending to be sent in the site. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param onlyDeviceOffline True to only sync discussions that failed because device was offline. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllDiscussionsFunc(siteId: string, onlyDeviceOffline = false): Promise { + const userIds: number[] = []; + const conversationIds: number[] = []; + const promises: Promise[] = []; + + const messages = onlyDeviceOffline + ? await AddonMessagesOffline.instance.getAllDeviceOfflineMessages(siteId) + : await AddonMessagesOffline.instance.getAllMessages(siteId); + + // Get all the conversations to be synced. + messages.forEach((message) => { + if ('conversationid' in message) { + if (conversationIds.indexOf(message.conversationid) == -1) { + conversationIds.push(message.conversationid); + } + } else if (userIds.indexOf(message.touserid) == -1) { + userIds.push(message.touserid); + } + }); + + // Sync all conversations. + conversationIds.forEach((conversationId) => { + promises.push(this.syncDiscussion(conversationId, undefined, siteId).then((result) => { + if (typeof result == 'undefined') { + return; + } + + // Sync successful, send event. + CoreEvents.trigger(AddonMessagesSyncProvider.AUTO_SYNCED, result, siteId); + + return; + })); + }); + + userIds.forEach((userId) => { + promises.push(this.syncDiscussion(undefined, userId, siteId).then((result) => { + if (typeof result == 'undefined') { + return; + } + + // Sync successful, send event. + CoreEvents.trigger(AddonMessagesSyncProvider.AUTO_SYNCED, result, siteId); + + return; + })); + }); + + await Promise.all(promises); + } + + /** + * Synchronize a discussion. + * + * @param conversationId Conversation ID. + * @param userId User ID talking to (if no conversation ID). + * @param siteId Site ID. + * @return Promise resolved with the list of warnings if sync is successful, rejected otherwise. + */ + syncDiscussion(conversationId?: number, userId?: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const syncId = this.getSyncId(conversationId, userId); + + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for this conversation, return the promise. + return this.getOngoingSync(syncId, siteId)!; + } + + return this.addOngoingSync(syncId, this.performSyncDiscussion(conversationId, userId, siteId), siteId); + } + + /** + * Perform the synchronization of a discussion. + * + * @param conversationId Conversation ID. + * @param userId User ID talking to (if no conversation ID). + * @param siteId Site ID. + * @return Promise resolved with the list of warnings if sync is successful, rejected otherwise. + */ + protected async performSyncDiscussion( + conversationId: number | undefined, + userId: number | undefined, + siteId: string, + ): Promise { + const result: AddonMessagesSyncEvents = { + warnings: [], + userId, + conversationId, + }; + + const groupMessagingEnabled = AddonMessages.instance.isGroupMessagingEnabled(); + let messages: AddonMessagesOfflineAnyMessagesFormatted[]; + const errors: (string | CoreError | CoreTextErrorObject)[] = []; + + if (conversationId) { + this.logger.debug(`Try to sync conversation '${conversationId}'`); + messages = await AddonMessagesOffline.instance.getConversationMessages(conversationId, undefined, siteId); + } else if (userId) { + this.logger.debug(`Try to sync discussion with user '${userId}'`); + messages = await AddonMessagesOffline.instance.getMessages(userId, siteId); + } else { + // Should not happen. + throw new CoreError('Incorrect messages sync.'); + } + + if (!messages.length) { + // Nothing to sync. + return result; + } else if (!CoreApp.instance.isOnline()) { + // Cannot sync in offline. Mark messages as device offline. + AddonMessagesOffline.instance.setMessagesDeviceOffline(messages, true); + + throw new CoreError('Cannot sync in offline. Mark messages as device offline.'); + } + + // Order message by timecreated. + messages = AddonMessages.instance.sortMessages(messages); + + // Get messages sent by the user after the first offline message was sent. + // We subtract some time because the message could've been saved in server before it was in the app. + const timeFrom = Math.floor((messages[0].timecreated - CoreConstants.WS_TIMEOUT - 1000) / 1000); + + const onlineMessages = await this.getMessagesSentAfter(timeFrom, conversationId, userId, siteId); + + // Send the messages. Send them 1 by 1 to simulate web's behaviour and to make sure we know which message has failed. + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + + const text = ('text' in message ? message.text : message.smallmessage) || ''; + const textFieldName = conversationId ? 'text' : 'smallmessage'; + const wrappedText = message[textFieldName][0] != '<' ? '

' + text + '

' : text; + + try { + if (onlineMessages.indexOf(wrappedText) != -1) { + // Message already sent, ignore it to prevent duplicates. + } else if (conversationId) { + await AddonMessages.instance.sendMessageToConversationOnline(conversationId, text, siteId); + } else if (userId) { + await AddonMessages.instance.sendMessageOnline(userId, text, siteId); + } + } catch (error) { + if (!CoreUtils.instance.isWebServiceError(error)) { + // Error sending, stop execution. + if (CoreApp.instance.isOnline()) { + // App is online, unmark deviceoffline if marked. + AddonMessagesOffline.instance.setMessagesDeviceOffline(messages, false); + } + + throw error; + } + + // Error returned by WS. Store the error to show a warning but keep sending messages. + if (errors.indexOf(error) == -1) { + errors.push(error); + } + } + + // Message was sent, delete it from local DB. + if (conversationId) { + await AddonMessagesOffline.instance.deleteConversationMessage(conversationId, text, message.timecreated, siteId); + } else if (userId) { + await AddonMessagesOffline.instance.deleteMessage(userId, text, message.timecreated, siteId); + } + + // In some Moodle versions, wait 1 second to make sure timecreated is different. + // This is because there was a bug where messages with the same timecreated had a wrong order. + if (!groupMessagingEnabled && i < messages.length - 1) { + await CoreUtils.instance.wait(1000); + } + } + + await this.handleSyncErrors(conversationId, userId, errors, result.warnings); + + // All done, return the warnings. + return result; + } + + /** + * Get messages sent by current user after a certain time. + * + * @param time Time in seconds. + * @param conversationId Conversation ID. + * @param userId User ID talking to (if no conversation ID). + * @param siteId Site ID. + * @return Promise resolved with the messages texts. + */ + protected async getMessagesSentAfter( + time: number, + conversationId?: number, + userId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const siteCurrentUserId = site.getUserId(); + + if (conversationId) { + try { + const result = await AddonMessages.instance.getConversationMessages(conversationId, { + excludePending: true, + ignoreCache: true, + timeFrom: time, + }); + + const sentMessages = result.messages.filter((message) => message.useridfrom == siteCurrentUserId); + + return sentMessages.map((message) => message.text); + } catch (error) { + if (error && error.errorcode == 'invalidresponse') { + // There's a bug in Moodle that causes this error if there are no new messages. Return empty array. + return []; + } + + throw error; + } + } else if (userId) { + const params: AddonMessagesGetMessagesWSParams = { + useridto: userId, + useridfrom: siteCurrentUserId, + limitnum: AddonMessagesProvider.LIMIT_MESSAGES, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: AddonMessages.instance.getCacheKeyForDiscussion(userId), + getFromCache: false, + emergencyCache: false, + }; + + const messages = await AddonMessages.instance.getRecentMessages(params, preSets, 0, 0, false, siteId); + + time = time * 1000; // Convert to milliseconds. + const messagesAfterTime = messages.filter((message) => message.timecreated >= time); + + return messagesAfterTime.map((message) => message.text); + } else { + throw new CoreError('Incorrect messages sync identifier'); + } + } + + /** + * Handle sync errors. + * + * @param conversationId Conversation ID. + * @param userId User ID talking to (if no conversation ID). + * @param errors List of errors. + * @param warnings Array where to place the warnings. + * @return Promise resolved when done. + */ + protected async handleSyncErrors( + conversationId?: number, + userId?: number, + errors: (string | CoreError | CoreTextErrorObject)[] = [], + warnings: string[] = [], + ): Promise { + if (!errors || errors.length <= 0) { + return; + } + + if (conversationId) { + let conversationIdentifier = String(conversationId); + try { + // Get conversation name and add errors to warnings array. + const conversation = await AddonMessages.instance.getConversation(conversationId, false, false); + conversationIdentifier = conversation.name || String(conversationId); + } catch { + // Ignore errors. + } + + errors.forEach((error) => { + warnings.push(Translate.instance.instant('addon.messages.warningconversationmessagenotsent', { + conversation: conversationIdentifier, + error: CoreTextUtils.instance.getErrorMessageFromError(error), + })); + }); + } else if (userId) { + + // Get user full name and add errors to warnings array. + let userIdentifier = String(userId); + try { + const user = await CoreUser.instance.getProfile(userId, undefined, true); + userIdentifier = user.fullname; + } catch { + // Ignore errors. + } + + errors.forEach((error) => { + warnings.push(Translate.instance.instant('addon.messages.warningmessagenotsent', { + user: userIdentifier, + error: CoreTextUtils.instance.getErrorMessageFromError(error), + })); + }); + } + } + + /** + * If there's an ongoing sync for a certain conversation, wait for it to end. + * If there's no sync ongoing the promise will be resolved right away. + * + * @param conversationId Conversation ID. + * @param userId User ID talking to (if no conversation ID). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when there's no sync going on for the identifier. + */ + waitForSyncConversation( + conversationId?: number, + userId?: number, + siteId?: string, + ): Promise { + const syncId = this.getSyncId(conversationId, userId); + + return this.waitForSync(syncId, siteId); + } + +} + +export class AddonMessagesSync extends makeSingleton(AddonMessagesSyncProvider) {} + +export type AddonMessagesSyncEvents = { + warnings: string[]; + conversationId?: number; + userId?: number; +}; diff --git a/src/addons/messages/services/messages.ts b/src/addons/messages/services/messages.ts new file mode 100644 index 000000000..41903574c --- /dev/null +++ b/src/addons/messages/services/messages.ts @@ -0,0 +1,3751 @@ +// (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 { CoreLogger } from '@singletons/logger'; +import { CoreSites } from '@services/sites'; +import { CoreApp } from '@services/app'; +import { CoreUser, CoreUserBasicData } from '@features/user/services/user'; +import { + AddonMessagesOffline, + AddonMessagesOfflineAnyMessagesFormatted, + AddonMessagesOfflineConversationMessagesDBRecordFormatted, + AddonMessagesOfflineMessagesDBRecordFormatted, +} from './messages-offline'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreEvents } from '@singletons/events'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { CoreError } from '@classes/errors/error'; +import { AddonMessagesMainMenuHandlerService } from './handlers/mainmenu'; + +const ROOT_CACHE_KEY = 'mmaMessages:'; + +/** + * Service to handle messages. + */ +@Injectable({ providedIn: 'root' }) +export class AddonMessagesProvider { + + static readonly NEW_MESSAGE_EVENT = 'addon_messages_new_message_event'; + static readonly READ_CHANGED_EVENT = 'addon_messages_read_changed_event'; + static readonly OPEN_CONVERSATION_EVENT = 'addon_messages_open_conversation_event'; // Notify a conversation should be opened. + static readonly UPDATE_CONVERSATION_LIST_EVENT = 'addon_messages_update_conversation_list_event'; + static readonly MEMBER_INFO_CHANGED_EVENT = 'addon_messages_member_changed_event'; + static readonly UNREAD_CONVERSATION_COUNTS_EVENT = 'addon_messages_unread_conversation_counts_event'; + static readonly CONTACT_REQUESTS_COUNT_EVENT = 'addon_messages_contact_requests_count_event'; + static readonly POLL_INTERVAL = 10000; + static readonly PUSH_SIMULATION_COMPONENT = 'AddonMessagesPushSimulation'; + + static readonly MESSAGE_PRIVACY_COURSEMEMBER = 0; // Privacy setting for being messaged by anyone within courses user is member. + static readonly MESSAGE_PRIVACY_ONLYCONTACTS = 1; // Privacy setting for being messaged only by contacts. + static readonly MESSAGE_PRIVACY_SITE = 2; // Privacy setting for being messaged by anyone on the site. + static readonly MESSAGE_CONVERSATION_TYPE_INDIVIDUAL = 1; // An individual conversation. + static readonly MESSAGE_CONVERSATION_TYPE_GROUP = 2; // A group conversation. + static readonly MESSAGE_CONVERSATION_TYPE_SELF = 3; // A self conversation. + static readonly LIMIT_CONTACTS = 50; + static readonly LIMIT_MESSAGES = 50; + static readonly LIMIT_INITIAL_USER_SEARCH = 3; + static readonly LIMIT_SEARCH = 50; + + static readonly NOTIFICATION_PREFERENCES_KEY = 'message_provider_moodle_instantmessage'; + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('AddonMessages'); + } + + /** + * Add a contact. + * + * @param userId User ID of the person to add. + * @param siteId Site ID. If not defined, use current site. + * @return Resolved when done. + * @deprecatedonmoodle since Moodle 3.6 + */ + protected async addContact(userId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params = { + userids: [userId], + }; + + await site.write('core_message_create_contacts', params); + + await this.invalidateAllContactsCache(site.getId()); + } + + /** + * Block a user. + * + * @param userId User ID of the person to block. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved when done. + */ + async blockContact(userId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + try { + if (site.wsAvailable('core_message_block_user')) { + // Since Moodle 3.6 + const params: AddonMessagesBlockUserWSParams = { + userid: site.getUserId(), + blockeduserid: userId, + }; + await site.write('core_message_block_user', params); + } else { + const params: { userids: number[] } = { + userids: [userId], + }; + await site.write('core_message_block_contacts', params); + } + + await this.invalidateAllMemberInfo(userId, site); + } finally { + const data: AddonMessagesMemberInfoChangedEventData = { userId, userBlocked: true }; + + CoreEvents.trigger(AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT, data, site.id); + } + } + + /** + * Confirm a contact request from another user. + * + * @param userId ID of the user who made the contact request. + * @param siteId Site ID. If not defined, use current site. + * @return Resolved when done. + * @since 3.6 + */ + async confirmContactRequest(userId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonMessagesConfirmContactRequestWSParams = { + userid: userId, + requesteduserid: site.getUserId(), + }; + + await site.write('core_message_confirm_contact_request', params); + + await CoreUtils.instance.allPromises([ + this.invalidateAllMemberInfo(userId, site), + this.invalidateContactsCache(site.id), + this.invalidateUserContacts(site.id), + this.refreshContactRequestsCount(site.id), + ]).finally(() => { + const data: AddonMessagesMemberInfoChangedEventData = { userId, contactRequestConfirmed: true }; + CoreEvents.trigger(AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT, data, site.id); + }); + } + + /** + * Send a contact request to another user. + * + * @param userId ID of the receiver of the contact request. + * @param siteId Site ID. If not defined, use current site. + * @return Resolved when done. + * @since 3.6 + */ + async createContactRequest(userId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + // Use legacy function if not available. + if (!site.wsAvailable('core_message_create_contact_request')) { + await this.addContact(userId, site.getId()); + } else { + const params: AddonMessagesCreateContactRequestWSParams = { + userid: site.getUserId(), + requesteduserid: userId, + }; + + await site.write('core_message_create_contact_request', params); + } + + await this.invalidateAllMemberInfo(userId, site).finally(() => { + const data: AddonMessagesMemberInfoChangedEventData = { userId, contactRequestCreated: true }; + CoreEvents.trigger(AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT, data, site.id); + }); + } + + /** + * Decline a contact request from another user. + * + * @param userId ID of the user who made the contact request. + * @param siteId Site ID. If not defined, use current site. + * @return Resolved when done. + * @since 3.6 + */ + async declineContactRequest(userId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonMessagesDeclineContactRequestWSParams = { + userid: userId, + requesteduserid: site.getUserId(), + }; + + await site.write('core_message_decline_contact_request', params); + + await CoreUtils.instance.allPromises([ + this.invalidateAllMemberInfo(userId, site), + this.refreshContactRequestsCount(site.id), + ]).finally(() => { + const data: AddonMessagesMemberInfoChangedEventData = { userId, contactRequestDeclined: true }; + CoreEvents.trigger(AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT, data, site.id); + }); + } + + /** + * Delete a conversation. + * + * @param conversationId Conversation to delete. + * @param siteId Site ID. If not defined, use current site. + * @param userId User ID. If not defined, current user in the site. + * @return Promise resolved when the conversation has been deleted. + */ + async deleteConversation(conversationId: number, siteId?: string, userId?: number): Promise { + await this.deleteConversations([conversationId], siteId, userId); + } + + /** + * Delete several conversations. + * + * @param conversationIds Conversations to delete. + * @param siteId Site ID. If not defined, use current site. + * @param userId User ID. If not defined, current user in the site. + * @return Promise resolved when the conversations have been deleted. + */ + async deleteConversations(conversationIds: number[], siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + + const params: AddonMessagesDeleteConversationsByIdWSParams = { + userid: userId, + conversationids: conversationIds, + }; + + await site.write('core_message_delete_conversations_by_id', params); + + await Promise.all(conversationIds.map(async (conversationId) => { + try { + return AddonMessagesOffline.instance.deleteConversationMessages(conversationId, site.getId()); + } catch { + // Ignore errors. + } + })); + } + + /** + * Delete a message (online or offline). + * + * @param message Message to delete. + * @param deleteForAll Whether the message should be deleted for all users. + * @return Promise resolved when the message has been deleted. + */ + deleteMessage(message: AddonMessagesConversationMessageFormatted, deleteForAll?: boolean): Promise { + if ('id' in message) { + // Message has ID, it means it has been sent to the server. + if (deleteForAll) { + return this.deleteMessageForAllOnline(message.id); + } else { + return this.deleteMessageOnline(message.id, !!('read' in message && message.read)); + } + } + + // It's an offline message. + if (!('conversationid' in message)) { + return AddonMessagesOffline.instance.deleteMessage(message.touserid, message.smallmessage, message.timecreated); + } + + return AddonMessagesOffline.instance.deleteConversationMessage(message.conversationid, message.text, message.timecreated); + } + + /** + * Delete a message from the server. + * + * @param id Message ID. + * @param read True if message is read, false otherwise. + * @param userId User we want to delete the message for. If not defined, use current user. + * @return Promise resolved when the message has been deleted. + */ + async deleteMessageOnline(id: number, read: boolean, userId?: number): Promise { + userId = userId || CoreSites.instance.getCurrentSiteUserId(); + + const params: AddonMessagesDeleteMessageWSParams = { + messageid: id, + userid: userId, + }; + + if (typeof read != 'undefined') { + params.read = read; + } + + await CoreSites.instance.getCurrentSite()?.write('core_message_delete_message', params); + + await this.invalidateDiscussionCache(userId); + } + + /** + * Delete a message for all users. + * + * @param id Message ID. + * @param userId User we want to delete the message for. If not defined, use current user. + * @return Promise resolved when the message has been deleted. + */ + async deleteMessageForAllOnline(id: number, userId?: number): Promise { + userId = userId || CoreSites.instance.getCurrentSiteUserId(); + + const params: AddonMessagesDeleteMessageForAllUsersWSParams = { + messageid: id, + userid: userId, + }; + + await CoreSites.instance.getCurrentSite()?.write('core_message_delete_message_for_all_users', params); + + await this.invalidateDiscussionCache(userId); + } + + /** + * Format a conversation. + * + * @param conversation Conversation to format. + * @param userId User ID viewing the conversation. + * @return Formatted conversation. + */ + protected formatConversation( + conversation: AddonMessagesConversationFormatted, + userId: number, + ): AddonMessagesConversationFormatted { + + const numMessages = conversation.messages.length; + const lastMessage = numMessages ? conversation.messages[numMessages - 1] : null; + + conversation.lastmessage = lastMessage ? lastMessage.text : undefined; + conversation.lastmessagedate = lastMessage ? lastMessage.timecreated : undefined; + conversation.sentfromcurrentuser = lastMessage ? lastMessage.useridfrom == userId : undefined; + + if (conversation.type != AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP) { + const isIndividual = conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL; + + const otherUser = conversation.members.find((member) => + (isIndividual && member.id != userId) || (!isIndividual && member.id == userId)); + + if (otherUser) { + conversation.name = conversation.name ? conversation.name : otherUser.fullname; + conversation.imageurl = conversation.imageurl ? conversation.imageurl : otherUser.profileimageurl; + + conversation.otherUser = otherUser; + conversation.userid = otherUser.id; + conversation.showonlinestatus = otherUser.showonlinestatus; + conversation.isonline = otherUser.isonline; + conversation.isblocked = otherUser.isblocked; + conversation.otherUser = otherUser; + } + } + + return conversation; + } + + /** + * Get the cache key for blocked contacts. + * + * @param userId The user who's contacts we're looking for. + * @return Cache key. + */ + protected getCacheKeyForBlockedContacts(userId: number): string { + return ROOT_CACHE_KEY + 'blockedContacts:' + userId; + } + + /** + * Get the cache key for contacts. + * + * @return Cache key. + */ + protected getCacheKeyForContacts(): string { + return ROOT_CACHE_KEY + 'contacts'; + } + + /** + * Get the cache key for comfirmed contacts. + * + * @return Cache key. + */ + protected getCacheKeyForUserContacts(): string { + return ROOT_CACHE_KEY + 'userContacts'; + } + + /** + * Get the cache key for contact requests. + * + * @return Cache key. + */ + protected getCacheKeyForContactRequests(): string { + return ROOT_CACHE_KEY + 'contactRequests'; + } + + /** + * Get the cache key for contact requests count. + * + * @return Cache key. + */ + protected getCacheKeyForContactRequestsCount(): string { + return ROOT_CACHE_KEY + 'contactRequestsCount'; + } + + /** + * Get the cache key for a discussion. + * + * @param userId The other person with whom the current user is having the discussion. + * @return Cache key. + */ + getCacheKeyForDiscussion(userId: number): string { + return ROOT_CACHE_KEY + 'discussion:' + userId; + } + + /** + * Get the cache key for the message count. + * + * @param userId User ID. + * @return Cache key. + */ + protected getCacheKeyForMessageCount(userId: number): string { + return ROOT_CACHE_KEY + 'count:' + userId; + } + + /** + * Get the cache key for unread conversation counts. + * + * @return Cache key. + */ + protected getCacheKeyForUnreadConversationCounts(): string { + return ROOT_CACHE_KEY + 'unreadConversationCounts'; + } + + /** + * Get the cache key for the list of discussions. + * + * @return Cache key. + */ + protected getCacheKeyForDiscussions(): string { + return ROOT_CACHE_KEY + 'discussions'; + } + + /** + * Get cache key for get conversations. + * + * @param userId User ID. + * @param conversationId Conversation ID. + * @return Cache key. + */ + protected getCacheKeyForConversation(userId: number, conversationId: number): string { + return ROOT_CACHE_KEY + 'conversation:' + userId + ':' + conversationId; + } + + /** + * Get cache key for get conversations between users. + * + * @param userId User ID. + * @param otherUserId Other user ID. + * @return Cache key. + */ + protected getCacheKeyForConversationBetweenUsers(userId: number, otherUserId: number): string { + return ROOT_CACHE_KEY + 'conversationBetweenUsers:' + userId + ':' + otherUserId; + } + + /** + * Get cache key for get conversation members. + * + * @param userId User ID. + * @param conversationId Conversation ID. + * @return Cache key. + */ + protected getCacheKeyForConversationMembers(userId: number, conversationId: number): string { + return ROOT_CACHE_KEY + 'conversationMembers:' + userId + ':' + conversationId; + } + + /** + * Get cache key for get conversation messages. + * + * @param userId User ID. + * @param conversationId Conversation ID. + * @return Cache key. + */ + protected getCacheKeyForConversationMessages(userId: number, conversationId: number): string { + return ROOT_CACHE_KEY + 'conversationMessages:' + userId + ':' + conversationId; + } + + /** + * Get cache key for get conversations. + * + * @param userId User ID. + * @param type Filter by type. + * @param favourites Filter favourites. + * @return Cache key. + */ + protected getCacheKeyForConversations(userId: number, type?: number, favourites?: boolean): string { + return this.getCommonCacheKeyForUserConversations(userId) + ':' + type + ':' + favourites; + } + + /** + * Get cache key for conversation counts. + * + * @return Cache key. + */ + protected getCacheKeyForConversationCounts(): string { + return ROOT_CACHE_KEY + 'conversationCounts'; + } + + /** + * Get cache key for member info. + * + * @param userId User ID. + * @param otherUserId The other user ID. + * @return Cache key. + */ + protected getCacheKeyForMemberInfo(userId: number, otherUserId: number): string { + return ROOT_CACHE_KEY + 'memberInfo:' + userId + ':' + otherUserId; + } + + /** + * Get cache key for get self conversation. + * + * @param userId User ID. + * @return Cache key. + */ + protected getCacheKeyForSelfConversation(userId: number): string { + return ROOT_CACHE_KEY + 'selfconversation:' + userId; + } + + /** + * Get common cache key for get user conversations. + * + * @param userId User ID. + * @return Cache key. + */ + protected getCommonCacheKeyForUserConversations(userId: number): string { + return this.getRootCacheKeyForConversations() + userId; + } + + /** + * Get root cache key for get conversations. + * + * @return Cache key. + */ + protected getRootCacheKeyForConversations(): string { + return ROOT_CACHE_KEY + 'conversations:'; + } + + /** + * Get all the contacts of the current user. + * + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the WS data. + * @deprecatedonmoodle since Moodle 3.6 + */ + async getAllContacts(siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const contacts = await this.getContacts(siteId); + + try { + const blocked = await this.getBlockedContacts(siteId); + contacts.blocked = blocked.users; + this.storeUsersFromAllContacts(contacts); + + return contacts; + } catch { + // The WS for blocked contacts might fail, but we still want the contacts. + contacts.blocked = []; + this.storeUsersFromAllContacts(contacts); + + return contacts; + } + } + + /** + * Get all the users blocked by the current user. + * + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the WS data. + */ + async getBlockedContacts(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const userId = site.getUserId(); + + const params: AddonMessagesGetBlockedUsersWSParams = { + userid: userId, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForBlockedContacts(userId), + updateFrequency: CoreSite.FREQUENCY_OFTEN, + }; + + return site.read('core_message_get_blocked_users', params, preSets); + } + + /** + * Get the contacts of the current user. + * + * This excludes the blocked users. + * + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the WS data. + * @deprecatedonmoodle since Moodle 3.6 + */ + async getContacts(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForContacts(), + updateFrequency: CoreSite.FREQUENCY_OFTEN, + }; + + const contacts = await site.read('core_message_get_contacts', undefined, preSets); + + // Filter contacts with negative ID, they are notifications. + const validContacts: AddonMessagesGetContactsWSResponse = { + online: [], + offline: [], + strangers: [], + }; + + for (const typeName in contacts) { + if (!validContacts[typeName]) { + validContacts[typeName] = []; + } + + contacts[typeName].forEach((contact: AddonMessagesGetContactsContact) => { + if (contact.id > 0) { + validContacts[typeName].push(contact); + } + }); + } + + return validContacts; + } + + /** + * Get the list of user contacts. + * + * @param limitFrom Position of the first contact to fetch. + * @param limitNum Number of contacts to fetch. Default is AddonMessagesProvider.LIMIT_CONTACTS. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the list of user contacts. + * @since 3.6 + */ + async getUserContacts( + limitFrom: number = 0, + limitNum: number = AddonMessagesProvider.LIMIT_CONTACTS, + siteId?: string, + ): Promise<{contacts: AddonMessagesConversationMember[]; canLoadMore: boolean}> { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonMessagesGetUserContactsWSParams = { + userid: site.getUserId(), + limitfrom: limitFrom, + limitnum: limitNum <= 0 ? 0 : limitNum + 1, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForUserContacts(), + updateFrequency: CoreSite.FREQUENCY_OFTEN, + }; + + const contacts = await site.read('core_message_get_user_contacts', params, preSets); + + if (!contacts || !contacts.length) { + return { contacts: [], canLoadMore: false }; + } + + CoreUser.instance.storeUsers(contacts, site.id); + if (limitNum <= 0) { + return { contacts, canLoadMore: false }; + } + + return { + contacts: contacts.slice(0, limitNum), + canLoadMore: contacts.length > limitNum, + }; + } + + /** + * Get the contact request sent to the current user. + * + * @param limitFrom Position of the first contact request to fetch. + * @param limitNum Number of contact requests to fetch. Default is AddonMessagesProvider.LIMIT_CONTACTS. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the list of contact requests. + * @since 3.6 + */ + async getContactRequests( + limitFrom: number = 0, + limitNum: number = AddonMessagesProvider.LIMIT_CONTACTS, + siteId?: string, + ): Promise<{requests: AddonMessagesConversationMember[]; canLoadMore: boolean}> { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonMessagesGetContactRequestsWSParams = { + userid: site.getUserId(), + limitfrom: limitFrom, + limitnum: limitNum <= 0 ? 0 : limitNum + 1, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForContactRequests(), + updateFrequency: CoreSite.FREQUENCY_OFTEN, + }; + + const requests = await site.read( + 'core_message_get_contact_requests', + params, + preSets, + ); + + if (!requests || !requests.length) { + return { requests: [], canLoadMore: false }; + } + + CoreUser.instance.storeUsers(requests, site.id); + if (limitNum <= 0) { + return { requests, canLoadMore: false }; + } + + return { + requests: requests.slice(0, limitNum), + canLoadMore: requests.length > limitNum, + }; + } + + /** + * Get the number of contact requests sent to the current user. + * + * @param siteId Site ID. If not defined, use current site. + * @return Resolved with the number of contact requests. + * @since 3.6 + */ + async getContactRequestsCount(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonMessagesGetReceivedContactRequestsCountWSParams = { + userid: site.getUserId(), + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForContactRequestsCount(), + typeExpected: 'number', + }; + + const data: AddonMessagesContactRequestCountEventData = { + count: await site.read('core_message_get_received_contact_requests_count', params, preSets), + }; + + // Notify the new count so all badges are updated. + CoreEvents.trigger(AddonMessagesProvider.CONTACT_REQUESTS_COUNT_EVENT, data , site.id); + + return data.count; + + } + + /** + * Get a conversation by the conversation ID. + * + * @param conversationId Conversation ID to fetch. + * @param includeContactRequests Include contact requests. + * @param includePrivacyInfo Include privacy info. + * @param messageOffset Offset for messages list. + * @param messageLimit Limit of messages. Defaults to 1 (last message). + * We recommend getConversationMessages to get them. + * @param memberOffset Offset for members list. + * @param memberLimit Limit of members. Defaults to 2 (to be able to know the other user in individual ones). + * We recommend getConversationMembers to get them. + * @param newestFirst Whether to order messages by newest first. + * @param siteId Site ID. If not defined, use current site. + * @param userId User ID. If not defined, current user in the site. + * @return Promise resolved with the response. + * @since 3.6 + */ + async getConversation( + conversationId: number, + includeContactRequests: boolean = false, + includePrivacyInfo: boolean = false, + messageOffset: number = 0, + messageLimit: number = 1, + memberOffset: number = 0, + memberLimit: number = 2, + newestFirst: boolean = true, + siteId?: string, + userId?: number, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForConversation(userId, conversationId), + }; + + const params: AddonMessagesGetConversationWSParams = { + userid: userId, + conversationid: conversationId, + includecontactrequests: includeContactRequests, + includeprivacyinfo: includePrivacyInfo, + messageoffset: messageOffset, + messagelimit: messageLimit, + memberoffset: memberOffset, + memberlimit: memberLimit, + newestmessagesfirst: newestFirst, + }; + + const conversation = await site.read( + 'core_message_get_conversation', + params, + preSets, + ); + + return this.formatConversation(conversation, userId); + } + + /** + * Get a conversation between two users. + * + * @param otherUserId The other user ID. + * @param includeContactRequests Include contact requests. + * @param includePrivacyInfo Include privacy info. + * @param messageOffset Offset for messages list. + * @param messageLimit Limit of messages. Defaults to 1 (last message). + * We recommend getConversationMessages to get them. + * @param memberOffset Offset for members list. + * @param memberLimit Limit of members. Defaults to 2 (to be able to know the other user in individual ones). + * We recommend getConversationMembers to get them. + * @param newestFirst Whether to order messages by newest first. + * @param siteId Site ID. If not defined, use current site. + * @param userId User ID. If not defined, current user in the site. + * @param preferCache True if shouldn't call WS if data is cached, false otherwise. + * @return Promise resolved with the response. + * @since 3.6 + */ + async getConversationBetweenUsers( + otherUserId: number, + includeContactRequests?: boolean, + includePrivacyInfo?: boolean, + messageOffset: number = 0, + messageLimit: number = 1, + memberOffset: number = 0, + memberLimit: number = 2, + newestFirst: boolean = true, + siteId?: string, + userId?: number, + preferCache?: boolean, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + userId = userId || site.getUserId(); + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForConversationBetweenUsers(userId, otherUserId), + omitExpires: !!preferCache, + }; + + const params: AddonMessagesGetConversationBetweenUsersWSParams = { + userid: userId, + otheruserid: otherUserId, + includecontactrequests: !!includeContactRequests, + includeprivacyinfo: !!includePrivacyInfo, + messageoffset: messageOffset, + messagelimit: messageLimit, + memberoffset: memberOffset, + memberlimit: memberLimit, + newestmessagesfirst: !!newestFirst, + }; + + const conversation: AddonMessagesConversation = + await site.read('core_message_get_conversation_between_users', params, preSets); + + return this.formatConversation(conversation, userId); + } + + /** + * Get a conversation members. + * + * @param conversationId Conversation ID to fetch. + * @param limitFrom Offset for members list. + * @param limitTo Limit of members. + * @param siteId Site ID. If not defined, use current site. + * @param userId User ID. If not defined, current user in + * @since 3.6 + */ + async getConversationMembers( + conversationId: number, + limitFrom: number = 0, + limitTo?: number, + includeContactRequests?: boolean, + siteId?: string, + userId?: number, + ): Promise<{members: AddonMessagesConversationMember[]; canLoadMore: boolean}> { + const site = await CoreSites.instance.getSite(siteId); + userId = userId || site.getUserId(); + + if (typeof limitTo == 'undefined' || limitTo === null) { + limitTo = AddonMessagesProvider.LIMIT_MESSAGES; + } + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForConversationMembers(userId, conversationId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + }; + + const params: AddonMessagesGetConversationMembersWSParams = { + userid: userId, + conversationid: conversationId, + limitfrom: limitFrom, + limitnum: limitTo < 1 ? limitTo : limitTo + 1, + includecontactrequests: !!includeContactRequests, + includeprivacyinfo: true, + }; + + const members: AddonMessagesConversationMember[] = + await site.read('core_message_get_conversation_members', params, preSets); + if (limitTo < 1) { + return { + canLoadMore: false, + members: members, + }; + } + + return { + canLoadMore: members.length > limitTo, + members: members.slice(0, limitTo), + }; + } + + /** + * Get a conversation by the conversation ID. + * + * @param conversationId Conversation ID to fetch. + * @param options Options. + * @return Promise resolved with the response. + * @since 3.6 + */ + async getConversationMessages( + conversationId: number, + options: AddonMessagesGetConversationMessagesOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + options.userId = options.userId || site.getUserId(); + options.limitFrom = options.limitFrom || 0; + options.limitTo = options.limitTo === undefined || options.limitTo === null + ? AddonMessagesProvider.LIMIT_MESSAGES + : options.limitTo; + options.timeFrom = options.timeFrom || 0; + options.newestFirst = options.newestFirst === undefined || options.newestFirst === null ? true : options.newestFirst; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForConversationMessages(options.userId, conversationId), + }; + const params: AddonMessagesGetConversationMessagesWSParams = { + currentuserid: options.userId, + convid: conversationId, + limitfrom: options.limitFrom, + limitnum: options.limitTo < 1 ? options.limitTo : options.limitTo + 1, // If there's a limit, get 1 more than requested. + newest: !!options.newestFirst, + timefrom: options.timeFrom, + }; + + if (options.limitFrom > 0) { + // Do not use cache when retrieving older messages. + // This is to prevent storing too much data and to prevent inconsistencies between "pages" loaded. + preSets.getFromCache = false; + preSets.saveToCache = false; + preSets.emergencyCache = false; + } else if (options.forceCache) { + preSets.omitExpires = true; + } else if (options.ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + const result: AddonMessagesGetConversationMessagesResult = + await site.read('core_message_get_conversation_messages', params, preSets); + + if (options.limitTo < 1) { + result.canLoadMore = false; + } else { + result.canLoadMore = result.messages.length > options.limitTo; + result.messages = result.messages.slice(0, options.limitTo); + } + + result.messages.forEach((message) => { + // Convert time to milliseconds. + message.timecreated = message.timecreated ? message.timecreated * 1000 : 0; + }); + + if (options.excludePending) { + // No need to get offline messages, return the ones we have. + return result; + } + + // Get offline messages. + const offlineMessages = + await AddonMessagesOffline.instance.getConversationMessages(conversationId, options.userId, site.getId()); + + result.messages = result.messages.concat(offlineMessages); + + return result; + } + + /** + * Get the discussions of a certain user. This function is used in Moodle sites higher than 3.6. + * If the site is older than 3.6, please use getDiscussions. + * + * @param type Filter by type. + * @param favourites Whether to restrict the results to contain NO favourite conversations (false), ONLY favourite + * conversation (true), or ignore any restriction altogether (undefined or null). + * @param limitFrom The offset to start at. + * @param siteId Site ID. If not defined, use current site. + * @param userId User ID. If not defined, current user in the site. + * @param forceCache True if it should return cached data. Has priority over ignoreCache. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @return Promise resolved with the conversations. + * @since 3.6 + */ + async getConversations( + type?: number, + favourites?: boolean, + limitFrom: number = 0, + siteId?: string, + userId?: number, + forceCache?: boolean, + ignoreCache?: boolean, + ): Promise<{conversations: AddonMessagesConversationFormatted[]; canLoadMore: boolean}> { + + const site = await CoreSites.instance.getSite(siteId); + userId = userId || site.getUserId(); + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForConversations(userId, type, favourites), + }; + + const params: AddonMessagesGetConversationsWSParams = { + userid: userId, + limitfrom: limitFrom, + limitnum: AddonMessagesProvider.LIMIT_MESSAGES + 1, + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + if (typeof type != 'undefined' && type != null) { + params.type = type; + } + if (typeof favourites != 'undefined' && favourites != null) { + params.favourites = !!favourites; + } + if (site.isVersionGreaterEqualThan('3.7') && type != AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP) { + // Add self conversation to the list. + params.mergeself = true; + } + + let response: AddonMessagesGetConversationsResult; + try { + response = await site.read('core_message_get_conversations', params, preSets); + } catch (error) { + if (params.mergeself) { + // Try again without the new param. Maybe the user is offline and he has a previous request cached. + delete params.mergeself; + + return site.read('core_message_get_conversations', params, preSets); + } + + throw error; + } + + // Format the conversations, adding some calculated fields. + const conversations = response.conversations + .slice(0, AddonMessagesProvider.LIMIT_MESSAGES) + .map((conversation) => this.formatConversation(conversation, userId!)); + + return { + conversations, + canLoadMore: response.conversations.length > AddonMessagesProvider.LIMIT_MESSAGES, + }; + } + + /** + * Get conversation counts by type. + * + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with favourite, + * individual, group and self conversation counts. + * @since 3.6 + */ + async getConversationCounts(siteId?: string): Promise<{favourites: number; individual: number; group: number; self: number}> { + const site = await CoreSites.instance.getSite(siteId); + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForConversationCounts(), + }; + + const result = await site.read( + 'core_message_get_conversation_counts', + { }, + preSets, + ); + + const counts = { + favourites: result.favourites, + individual: result.types[AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL], + group: result.types[AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP], + self: result.types[AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_SELF] || 0, + }; + + return counts; + } + + /** + * Return the current user's discussion with another user. + * + * @param userId The ID of the other user. + * @param excludePending True to exclude messages pending to be sent. + * @param lfReceivedUnread Number of unread received messages already fetched, so fetch will be done from this. + * @param lfReceivedRead Number of read received messages already fetched, so fetch will be done from this. + * @param lfSentUnread Number of unread sent messages already fetched, so fetch will be done from this. + * @param lfSentRead Number of read sent messages already fetched, so fetch will be done from this. + * @param notUsed Deprecated since 3.9.5 + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with messages and a boolean telling if can load more messages. + */ + async getDiscussion( + userId: number, + excludePending: boolean, + lfReceivedUnread: number = 0, + lfReceivedRead: number = 0, + lfSentUnread: number = 0, + lfSentRead: number = 0, + notUsed: boolean = false, // eslint-disable-line @typescript-eslint/no-unused-vars + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const result: AddonMessagesGetDiscussionMessages = { + messages: [], + canLoadMore: false, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForDiscussion(userId), + }; + const params: AddonMessagesGetMessagesWSParams = { + useridto: site.getUserId(), + useridfrom: userId, + limitnum: AddonMessagesProvider.LIMIT_MESSAGES, + }; + + if (lfReceivedUnread > 0 || lfReceivedRead > 0 || lfSentUnread > 0 || lfSentRead > 0) { + // Do not use cache when retrieving older messages. + // This is to prevent storing too much data and to prevent inconsistencies between "pages" loaded. + preSets.getFromCache = false; + preSets.saveToCache = false; + preSets.emergencyCache = false; + } + + // Get message received by current user. + const received = await this.getRecentMessages(params, preSets, lfReceivedUnread, lfReceivedRead, undefined, site.getId()); + result.messages = received; + const hasReceived = received.length > 0; + + // Get message sent by current user. + params.useridto = userId; + params.useridfrom = site.getUserId(); + const sent = await this.getRecentMessages(params, preSets, lfSentUnread, lfSentRead, undefined, siteId); + result.messages = result.messages.concat(sent); + const hasSent = sent.length > 0; + + if (result.messages.length > AddonMessagesProvider.LIMIT_MESSAGES) { + // Sort messages and get the more recent ones. + result.canLoadMore = true; + result.messages = this.sortMessages(result['messages']); + result.messages = result.messages.slice(-AddonMessagesProvider.LIMIT_MESSAGES); + } else { + result.canLoadMore = result.messages.length == AddonMessagesProvider.LIMIT_MESSAGES && (!hasReceived || !hasSent); + } + + if (excludePending) { + // No need to get offline messages, return the ones we have. + return result; + } + + // Get offline messages. + const offlineMessages = await AddonMessagesOffline.instance.getMessages(userId, site.getId()); + + result.messages = result.messages.concat(offlineMessages); + + return result; + } + + /** + * Get the discussions of the current user. This function is used in Moodle sites older than 3.6. + * If the site is 3.6 or higher, please use getConversations. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with an object where the keys are the user ID of the other user. + */ + async getDiscussions(siteId?: string): Promise<{[userId: number]: AddonMessagesDiscussion}> { + const discussions: { [userId: number]: AddonMessagesDiscussion } = {}; + + /** + * Convenience function to treat a recent message, adding it to discussions list if needed. + */ + const treatRecentMessage = ( + message: AddonMessagesGetMessagesMessage | + AddonMessagesOfflineConversationMessagesDBRecordFormatted | + AddonMessagesOfflineMessagesDBRecordFormatted, + userId: number, + userFullname: string, + ): void => { + if (typeof discussions[userId] === 'undefined') { + discussions[userId] = { + fullname: userFullname, + profileimageurl: '', + }; + + if ((!('timeread' in message) || !message.timeread) && !message.pending && message.useridfrom != currentUserId) { + discussions[userId].unread = true; + } + } + + const messageId = ('id' in message) ? message.id : 0; + + // Extract the most recent message. Pending messages are considered more recent than messages already sent. + const discMessage = discussions[userId].message; + if (typeof discMessage === 'undefined' || (!discMessage.pending && message.pending) || + (discMessage.pending == message.pending && (discMessage.timecreated < message.timecreated || + (discMessage.timecreated == message.timecreated && discMessage.id < messageId)))) { + + discussions[userId].message = { + id: messageId, + user: userId, + message: message.text || '', + timecreated: message.timecreated, + pending: !!message.pending, + }; + } + }; + + const site = await CoreSites.instance.getSite(siteId); + + const currentUserId = site.getUserId(); + const params: AddonMessagesGetMessagesWSParams = { + useridto: currentUserId, + useridfrom: 0, + limitnum: AddonMessagesProvider.LIMIT_MESSAGES, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForDiscussions(), + }; + + const received = await this.getRecentMessages(params, preSets, undefined, undefined, undefined, site.getId()); + // Extract the discussions by filtering same senders. + received.forEach((message) => { + treatRecentMessage(message, message.useridfrom, message.userfromfullname); + }); + + // Now get the last messages sent by the current user. + params.useridfrom = params.useridto; + params.useridto = 0; + + const sent = await this.getRecentMessages(params, preSets); + // Extract the discussions by filtering same senders. + sent.forEach((message) => { + treatRecentMessage(message, message.useridto, message.usertofullname); + }); + + const offlineMessages = await AddonMessagesOffline.instance.getAllMessages(site.getId()); + + offlineMessages.forEach((message) => { + treatRecentMessage(message, 'touserid' in message ? message.touserid : 0, ''); + }); + + const discussionsWithUserImg = await this.getDiscussionsUserImg(discussions, site.getId()); + this.storeUsersFromDiscussions(discussionsWithUserImg); + + return discussionsWithUserImg; + } + + /** + * Get user images for all the discussions that don't have one already. + * + * @param discussions List of discussions. + * @param siteId Site ID. If not defined, current site. + * @return Promise always resolved. Resolve param is the formatted discussions. + */ + protected async getDiscussionsUserImg( + discussions: { [userId: number]: AddonMessagesDiscussion }, + siteId?: string, + ): Promise<{[userId: number]: AddonMessagesDiscussion}> { + const promises: Promise[] = []; + + for (const userId in discussions) { + if (!discussions[userId].profileimageurl && discussions[userId].message) { + // We don't have the user image. Try to retrieve it. + promises.push(CoreUser.instance.getProfile(discussions[userId].message!.user, 0, true, siteId).then((user) => { + discussions[userId].profileimageurl = user.profileimageurl; + + return; + }).catch(() => { + // Error getting profile, resolve promise without adding any extra data. + })); + } + } + + await Promise.all(promises); + + return discussions; + } + + /** + * Get conversation member info by user id, works even if no conversation betwen the users exists. + * + * @param otherUserId The other user ID. + * @param siteId Site ID. If not defined, use current site. + * @param userId User ID. If not defined, current user in the site. + * @return Promise resolved with the member info. + * @since 3.6 + */ + async getMemberInfo(otherUserId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForMemberInfo(userId, otherUserId), + updateFrequency: CoreSite.FREQUENCY_OFTEN, + }; + const params: AddonMessagesGetMemberInfoWSParams = { + referenceuserid: userId, + userids: [otherUserId], + includecontactrequests: true, + includeprivacyinfo: true, + }; + const members: AddonMessagesConversationMember[] = await site.read('core_message_get_member_info', params, preSets); + if (!members || members.length < 1) { + // Should never happen. + throw new CoreError('Error fetching member info.'); + } + + return members[0]; + } + + /** + * Get the cache key for the get message preferences call. + * + * @return Cache key. + */ + protected getMessagePreferencesCacheKey(): string { + return ROOT_CACHE_KEY + 'messagePreferences'; + } + + /** + * Get message preferences. + * + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the message preferences. + */ + async getMessagePreferences(siteId?: string): Promise { + this.logger.debug('Get message preferences'); + + const site = await CoreSites.instance.getSite(siteId); + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getMessagePreferencesCacheKey(), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + }; + + const data = await site.read( + 'core_message_get_user_message_preferences', + {}, + preSets, + ); + + if (data.preferences) { + data.preferences.blocknoncontacts = data.blocknoncontacts; + + return data.preferences; + } + + throw new CoreError('Error getting message preferences'); + } + + /** + * Gets the site main messages page path for a site. + * + * @param siteId Site ID. If not defined, use current site. + * @return Main messages page path of the site. + */ + async getMainMessagesPagePathInSite(siteId?: string): Promise { + const enabled = await this.isGroupMessagingEnabledInSite(siteId); + + return AddonMessagesMainMenuHandlerService.PAGE_NAME + ( enabled ? '/group-conversations' : ''); + } + + /** + * Gets the site main messages page path. + * + * @return Main messages page path of the site. + */ + getMainMessagesPagePath(): string { + const enabled = this.isGroupMessagingEnabled(); + + return AddonMessagesMainMenuHandlerService.PAGE_NAME + ( enabled ? '/group-conversations' : ''); + } + + + /** + * Get messages according to the params. + * + * @param params Parameters to pass to the WS. + * @param preSets Set of presets for the WS. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the data. + */ + protected async getMessages( + params: AddonMessagesGetMessagesWSParams, + preSets: CoreSiteWSPreSets, + siteId?: string, + ): Promise { + + params.type = 'conversations'; + params.newestfirst = true; + + const site = await CoreSites.instance.getSite(siteId); + const response: AddonMessagesGetMessagesResult = await site.read('core_message_get_messages', params, preSets); + + response.messages.forEach((message) => { + message.read = !!params.read; + // Convert times to milliseconds. + message.timecreated = message.timecreated ? message.timecreated * 1000 : 0; + message.timeread = message.timeread ? message.timeread * 1000 : 0; + }); + + return response; + } + + /** + * Get the most recent messages. + * + * @param params Parameters to pass to the WS. + * @param preSets Set of presets for the WS. + * @param limitFromUnread Number of read messages already fetched, so fetch will be done from this number. + * @param limitFromRead Number of unread messages already fetched, so fetch will be done from this number. + * @param notUsed // Deprecated 3.9.5 + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the data. + */ + async getRecentMessages( + params: AddonMessagesGetMessagesWSParams, + preSets: CoreSiteWSPreSets, + limitFromUnread: number = 0, + limitFromRead: number = 0, + notUsed: boolean = false, // eslint-disable-line @typescript-eslint/no-unused-vars + siteId?: string, + ): Promise { + limitFromUnread = limitFromUnread || 0; + limitFromRead = limitFromRead || 0; + + params.read = false; + params.limitfrom = limitFromUnread; + + const response = await this.getMessages(params, preSets, siteId); + let messages = response.messages; + + if (!messages) { + throw new CoreError('Error fetching recent messages'); + } + + if (messages.length >= (params.limitnum || 0)) { + return messages; + } + + // We need to fetch more messages. + params.limitnum = (params.limitnum || 0) - messages.length; + params.read = true; + params.limitfrom = limitFromRead; + + try { + const response = await this.getMessages(params, preSets, siteId); + if (response.messages) { + messages = messages.concat(response.messages); + } + + return messages; + } catch { + return messages; + } + } + + /** + * Get a self conversation. + * + * @param messageOffset Offset for messages list. + * @param messageLimit Limit of messages. Defaults to 1 (last message). + * We recommend getConversationMessages to get them. + * @param newestFirst Whether to order messages by newest first. + * @param siteId Site ID. If not defined, use current site. + * @param userId User ID to get the self conversation for. If not defined, current user in the site. + * @return Promise resolved with the response. + * @since 3.7 + */ + async getSelfConversation( + messageOffset: number = 0, + messageLimit: number = 1, + newestFirst: boolean = true, + siteId?: string, + userId?: number, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + userId = userId || site.getUserId(); + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForSelfConversation(userId), + }; + + const params: AddonMessagesGetSelfConversationWSParams = { + userid: userId, + messageoffset: messageOffset, + messagelimit: messageLimit, + newestmessagesfirst: !!newestFirst, + }; + const conversation = await site.read('core_message_get_self_conversation', params, preSets); + + return this.formatConversation(conversation, userId); + } + + /** + * Get unread conversation counts by type. + * + * @param siteId Site ID. If not defined, use current site. + * @return Resolved with the unread favourite, individual and group conversation counts. + */ + async getUnreadConversationCounts( + siteId?: string, + ): Promise<{favourites: number; individual: number; group: number; self: number; orMore?: boolean}> { + const site = await CoreSites.instance.getSite(siteId); + + let counts: AddonMessagesUnreadConversationCountsEventData; + + if (this.isGroupMessagingEnabled()) { + // @since 3.6 + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForUnreadConversationCounts(), + }; + + const result: AddonMessagesGetConversationCountsWSResponse = + await site.read('core_message_get_unread_conversation_counts', {}, preSets); + + counts = { + favourites: result.favourites, + individual: result.types[AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL], + group: result.types[AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP], + self: result.types[AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_SELF] || 0, + }; + + } else if (this.isMessageCountEnabled()) { + // @since 3.2 + const params: { useridto: number } = { + useridto: site.getUserId(), + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCacheKeyForMessageCount(site.getUserId()), + typeExpected: 'number', + }; + + const count: number = await site.read('core_message_get_unread_conversations_count', params, preSets); + + counts = { favourites: 0, individual: count, group: 0, self: 0 }; + } else { + // Fallback call. + const params: AddonMessagesGetMessagesWSParams = { + read: false, + limitfrom: 0, + limitnum: AddonMessagesProvider.LIMIT_MESSAGES + 1, + useridto: site.getUserId(), + useridfrom: 0, + }; + + const response = await this.getMessages(params, {}, siteId); + + // Count the discussions by filtering same senders. + const discussions = {}; + response.messages.forEach((message) => { + discussions[message.useridto] = 1; + }); + + const count = Object.keys(discussions).length; + + counts = { + favourites: 0, + individual: count, + group: 0, + self: 0, + orMore: count > AddonMessagesProvider.LIMIT_MESSAGES, + }; + } + // Notify the new counts so all views are updated. + CoreEvents.trigger(AddonMessagesProvider.UNREAD_CONVERSATION_COUNTS_EVENT, counts, site.id); + + return counts; + } + + /** + * Get the latest unread received messages. + * + * @param toDisplay True if messages will be displayed to the user, either in view or in a notification. + * @param forceCache True if it should return cached data. Has priority over ignoreCache. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the message unread count. + */ + async getUnreadReceivedMessages( + notUsed: boolean = true, // eslint-disable-line @typescript-eslint/no-unused-vars + forceCache: boolean = false, + ignoreCache: boolean = false, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonMessagesGetMessagesWSParams = { + read: false, + limitfrom: 0, + limitnum: AddonMessagesProvider.LIMIT_MESSAGES, + useridto: site.getUserId(), + useridfrom: 0, + }; + const preSets: CoreSiteWSPreSets = {}; + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return await this.getMessages(params, preSets, siteId); + } + + /** + * Invalidate all contacts cache. + * + * @param userId The user ID. + * @param siteId Site ID. If not defined, current site. + * @return Resolved when done. + */ + async invalidateAllContactsCache(siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + await this.invalidateContactsCache(siteId); + + await this.invalidateBlockedContactsCache(siteId); + } + + /** + * Invalidate blocked contacts cache. + * + * @param userId The user ID. + * @param siteId Site ID. If not defined, current site. + */ + async invalidateBlockedContactsCache(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const userId = site.getUserId(); + + await site.invalidateWsCacheForKey(this.getCacheKeyForBlockedContacts(userId)); + } + + /** + * Invalidate contacts cache. + * + * @param siteId Site ID. If not defined, current site. + * @return Resolved when done. + */ + async invalidateContactsCache(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getCacheKeyForContacts()); + } + + /** + * Invalidate user contacts cache. + * + * @param siteId Site ID. If not defined, current site. + * @return Resolved when done. + */ + async invalidateUserContacts(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getCacheKeyForUserContacts()); + } + + /** + * Invalidate contact requests cache. + * + * @param siteId Site ID. If not defined, current site. + * @return Resolved when done. + */ + async invalidateContactRequestsCache(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.invalidateWsCacheForKey(this.getCacheKeyForContactRequests()); + } + + /** + * Invalidate contact requests count cache. + * + * @param siteId Site ID. If not defined, current site. + * @return Resolved when done. + */ + async invalidateContactRequestsCountCache(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getCacheKeyForContactRequestsCount()); + } + + /** + * Invalidate conversation. + * + * @param conversationId Conversation ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, current user in the site. + * @return Resolved when done. + */ + async invalidateConversation(conversationId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + userId = userId || site.getUserId(); + + await site.invalidateWsCacheForKey(this.getCacheKeyForConversation(userId, conversationId)); + } + + /** + * Invalidate conversation between users. + * + * @param otherUserId Other user ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, current user in the site. + * @return Resolved when done. + */ + async invalidateConversationBetweenUsers(otherUserId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + userId = userId || site.getUserId(); + + + await site.invalidateWsCacheForKey(this.getCacheKeyForConversationBetweenUsers(userId, otherUserId)); + } + + /** + * Invalidate conversation members cache. + * + * @param conversationId Conversation ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, current user in the site. + * @return Resolved when done. + */ + async invalidateConversationMembers(conversationId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + + await site.invalidateWsCacheForKey(this.getCacheKeyForConversationMembers(userId, conversationId)); + } + + /** + * Invalidate conversation messages cache. + * + * @param conversationId Conversation ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, current user in the site. + * @return Resolved when done. + */ + async invalidateConversationMessages(conversationId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + + await site.invalidateWsCacheForKey(this.getCacheKeyForConversationMessages(userId, conversationId)); + } + + /** + * Invalidate conversations cache. + * + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, current user in the site. + * @return Resolved when done. + */ + async invalidateConversations(siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + + await site.invalidateWsCacheForKeyStartingWith(this.getCommonCacheKeyForUserConversations(userId)); + } + + /** + * Invalidate conversation counts cache. + * + * @param siteId Site ID. If not defined, current site. + * @return Resolved when done. + */ + async invalidateConversationCounts(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getCacheKeyForConversationCounts()); + } + + /** + * Invalidate discussion cache. + * + * @param userId The user ID with whom the current user is having the discussion. + * @param siteId Site ID. If not defined, current site. + * @return Resolved when done. + */ + async invalidateDiscussionCache(userId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getCacheKeyForDiscussion(userId)); + } + + /** + * Invalidate discussions cache. + * + * Note that {@link this.getDiscussions} uses the contacts, so we need to invalidate contacts too. + * + * @param siteId Site ID. If not defined, current site. + * @return Resolved when done. + */ + async invalidateDiscussionsCache(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const promises: Promise[] = []; + promises.push(site.invalidateWsCacheForKey(this.getCacheKeyForDiscussions())); + promises.push(this.invalidateContactsCache(site.getId())); + + await Promise.all(promises); + } + + /** + * Invalidate member info cache. + * + * @param otherUserId The other user ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, current user in the site. + * @return Resolved when done. + */ + async invalidateMemberInfo(otherUserId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + + await site.invalidateWsCacheForKey(this.getCacheKeyForMemberInfo(userId, otherUserId)); + } + + /** + * Invalidate get message preferences. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when data is invalidated. + */ + async invalidateMessagePreferences(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getMessagePreferencesCacheKey()); + } + + /** + * Invalidate all cache entries with member info. + * + * @param userId Id of the user to invalidate. + * @param site Site object. + * @return Promise resolved when done. + */ + protected async invalidateAllMemberInfo(userId: number, site: CoreSite): Promise { + await CoreUtils.instance.allPromises([ + this.invalidateMemberInfo(userId, site.id), + this.invalidateUserContacts(site.id), + this.invalidateBlockedContactsCache(site.id), + this.invalidateContactRequestsCache(site.id), + this.invalidateConversations(site.id), + this.getConversationBetweenUsers( + userId, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + site.id, + undefined, + true, + ).then((conversation) => CoreUtils.instance.allPromises([ + this.invalidateConversation(conversation.id), + this.invalidateConversationMembers(conversation.id, site.id), + ])).catch(() => { + // The conversation does not exist or we can't fetch it now, ignore it. + }), + ]); + } + + /** + * Invalidate a self conversation. + * + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, current user in the site. + * @return Resolved when done. + */ + async invalidateSelfConversation(siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + + await site.invalidateWsCacheForKey(this.getCacheKeyForSelfConversation(userId)); + } + + /** + * Invalidate unread conversation counts cache. + * + * @param siteId Site ID. If not defined, current site. + * @return Resolved when done. + */ + async invalidateUnreadConversationCounts(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + if (this.isGroupMessagingEnabled()) { + // @since 3.6 + return site.invalidateWsCacheForKey(this.getCacheKeyForUnreadConversationCounts()); + + } else if (this.isMessageCountEnabled()) { + // @since 3.2 + return site.invalidateWsCacheForKey(this.getCacheKeyForMessageCount(site.getUserId())); + } + } + + /** + * Checks if the a user is blocked by the current user. + * + * @param userId The user ID to check against. + * @param siteId Site ID. If not defined, use current site. + * @return Resolved with boolean, rejected when we do not know. + */ + async isBlocked(userId: number, siteId?: string): Promise { + if (this.isGroupMessagingEnabled()) { + const member = await this.getMemberInfo(userId, siteId); + + return member.isblocked; + } + + const blockedContacts = await this.getBlockedContacts(siteId); + if (!blockedContacts.users || blockedContacts.users.length < 1) { + return false; + } + + return blockedContacts.users.some((user) => userId == user.id); + } + + /** + * Checks if the a user is a contact of the current user. + * + * @param userId The user ID to check against. + * @param siteId Site ID. If not defined, use current site. + * @return Resolved with boolean, rejected when we do not know. + */ + async isContact(userId: number, siteId?: string): Promise { + if (this.isGroupMessagingEnabled()) { + const member = await this.getMemberInfo(userId, siteId); + + return member.iscontact; + } + + const contacts = await this.getContacts(siteId); + + return ['online', 'offline'].some((type) => { + if (contacts[type] && contacts[type].length > 0) { + return contacts[type].some((user: AddonMessagesGetContactsContact) => userId == user.id); + } + + return false; + }); + } + + /** + * Returns whether or not group messaging is supported. + * + * @return If related WS is available on current site. + * @since 3.6 + */ + isGroupMessagingEnabled(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('core_message_get_conversations'); + } + + /** + * Returns whether or not group messaging is supported in a certain site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether related WS is available on a certain site. + * @since 3.6 + */ + async isGroupMessagingEnabledInSite(siteId?: string): Promise { + try { + const site = await CoreSites.instance.getSite(siteId); + + return site.wsAvailable('core_message_get_conversations'); + } catch { + return false; + } + } + + /** + * Returns whether or not we can mark all messages as read. + * + * @return If related WS is available on current site. + * @since 3.2 + */ + isMarkAllMessagesReadEnabled(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('core_message_mark_all_conversation_messages_as_read') || + CoreSites.instance.wsAvailableInCurrentSite('core_message_mark_all_messages_as_read'); + } + + /** + * Returns whether or not we can count unread messages. + * + * @return True if enabled, false otherwise. + * @since 3.2 + */ + isMessageCountEnabled(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('core_message_get_unread_conversations_count'); + } + + /** + * Returns whether or not the message preferences are enabled for the current site. + * + * @return True if enabled, false otherwise. + * @since 3.2 + */ + isMessagePreferencesEnabled(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('core_message_get_user_message_preferences'); + } + + /** + * Returns whether or not messaging is enabled for a certain site. + * + * This could call a WS so do not abuse this method. + * + * @param siteId Site ID. If not defined, current site. + * @return Resolved when enabled, otherwise rejected. + */ + async isMessagingEnabledForSite(siteId?: string): Promise { + const enabled = await this.isPluginEnabled(siteId); + + if (!enabled) { + throw new CoreError('Messaging not enabled for the site'); + } + } + + /** + * Returns whether or not a site supports muting or unmuting a conversation. + * + * @param site The site to check, undefined for current site. + * @return If related WS is available on current site. + * @since 3.7 + */ + isMuteConversationEnabled(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.wsAvailable('core_message_mute_conversations'); + } + + /** + * Returns whether or not a site supports muting or unmuting a conversation. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether related WS is available on a certain site. + * @since 3.7 + */ + async isMuteConversationEnabledInSite(siteId?: string): Promise { + try { + const site = await CoreSites.instance.getSite(siteId); + + return this.isMuteConversationEnabled(site); + } catch { + return false; + } + } + + /** + * Returns whether or not the plugin is enabled in a certain site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if enabled, rejected or resolved with false otherwise. + */ + async isPluginEnabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.canUseAdvancedFeature('messaging'); + } + + /** + * Returns whether or not we can search messages. + * + * @since 3.2 + */ + isSearchMessagesEnabled(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('core_message_data_for_messagearea_search_messages'); + } + + /** + * Returns whether or not self conversation is supported in a certain site. + * + * @param site Site. If not defined, current site. + * @return If related WS is available on the site. + * @since 3.7 + */ + isSelfConversationEnabled(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.wsAvailable('core_message_get_self_conversation'); + } + + /** + * Returns whether or not self conversation is supported in a certain site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether related WS is available on a certain site. + * @since 3.7 + */ + async isSelfConversationEnabledInSite(siteId?: string): Promise { + try { + const site = await CoreSites.instance.getSite(siteId); + + return this.isSelfConversationEnabled(site); + } catch { + return false; + } + } + + /** + * Mark message as read. + * + * @param messageId ID of message to mark as read + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean marking success or not. + */ + async markMessageRead(messageId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonMessagesMarkMessageReadWSParams = { + messageid: messageId, + timeread: CoreTimeUtils.instance.timestamp(), + }; + + return site.write('core_message_mark_message_read', params); + } + + /** + * Mark all messages of a conversation as read. + * + * @param conversationId Conversation ID. + * @return Promise resolved if success. + * @since 3.6 + */ + async markAllConversationMessagesRead(conversationId: number): Promise { + const params: AddonMessagesMarkAllConversationMessagesAsReadWSParams = { + userid: CoreSites.instance.getCurrentSiteUserId(), + conversationid: conversationId, + }; + + const preSets: CoreSiteWSPreSets = { + responseExpected: false, + }; + + await CoreSites.instance.getCurrentSite()?.write('core_message_mark_all_conversation_messages_as_read', params, preSets); + } + + /** + * Mark all messages of a discussion as read. + * + * @param userIdFrom User Id for the sender. + * @return Promise resolved with boolean marking success or not. + * @deprecatedonmoodle since Moodle 3.6 + */ + async markAllMessagesRead(userIdFrom?: number): Promise { + const params: AddonMessagesMarkAllMessagesAsReadWSParams = { + useridto: CoreSites.instance.getCurrentSiteUserId(), + useridfrom: userIdFrom, + }; + + const preSets: CoreSiteWSPreSets = { + typeExpected: 'boolean', + }; + + const site = CoreSites.instance.getCurrentSite(); + + if (!site) { + return false; + } + + return site.write('core_message_mark_all_messages_as_read', params, preSets); + } + + /** + * Mute or unmute a conversation. + * + * @param conversationId Conversation ID. + * @param set Whether to mute or unmute. + * @param siteId Site ID. If not defined, use current site. + * @param userId User ID. If not defined, current user in the site. + * @return Resolved when done. + */ + async muteConversation(conversationId: number, set: boolean, siteId?: string, userId?: number): Promise { + await this.muteConversations([conversationId], set, siteId, userId); + } + + /** + * Mute or unmute some conversations. + * + * @param conversations Conversation IDs. + * @param set Whether to mute or unmute. + * @param siteId Site ID. If not defined, use current site. + * @param userId User ID. If not defined, current user in the site. + * @return Resolved when done. + */ + async muteConversations(conversations: number[], set: boolean, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + const params: AddonMessagesMuteConversationsWSParams = { + userid: userId, + conversationids: conversations, + }; + + const wsName = set ? 'core_message_mute_conversations' : 'core_message_unmute_conversations'; + await site.write(wsName, params); + + // Invalidate the conversations data. + const promises = conversations.map((conversationId) => this.invalidateConversation(conversationId, site.getId(), userId)); + + try { + await Promise.all(promises); + } catch { + // Ignore errors. + } + } + + /** + * Refresh the number of contact requests sent to the current user. + * + * @param siteId Site ID. If not defined, use current site. + * @return Resolved with the number of contact requests. + * @since 3.6 + */ + async refreshContactRequestsCount(siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + await this.invalidateContactRequestsCountCache(siteId); + + return this.getContactRequestsCount(siteId); + } + + /** + * Refresh unread conversation counts and trigger event. + * + * @param siteId Site ID. If not defined, use current site. + * @return Resolved with the unread favourite, individual and group conversation counts. + */ + async refreshUnreadConversationCounts( + siteId?: string, + ): Promise<{favourites: number; individual: number; group: number; orMore?: boolean}> { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + await this.invalidateUnreadConversationCounts(siteId); + + return this.getUnreadConversationCounts(siteId); + } + + /** + * Remove a contact. + * + * @param userId User ID of the person to remove. + * @param siteId Site ID. If not defined, use current site. + * @return Resolved when done. + */ + async removeContact(userId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonMessagesDeleteContactsWSParams = { + userids: [userId], + }; + + const preSets: CoreSiteWSPreSets = { + responseExpected: false, + }; + + await site.write('core_message_delete_contacts', params, preSets); + + return CoreUtils.instance.allPromises([ + this.invalidateUserContacts(site.id), + this.invalidateAllMemberInfo(userId, site), + this.invalidateContactsCache(site.id), + ]).then(() => { + const data: AddonMessagesMemberInfoChangedEventData = { userId, contactRemoved: true }; + CoreEvents.trigger(AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT, data, site.id); + + return; + }); + } + + /** + * Search for contacts. + * + * By default this only returns the first 100 contacts, but note that the WS can return thousands + * of results which would take a while to process. The limit here is just a convenience to + * prevent viewed to crash because too many DOM elements are created. + * + * @param query The query string. + * @param limit The number of results to return, 0 for none. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the contacts. + */ + async searchContacts(query: string, limit: number = 100, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonMessagesSearchContactsWSParams = { + searchtext: query, + onlymycourses: false, + }; + + const preSets: CoreSiteWSPreSets = { + getFromCache: false, + }; + + let contacts: AddonMessagesSearchContactsContact[] = await site.read('core_message_search_contacts', params, preSets); + + if (limit && contacts.length > limit) { + contacts = contacts.splice(0, limit); + } + + CoreUser.instance.storeUsers(contacts); + + return contacts; + } + + /** + * Search for all the messges with a specific text. + * + * @param query The query string. + * @param userId The user ID. If not defined, current user. + * @param limitFrom Position of the first result to get. Defaults to 0. + * @param limitNum Number of results to get. Defaults to AddonMessagesProvider.LIMIT_SEARCH. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the results. + */ + async searchMessages( + query: string, + userId?: number, + limitFrom: number = 0, + limitNum: number = AddonMessagesProvider.LIMIT_SEARCH, + siteId?: string, + ): Promise<{messages: AddonMessagesMessageAreaContact[]; canLoadMore: boolean}> { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonMessagesDataForMessageareaSearchMessagesWSParams = { + userid: userId || site.getUserId(), + search: query, + limitfrom: limitFrom, + limitnum: limitNum <= 0 ? 0 : limitNum + 1, + }; + + const preSets: CoreSiteWSPreSets = { + getFromCache: false, + }; + + const result: AddonMessagesDataForMessageareaSearchMessagesWSResponse = + await site.read('core_message_data_for_messagearea_search_messages', params, preSets); + if (!result.contacts || !result.contacts.length) { + return { messages: [], canLoadMore: false }; + } + + const users: CoreUserBasicData[] = result.contacts.map((contact) => ({ + id: contact.userid, + fullname: contact.fullname, + profileimageurl: contact.profileimageurl, + })); + + CoreUser.instance.storeUsers(users, site.id); + + if (limitNum <= 0) { + return { messages: result.contacts, canLoadMore: false }; + } + + return { + messages: result.contacts.slice(0, limitNum), + canLoadMore: result.contacts.length > limitNum, + }; + } + + /** + * Search for users. + * + * @param query Text to search for. + * @param limitFrom Position of the first found user to fetch. + * @param limitNum Number of found users to fetch. Defaults to AddonMessagesProvider.LIMIT_SEARCH. + * @param siteId Site ID. If not defined, use current site. + * @return Resolved with two lists of found users: contacts and non-contacts. + * @since 3.6 + */ + async searchUsers( + query: string, + limitFrom: number = 0, + limitNum: number = AddonMessagesProvider.LIMIT_SEARCH, + siteId?: string, + ): Promise<{ + contacts: AddonMessagesConversationMember[]; + nonContacts: AddonMessagesConversationMember[]; + canLoadMoreContacts: boolean; + canLoadMoreNonContacts: boolean; + }> { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonMessagesMessageSearchUsersWSParams = { + userid: site.getUserId(), + search: query, + limitfrom: limitFrom, + limitnum: limitNum <= 0 ? 0 : limitNum + 1, + }; + const preSets: CoreSiteWSPreSets = { + getFromCache: false, + }; + + const result: AddonMessagesSearchUsersWSResponse = await site.read('core_message_message_search_users', params, preSets); + const contacts = result.contacts || []; + const nonContacts = result.noncontacts || []; + + CoreUser.instance.storeUsers(contacts, site.id); + CoreUser.instance.storeUsers(nonContacts, site.id); + + if (limitNum <= 0) { + return { contacts, nonContacts, canLoadMoreContacts: false, canLoadMoreNonContacts: false }; + } + + return { + contacts: contacts.slice(0, limitNum), + nonContacts: nonContacts.slice(0, limitNum), + canLoadMoreContacts: contacts.length > limitNum, + canLoadMoreNonContacts: nonContacts.length > limitNum, + }; + } + + /** + * Send a message to someone. + * + * @param userIdTo User ID to send the message to. + * @param message The message to send + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with: + * - sent (Boolean) True if message was sent to server, false if stored in device. + * - message (Object) If sent=false, contains the stored message. + */ + async sendMessage( + toUserId: number, + message: string, + siteId?: string, + ): Promise { + + // Convenience function to store a message to be synchronized later. + const storeOffline = async (): Promise => { + const entry = await AddonMessagesOffline.instance.saveMessage(toUserId, message, siteId); + + return { + sent: false, + message: { + msgid: -1, + text: entry.smallmessage, + timecreated: entry.timecreated, + conversationid: 0, + useridfrom: entry.useridfrom, + candeletemessagesforallusers: true, + }, + }; + }; + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!CoreApp.instance.isOnline()) { + // App is offline, store the message. + return storeOffline(); + } + + // Check if this conversation already has offline messages. + // If so, store this message since they need to be sent in order. + let hasStoredMessages = false; + try { + hasStoredMessages = await AddonMessagesOffline.instance.hasMessages(toUserId, siteId); + } catch { + // Error, it's safer to assume it has messages. + hasStoredMessages = true; + } + + if (hasStoredMessages) { + return storeOffline(); + } + + try { + // Online and no messages stored. Send it to server. + const result = await this.sendMessageOnline(toUserId, message); + + return { + sent: true, + message: result, + }; + } 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; + } + + // Error sending message, store it to retry later. + return storeOffline(); + } + } + + /** + * Send a message to someone. It will fail if offline or cannot connect. + * + * @param toUserId User ID to send the message to. + * @param message The message to send + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success, rejected if failure. + */ + async sendMessageOnline(toUserId: number, message: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const messages = [ + { + touserid: toUserId, + text: message, + textformat: 1, + }, + ]; + + const response = await this.sendMessagesOnline(messages, siteId); + + if (response && response[0] && response[0].msgid === -1) { + // There was an error, and it should be translated already. + throw new CoreError(response[0].errormessage); + } + + try { + await this.invalidateDiscussionCache(toUserId, siteId); + } catch { + // Ignore errors. + } + + return response[0]; + } + + /** + * Send some messages. It will fail if offline or cannot connect. + * IMPORTANT: Sending several messages at once for the same discussions can cause problems with display order, + * since messages with same timecreated aren't ordered by ID. + * + * @param messages Messages to send. Each message must contain touserid, text and textformat. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success, rejected if failure. Promise resolved doesn't mean that messages + * have been sent, the resolve param can contain errors for messages not sent. + */ + async sendMessagesOnline( + messages: AddonMessagesMessageData[], + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const data: AddonMessagesSendInstantMessagesWSParams = { + messages, + }; + + return await site.write('core_message_send_instant_messages', data); + } + + /** + * Send a message to a conversation. + * + * @param conversation Conversation. + * @param message The message to send. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with: + * - sent (boolean) True if message was sent to server, false if stored in device. + * - message (any) If sent=false, contains the stored message. + * @since 3.6 + */ + async sendMessageToConversation( + conversation: AddonMessagesConversation, + message: string, + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + siteId = site.getId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = async(): Promise => { + const entry = await AddonMessagesOffline.instance.saveConversationMessage(conversation, message, siteId); + + return { + sent: false, + message: { + id: -1, + useridfrom: site.getUserId(), + text: entry.text, + timecreated: entry.timecreated, + }, + }; + }; + + if (!CoreApp.instance.isOnline()) { + // App is offline, store the message. + return storeOffline(); + } + + // Check if this conversation already has offline messages. + // If so, store this message since they need to be sent in order. + let hasStoredMessages = false; + try { + hasStoredMessages = await AddonMessagesOffline.instance.hasConversationMessages(conversation.id, siteId); + } catch { + // Error, it's safer to assume it has messages. + hasStoredMessages = true; + } + + if (hasStoredMessages) { + return storeOffline(); + } + + try { + // Online and no messages stored. Send it to server. + const result = await this.sendMessageToConversationOnline(conversation.id, message, siteId); + + return { + sent: true, + message: result, + }; + } 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; + } + + // Error sending message, store it to retry later. + return storeOffline(); + } + } + + /** + * Send a message to a conversation. It will fail if offline or cannot connect. + * + * @param conversationId Conversation ID. + * @param message The message to send + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success, rejected if failure. + * @since 3.6 + */ + async sendMessageToConversationOnline( + conversationId: number, + message: string, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const messages = [ + { + text: message, + textformat: 1, + }, + ]; + + const response = await this.sendMessagesToConversationOnline(conversationId, messages, siteId); + + try { + await this.invalidateConversationMessages(conversationId, siteId); + } catch { + // Ignore errors. + } + + return response[0]; + } + + /** + * Send some messages to a conversation. It will fail if offline or cannot connect. + * + * @param conversationId Conversation ID. + * @param messages Messages to send. Each message must contain text and, optionally, textformat. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success, rejected if failure. + * @since 3.6 + */ + async sendMessagesToConversationOnline( + conversationId: number, + messages: CoreMessageSendMessagesToConversationMessageData[], + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + const params: CoreMessageSendMessagesToConversationWSParams = { + conversationid: conversationId, + messages: messages.map((message) => ({ + text: message.text, + textformat: typeof message.textformat != 'undefined' ? message.textformat : 1, + })), + }; + + return await site.write('core_message_send_messages_to_conversation', params); + } + + /** + * Set or unset a conversation as favourite. + * + * @param conversationId Conversation ID. + * @param set Whether to set or unset it as favourite. + * @param siteId Site ID. If not defined, use current site. + * @param userId User ID. If not defined, current user in the site. + * @return Resolved when done. + */ + setFavouriteConversation(conversationId: number, set: boolean, siteId?: string, userId?: number): Promise { + return this.setFavouriteConversations([conversationId], set, siteId, userId); + } + + /** + * Set or unset some conversations as favourites. + * + * @param conversations Conversation IDs. + * @param set Whether to set or unset them as favourites. + * @param siteId Site ID. If not defined, use current site. + * @param userId User ID. If not defined, current user in the site. + * @return Resolved when done. + */ + async setFavouriteConversations(conversations: number[], set: boolean, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + + const params: AddonMessagesSetFavouriteConversationsWSParams = { + userid: userId, + conversations: conversations, + }; + const wsName = set ? 'core_message_set_favourite_conversations' : 'core_message_unset_favourite_conversations'; + + await site.write(wsName, params); + + // Invalidate the conversations data. + const promises = conversations.map((conversationId) => this.invalidateConversation(conversationId, site.getId(), userId)); + + try { + await Promise.all(promises); + } catch { + // Ignore errors. + } + } + + /** + * Helper method to sort conversations by last message time. + * + * @param conversations Array of conversations. + * @return Conversations sorted with most recent last. + */ + sortConversations(conversations: AddonMessagesConversationFormatted[]): AddonMessagesConversationFormatted[] { + return conversations.sort((a, b) => { + const timeA = Number(a.lastmessagedate); + const timeB = Number(b.lastmessagedate); + + if (timeA == timeB && a.id) { + // Same time, sort by ID. + return a.id <= b.id ? 1 : -1; + } + + return timeA <= timeB ? 1 : -1; + }); + } + + /** + * Helper method to sort messages by time. + * + * @param messages Array of messages containing the key 'timecreated'. + * @return Messages sorted with most recent last. + */ + sortMessages(messages: AddonMessagesConversationMessageFormatted[]): AddonMessagesConversationMessageFormatted[]; + sortMessages( + messages: (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[], + ): (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[]; + sortMessages(messages: AddonMessagesOfflineAnyMessagesFormatted[]): AddonMessagesOfflineAnyMessagesFormatted[]; + sortMessages( + messages: (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[] | + AddonMessagesOfflineAnyMessagesFormatted[] | + AddonMessagesConversationMessageFormatted[], + ): (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[] | + AddonMessagesOfflineAnyMessagesFormatted[] | + AddonMessagesConversationMessageFormatted[] { + return messages.sort((a, b) => { + // Pending messages last. + if (a.pending && !b.pending) { + return 1; + } else if (!a.pending && b.pending) { + return -1; + } + + const timecreatedA = a.timecreated; + const timecreatedB = b.timecreated; + if (timecreatedA == timecreatedB && 'id' in a) { + const bId = 'id' in b ? b.id : 0; + + // Same time, sort by ID. + return a.id >= bId ? 1 : -1; + } + + return timecreatedA >= timecreatedB ? 1 : -1; + }); + } + + /** + * Store user data from contacts in local DB. + * + * @param contactTypes List of contacts grouped in types. + */ + protected storeUsersFromAllContacts(contactTypes: AddonMessagesGetContactsWSResponse): void { + for (const x in contactTypes) { + CoreUser.instance.storeUsers(contactTypes[x]); + } + } + + /** + * Store user data from discussions in local DB. + * + * @param discussions List of discussions. + * @param siteId Site ID. If not defined, current site. + */ + protected storeUsersFromDiscussions(discussions: { [userId: number]: AddonMessagesDiscussion }, siteId?: string): void { + const users: CoreUserBasicData[] = []; + + for (const userId in discussions) { + users.push({ + id: parseInt(userId, 10), + fullname: discussions[userId].fullname, + profileimageurl: discussions[userId].profileimageurl, + }); + } + CoreUser.instance.storeUsers(users, siteId); + } + + /** + * Unblock a user. + * + * @param userId User ID of the person to unblock. + * @param siteId Site ID. If not defined, use current site. + * @return Resolved when done. + */ + async unblockContact(userId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + try { + if (site.wsAvailable('core_message_unblock_user')) { + // Since Moodle 3.6 + const params: AddonMessagesUnblockUserWSParams = { + userid: site.getUserId(), + unblockeduserid: userId, + }; + await site.write('core_message_unblock_user', params); + } else { + const params: { userids: number[] } = { + userids: [userId], + }; + const preSets: CoreSiteWSPreSets = { + responseExpected: false, + }; + await site.write('core_message_unblock_contacts', params, preSets); + } + + await this.invalidateAllMemberInfo(userId, site); + } finally { + const data: AddonMessagesMemberInfoChangedEventData = { userId, userUnblocked: true }; + + CoreEvents.trigger(AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT, data, site.id); + } + } + +} + +export class AddonMessages extends makeSingleton(AddonMessagesProvider) {} + +/** + * Options to pass to getConversationMessages. + */ +export type AddonMessagesGetConversationMessagesOptions = { + excludePending?: boolean; // True to exclude messages pending to be sent. + limitFrom?: number; // Offset for messages list. Defaults to 0. + limitTo?: number; // Limit of messages. + newestFirst?: boolean; // Whether to order messages by newest first. + timeFrom?: number; // The timestamp from which the messages were created (in seconds). Defaults to 0. + siteId?: string; // Site ID. If not defined, use current site. + userId?: number; // User ID. If not defined, current user in the site. + forceCache?: boolean; // True if it should return cached data. Has priority over ignoreCache. + ignoreCache?: boolean; // True if it should ignore cached data (it will always fail in offline or server down). +}; + +/** + * Data returned by core_message_get_self_conversation WS. + */ +export type AddonMessagesConversation = { + id: number; // The conversation id. + name?: string; // The conversation name, if set. + subname?: string; // A subtitle for the conversation name, if set. + imageurl?: string; // A link to the conversation picture, if set. + type: number; // The type of the conversation (1=individual,2=group,3=self). + membercount: number; // Total number of conversation members. + ismuted: boolean; // If the user muted this conversation. + isfavourite: boolean; // If the user marked this conversation as a favourite. + isread: boolean; // If the user has read all messages in the conversation. + unreadcount?: number; // The number of unread messages in this conversation. + members: AddonMessagesConversationMember[]; + messages: AddonMessagesConversationMessage[]; + candeletemessagesforallusers: boolean; // @since 3.7. If the user can delete messages in the conversation for all users. +}; + +/** + * Params of core_message_get_conversation WS. + */ +type AddonMessagesGetConversationWSParams = { + userid: number; // The id of the user who we are viewing conversations for. + conversationid: number; // The id of the conversation to fetch. + includecontactrequests: boolean; // Include contact requests in the members. + includeprivacyinfo: boolean; // Include privacy info in the members. + memberlimit?: number; // Limit for number of members. + memberoffset?: number; // Offset for member list. + messagelimit?: number; // Limit for number of messages. + messageoffset?: number; // Offset for messages list. + newestmessagesfirst?: boolean; // Order messages by newest first. +}; + +/** + * Data returned by core_message_get_conversation WS. + */ +type AddonMessagesGetConversationWSResponse = AddonMessagesConversation; + +/** + * Params of core_message_get_self_conversation WS. + */ +type AddonMessagesGetSelfConversationWSParams = { + userid: number; // The id of the user who we are viewing self-conversations for. + messagelimit?: number; // Limit for number of messages. + messageoffset?: number; // Offset for messages list. + newestmessagesfirst?: boolean; // Order messages by newest first. +}; + +/** + * Conversation with some calculated data. + */ +export type AddonMessagesConversationFormatted = AddonMessagesConversation & { + lastmessage?: string; // Calculated in the app. Last message. + lastmessagedate?: number; // Calculated in the app. Date the last message was sent. + sentfromcurrentuser?: boolean; // Calculated in the app. Whether last message was sent by the current user. + name?: string; // Calculated in the app. If private conversation, name of the other user. + userid?: number; // Calculated in the app. URL. If private conversation, ID of the other user. + showonlinestatus?: boolean; // Calculated in the app. If private conversation, whether to show online status of the other user. + isonline?: boolean; // Calculated in the app. If private conversation, whether the other user is online. + isblocked?: boolean; // Calculated in the app. If private conversation, whether the other user is blocked. + otherUser?: AddonMessagesConversationMember; // Calculated in the app. Other user in the conversation. +}; + +/** + * Params of core_message_get_conversation_between_users WS. + */ +type AddonMessagesGetConversationBetweenUsersWSParams = { + userid: number; // The id of the user who we are viewing conversations for. + otheruserid: number; // The other user id. + includecontactrequests: boolean; // Include contact requests in the members. + includeprivacyinfo: boolean; // Include privacy info in the members. + memberlimit?: number; // Limit for number of members. + memberoffset?: number; // Offset for member list. + messagelimit?: number; // Limit for number of messages. + messageoffset?: number; // Offset for messages list. + newestmessagesfirst?: boolean; // Order messages by newest first. +}; + +/** + * Params of core_message_get_member_info WS. + */ +type AddonMessagesGetMemberInfoWSParams = { + referenceuserid: number; // Id of the user. + userids: number[]; + includecontactrequests?: boolean; // Include contact requests in response. + includeprivacyinfo?: boolean; // Include privacy info in response. +}; + +/** + * Params of core_message_get_conversation_members WS. + */ +type AddonMessagesGetConversationMembersWSParams = { + userid: number; // The id of the user we are performing this action on behalf of. + conversationid: number; // The id of the conversation. + includecontactrequests?: boolean; // Do we want to include contact requests?. + includeprivacyinfo?: boolean; // Do we want to include privacy info?. + limitfrom?: number; // Limit from. + limitnum?: number; // Limit number. +}; + +/** + * Conversation member returned by core_message_get_member_info and core_message_get_conversation_members WS. + */ +export type AddonMessagesConversationMember = { + id: number; // The user id. + fullname: string; // The user's name. + profileurl: string; // The link to the user's profile page. + profileimageurl: string; // User picture URL. + profileimageurlsmall: string; // Small user picture URL. + isonline: boolean; // The user's online status. + showonlinestatus: boolean; // Show the user's online status?. + isblocked: boolean; // If the user has been blocked. + iscontact: boolean; // Is the user a contact?. + isdeleted: boolean; // Is the user deleted?. + canmessageevenifblocked: boolean; // @since 3.8. If the user can still message even if they get blocked. + canmessage: boolean; // If the user can be messaged. + requirescontact: boolean; // If the user requires to be contacts. + contactrequests?: { // The contact requests. + id: number; // The id of the contact request. + userid: number; // The id of the user who created the contact request. + requesteduserid: number; // The id of the user confirming the request. + timecreated: number; // The timecreated timestamp for the contact request. + }[]; + conversations?: { // Conversations between users. + id: number; // Conversations id. + type: number; // Conversation type: private or public. + name: string; // Multilang compatible conversation name2. + timecreated: number; // The timecreated timestamp for the conversation. + }[]; +}; + +/** + * Conversation message. + */ +export type AddonMessagesConversationMessage = { + id: number; // The id of the message. + useridfrom: number; // The id of the user who sent the message. + text: string; // The text of the message. + timecreated: number; // The timecreated timestamp for the message. +}; + +/** + * Conversation message with some calculated data. + */ +export type AddonMessagesConversationMessageFormatted = + (AddonMessagesConversationMessage + | AddonMessagesGetMessagesMessage + | AddonMessagesOfflineMessagesDBRecordFormatted + | AddonMessagesOfflineConversationMessagesDBRecordFormatted) & { + pending?: boolean; // Calculated in the app. Whether the message is pending to be sent. + sending?: boolean; // Calculated in the app. Whether the message is being sent right now. + hash?: string; // Calculated in the app. A hash to identify the message. + showDate?: boolean; // Calculated in the app. Whether to show the date before the message. + showUserData?: boolean; // Calculated in the app. Whether to show the user data in the message. + showTail?: boolean; // Calculated in the app. Whether to show a "tail" in the message. + }; + + +/** + * Data returned by core_message_get_user_message_preferences WS. + */ +export type AddonMessagesGetUserMessagePreferencesWSResponse = { + preferences: AddonMessagesMessagePreferences; + blocknoncontacts: number; // Privacy messaging setting to define who can message you. + entertosend: boolean; // User preference for using enter to send messages. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Message preferences. + */ +export type AddonMessagesMessagePreferences = { + userid: number; // User id. + disableall: number; // Whether all the preferences are disabled. + processors: { // Config form values. + displayname: string; // Display name. + name: string; // Processor name. + hassettings: boolean; // Whether has settings. + contextid: number; // Context id. + userconfigured: number; // Whether is configured by the user. + }[]; + components: { // Available components. + displayname: string; // Display name. + notifications: AddonMessagesMessagePreferencesNotification[]; // List of notificaitons for the component. + }[]; +} & AddonMessagesMessagePreferencesCalculatedData; + +/** + * Notification processor in message preferences. + */ +export type AddonMessagesMessagePreferencesNotification = { + displayname: string; // Display name. + preferencekey: string; // Preference key. + processors: AddonMessagesMessagePreferencesNotificationProcessor[]; // Processors values for this notification. +}; + +/** + * Notification processor in message preferences. + */ +export type AddonMessagesMessagePreferencesNotificationProcessor = { + displayname: string; // Display name. + name: string; // Processor name. + locked: boolean; // Is locked by admin?. + lockedmessage?: string; // @since 3.6. Text to display if locked. + userconfigured: number; // Is configured?. + loggedin: { + name: string; // Name. + displayname: string; // Display name. + checked: boolean; // Is checked?. + }; + loggedoff: { + name: string; // Name. + displayname: string; // Display name. + checked: boolean; // Is checked?. + }; +}; + +/** + * Message discussion (before 3.6). + */ +export type AddonMessagesDiscussion = { + fullname: string; // Full name of the other user in the discussion. + profileimageurl?: string; // Profile image of the other user in the discussion. + message?: { // Last message. + id: number; // Message ID. + user: number; // User ID that sent the message. + message: string; // Text of the message. + timecreated: number; // Time the message was sent. + pending?: boolean; // Whether the message is pending to be sent. + }; + unread?: boolean; // Whether the discussion has unread messages. +}; + +/** + * Contact for message area. + */ +export type AddonMessagesMessageAreaContact = { + userid: number; // The user's id. + fullname: string; // The user's name. + profileimageurl: string; // User picture URL. + profileimageurlsmall: string; // Small user picture URL. + ismessaging: boolean; // If we are messaging the user. + sentfromcurrentuser: boolean; // Was the last message sent from the current user?. + lastmessage: string; // The user's last message. + lastmessagedate: number; // @since 3.6. Timestamp for last message. + messageid: number; // The unique search message id. + showonlinestatus: boolean; // Show the user's online status?. + isonline: boolean; // The user's online status. + isread: boolean; // If the user has read the message. + isblocked: boolean; // If the user has been blocked. + unreadcount: number; // The number of unread messages in this conversation. + conversationid: number; // @since 3.6. The id of the conversation. +} & AddonMessagesMessageAreaContactCalculatedData; + +/** + * Params of core_message_get_blocked_users WS. + */ +type AddonMessagesGetBlockedUsersWSParams = { + userid: number; // The user whose blocked users we want to retrieve. +}; + +/** + * Result of WS core_message_get_blocked_users. + */ +export type AddonMessagesGetBlockedUsersWSResponse = { + users: AddonMessagesBlockedUser[]; // List of blocked users. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * User data returned by core_message_get_blocked_users. + */ +export type AddonMessagesBlockedUser = { + id: number; // User ID. + fullname: string; // User full name. + profileimageurl?: string; // User picture URL. +}; + +/** + * Result of WS core_message_get_contacts. + */ +export type AddonMessagesGetContactsWSResponse = { + online: AddonMessagesGetContactsContact[]; // List of online contacts. + offline: AddonMessagesGetContactsContact[]; // List of offline contacts. + strangers: AddonMessagesGetContactsContact[]; // List of users that are not in the user's contact list but have sent a message. +} & AddonMessagesGetContactsCalculatedData; + +/** + * User data returned by core_message_get_contacts. + */ +export type AddonMessagesGetContactsContact = { + id: number; // User ID. + fullname: string; // User full name. + profileimageurl?: string; // User picture URL. + profileimageurlsmall?: string; // Small user picture URL. + unread: number; // Unread message count. +}; + +/** + * Params of core_message_search_contacts WS. + */ +type AddonMessagesSearchContactsWSParams = { + searchtext: string; // String the user's fullname has to match to be found. + onlymycourses?: boolean; // Limit search to the user's courses. +}; + +/** + * User data returned by core_message_search_contacts. + */ +export type AddonMessagesSearchContactsContact = { + id: number; // User ID. + fullname: string; // User full name. + profileimageurl?: string; // User picture URL. + profileimageurlsmall?: string; // Small user picture URL. +}; + +/** + * Params of core_message_get_conversation_messages WS. + */ +type AddonMessagesGetConversationMessagesWSParams = { + currentuserid: number; // The current user's id. + convid: number; // The conversation id. + limitfrom?: number; // Limit from. + limitnum?: number; // Limit number. + newest?: boolean; // Newest first?. + timefrom?: number; // The timestamp from which the messages were created. +}; + +/** + * Data returned by core_message_get_conversation_messages WS. + */ +type AddonMessagesGetConversationMessagesWSResponse = { + id: number; // The conversation id. + members: AddonMessagesConversationMember[]; + messages: AddonMessagesConversationMessage[]; +}; + +/** + * Result formatted of WS core_message_get_conversation_messages. + */ +export type AddonMessagesGetConversationMessagesResult = Omit & { + messages: (AddonMessagesConversationMessage | AddonMessagesOfflineConversationMessagesDBRecordFormatted)[]; +} & AddonMessagesGetConversationMessagesCalculatedData; + +/** + * Params of core_message_get_conversations WS. + */ +type AddonMessagesGetConversationsWSParams = { + userid: number; // The id of the user who we are viewing conversations for. + limitfrom?: number; // The offset to start at. + limitnum?: number; // Limit number of conversations to this. + type?: number; // Filter by type. + favourites?: boolean; // Whether to restrict the results to contain NO favourite conversations (false), ONLY favourite + // conversation(true), or ignore any restriction altogether(null). + mergeself?: boolean; // Whether to include self-conversations (true) or ONLY private conversations (false) when private + // conversations are requested. + +}; + +/** + * Result of WS core_message_get_conversations. + */ +export type AddonMessagesGetConversationsResult = { + conversations: AddonMessagesConversation[]; +}; + +/** + * Params of core_message_get_messages WS. + */ +export type AddonMessagesGetMessagesWSParams = { + useridto: number; // The user id who received the message, 0 for any user. + useridfrom?: number; // The user id who send the message, 0 for any user. -10 or -20 for no-reply or support user. + type?: string; // Type of message to return, expected values are: notifications, conversations and both. + read?: boolean; // True for getting read messages, false for unread. + newestfirst?: boolean; // True for ordering by newest first, false for oldest first. + limitfrom?: number; // Limit from. + limitnum?: number; // Limit number. +}; + +/** + * Result of WS core_message_get_messages. + */ +export type AddonMessagesGetMessagesResult = { + messages: AddonMessagesGetMessagesMessage[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Message data returned by core_message_get_messages. + */ +export type AddonMessagesGetMessagesMessage = { + id: number; // Message id. + useridfrom: number; // User from id. + useridto: number; // User to id. + subject: string; // The message subject. + text: string; // The message text formated. + fullmessage: string; // The message. + fullmessageformat: number; // Fullmessage format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + fullmessagehtml: string; // The message in html. + smallmessage: string; // The shorten message. + notification: number; // Is a notification?. + contexturl: string; // Context URL. + contexturlname: string; // Context URL link name. + timecreated: number; // Time created. + timeread: number; // Time read. + usertofullname: string; // User to full name. + userfromfullname: string; // User from full name. + component?: string; // @since 3.7. The component that generated the notification. + eventtype?: string; // @since 3.7. The type of notification. + customdata?: string; // @since 3.7. Custom data to be passed to the message processor. +} & AddonMessagesGetMessagesMessageCalculatedData; + +/** + * Response object on get discussion. + */ +export type AddonMessagesGetDiscussionMessages = { + messages: (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[]; + canLoadMore: boolean; +}; + +/** + * Params of core_message_data_for_messagearea_search_messages WS. + */ +type AddonMessagesDataForMessageareaSearchMessagesWSParams = { + userid: number; // The id of the user who is performing the search. + search: string; // The string being searched. + limitfrom?: number; // Limit from. + limitnum?: number; // Limit number. +}; + +/** + * Result of WS core_message_data_for_messagearea_search_messages. + */ +export type AddonMessagesDataForMessageareaSearchMessagesWSResponse = { + contacts: AddonMessagesMessageAreaContact[]; +}; + +/** + * Params of core_message_message_search_users WS. + */ +type AddonMessagesMessageSearchUsersWSParams = { + userid: number; // The id of the user who is performing the search. + search: string; // The string being searched. + limitfrom?: number; // Limit from. + limitnum?: number; // Limit number. +}; + +/** + * Result of WS core_message_message_search_users. + */ +export type AddonMessagesSearchUsersWSResponse = { + contacts: AddonMessagesConversationMember[]; + noncontacts: AddonMessagesConversationMember[]; +}; + +/** + * Params of core_message_mark_message_read WS. + */ +type AddonMessagesMarkMessageReadWSParams = { + messageid: number; // Id of the message in the messages table. + timeread?: number; // Timestamp for when the message should be marked read. +}; + +/** + * Result of WS core_message_mark_message_read. + */ +export type AddonMessagesMarkMessageReadResult = { + messageid: number; // The id of the message in the messages table. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Result of WS core_message_send_instant_messages. + */ +export type AddonMessagesSendInstantMessagesMessage = { + msgid: number; // Test this to know if it succeeds: id of the created message if it succeeded, -1 when failed. + clientmsgid?: string; // Your own id for the message. + errormessage?: string; // Error message - if it failed. + text?: string; // @since 3.6. The text of the message. + timecreated?: number; // @since 3.6. The timecreated timestamp for the message. + conversationid?: number; // @since 3.6. The conversation id for this message. + useridfrom?: number; // @since 3.6. The user id who sent the message. + candeletemessagesforallusers: boolean; // @since 3.7. If the user can delete messages in the conversation for all users. +}; + +export type CoreMessageSendMessagesToConversationMessageData ={ + text: string; // The text of the message. + textformat?: number; // Text format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). +}; + +/** + * Params of core_message_send_messages_to_conversation WS. + */ +type CoreMessageSendMessagesToConversationWSParams = { + conversationid: number; // Id of the conversation. + messages: CoreMessageSendMessagesToConversationMessageData[]; +}; + +/** + * Result of WS core_message_send_messages_to_conversation. + */ +export type AddonMessagesSendMessagesToConversationMessage = { + id: number; // The id of the message. + useridfrom: number; // The id of the user who sent the message. + text: string; // The text of the message. + timecreated: number; // The timecreated timestamp for the message. +}; + +/** + * Result for Send Messages functions trying online or storing in offline. + */ +export type AddonMessagesSendMessageResults = { + sent: boolean; + message: AddonMessagesSendMessagesToConversationMessage | AddonMessagesSendInstantMessagesMessage; +}; + +/** + * Calculated data for core_message_get_contacts. + */ +export type AddonMessagesGetContactsCalculatedData = { + blocked?: AddonMessagesBlockedUser[]; // Calculated in the app. List of blocked users. +}; + +/** + * Calculated data for core_message_get_conversation_messages. + */ +export type AddonMessagesGetConversationMessagesCalculatedData = { + canLoadMore?: boolean; // Calculated in the app. Whether more messages can be loaded. +}; + +/** + * Calculated data for message preferences. + */ +export type AddonMessagesMessagePreferencesCalculatedData = { + blocknoncontacts?: number; // Calculated in the app. Based on the result of core_message_get_user_message_preferences. +}; + +/** + * Calculated data for messages returned by core_message_get_messages. + */ +export type AddonMessagesGetMessagesMessageCalculatedData = { + pending?: boolean; // Calculated in the app. Whether the message is pending to be sent. + read?: boolean; // Calculated in the app. Whether the message has been read. +}; + +/** + * Calculated data for contact for message area. + */ +export type AddonMessagesMessageAreaContactCalculatedData = { + id?: number; // Calculated in the app. User ID. +}; + +/** + * Params of core_message_block_user WS. + */ +type AddonMessagesBlockUserWSParams = { + userid: number; // The id of the user who is blocking. + blockeduserid: number; // The id of the user being blocked. +}; + +/** + * Params of core_message_unblock_user WS. + */ +type AddonMessagesUnblockUserWSParams = { + userid: number; // The id of the user who is unblocking. + unblockeduserid: number; // The id of the user being unblocked. +}; + +/** + * Params of core_message_confirm_contact_request WS. + */ +type AddonMessagesConfirmContactRequestWSParams = { + userid: number; // The id of the user making the request. + requesteduserid: number; // The id of the user being requested. +}; + +/** + * Params of core_message_create_contact_request WS. + */ +type AddonMessagesCreateContactRequestWSParams = AddonMessagesConfirmContactRequestWSParams; + +/** + * Params of core_message_decline_contact_request WS. + */ +type AddonMessagesDeclineContactRequestWSParams = AddonMessagesConfirmContactRequestWSParams; + +/** + * Params of core_message_delete_conversations_by_id WS. + */ +type AddonMessagesDeleteConversationsByIdWSParams = { + userid: number; // The user id of who we want to delete the conversation for. + conversationids: number[]; // List of conversation IDs. +}; + +/** + * Params of core_message_delete_message WS. + */ +type AddonMessagesDeleteMessageWSParams = { + messageid: number; // The message id. + userid: number; // The user id of who we want to delete the message for. + read?: boolean; // If is a message read. +}; + +/** + * Params of core_message_delete_message_for_all_users WS. + */ +type AddonMessagesDeleteMessageForAllUsersWSParams = { + messageid: number; // The message id. + userid: number; // The user id of who we want to delete the message for all users. +}; + +/** + * Params of core_message_get_user_contacts WS. + */ +type AddonMessagesGetUserContactsWSParams = { + userid: number; // The id of the user who we retrieving the contacts for. + limitfrom?: number; // Limit from. + limitnum?: number; // Limit number. +}; + +/** + * Data returned by core_message_get_user_contacts WS. + */ +export type AddonMessagesGetUserContactsWSResponse = { + id: number; // The user id. + fullname: string; // The user's name. + profileurl: string; // The link to the user's profile page. + profileimageurl: string; // User picture URL. + profileimageurlsmall: string; // Small user picture URL. + isonline: boolean; // The user's online status. + showonlinestatus: boolean; // Show the user's online status?. + isblocked: boolean; // If the user has been blocked. + iscontact: boolean; // Is the user a contact?. + isdeleted: boolean; // Is the user deleted?. + canmessageevenifblocked: boolean; // If the user can still message even if they get blocked. + canmessage: boolean; // If the user can be messaged. + requirescontact: boolean; // If the user requires to be contacts. + contactrequests?: { // The contact requests. + id: number; // The id of the contact request. + userid: number; // The id of the user who created the contact request. + requesteduserid: number; // The id of the user confirming the request. + timecreated: number; // The timecreated timestamp for the contact request. + }[]; + conversations?: { // Conversations between users. + id: number; // Conversations id. + type: number; // Conversation type: private or public. + name: string; // Multilang compatible conversation name2. + timecreated: number; // The timecreated timestamp for the conversation. + }[]; +}[]; + + +/** + * Params of core_message_get_contact_requests WS. + */ +type AddonMessagesGetContactRequestsWSParams = { + userid: number; // The id of the user we want the requests for. + limitfrom?: number; // Limit from. + limitnum?: number; // Limit number. +}; + +/** + * Data returned by core_message_get_contact_requests WS. + */ +export type AddonMessagesGetContactRequestsWSResponse = { + id: number; // The user id. + fullname: string; // The user's name. + profileurl: string; // The link to the user's profile page. + profileimageurl: string; // User picture URL. + profileimageurlsmall: string; // Small user picture URL. + isonline: boolean; // The user's online status. + showonlinestatus: boolean; // Show the user's online status?. + isblocked: boolean; // If the user has been blocked. + iscontact: boolean; // Is the user a contact?. + isdeleted: boolean; // Is the user deleted?. + canmessageevenifblocked: boolean; // If the user can still message even if they get blocked. + canmessage: boolean; // If the user can be messaged. + requirescontact: boolean; // If the user requires to be contacts. + contactrequests?: { // The contact requests. + id: number; // The id of the contact request. + userid: number; // The id of the user who created the contact request. + requesteduserid: number; // The id of the user confirming the request. + timecreated: number; // The timecreated timestamp for the contact request. + }[]; + conversations?: { // Conversations between users. + id: number; // Conversations id. + type: number; // Conversation type: private or public. + name: string; // Multilang compatible conversation name2. + timecreated: number; // The timecreated timestamp for the conversation. + }[]; +}[]; + +/** + * Params of core_message_get_received_contact_requests_count WS. + */ +type AddonMessagesGetReceivedContactRequestsCountWSParams = { + userid: number; // The id of the user we want to return the number of received contact requests for. +}; + +/** + * Params of core_message_mark_all_conversation_messages_as_read WS. + */ +type AddonMessagesMarkAllConversationMessagesAsReadWSParams = { + userid: number; // The user id who who we are marking the messages as read for. + conversationid: number; // The conversation id who who we are marking the messages as read for. +}; + +/** + * Params of core_message_mark_all_messages_as_read WS. Deprecated on Moodle 3.6 + */ +type AddonMessagesMarkAllMessagesAsReadWSParams = { + useridto: number; // The user id who received the message, 0 for any user. + useridfrom?: number; // The user id who send the message, 0 for any user. -10 or -20 for no-reply or support user. +}; + +/** + * Params of core_message_mute_conversations and core_message_unmute_conversations WS. + */ +type AddonMessagesMuteConversationsWSParams = { + userid: number; // The id of the user who is blocking. + conversationids: number[]; +}; + +/** + * Params of core_message_delete_contacts WS. + */ +type AddonMessagesDeleteContactsWSParams = { + userids: number[]; // List of user IDs. + userid?: number; // The id of the user we are deleting the contacts for, 0 for the current user. + +}; + +/** + * One message data. + */ +export type AddonMessagesMessageData = { + touserid: number; // Id of the user to send the private message. + text: string; // The text of the message. + textformat?: number; // Text format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + clientmsgid?: string; // Your own client id for the message. If this id is provided, the fail message id will be returned. +}; + +/** + * Params of core_message_send_instant_messages WS. + */ +type AddonMessagesSendInstantMessagesWSParams = { + messages: AddonMessagesMessageData[]; +}; + +/** + * Data returned by core_message_get_conversation_counts and core_message_get_unread_conversation_counts WS. + */ +export type AddonMessagesGetConversationCountsWSResponse = { + favourites: number; // Total number of favourite conversations. + types: { + 1: number; // Total number of individual conversations. + 2: number; // Total number of group conversations. + 3: number; // Total number of self conversations. + }; +}; + +/** + * Params of core_message_set_favourite_conversations and core_message_unset_favourite_conversations WS. + */ +type AddonMessagesSetFavouriteConversationsWSParams = { + userid?: number; // Id of the user, 0 for current user. + conversations: number[]; +}; + +/** + * Data sent by UNREAD_CONVERSATION_COUNTS_EVENT event. + */ +export type AddonMessagesUnreadConversationCountsEventData = { + favourites: number; + individual: number; + group: number; + self: number; + orMore?: boolean; +}; + +/** + * Data sent by CONTACT_REQUESTS_COUNT_EVENT event. + */ +export type AddonMessagesContactRequestCountEventData = { + count: number; +}; + +/** + * Data sent by MEMBER_INFO_CHANGED_EVENT event. + */ +export type AddonMessagesMemberInfoChangedEventData = { + userId: number; + userBlocked?: boolean; + userUnblocked?: boolean; + contactRequestConfirmed?: boolean; + contactRequestCreated?: boolean; + contactRequestDeclined?: boolean; + contactRemoved?: boolean; +}; + +/** + * Data sent by READ_CHANGED_EVENT event. + */ +export type AddonMessagesReadChangedEventData = { + userId?: number; + conversationId?: number; +}; + +/** + * Data sent by NEW_MESSAGE_EVENT event. + */ +export type AddonMessagesNewMessagedEventData = { + conversationId?: number; + userId?: number; + message: string; + timecreated: number; + isfavourite: boolean; + type?: number; +}; + +/** + * Data sent by UPDATE_CONVERSATION_LIST_EVENT event. + */ +export type AddonMessagesUpdateConversationListEventData = { + conversationId: number; + action: string; + value?: boolean; +}; + +/** + * Data sent by OPEN_CONVERSATION_EVENT event. + */ +export type AddonMessagesOpenConversationEventData = { + userId?: number; + conversationId?: number; +}; diff --git a/src/addons/notifications/pages/settings/settings.html b/src/addons/notifications/pages/settings/settings.html index 4209719f3..6283ec6e6 100644 --- a/src/addons/notifications/pages/settings/settings.html +++ b/src/addons/notifications/pages/settings/settings.html @@ -73,7 +73,8 @@ !(notification.processorsByName[currentProcessor!.name][state] && notification.processorsByName[currentProcessor!.name][state].updating)"> - @@ -95,15 +96,19 @@ {{ 'core.settings.' + state | translate }} - - - + {{'core.settings.locked' | translate }} {{ 'core.settings.disabled' | translate }} @@ -112,4 +117,4 @@ - \ No newline at end of file + diff --git a/src/addons/notifications/services/handlers/mainmenu.ts b/src/addons/notifications/services/handlers/mainmenu.ts index ee646c8c2..8ece315fe 100644 --- a/src/addons/notifications/services/handlers/mainmenu.ts +++ b/src/addons/notifications/services/handlers/mainmenu.ts @@ -22,6 +22,7 @@ import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '@features/mainmenu import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications'; import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate'; import { AddonNotifications, AddonNotificationsProvider } from '../notifications'; +import { AddonMessagesReadChangedEventData } from '@addons/messages/services/messages'; /** * Handler to inject an option into main menu. @@ -48,7 +49,7 @@ export class AddonNotificationsMainMenuHandlerService implements CoreMainMenuHan * Initialize the handler. */ initialize(): void { - CoreEvents.on(AddonNotificationsProvider.READ_CHANGED_EVENT, (data: CoreEventSiteData) => { + CoreEvents.on(AddonNotificationsProvider.READ_CHANGED_EVENT, (data) => { this.updateBadge(data.siteId); }); diff --git a/src/addons/notifications/services/notifications.ts b/src/addons/notifications/services/notifications.ts index 987259289..7a942f73e 100644 --- a/src/addons/notifications/services/notifications.ts +++ b/src/addons/notifications/services/notifications.ts @@ -19,7 +19,7 @@ import { CoreWSExternalWarning } from '@services/ws'; import { CoreTextUtils } from '@services/utils/text'; import { CoreTimeUtils } from '@services/utils/time'; import { CoreUser } from '@features/user/services/user'; -// @todo import { AddonMessages, AddonMessagesMarkMessageReadResult } from '@addon/messages/services/messages'; +import { AddonMessages, AddonMessagesMarkMessageReadResult } from '@addons/messages/services/messages'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreLogger } from '@singletons/logger'; import { makeSingleton } from '@singletons'; @@ -350,7 +350,7 @@ export class AddonNotificationsProvider { async markNotificationRead( notificationId: number, siteId?: string, - ): Promise { // @todo | AddonMessagesMarkMessageReadResult + ): Promise { const site = await CoreSites.instance.getSite(siteId); @@ -363,7 +363,7 @@ export class AddonNotificationsProvider { return site.write('core_message_mark_notification_read', params); } else { // Fallback for versions prior to 3.5. - // @todo return AddonMessageProvider.instance.markMessageRead(notificationId, site.id); + return AddonMessages.instance.markMessageRead(notificationId, site.id); } } diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 7a8169648..0155f8833 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -16,6 +16,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { IonicModule } from '@ionic/angular'; import { TranslateModule } from '@ngx-translate/core'; +import { FormsModule } from '@angular/forms'; import { CoreChronoComponent } from './chrono/chrono'; import { CoreDownloadRefreshComponent } from './download-refresh/download-refresh'; @@ -38,10 +39,11 @@ import { CoreContextMenuItemComponent } from './context-menu/context-menu-item'; import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-popover'; import { CoreUserAvatarComponent } from './user-avatar/user-avatar'; import { CoreDynamicComponent } from './dynamic-component/dynamic-component'; +import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; +import { CoreSendMessageFormComponent } from './send-message-form/send-message-form'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CorePipesModule } from '@pipes/pipes.module'; -import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; @NgModule({ declarations: [ @@ -67,10 +69,12 @@ import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; CoreNavBarButtonsComponent, CoreUserAvatarComponent, CoreDynamicComponent, + CoreSendMessageFormComponent, ], imports: [ CommonModule, IonicModule.forRoot(), + FormsModule, TranslateModule.forChild(), CoreDirectivesModule, CorePipesModule, @@ -98,6 +102,7 @@ import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; CoreNavBarButtonsComponent, CoreUserAvatarComponent, CoreDynamicComponent, + CoreSendMessageFormComponent, ], }) export class CoreComponentsModule {} diff --git a/src/core/components/send-message-form/core-send-message-form.html b/src/core/components/send-message-form/core-send-message-form.html new file mode 100644 index 000000000..118b9daa2 --- /dev/null +++ b/src/core/components/send-message-form/core-send-message-form.html @@ -0,0 +1,13 @@ +
+ + + + + + +
+ diff --git a/src/core/components/send-message-form/send-message-form.scss b/src/core/components/send-message-form/send-message-form.scss new file mode 100644 index 000000000..0faebb6ee --- /dev/null +++ b/src/core/components/send-message-form/send-message-form.scss @@ -0,0 +1,33 @@ +:host { + background: var(--white); + + form { + position: relative; + display: flex; + align-items: center; + width: 100%; + margin-top: 5px; + margin-bottom: 5px; + } + + .core-send-message-input { + appearance: none; + display: block; + width: 100%; + min-height: 28px; + border: 0; + font-family: inherit; + background: var(--core-send-message-input-background); + color: var(--core-send-message-input-color); + border-radius: 5px; + margin: 0 5px; + } + + .core-send-message-button { + margin: 0; + padding: 0; + display: none; + min-height: 0; + align-self: self-end; + } +} diff --git a/src/core/components/send-message-form/send-message-form.ts b/src/core/components/send-message-form/send-message-form.ts new file mode 100644 index 000000000..06ce5ffe3 --- /dev/null +++ b/src/core/components/send-message-form/send-message-form.ts @@ -0,0 +1,126 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, Output, EventEmitter, OnInit, ViewChild, ElementRef } from '@angular/core'; +import { CoreApp } from '@services/app'; +import { CoreConfig } from '@services/config'; +import { CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreConstants } from '@/core/constants'; + +/** + * Component to display a "send message form". + * + * @description + * This component will display a standalone send message form in order to have a better UX. + * + * Example usage: + * + */ +@Component({ + selector: 'core-send-message-form', + templateUrl: 'core-send-message-form.html', + styleUrls: ['send-message-form.scss'], +}) +export class CoreSendMessageFormComponent implements OnInit { + + @Input() message = ''; // Input text. + @Input() placeholder = ''; // Placeholder for the input area. + @Input() showKeyboard = false; // If keyboard is shown or not. + @Input() sendDisabled = false; // If send is disabled. + @Output() onSubmit: EventEmitter; // Send data when submitting the message form. + @Output() onResize: EventEmitter; // Emit when resizing the textarea. + + @ViewChild('messageForm') formElement!: ElementRef; + + protected sendOnEnter = false; + + constructor() { + + this.onSubmit = new EventEmitter(); + this.onResize = new EventEmitter(); + + CoreConfig.instance.get(CoreConstants.SETTINGS_SEND_ON_ENTER, !CoreApp.instance.isMobile()).then((sendOnEnter) => { + this.sendOnEnter = !!sendOnEnter; + + return; + }).catch(() => { + // Nothing to do. + }); + + CoreEvents.on(CoreEvents.SEND_ON_ENTER_CHANGED, (newValue) => { + this.sendOnEnter = newValue; + }, CoreSites.instance.getCurrentSiteId()); + } + + ngOnInit(): void { + this.showKeyboard = CoreUtils.instance.isTrueOrOne(this.showKeyboard); + } + + /** + * Form submitted. + * + * @param $event Mouse event. + */ + submitForm($event: Event): void { + $event.preventDefault(); + $event.stopPropagation(); + + let value = this.message.trim(); + + if (!value) { + // Silent error. + return; + } + + this.message = ''; // Reset the form. + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId()); + + value = CoreTextUtils.instance.replaceNewLines(value, '
'); + this.onSubmit.emit(value); + } + + /** + * Textarea resized. + */ + textareaResized(): void { + this.onResize.emit(); + } + + /** + * Enter key clicked. + * + * @param e Event. + * @param other The name of the other key that was clicked, undefined if no other key. + */ + enterClicked(e: Event, other?: string): void { + if (this.sendDisabled) { + return; + } + + if (this.sendOnEnter && !other) { + // Enter clicked, send the message. + this.submitForm(e); + } else if (!this.sendOnEnter && !CoreApp.instance.isMobile() && other == 'control') { + // Cmd+Enter or Ctrl+Enter, send message. + this.submitForm(e); + } + } + +} diff --git a/src/core/components/split-view/split-view.html b/src/core/components/split-view/split-view.html index a585f4b0a..569af4224 100644 --- a/src/core/components/split-view/split-view.html +++ b/src/core/components/split-view/split-view.html @@ -1,5 +1,7 @@ - - + + + diff --git a/src/core/components/split-view/split-view.scss b/src/core/components/split-view/split-view.scss index d94a6efcc..13f0c2db4 100644 --- a/src/core/components/split-view/split-view.scss +++ b/src/core/components/split-view/split-view.scss @@ -1,11 +1,13 @@ -// @todo RTL layout - :host { --menu-min-width: 270px; --menu-max-width: 28%; + --menu-box-shadow: var(--core-menu-box-shadow-end); + --menu-z: 2; + --menu-border-width: 1; --menu-display: flex; --content-display: block; - --border-width: 1; + --content-outlet-display: none; + --content-placeholder-display: var(--content-display); top: 0; right: 0; @@ -17,58 +19,74 @@ flex-direction: row; flex-wrap: nowrap; contain: strict; + + .menu, + .content-outlet { + top: 0; + right: 0; + bottom: 0; + left: 0; + position: relative; + box-shadow: none; + z-index: 0; + } + + .menu { + box-shadow: var(--menu-box-shadow); + z-index: var(--menu-z); + display: var(--menu-display); + flex-shrink: 0; + order: -1; + width: 100%; + border-inline-start: 0; + border-inline-end: var(--border); + min-width: var(--menu-min-width); + max-width: var(--menu-max-width); + } + + .content-outlet { + display: var(--content-outlet-display); + flex: 1; + + ::ng-deep ion-header { + display: none; + } + + } + + .content-placeholder { + display: var(--content-placeholder-display); + flex: 1; + position: relative; + background-color: var(--ion-background); + } + } :host(.menu-only) { --menu-min-width: 0; --menu-max-width: 100%; --content-display: none; - --border-width: 0; + --menu-border-width: 0; + --menu-box-shadow: none; + --menu-z: 0; + --selected-item-border-width: 0; } :host(.content-only) { --menu-display: none; - --border-width: 0; + --menu-border-width: 0; +} + +:host(.outlet-activated) { + --content-placeholder-display: none; + --content-outlet-display: var(--content-display); } :host-context(ion-app.md) { - --border: calc(var(--border-width) * 1px) solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, .13)))); + --border: calc(var(--menu-border-width) * 1px) solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, .13)))); } :host-context(ion-app.ios) { - --border: calc(var(--border-width) * .55px) solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-250, #c8c7cc))); -} - -.menu, -.content { - top: 0; - right: 0; - bottom: 0; - left: 0; - position: relative; - box-shadow: none !important; - z-index: 0; -} - -.menu { - display: var(--menu-display); - flex-shrink: 0; - order: -1; - border-left: unset; - border-right: unset; - border-inline-start: 0; - border-inline-end: var(--border); - min-width: var(--menu-min-width); - max-width: var(--menu-max-width); - width: 100%; -} - -.content { - display: var(--content-display); - flex: 1; - - ::ng-deep ion-header { - display: none; - } - + --border: calc(var(--menu-border-width) * .55px) solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-250, #c8c7cc))); } diff --git a/src/core/components/split-view/split-view.ts b/src/core/components/split-view/split-view.ts index 22d8b1e98..c9ab27d97 100644 --- a/src/core/components/split-view/split-view.ts +++ b/src/core/components/split-view/split-view.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { AfterViewInit, Component, ElementRef, HostBinding, OnDestroy, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, HostBinding, Input, OnDestroy, ViewChild } from '@angular/core'; import { IonRouterOutlet } from '@ionic/angular'; import { CoreScreen } from '@services/screen'; import { Subscription } from 'rxjs'; @@ -32,6 +32,7 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy { @ViewChild(IonRouterOutlet) outlet!: IonRouterOutlet; @HostBinding('class') classes = ''; + @Input() placeholderText = 'core.emptysplit'; isNested = false; private subscriptions?: Subscription[]; @@ -65,6 +66,10 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy { private updateClasses(): void { const classes: string[] = [this.getCurrentMode()]; + if (this.outlet.isActivated) { + classes.push('outlet-activated'); + } + if (this.isNested) { classes.push('nested'); } @@ -92,4 +97,13 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy { return CoreSplitViewMode.MenuAndContent; } + /** + * Check if both panels are shown. It depends on screen width. + * + * @return If split view is enabled. + */ + isOn(): boolean { + return this.outlet.isActivated; + } + } diff --git a/src/core/components/user-avatar/user-avatar.scss b/src/core/components/user-avatar/user-avatar.scss index 94b738f0e..ae730b0e0 100644 --- a/src/core/components/user-avatar/user-avatar.scss +++ b/src/core/components/user-avatar/user-avatar.scss @@ -5,6 +5,33 @@ border-radius: 50%; width: var(--core-avatar-size); height: var(--core-avatar-size); + max-width: var(--core-avatar-size); + max-height: var(--core-avatar-size); + } + img[core-external-content]:not([src]), + img[core-external-content][src=""] { + visibility: visible; + display: inline-block; + position: relative; + &:after { + border-radius: 50%; + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: url('/assets/img/user-avatar.png'); + background-size: contain; + content: ""; + } + } + &.core-bar-button-image img { + padding: 0; + width: var(--core-toolbar-button-image-width); + height: var(--core-toolbar-button-image-width); + max-width: var(--core-toolbar-button-image-width); + border-radius: 50%; } .contact-status { diff --git a/src/core/directives/auto-rows.ts b/src/core/directives/auto-rows.ts new file mode 100644 index 000000000..05d4f12bc --- /dev/null +++ b/src/core/directives/auto-rows.ts @@ -0,0 +1,79 @@ +// (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 { Directive, ElementRef, HostListener, Output, EventEmitter, AfterViewInit } from '@angular/core'; + +/** + * Directive to adapt a textarea rows depending on the input text. It's based on Moodle's data-auto-rows. + * + * @description + * Usage: + * + */ +@Directive({ + selector: 'textarea[core-auto-rows], ion-textarea[core-auto-rows]', +}) +export class CoreAutoRowsDirective implements AfterViewInit { + + protected height = 0; + + @Output() onResize: EventEmitter; // Emit when resizing the textarea. + + constructor(protected element: ElementRef) { + this.onResize = new EventEmitter(); + } + + @HostListener('input') onInput(): void { + this.resize(); + } + + @HostListener('change') onChange(): void { + // Fired on reset. Wait to the change to be finished. + setTimeout(() => { + this.resize(); + }, 300); + } + + /** + * Resize after content. + */ + ngAfterViewInit(): void { + // Wait for rendering of child views. + setTimeout(() => { + this.resize(); + }, 300); + } + + /** + * Resize the textarea. + */ + protected resize(): void { + let nativeElement = this.element.nativeElement; + if (nativeElement.tagName == 'ION-TEXTAREA') { + // The first child of ion-textarea is the actual textarea element. + nativeElement = nativeElement.firstElementChild; + } + + // Set height to 1px to force scroll height to calculate correctly. + nativeElement.style.height = '1px'; + nativeElement.style.height = nativeElement.scrollHeight + 'px'; + + // Emit event when resizing. + if (this.height != nativeElement.scrollHeight) { + this.height = nativeElement.scrollHeight; + this.onResize.emit(); + } + } + +} diff --git a/src/core/directives/directives.module.ts b/src/core/directives/directives.module.ts index 06e4c6fa5..494c1e1d2 100644 --- a/src/core/directives/directives.module.ts +++ b/src/core/directives/directives.module.ts @@ -23,6 +23,7 @@ import { CoreLongPressDirective } from './long-press'; import { CoreSupressEventsDirective } from './supress-events'; import { CoreFaIconDirective } from './fa-icon'; import { CoreUserLinkDirective } from './user-link'; +import { CoreAutoRowsDirective } from './auto-rows'; @NgModule({ declarations: [ @@ -35,6 +36,7 @@ import { CoreUserLinkDirective } from './user-link'; CoreFabDirective, CoreFaIconDirective, CoreUserLinkDirective, + CoreAutoRowsDirective, ], imports: [], exports: [ @@ -47,6 +49,7 @@ import { CoreUserLinkDirective } from './user-link'; CoreFabDirective, CoreFaIconDirective, CoreUserLinkDirective, + CoreAutoRowsDirective, ], }) export class CoreDirectivesModule {} diff --git a/src/core/features/block/components/course-blocks/core-block-course-blocks.html b/src/core/features/block/components/course-blocks/core-block-course-blocks.html index 3743bb42d..3eaacf421 100644 --- a/src/core/features/block/components/course-blocks/core-block-course-blocks.html +++ b/src/core/features/block/components/course-blocks/core-block-course-blocks.html @@ -8,7 +8,7 @@ + [extraData]="{'downloadEnabled': downloadEnabled}"> diff --git a/src/core/features/block/components/course-blocks/course-blocks.scss b/src/core/features/block/components/course-blocks/course-blocks.scss index 04f290e4f..314fa25ff 100644 --- a/src/core/features/block/components/course-blocks/course-blocks.scss +++ b/src/core/features/block/components/course-blocks/course-blocks.scss @@ -1,4 +1,6 @@ :host { + --side-blocks-box-shadow: var(--core-menu-box-shadow-start); + &.core-no-blocks .core-course-blocks-content { height: auto; } @@ -20,7 +22,7 @@ div.core-course-blocks-side { max-width: var(--side-blocks-max-width); min-width: var(--side-blocks-min-width); - box-shadow: -4px 0px 16px rgba(0, 0, 0, 0.18); + box-shadow: var(--side-blocks-box-shadow); z-index: 2; // @todo @include core-split-area-end(); } @@ -53,7 +55,7 @@ :host-context([dir="rtl"]).core-has-blocks { @media (min-width: 768px) { div.core-course-blocks-side { - box-shadow: 4px 0px 16px rgba(0, 0, 0, 0.18); + box-shadow: var(--side-blocks-box-shadow); } } } diff --git a/src/core/features/course/components/module/core-course-module.html b/src/core/features/course/components/module/core-course-module.html index 28ebf7165..cab6e929c 100644 --- a/src/core/features/course/components/module/core-course-module.html +++ b/src/core/features/course/components/module/core-course-module.html @@ -22,8 +22,9 @@
- diff --git a/src/core/features/courses/pages/categories/categories.html b/src/core/features/courses/pages/categories/categories.html index b9b5aa421..8015b9cd0 100644 --- a/src/core/features/courses/pages/categories/categories.html +++ b/src/core/features/courses/pages/categories/categories.html @@ -39,7 +39,8 @@
- @@ -61,7 +62,8 @@
- + diff --git a/src/core/features/courses/pages/course-preview/course-preview.html b/src/core/features/courses/pages/course-preview/course-preview.html index 4f2a6a09a..2b6c3bcbf 100644 --- a/src/core/features/courses/pages/course-preview/course-preview.html +++ b/src/core/features/courses/pages/course-preview/course-preview.html @@ -3,7 +3,9 @@ - + + + @@ -17,10 +19,14 @@
- + -

+

+ + +

diff --git a/src/core/features/grades/pages/course/course.scss b/src/core/features/grades/pages/course/course.scss index fdad1dc2b..107b3972b 100644 --- a/src/core/features/grades/pages/course/course.scss +++ b/src/core/features/grades/pages/course/course.scss @@ -1,4 +1,4 @@ -@import "~theme/breakpoints"; +@import "~theme/globals"; // @todo darkmode // @todo RTL layout diff --git a/src/core/features/grades/pages/grade/grade.html b/src/core/features/grades/pages/grade/grade.html index 0e81eec52..3e9993a8a 100644 --- a/src/core/features/grades/pages/grade/grade.html +++ b/src/core/features/grades/pages/grade/grade.html @@ -14,11 +14,13 @@ - + -

+

+

@@ -26,7 +28,8 @@ -

+

+

@@ -82,7 +85,8 @@

{{ 'core.grades.feedback' | translate}}

-

+

diff --git a/src/core/features/login/components/site-help/site-help.html b/src/core/features/login/components/site-help/site-help.html index 6f01bc259..c4784e891 100644 --- a/src/core/features/login/components/site-help/site-help.html +++ b/src/core/features/login/components/site-help/site-help.html @@ -67,7 +67,7 @@

{{ 'core.login.faqwhereisqrcode' | translate }}

- +