From f9b6a66e75ec41dcfaa73d1362fc03bff1bc6d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Mon, 18 Jan 2021 16:40:34 +0100 Subject: [PATCH] MOBILE-3631 messages: Messages main page and basic services --- src/addons/addons.module.ts | 2 + src/addons/messages/lang.json | 84 + src/addons/messages/messages-common.scss | 66 + src/addons/messages/messages-lazy.module.ts | 63 + src/addons/messages/messages.module.ts | 129 + .../messages/pages/contacts-35/contacts.html | 35 + .../pages/contacts-35/contacts.module.ts | 47 + .../pages/contacts-35/contacts.page.ts | 264 ++ .../pages/discussions-35/discussions.html | 56 + .../discussions-35/discussions.module.ts | 47 + .../pages/discussions-35/discussions.page.ts | 284 ++ .../group-conversations.html | 127 + .../group-conversations.module.ts | 45 + .../group-conversations.page.ts | 810 ++++ src/addons/messages/pages/index-35/index.html | 16 + .../messages/pages/index-35/index.module.ts | 64 + .../messages/pages/index-35/index.page.ts | 102 + .../index-35/messages-index-routing.module.ts | 33 + .../messages/services/database/messages.ts | 111 + .../messages/services/handlers/mainmenu.ts | 223 + .../messages/services/messages-offline.ts | 383 ++ src/addons/messages/services/messages.ts | 3641 +++++++++++++++++ src/core/singletons/events.ts | 6 +- src/theme/variables.scss | 2 +- 24 files changed, 6638 insertions(+), 2 deletions(-) create mode 100644 src/addons/messages/lang.json create mode 100644 src/addons/messages/messages-common.scss create mode 100644 src/addons/messages/messages-lazy.module.ts create mode 100644 src/addons/messages/messages.module.ts create mode 100644 src/addons/messages/pages/contacts-35/contacts.html create mode 100644 src/addons/messages/pages/contacts-35/contacts.module.ts create mode 100644 src/addons/messages/pages/contacts-35/contacts.page.ts create mode 100644 src/addons/messages/pages/discussions-35/discussions.html create mode 100644 src/addons/messages/pages/discussions-35/discussions.module.ts create mode 100644 src/addons/messages/pages/discussions-35/discussions.page.ts create mode 100644 src/addons/messages/pages/group-conversations/group-conversations.html create mode 100644 src/addons/messages/pages/group-conversations/group-conversations.module.ts create mode 100644 src/addons/messages/pages/group-conversations/group-conversations.page.ts create mode 100644 src/addons/messages/pages/index-35/index.html create mode 100644 src/addons/messages/pages/index-35/index.module.ts create mode 100644 src/addons/messages/pages/index-35/index.page.ts create mode 100644 src/addons/messages/pages/index-35/messages-index-routing.module.ts create mode 100644 src/addons/messages/services/database/messages.ts create mode 100644 src/addons/messages/services/handlers/mainmenu.ts create mode 100644 src/addons/messages/services/messages-offline.ts create mode 100644 src/addons/messages/services/messages.ts 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/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..fab67f67d --- /dev/null +++ b/src/addons/messages/messages-lazy.module.ts @@ -0,0 +1,63 @@ +// (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 { RouterModule, ROUTES, Routes } from '@angular/router'; + +import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { AddonMessagesIndexRoutingModule } from './pages/index-35/messages-index-routing.module'; + +function buildRoutes(injector: Injector): Routes { + return [ + { + path: 'index', // 3.5 or lower. + loadChildren: () => import('./pages/index-35/index.module').then( m => m.AddonMessagesIndex35PageModule), + }, + { + path: 'group-conversations', // 3.6 or greater. + loadChildren: () => import('./pages/group-conversations/group-conversations.module') + .then(m => m.AddonMessagesGroupConversationsPageModule), + }, + ...buildTabMainRoutes(injector, { + redirectTo: 'index', + pathMatch: 'full', + }), + ]; +} + +// 3.5 or lower. +const indexTabRoutes: Routes = [ + { + path: 'discussions', + loadChildren: () => import('./pages/discussions-35/discussions.module').then(m => m.AddonMessagesDiscussions35PageModule), + }, + { + path: 'contacts', + loadChildren: () => import('./pages/contacts-35/contacts.module').then(m => m.AddonMessagesContacts35PageModule), + }, +]; + +@NgModule({ + imports: [AddonMessagesIndexRoutingModule.forChild({ children: indexTabRoutes })], + 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..7173c6296 --- /dev/null +++ b/src/addons/messages/messages.module.ts @@ -0,0 +1,129 @@ +// (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'; + +const mainMenuChildrenRoutes: Routes = [ + { + path: AddonMessagesMainMenuHandlerService.PAGE_NAME, + loadChildren: () => import('./messages-lazy.module').then(m => m.AddonMessagesLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuRoutingModule.forChild({ children: mainMenuChildrenRoutes }), + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [MESSAGES_OFFLINE_SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreMainMenuDelegate.instance.registerHandler(AddonMessagesMainMenuHandler.instance); + CoreCronDelegate.instance.register(AddonMessagesMainMenuHandler.instance); + }, + }, + + ], +}) +export class AddonMessagesModule { + + /* constructor( + contentLinksDelegate: CoreContentLinksDelegate, + indexLinkHandler: AddonMessagesIndexLinkHandler, + discussionLinkHandler: AddonMessagesDiscussionLinkHandler, + sendMessageHandler: AddonMessagesSendMessageUserHandler, + userDelegate: CoreUserDelegate, + cronDelegate: CoreCronDelegate, + syncHandler: AddonMessagesSyncCronHandler, + network: Network, + zone: NgZone, + messagesSync: AddonMessagesSyncProvider, + messagesProvider: AddonMessagesProvider, + sitesProvider: CoreSitesProvider, + linkHelper: CoreContentLinksHelperProvider, + settingsHandler: AddonMessagesSettingsHandler, + settingsDelegate: CoreSettingsDelegate, + pushNotificationsDelegate: CorePushNotificationsDelegate, + addContactHandler: AddonMessagesAddContactUserHandler, + blockContactHandler: AddonMessagesBlockContactUserHandler, + contactRequestLinkHandler: AddonMessagesContactRequestLinkHandler, + pushClickHandler: AddonMessagesPushClickHandler, + ) { + // Register handlers. + contentLinksDelegate.registerHandler(indexLinkHandler); + contentLinksDelegate.registerHandler(discussionLinkHandler); + contentLinksDelegate.registerHandler(contactRequestLinkHandler); + userDelegate.registerHandler(sendMessageHandler); + userDelegate.registerHandler(addContactHandler); + userDelegate.registerHandler(blockContactHandler); + cronDelegate.register(syncHandler); + settingsDelegate.registerHandler(settingsHandler); + pushNotificationsDelegate.registerClickHandler(pushClickHandler); + + // Sync some discussions when device goes online. + network.onConnect().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + zone.run(() => { + messagesSync.syncAllDiscussions(undefined, true); + }); + }); + + const notificationClicked = (notification: any): void => { + messagesProvider.isMessagingEnabledForSite(notification.site).then(() => { + sitesProvider.isFeatureDisabled('CoreMainMenuDelegate_AddonMessages', notification.site).then((disabled) => { + if (disabled) { + // Messages are disabled, stop. + return; + } + + messagesProvider.invalidateDiscussionsCache(notification.site).finally(() => { + // Check if group messaging is enabled, to determine which page should be loaded. + messagesProvider.isGroupMessagingEnabledInSite(notification.site).then((enabled) => { + const pageParams: any = {}; + let pageName = 'AddonMessagesIndexPage'; + if (enabled) { + pageName = 'AddonMessagesGroupConversationsPage'; + } + + // Check if we have enough information to open the conversation. + if (notification.convid && enabled) { + pageParams.conversationId = Number(notification.convid); + } else if (notification.userfromid || notification.useridfrom) { + pageParams.discussionUserId = Number(notification.userfromid || notification.useridfrom); + } + + linkHelper.goInSite(undefined, pageName, pageParams, notification.site); + }); + }); + }); + }); + }; + }*/ + +} 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..e209333f3 --- /dev/null +++ b/src/addons/messages/pages/contacts-35/contacts.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + +

