From 572ee7bead1dc2f7f686400e2cca2dff84843024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 22 Jan 2021 15:03:56 +0100 Subject: [PATCH] MOBILE-3631 messages: Message sync --- src/addons/messages/messages.module.ts | 8 +- .../messages/services/handlers/sync-cron.ts | 50 +++ .../messages/services/messages-offline.ts | 4 +- src/addons/messages/services/messages-sync.ts | 409 ++++++++++++++++++ src/addons/messages/services/messages.ts | 53 ++- 5 files changed, 507 insertions(+), 17 deletions(-) create mode 100644 src/addons/messages/services/handlers/sync-cron.ts create mode 100644 src/addons/messages/services/messages-sync.ts diff --git a/src/addons/messages/messages.module.ts b/src/addons/messages/messages.module.ts index 30adcf591..17c817896 100644 --- a/src/addons/messages/messages.module.ts +++ b/src/addons/messages/messages.module.ts @@ -34,6 +34,8 @@ import { CoreUserDelegate } from '@features/user/services/user-delegate'; import { AddonMessagesSendMessageUserHandler } from './services/handlers/user-send-message'; import { AddonMessagesAddContactUserHandler } from './services/handlers/user-add-contact'; import { AddonMessagesBlockContactUserHandler } from './services/handlers/user-block-contact'; +import { Network, NgZone } from '@singletons'; +import { AddonMessagesSync } from './services/messages-sync'; const mainMenuChildrenRoutes: Routes = [ { @@ -61,7 +63,7 @@ const mainMenuChildrenRoutes: Routes = [ // Register handlers. CoreMainMenuDelegate.instance.registerHandler(AddonMessagesMainMenuHandler.instance); CoreCronDelegate.instance.register(AddonMessagesMainMenuHandler.instance); - // @todo CoreCronDelegate.instance.register(AddonMessagesSyncCronHandler.instance); + CoreCronDelegate.instance.register(AddonMessagesPushClickHandler.instance); CoreSettingsDelegate.instance.registerHandler(AddonMessagesSettingsHandler.instance); CoreContentLinksDelegate.instance.registerHandler(AddonMessagesIndexLinkHandler.instance); CoreContentLinksDelegate.instance.registerHandler(AddonMessagesDiscussionLinkHandler.instance); @@ -72,12 +74,12 @@ const mainMenuChildrenRoutes: Routes = [ CoreUserDelegate.instance.registerHandler(AddonMessagesBlockContactUserHandler.instance); // Sync some discussions when device goes online. - /* @todo Network.instance.onConnect().subscribe(() => { + Network.instance.onConnect().subscribe(() => { // Execute the callback in the Angular zone, so change detection doesn't stop working. NgZone.instance.run(() => { AddonMessagesSync.instance.syncAllDiscussions(undefined, true); }); - });*/ + }); }, }, diff --git a/src/addons/messages/services/handlers/sync-cron.ts b/src/addons/messages/services/handlers/sync-cron.ts new file mode 100644 index 000000000..cd64120ae --- /dev/null +++ b/src/addons/messages/services/handlers/sync-cron.ts @@ -0,0 +1,50 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { AddonMessagesSync } from '../messages-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonMessagesSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonMessagesSyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @return Promise resolved when done, rejected if failure. + */ + execute(siteId?: string): Promise { + return AddonMessagesSync.instance.syncAllDiscussions(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return 300000; // 5 minutes. + } + +} + +export class AddonMessagesSyncCronHandler extends makeSingleton(AddonMessagesSyncCronHandlerService) {} diff --git a/src/addons/messages/services/messages-offline.ts b/src/addons/messages/services/messages-offline.ts index 4de9ba0dc..6e4d5f396 100644 --- a/src/addons/messages/services/messages-offline.ts +++ b/src/addons/messages/services/messages-offline.ts @@ -348,7 +348,7 @@ export class AddonMessagesOfflineProvider { * @return Promise resolved if stored, rejected if failure. */ async setMessagesDeviceOffline( - messages: (AddonMessagesOfflineConversationMessagesDBRecord | AddonMessagesOfflineMessagesDBRecord)[], + messages: (AddonMessagesOfflineMessagesDBRecordFormatted | AddonMessagesOfflineConversationMessagesDBRecordFormatted)[], value: boolean, siteId?: string, ): Promise { @@ -359,7 +359,7 @@ export class AddonMessagesOfflineProvider { const promises: Promise[] = []; const data = { deviceoffline: value ? 1 : 0 }; - messages.forEach((message: AddonMessagesOfflineConversationMessagesDBRecord | AddonMessagesOfflineMessagesDBRecord) => { + messages.forEach((message) => { if ('conversationid' in message) { promises.push(db.updateRecords( CONVERSATION_MESSAGES_TABLE, diff --git a/src/addons/messages/services/messages-sync.ts b/src/addons/messages/services/messages-sync.ts new file mode 100644 index 000000000..1fc060fd7 --- /dev/null +++ b/src/addons/messages/services/messages-sync.ts @@ -0,0 +1,409 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { AddonMessagesOffline } from './messages-offline'; +import { + AddonMessagesProvider, + AddonMessages, + AddonMessagesGetMessagesWSParams, +} from './messages'; +import { CoreEvents } from '@singletons/events'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreSites } from '@services/sites'; +import { CoreApp } from '@services/app'; +import { CoreConstants } from '@/core/constants'; +import { CoreUser } from '@features/user/services/user'; +import { CoreError } from '@classes/errors/error'; +import { + AddonMessagesOfflineConversationMessagesDBRecordFormatted, + AddonMessagesOfflineMessagesDBRecordFormatted, +} from './database/messages'; +import { CoreTextErrorObject, CoreTextUtils } from '@services/utils/text'; +import { CoreSiteWSPreSets } from '@classes/site'; + +/** + * Service to sync messages. + */ +@Injectable({ providedIn: 'root' }) +export class AddonMessagesSyncProvider extends CoreSyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_messages_autom_synced'; + + constructor() { + super('AddonMessagesSync'); + } + + /** + * Get the ID of a discussion sync. + * + * @param conversationId Conversation ID. + * @param userId User ID talking to (if no conversation ID). + * @return Sync ID. + */ + protected getSyncId(conversationId?: number, userId?: number): string { + if (conversationId) { + return 'conversationid:' + conversationId; + } else if (userId) { + return 'userid:' + userId; + } else { + // Should not happen. + throw new CoreError('Incorrect messages sync id.'); + } + } + + /** + * Try to synchronize all the discussions in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param onlyDeviceOffline True to only sync discussions that failed because device was offline, + * false to sync all. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllDiscussions(siteId?: string, onlyDeviceOffline: boolean = false): Promise { + const syncFunctionLog = 'all discussions' + (onlyDeviceOffline ? ' (Only offline)' : ''); + + return this.syncOnSites(syncFunctionLog, this.syncAllDiscussionsFunc.bind(this, [onlyDeviceOffline]), siteId); + } + + /** + * Get all messages pending to be sent in the site. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param onlyDeviceOffline True to only sync discussions that failed because device was offline. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllDiscussionsFunc(siteId: string, onlyDeviceOffline = false): Promise { + const userIds: number[] = []; + const conversationIds: number[] = []; + const promises: Promise[] = []; + + const messages = onlyDeviceOffline + ? await AddonMessagesOffline.instance.getAllDeviceOfflineMessages(siteId) + : await AddonMessagesOffline.instance.getAllMessages(siteId); + + // Get all the conversations to be synced. + messages.forEach((message) => { + if ('conversationid' in message) { + if (conversationIds.indexOf(message.conversationid) == -1) { + conversationIds.push(message.conversationid); + } + } else if (userIds.indexOf(message.touserid) == -1) { + userIds.push(message.touserid); + } + }); + + // Sync all conversations. + conversationIds.forEach((conversationId) => { + promises.push(this.syncDiscussion(conversationId, undefined, siteId).then((result) => { + if (typeof result == 'undefined') { + return; + } + + // Sync successful, send event. + CoreEvents.trigger(AddonMessagesSyncProvider.AUTO_SYNCED, result, siteId); + + return; + })); + }); + + userIds.forEach((userId) => { + promises.push(this.syncDiscussion(undefined, userId, siteId).then((result) => { + if (typeof result == 'undefined') { + return; + } + + // Sync successful, send event. + CoreEvents.trigger(AddonMessagesSyncProvider.AUTO_SYNCED, result, siteId); + + return; + })); + }); + + await Promise.all(promises); + } + + /** + * Synchronize a discussion. + * + * @param conversationId Conversation ID. + * @param userId User ID talking to (if no conversation ID). + * @param siteId Site ID. + * @return Promise resolved with the list of warnings if sync is successful, rejected otherwise. + */ + syncDiscussion(conversationId?: number, userId?: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const syncId = this.getSyncId(conversationId, userId); + + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for this conversation, return the promise. + return this.getOngoingSync(syncId, siteId)!; + } + + return this.addOngoingSync(syncId, this.performSyncDiscussion(conversationId, userId, siteId), siteId); + } + + /** + * Perform the synchronization of a discussion. + * + * @param conversationId Conversation ID. + * @param userId User ID talking to (if no conversation ID). + * @param siteId Site ID. + * @return Promise resolved with the list of warnings if sync is successful, rejected otherwise. + */ + protected async performSyncDiscussion( + conversationId: number | undefined, + userId: number | undefined, + siteId: string, + ): Promise { + const result: AddonMessagesSyncEvents = { + warnings: [], + userId, + conversationId, + }; + + const groupMessagingEnabled = AddonMessages.instance.isGroupMessagingEnabled(); + let messages: (AddonMessagesOfflineMessagesDBRecordFormatted | AddonMessagesOfflineConversationMessagesDBRecordFormatted)[]; + const errors: (string | CoreError | CoreTextErrorObject)[] = []; + + if (conversationId) { + this.logger.debug(`Try to sync conversation '${conversationId}'`); + messages = await AddonMessagesOffline.instance.getConversationMessages(conversationId, undefined, siteId); + } else if (userId) { + this.logger.debug(`Try to sync discussion with user '${userId}'`); + messages = await AddonMessagesOffline.instance.getMessages(userId, siteId); + } else { + // Should not happen. + throw new CoreError('Incorrect messages sync.'); + } + + if (!messages.length) { + // Nothing to sync. + return result; + } else if (!CoreApp.instance.isOnline()) { + // Cannot sync in offline. Mark messages as device offline. + AddonMessagesOffline.instance.setMessagesDeviceOffline(messages, true); + + throw new CoreError('Cannot sync in offline. Mark messages as device offline.'); + } + + // Order message by timecreated. + messages = AddonMessages.instance.sortMessages(messages); + + // Get messages sent by the user after the first offline message was sent. + // We subtract some time because the message could've been saved in server before it was in the app. + const timeFrom = Math.floor((messages[0].timecreated - CoreConstants.WS_TIMEOUT - 1000) / 1000); + + const onlineMessages = await this.getMessagesSentAfter(timeFrom, conversationId, userId, siteId); + + // Send the messages. Send them 1 by 1 to simulate web's behaviour and to make sure we know which message has failed. + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + + const text = ('text' in message ? message.text : message.smallmessage) || ''; + const textFieldName = conversationId ? 'text' : 'smallmessage'; + const wrappedText = message[textFieldName][0] != '<' ? '

' + text + '

' : text; + + try { + if (onlineMessages.indexOf(wrappedText) != -1) { + // Message already sent, ignore it to prevent duplicates. + } else if (conversationId) { + await AddonMessages.instance.sendMessageToConversationOnline(conversationId, text, siteId); + } else if (userId) { + await AddonMessages.instance.sendMessageOnline(userId, text, siteId); + } + } catch (error) { + if (!CoreUtils.instance.isWebServiceError(error)) { + // Error sending, stop execution. + if (CoreApp.instance.isOnline()) { + // App is online, unmark deviceoffline if marked. + AddonMessagesOffline.instance.setMessagesDeviceOffline(messages, false); + } + + throw error; + } + + // Error returned by WS. Store the error to show a warning but keep sending messages. + if (errors.indexOf(error) == -1) { + errors.push(error); + } + } + + // Message was sent, delete it from local DB. + if (conversationId) { + await AddonMessagesOffline.instance.deleteConversationMessage(conversationId, text, message.timecreated, siteId); + } else if (userId) { + await AddonMessagesOffline.instance.deleteMessage(userId, text, message.timecreated, siteId); + } + + // In some Moodle versions, wait 1 second to make sure timecreated is different. + // This is because there was a bug where messages with the same timecreated had a wrong order. + if (!groupMessagingEnabled && i < messages.length - 1) { + await CoreUtils.instance.wait(1000); + } + } + + await this.handleSyncErrors(conversationId, userId, errors, result.warnings); + + // All done, return the warnings. + return result; + } + + /** + * Get messages sent by current user after a certain time. + * + * @param time Time in seconds. + * @param conversationId Conversation ID. + * @param userId User ID talking to (if no conversation ID). + * @param siteId Site ID. + * @return Promise resolved with the messages texts. + */ + protected async getMessagesSentAfter( + time: number, + conversationId?: number, + userId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const siteCurrentUserId = site.getUserId(); + + if (conversationId) { + try { + const result = await AddonMessages.instance.getConversationMessages(conversationId, { + excludePending: true, + ignoreCache: true, + timeFrom: time, + }); + + const sentMessages = result.messages.filter((message) => message.useridfrom == siteCurrentUserId); + + return sentMessages.map((message) => message.text); + } catch (error) { + if (error && error.errorcode == 'invalidresponse') { + // There's a bug in Moodle that causes this error if there are no new messages. Return empty array. + return []; + } + + throw error; + } + } else if (userId) { + const params: AddonMessagesGetMessagesWSParams = { + useridto: userId, + useridfrom: siteCurrentUserId, + limitnum: AddonMessagesProvider.LIMIT_MESSAGES, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: AddonMessages.instance.getCacheKeyForDiscussion(userId), + getFromCache: false, + emergencyCache: false, + }; + + const messages = await AddonMessages.instance.getRecentMessages(params, preSets, 0, 0, false, siteId); + + time = time * 1000; // Convert to milliseconds. + const messagesAfterTime = messages.filter((message) => message.timecreated >= time); + + return messagesAfterTime.map((message) => message.text); + } else { + throw new CoreError('Incorrect messages sync identifier'); + } + } + + /** + * Handle sync errors. + * + * @param conversationId Conversation ID. + * @param userId User ID talking to (if no conversation ID). + * @param errors List of errors. + * @param warnings Array where to place the warnings. + * @return Promise resolved when done. + */ + protected async handleSyncErrors( + conversationId?: number, + userId?: number, + errors: (string | CoreError | CoreTextErrorObject)[] = [], + warnings: string[] = [], + ): Promise { + if (!errors || errors.length <= 0) { + return; + } + + if (conversationId) { + let conversationIdentifier = String(conversationId); + try { + // Get conversation name and add errors to warnings array. + const conversation = await AddonMessages.instance.getConversation(conversationId, false, false); + conversationIdentifier = conversation.name || String(conversationId); + } catch { + // Ignore errors. + } + + errors.forEach((error) => { + warnings.push(Translate.instance.instant('addon.messages.warningconversationmessagenotsent', { + conversation: conversationIdentifier, + error: CoreTextUtils.instance.getErrorMessageFromError(error), + })); + }); + } else if (userId) { + + // Get user full name and add errors to warnings array. + let userIdentifier = String(userId); + try { + const user = await CoreUser.instance.getProfile(userId, undefined, true); + userIdentifier = user.fullname; + } catch { + // Ignore errors. + } + + errors.forEach((error) => { + warnings.push(Translate.instance.instant('addon.messages.warningmessagenotsent', { + user: userIdentifier, + error: CoreTextUtils.instance.getErrorMessageFromError(error), + })); + }); + } + } + + /** + * If there's an ongoing sync for a certain conversation, wait for it to end. + * If there's no sync ongoing the promise will be resolved right away. + * + * @param conversationId Conversation ID. + * @param userId User ID talking to (if no conversation ID). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when there's no sync going on for the identifier. + */ + waitForSyncConversation( + conversationId?: number, + userId?: number, + siteId?: string, + ): Promise { + const syncId = this.getSyncId(conversationId, userId); + + return this.waitForSync(syncId, siteId); + } + +} + +export class AddonMessagesSync extends makeSingleton(AddonMessagesSyncProvider) {} + +export type AddonMessagesSyncEvents = { + warnings: string[]; + conversationId?: number; + userId?: number; +}; diff --git a/src/addons/messages/services/messages.ts b/src/addons/messages/services/messages.ts index b820c8fe6..a6f7e9dc0 100644 --- a/src/addons/messages/services/messages.ts +++ b/src/addons/messages/services/messages.ts @@ -2568,7 +2568,10 @@ export class AddonMessagesProvider { * @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 { + async sendMessagesOnline( + messages: AddonMessagesMessageData[], + siteId?: string, + ): Promise { const site = await CoreSites.instance.getSite(siteId); const data: AddonMessagesSendInstantMessagesWSParams = { @@ -2590,7 +2593,7 @@ export class AddonMessagesProvider { * @since 3.6 */ async sendMessageToConversation( - conversation: any, + conversation: AddonMessagesConversation, message: string, siteId?: string, ): Promise<{ sent: boolean; message: AddonMessagesSendMessagesToConversationMessage }> { @@ -2696,12 +2699,12 @@ export class AddonMessagesProvider { */ async sendMessagesToConversationOnline( conversationId: number, - messages: any[], + messages: CoreMessageSendMessagesToConversationMessageData[], siteId?: string, ): Promise { const site = await CoreSites.instance.getSite(siteId); - const params = { + const params: CoreMessageSendMessagesToConversationWSParams = { conversationid: conversationId, messages: messages.map((message) => ({ text: message.text, @@ -2785,7 +2788,15 @@ export class AddonMessagesProvider { */ sortMessages( messages: (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[], - ): (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[] { + ): (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[]; + sortMessages( + messages: (AddonMessagesOfflineMessagesDBRecordFormatted | AddonMessagesOfflineConversationMessagesDBRecordFormatted)[], + ): (AddonMessagesOfflineMessagesDBRecordFormatted | AddonMessagesOfflineConversationMessagesDBRecordFormatted)[]; + sortMessages( + messages: (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[] | + (AddonMessagesOfflineMessagesDBRecordFormatted | AddonMessagesOfflineConversationMessagesDBRecordFormatted)[], + ): (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[] | + (AddonMessagesOfflineMessagesDBRecordFormatted | AddonMessagesOfflineConversationMessagesDBRecordFormatted)[] { return messages.sort((a, b) => { // Pending messages last. if (a.pending && !b.pending) { @@ -3241,7 +3252,7 @@ export type AddonMessagesGetConversationsResult = { /** * Params of core_message_get_messages WS. */ -type AddonMessagesGetMessagesWSParams = { +export type AddonMessagesGetMessagesWSParams = { useridto: number; // The user id who received the message, 0 for any user. useridfrom?: number; // The user id who send the message, 0 for any user. -10 or -20 for no-reply or support user. type?: string; // Type of message to return, expected values are: notifications, conversations and both. @@ -3357,6 +3368,19 @@ export type AddonMessagesSendInstantMessagesMessage = { candeletemessagesforallusers: boolean; // @since 3.7. If the user can delete messages in the conversation for all users. }; +export type CoreMessageSendMessagesToConversationMessageData ={ + text: string; // The text of the message. + textformat?: number; // Text format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). +}; + +/** + * Params of core_message_send_messages_to_conversation WS. + */ +type CoreMessageSendMessagesToConversationWSParams = { + conversationid: number; // Id of the conversation. + messages: CoreMessageSendMessagesToConversationMessageData[]; +}; + /** * Result of WS core_message_send_messages_to_conversation. */ @@ -3583,16 +3607,21 @@ type AddonMessagesDeleteContactsWSParams = { }; +/** + * One message data. + */ +export type AddonMessagesMessageData = { + touserid: number; // Id of the user to send the private message. + text: string; // The text of the message. + textformat?: number; // Text format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + clientmsgid?: string; // Your own client id for the message. If this id is provided, the fail message id will be returned. +}; + /** * Params of core_message_send_instant_messages WS. */ type AddonMessagesSendInstantMessagesWSParams = { - messages: { - 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. - }[]; + messages: AddonMessagesMessageData[]; }; /**