{{ '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..75fab619a --- /dev/null +++ b/src/addons/messages/pages/contacts-35/contacts.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 { CoreSharedModule } from '@/core/shared.module'; +import { CoreSearchComponentsModule } from '@features/search/components/components.module'; + +import { AddonMessagesContacts35Page } from './contacts.page'; + +const routes: Routes = [ + { + path: '', + component: AddonMessagesContacts35Page, + }, +]; + +@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..72c298b23 --- /dev/null +++ b/src/addons/messages/pages/contacts-35/contacts.page.ts @@ -0,0 +1,264 @@ +// (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, + AddonMessagesGetContactsResult, + AddonMessagesSearchContactsContact, + AddonMessagesGetContactsContact, + AddonMessages, + AddonMessagesSplitViewLoadIndexEventData, + AddonMessagesMemberInfoChangedEventData, +} from '../../services/messages'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreApp } from '@services/app'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { ActivatedRoute } from '@angular/router'; +import { Translate } from '@singletons'; + +/** + * 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 currentUserId: number; + 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.currentUserId = CoreSites.instance.getCurrentSiteUserId(); + 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 params => { + this.discussionUserId = params['discussionUserId'] || undefined; + + 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) { + 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, true); + } + } + } 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(this.currentUserId); + 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. + * @param onlyWithSplitView Only go to Discussion if split view is on. + */ + gotoDiscussion(discussionUserId: number, onlyWithSplitView: boolean = false): void { + this.discussionUserId = discussionUserId; + + const params: AddonMessagesSplitViewLoadIndexEventData = { + discussion: discussionUserId, + onlyWithSplitView: onlyWithSplitView, + }; + CoreEvents.trigger(AddonMessagesProvider.SPLIT_VIEW_LOAD_INDEX_EVENT, params, this.siteId); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.memberInfoObserver?.off(); + } + +} + +/** + * Contacts with some calculated data. + */ +export type AddonMessagesGetContactsFormatted = AddonMessagesGetContactsResult & { + search?: AddonMessagesSearchContactsContact[]; // Calculated in the app. Result of searching users. +}; 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..51021d89c --- /dev/null +++ b/src/addons/messages/pages/discussions-35/discussions.html @@ -0,0 +1,56 @@ + + + + + + + + + + + + +

{{ '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..414211e23 --- /dev/null +++ b/src/addons/messages/pages/discussions-35/discussions.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 { CoreSharedModule } from '@/core/shared.module'; +import { CoreSearchComponentsModule } from '@features/search/components/components.module'; + +import { AddonMessagesDiscussions35Page } from './discussions.page'; + +const routes: Routes = [ + { + path: '', + component: AddonMessagesDiscussions35Page, + }, +]; + +@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..5f3650ca4 --- /dev/null +++ b/src/addons/messages/pages/discussions-35/discussions.page.ts @@ -0,0 +1,284 @@ +// (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, + AddonMessagesProvider, + AddonMessagesSplitViewLoadIndexEventData, +} from '../../services/messages'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreApp } from '@services/app'; +import { ActivatedRoute } 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'; + +/** + * 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 cronObserver: CoreEventObserver; + protected appResumeSubscription: Subscription; + protected pushObserver: Subscription; + protected loadingMessages: string; + protected siteId: string; + + loaded = false; + loadingMessage = ''; + discussions: AddonMessagesDiscussion[] = []; + discussionUserId?: number; + + search: { + enabled: boolean; + showResults: boolean; + results: AddonMessagesMessageAreaContact[]; + loading: string; + text: string; + } = { + 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: any) => { + 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: any) => { + 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); + + // Update unread conversation counts. + this.cronObserver = CoreEvents.on(AddonMessagesProvider.UNREAD_CONVERSATION_COUNTS_EVENT, () => { + 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 => { + this.discussionUserId = params['discussionUserId'] || undefined; + + 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) { + // Take first and load it. + this.gotoDiscussion(this.discussions[0].message!.user, undefined, true); + } + }); + } + + /** + * 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, onlyWithSplitView: boolean = false): void { + this.discussionUserId = discussionUserId; + + const params: AddonMessagesSplitViewLoadIndexEventData = { + discussion: discussionUserId, + onlyWithSplitView: onlyWithSplitView, + }; + if (messageId) { + params.message = messageId; + } + CoreEvents.trigger(AddonMessagesProvider.SPLIT_VIEW_LOAD_INDEX_EVENT, params, this.siteId); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.newMessagesObserver?.off(); + this.readChangedObserver?.off(); + this.cronObserver?.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..5d95e296f --- /dev/null +++ b/src/addons/messages/pages/group-conversations/group-conversations.html @@ -0,0 +1,127 @@ + + + + + + {{ '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..c0c9dca10 --- /dev/null +++ b/src/addons/messages/pages/group-conversations/group-conversations.module.ts @@ -0,0 +1,45 @@ +// (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 { AddonMessagesGroupConversationsPage } from './group-conversations.page'; + +const routes: Routes = [ + { + path: '', + component: AddonMessagesGroupConversationsPage, + }, +]; + +@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..fc50c1a7d --- /dev/null +++ b/src/addons/messages/pages/group-conversations/group-conversations.page.ts @@ -0,0 +1,810 @@ +// (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, +} from '../../services/messages'; +import { AddonMessagesOffline } 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 { + AddonMessagesOfflineConversationMessagesDBRecordFormatted, + AddonMessagesOfflineMessagesDBRecordFormatted, +} from '@addons/messages/services/database/messages'; +// import { CoreSplitViewComponent } from '@components/split-view/split-view'; + +/** + * 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(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; + @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: any) => { + // 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: any) => { + 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: any) => { + 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: any) => { + 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 = params['conversationId'] || undefined; + this.discussionUserId = !this.conversationId && (params['discussionUserId'] || 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(); + /* @todo if (!this.conversationId && !this.discussionUserId && this.splitviewCtrl.isOn()) { + // 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 { + // 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: AddonMessagesConversationForList[]; canLoadMore: boolean } = { + conversations: [], + canLoadMore: false, + }; + let offlineMessages: + (AddonMessagesOfflineConversationMessagesDBRecordFormatted | AddonMessagesOfflineMessagesDBRecordFormatted)[] = []; + + // 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 { + // @todo this.splitviewCtrl.getMasterNav().push('AddonMessagesContactsPage'); + 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 = { + conversationId: conversationId, + userId: userId, + }; + if (messageId) { + params.message = messageId; + } + + // @todo this.splitviewCtrl.push + CoreNavigator.instance.navigateToSitePath('discussion', { params }); + } + + /** + * Navigate to message settings. + */ + gotoSettings(): void { + // @todo this.splitviewCtrl.push + CoreNavigator.instance.navigateToSitePath('settings'); + } + + /** + * 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: (AddonMessagesOfflineConversationMessagesDBRecordFormatted | AddonMessagesOfflineMessagesDBRecordFormatted)[], + ): 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 || '', + }; message.conversation || {}; + + 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: AddonMessagesOfflineConversationMessagesDBRecordFormatted | AddonMessagesOfflineMessagesDBRecordFormatted, + ): 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): 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/index-35/index.html b/src/addons/messages/pages/index-35/index.html new file mode 100644 index 000000000..606bd99a3 --- /dev/null +++ b/src/addons/messages/pages/index-35/index.html @@ -0,0 +1,16 @@ + + + + + + {{ 'addon.messages.messages' | translate }} + + + + + + + + + + diff --git a/src/addons/messages/pages/index-35/index.module.ts b/src/addons/messages/pages/index-35/index.module.ts new file mode 100644 index 000000000..a6806f2c0 --- /dev/null +++ b/src/addons/messages/pages/index-35/index.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 { Injector, NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule, ROUTES, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; + +import { CoreSharedModule } from '@/core/shared.module'; + +import { AddonMessagesIndex35Page } from './index.page'; +import { ADDON_MESSAGES_INDEX_ROUTES } from './messages-index-routing.module'; +import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { resolveModuleRoutes } from '@/app/app-routing.module'; + +const routes: Routes = [ + { + path: '', + component: AddonMessagesIndex35Page, + }, +]; + +function buildRoutes(injector: Injector): Routes { + const routes = resolveModuleRoutes(injector, ADDON_MESSAGES_INDEX_ROUTES); + + return [ + ...buildTabMainRoutes(injector, { + path: '', + component: AddonMessagesIndex35Page, + children: routes.children, + }), + ...routes.siblings, + ]; +} + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + ], + providers: [ + { provide: ROUTES, multi: true, useFactory: buildRoutes, deps: [Injector] }, + ], + declarations: [ + AddonMessagesIndex35Page, + ], + exports: [RouterModule], +}) +export class AddonMessagesIndex35PageModule {} diff --git a/src/addons/messages/pages/index-35/index.page.ts b/src/addons/messages/pages/index-35/index.page.ts new file mode 100644 index 000000000..9ff29f162 --- /dev/null +++ b/src/addons/messages/pages/index-35/index.page.ts @@ -0,0 +1,102 @@ +// (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 { CoreTab } from '@components/tabs/tabs'; +import { Params } from '@angular/router'; +import { AddonMessagesProvider, AddonMessagesSplitViewLoadIndexEventData } from '../../services/messages'; +import { CoreNavigator } from '@services/navigator'; +// import { CoreSplitViewComponent } from '@components/split-view/split-view'; + +/** + * Page that displays the messages index page. + */ +@Component({ + selector: 'page-addon-messages-index', + templateUrl: 'index.html', +}) +export class AddonMessagesIndex35Page implements OnDestroy { + + // @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; + + tabs: CoreTab[] = [ + { + id: 'discussions-35', + class: '', + title: 'addon.messages.messages', + icon: 'fas-comments', + enabled: true, + page: 'main/messages/index/discussions', + }, + { + id: 'contacts-35', + class: '', + title: 'addon.messages.contacts', + icon: 'fas-address-book', + enabled: true, + page: 'main/messages/index/contacts', + }, + ]; + + protected loadSplitViewObserver?: CoreEventObserver; + protected siteId: string; + + constructor() { + + this.siteId = CoreSites.instance.getCurrentSiteId(); + + // Update split view or navigate. + this.loadSplitViewObserver = CoreEvents.on( + AddonMessagesProvider.SPLIT_VIEW_LOAD_INDEX_EVENT, + (data) => { + if (data.discussion /* @todo && (this.splitviewCtrl.isOn() || !data.onlyWithSplitView)*/) { + this.gotoDiscussion(data.discussion, data.message); + } + }, + + this.siteId, + ); + } + + /** + * Navigate to a particular discussion. + * + * @param discussionUserId Discussion Id to load. + * @param messageId Message to scroll after loading the discussion. Used when searching. + */ + gotoDiscussion(discussionUserId: number, messageId?: number): void { + const params: Params = { + userId: discussionUserId, + }; + + if (messageId) { + params.message = messageId; + } + + // @todo + // this.splitviewCtrl.push('discussion', { params }); + CoreNavigator.instance.navigateToSitePath('discussion', { params }); + } + + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.loadSplitViewObserver?.off(); + } + +} diff --git a/src/addons/messages/pages/index-35/messages-index-routing.module.ts b/src/addons/messages/pages/index-35/messages-index-routing.module.ts new file mode 100644 index 000000000..fe0972485 --- /dev/null +++ b/src/addons/messages/pages/index-35/messages-index-routing.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { InjectionToken, ModuleWithProviders, NgModule } from '@angular/core'; + +import { ModuleRoutesConfig } from '@/app/app-routing.module'; + +export const ADDON_MESSAGES_INDEX_ROUTES = new InjectionToken('ADDON_MESSAGES_INDEX_ROUTES'); + +@NgModule() +export class AddonMessagesIndexRoutingModule { + + static forChild(routes: ModuleRoutesConfig): ModuleWithProviders { + return { + ngModule: AddonMessagesIndexRoutingModule, + providers: [ + { provide: ADDON_MESSAGES_INDEX_ROUTES, multi: true, useValue: routes }, + ], + }; + } + +} diff --git a/src/addons/messages/services/database/messages.ts b/src/addons/messages/services/database/messages.ts new file mode 100644 index 000000000..f2626561b --- /dev/null +++ b/src/addons/messages/services/database/messages.ts @@ -0,0 +1,111 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSiteSchema } from '@services/sites'; +import { AddonMessagesConversation } from '../messages'; + +/** + * 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 AddonMessagesOfflineMessagesDBRecordFormatted = AddonMessagesOfflineMessagesDBRecord & { + pending?: boolean; // Will be likely true. + text?: string; // Copy of smallmessage. +}; + +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. +}; + +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. + }; + + 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/messages-offline.ts b/src/addons/messages/services/messages-offline.ts new file mode 100644 index 000000000..4de9ba0dc --- /dev/null +++ b/src/addons/messages/services/messages-offline.ts @@ -0,0 +1,383 @@ +// (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, + AddonMessagesOfflineConversationMessagesDBRecordFormatted, + AddonMessagesOfflineMessagesDBRecord, + AddonMessagesOfflineMessagesDBRecordFormatted, + 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<(AddonMessagesOfflineConversationMessagesDBRecordFormatted | AddonMessagesOfflineMessagesDBRecordFormatted)[]> { + const site = await CoreSites.instance.getSite(siteId); + + const promises: [ + Promise, + Promise, + ] = [ + site.getDb().getRecords(MESSAGES_TABLE, { deviceoffline: 1 }), + site.getDb().getRecords(CONVERSATION_MESSAGES_TABLE, { deviceoffline: 1 }), + ]; + + const [ + messages, + conversations, + ]: [ + AddonMessagesOfflineMessagesDBRecord[], + AddonMessagesOfflineConversationMessagesDBRecord[], + ] = await Promise.all(promises); + + + const messageResult: + (AddonMessagesOfflineConversationMessagesDBRecordFormatted | AddonMessagesOfflineMessagesDBRecordFormatted)[] = + 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<(AddonMessagesOfflineConversationMessagesDBRecordFormatted | AddonMessagesOfflineMessagesDBRecordFormatted)[]> { + const site = await CoreSites.instance.getSite(siteId); + + const promises: [ + Promise, + Promise, + ] = [ + site.getDb().getAllRecords(MESSAGES_TABLE), + site.getDb().getAllRecords(CONVERSATION_MESSAGES_TABLE), + ]; + + const [ + messages, + conversations, + ]: [ + AddonMessagesOfflineMessagesDBRecord[], + AddonMessagesOfflineConversationMessagesDBRecord[], + ] = await Promise.all(promises); + + + const messageResult: + (AddonMessagesOfflineConversationMessagesDBRecordFormatted | AddonMessagesOfflineMessagesDBRecordFormatted)[] = + 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: (AddonMessagesOfflineConversationMessagesDBRecord | AddonMessagesOfflineMessagesDBRecord)[], + 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: AddonMessagesOfflineConversationMessagesDBRecord | AddonMessagesOfflineMessagesDBRecord) => { + 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) {} diff --git a/src/addons/messages/services/messages.ts b/src/addons/messages/services/messages.ts new file mode 100644 index 000000000..eeac460f9 --- /dev/null +++ b/src/addons/messages/services/messages.ts @@ -0,0 +1,3641 @@ +// (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 } 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 { + AddonMessagesOfflineConversationMessagesDBRecordFormatted, + AddonMessagesOfflineMessagesDBRecordFormatted, +} from './database/messages'; +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 SPLIT_VIEW_LOAD_INDEX_EVENT = 'addon_messages_split_view_load_index_event'; // Used on 3.5 or lower. + 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 + */ + 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.getUserId(), 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); + + 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: any, deleteForAll?: boolean): Promise { + // @todo Add message type. + if (message.id) { + // 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, message.read); + } + } + + // It's an offline message. + if (!message.conversationid) { + 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: AddonMessagesGetContactsResult = { + 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. + * + * @return Main messages page path of the site. + */ + getMainMessagesPagePath(): string { + return AddonMessagesMainMenuHandlerService.PAGE_NAME + (this.isGroupMessagingEnabled() ? '/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 ? 0 : 1; + // 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(userId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + await this.invalidateContactsCache(siteId); + + await this.invalidateBlockedContactsCache(userId, siteId); + } + + /** + * Invalidate blocked contacts cache. + * + * @param userId The user ID. + * @param siteId Site ID. If not defined, current site. + */ + async invalidateBlockedContactsCache(userId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + 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.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. + */ + isBlocked(userId: number, siteId?: string): Promise { + if (this.isGroupMessagingEnabled()) { + return this.getMemberInfo(userId, siteId).then((member) => member.isblocked); + } + + return this.getBlockedContacts(siteId).then((blockedContacts) => { + 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); + + if (this.isGroupMessagingEnabled()) { + return CoreUtils.instance.allPromises([ + this.invalidateUserContacts(site.id), + this.invalidateAllMemberInfo(userId, site), + ]).then(() => { + const data: AddonMessagesMemberInfoChangedEventData = { userId, contactRemoved: true }; + CoreEvents.trigger(AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT, data, site.id); + + return; + }); + } else { + return this.invalidateContactsCache(site.id); + } + } + + /** + * 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<{ sent: boolean; message: AddonMessagesSendInstantMessagesMessage }> { + + // Convenience function to store a message to be synchronized later. + const storeOffline = async (): Promise<{ sent: boolean; message: AddonMessagesSendInstantMessagesMessage }> => { + 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: any[], 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: any, + message: string, + siteId?: string, + ): Promise<{ sent: boolean; message: AddonMessagesSendMessagesToConversationMessage }> { + + const site = await CoreSites.instance.getSite(siteId); + siteId = site.getId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = async(): Promise<{ sent: boolean; message: AddonMessagesSendMessagesToConversationMessage }> => { + 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: any[], + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + const params = { + 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: (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[], + ): (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[] { + 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: AddonMessagesGetContactsResult): 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. +}; + +/** + * 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. +}; + +/** + * 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 AddonMessagesGetBlockedUsersResult = { + 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 AddonMessagesGetContactsResult = { + 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. + */ +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. +}; + +/** + * 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. +}; + +/** + * 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?: number; // 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. + +}; + +/** + * Params of core_message_send_instant_messages WS. + */ +type AddonMessagesSendInstantMessagesWSParams = { + messages: { + 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. + }[]; +}; + +/** + * 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 SPLIT_VIEW_LOAD_INDEX_EVENT event. Used on 3.5 or lower. + */ +export type AddonMessagesSplitViewLoadIndexEventData = { + discussion: number; + onlyWithSplitView?: boolean; + message?: number; +}; diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index db1bdc8d4..91d5a072d 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -84,7 +84,11 @@ export class CoreEvents { * @param siteId Site where to trigger the event. Undefined won't check the site. * @return Observer to stop listening. */ - static on(eventName: string, callBack: (value: T) => void, siteId?: string): CoreEventObserver { + static on( + eventName: string, + callBack: (value: T & { siteId?: string }) => void, + siteId?: string, + ): CoreEventObserver { // If it's a unique event and has been triggered already, call the callBack. // We don't need to create an observer because the event won't be triggered again. if (this.uniqueEvents[eventName]) { diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 0795802dd..301933f9a 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -186,7 +186,7 @@ --core-large-avatar-size: var(--custom-large-avatar-size, 90px); - --core-avatar-size: var(--custom-avatar-size, 64px); + --core-avatar-size: var(--custom-avatar-size, 40px); --addon-calendar-event-category-color: var(--custom-calendar-event-category-color, var(--purple)); --addon-calendar-event-course-color: var(--custom-calendar-event-course-color, var(--red));