diff --git a/scripts/langindex.json b/scripts/langindex.json index e9720fd30..9f1566add 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -165,7 +165,9 @@ "addon.messages.errorwhileretrievingcontacts": "local_moodlemobileapp", "addon.messages.errorwhileretrievingdiscussions": "local_moodlemobileapp", "addon.messages.errorwhileretrievingmessages": "local_moodlemobileapp", + "addon.messages.groupinfo": "message", "addon.messages.groupmessages": "message", + "addon.messages.info": "message", "addon.messages.message": "message", "addon.messages.messagenotsent": "local_moodlemobileapp", "addon.messages.messagepreferences": "message", @@ -176,8 +178,10 @@ "addon.messages.nogroupmessages": "message", "addon.messages.nomessages": "message", "addon.messages.nousersfound": "local_moodlemobileapp", + "addon.messages.numparticipants": "message", "addon.messages.removecontact": "message", "addon.messages.removecontactconfirm": "local_moodlemobileapp", + "addon.messages.showdeletemessages": "local_moodlemobileapp", "addon.messages.type_blocked": "local_moodlemobileapp", "addon.messages.type_offline": "local_moodlemobileapp", "addon.messages.type_online": "local_moodlemobileapp", @@ -185,6 +189,7 @@ "addon.messages.type_strangers": "local_moodlemobileapp", "addon.messages.unblockuser": "message", "addon.messages.unblockuserconfirm": "message", + "addon.messages.warningconversationmessagenotsent": "local_moodlemobileapp", "addon.messages.warningmessagenotsent": "local_moodlemobileapp", "addon.messages.you": "message", "addon.mod_assign.acceptsubmissionstatement": "local_moodlemobileapp", diff --git a/src/addon/messages/lang/en.json b/src/addon/messages/lang/en.json index ed076fb7d..a95948ee2 100644 --- a/src/addon/messages/lang/en.json +++ b/src/addon/messages/lang/en.json @@ -17,7 +17,9 @@ "errorwhileretrievingcontacts": "Error while retrieving contacts from the server.", "errorwhileretrievingdiscussions": "Error while retrieving discussions from the server.", "errorwhileretrievingmessages": "Error while retrieving messages from the server.", + "groupinfo": "Group info", "groupmessages": "Group messages", + "info": "Info", "messagenotsent": "The message was not sent. Please try again later.", "message": "Message", "messagepreferences": "Message preferences", @@ -28,8 +30,10 @@ "nogroupmessages": "No group messages", "nomessages": "No messages", "nousersfound": "No users found", + "numparticipants": "{{$a}} participants", "removecontact": "Remove contact", "removecontactconfirm": "Contact will be removed from your contacts list.", + "showdeletemessages": "Show delete messages", "type_blocked": "Blocked", "type_offline": "Offline", "type_online": "Online", @@ -37,6 +41,7 @@ "type_strangers": "Others", "unblockuser": "Unblock user", "unblockuserconfirm": "Are you sure you want to unblock {{$a}}?", + "warningconversationmessagenotsent": "Couldn't send message(s) to conversation {{conversation}}. {{error}}", "warningmessagenotsent": "Couldn't send message(s) to user {{user}}. {{error}}", "you": "You:" } \ No newline at end of file diff --git a/src/addon/messages/pages/conversation-info/conversation-info.html b/src/addon/messages/pages/conversation-info/conversation-info.html new file mode 100644 index 000000000..cfebb4a96 --- /dev/null +++ b/src/addon/messages/pages/conversation-info/conversation-info.html @@ -0,0 +1,38 @@ + + + {{ 'addon.messages.groupinfo' | translate }} + + + + + + + + + + + + +
+ +
+

+

+

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

+
+ + + +

+

+ + +

+

+
+ + +
+
diff --git a/src/addon/messages/pages/conversation-info/conversation-info.module.ts b/src/addon/messages/pages/conversation-info/conversation-info.module.ts new file mode 100644 index 000000000..f2b0cb96c --- /dev/null +++ b/src/addon/messages/pages/conversation-info/conversation-info.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { AddonMessagesConversationInfoPage } from './conversation-info'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + AddonMessagesConversationInfoPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(AddonMessagesConversationInfoPage), + TranslateModule.forChild() + ], +}) +export class AddonMessagesConversationInfoPageModule {} diff --git a/src/addon/messages/pages/conversation-info/conversation-info.scss b/src/addon/messages/pages/conversation-info/conversation-info.scss new file mode 100644 index 000000000..26a039907 --- /dev/null +++ b/src/addon/messages/pages/conversation-info/conversation-info.scss @@ -0,0 +1,17 @@ +ion-app.app-root page-addon-messages-group-conversations { + h2 { + display: flex; + justify-content: space-between; + + .note { + margin: 0; + align-self: flex-end; + display: inline-flex; + font-size: initial; + } + } + + core-format-text.addon-message-last-message { + display: inline; + } +} diff --git a/src/addon/messages/pages/conversation-info/conversation-info.ts b/src/addon/messages/pages/conversation-info/conversation-info.ts new file mode 100644 index 000000000..cd2d3dd5f --- /dev/null +++ b/src/addon/messages/pages/conversation-info/conversation-info.ts @@ -0,0 +1,133 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit } from '@angular/core'; +import { IonicPage, NavParams, ViewController } from 'ionic-angular'; +import { AddonMessagesProvider } from '../../providers/messages'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; + +/** + * Page that displays the list of conversations, including group conversations. + */ +@IonicPage({ segment: 'addon-messages-conversation-info' }) +@Component({ + selector: 'page-addon-messages-conversation-info', + templateUrl: 'conversation-info.html', +}) +export class AddonMessagesConversationInfoPage implements OnInit { + + loaded = false; + conversation: any; + members = []; + canLoadMore = false; + loadMoreError = false; + + protected conversationId: number; + + constructor(private messagesProvider: AddonMessagesProvider, private domUtils: CoreDomUtilsProvider, navParams: NavParams, + protected viewCtrl: ViewController) { + this.conversationId = navParams.get('conversationId'); + } + + /** + * Component loaded. + */ + ngOnInit(): void { + this.fetchData().finally(() => { + this.loaded = true; + }); + } + + /** + * Fetch the required data. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchData(): Promise { + // Get the conversation data first. + return this.messagesProvider.getConversation(this.conversationId, false, false, 0, 0).then((conversation) => { + this.conversation = conversation; + + // Now get the members. + return this.fetchMembers(); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting members.'); + }); + } + + /** + * Get conversation members. + * + * @param {boolean} [loadingMore} Whether we are loading more data or just the first ones. + * @return {Promise} Promise resolved when done. + */ + protected fetchMembers(loadingMore?: boolean): Promise { + this.loadMoreError = false; + + const limitFrom = loadingMore ? this.members.length : 0; + + return this.messagesProvider.getConversationMembers(this.conversationId, limitFrom).then((data) => { + if (loadingMore) { + this.members = this.members.concat(data.members); + } else { + this.members = data.members; + } + + this.canLoadMore = data.canLoadMore; + }); + } + + /** + * Function to load more members. + * + * @param {any} [infiniteComplete] Infinite scroll complete function. Only used from core-infinite-loading. + * @return {Promise} Resolved when done. + */ + loadMoreMembers(infiniteComplete?: any): Promise { + return this.fetchMembers(true).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting members.'); + this.loadMoreError = true; + }).finally(() => { + infiniteComplete && infiniteComplete(); + }); + } + + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @return {Promise} Promise resolved when done. + */ + refreshData(refresher?: any): Promise { + const promises = []; + + promises.push(this.messagesProvider.invalidateConversation(this.conversationId)); + promises.push(this.messagesProvider.invalidateConversationMembers(this.conversationId)); + + return Promise.all(promises).then(() => { + return this.fetchData().finally(() => { + refresher && refresher.complete(); + }); + }); + } + + /** + * Close modal. + * + * @param {number} [userId] User conversation to load. + */ + closeModal(userId?: number): void { + this.viewCtrl.dismiss(userId); + } +} diff --git a/src/addon/messages/pages/discussion/discussion.html b/src/addon/messages/pages/discussion/discussion.html index 89e20fe8b..0629928fc 100644 --- a/src/addon/messages/pages/discussion/discussion.html +++ b/src/addon/messages/pages/discussion/discussion.html @@ -1,15 +1,17 @@ - + + + + - - - - + + + + + @@ -18,16 +20,22 @@ - + {{ message.timecreated | coreFormatDate: "LL" }} - + {{ 'addon.messages.newmessages' | translate:{$a: title} }} - + + + + + +

{{ members[message.useridfrom].fullname }}

+

@@ -46,8 +54,9 @@ - + + diff --git a/src/addon/messages/pages/discussion/discussion.ts b/src/addon/messages/pages/discussion/discussion.ts index 558421290..7cbe7df9f 100644 --- a/src/addon/messages/pages/discussion/discussion.ts +++ b/src/addon/messages/pages/discussion/discussion.ts @@ -12,12 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnDestroy, ViewChild } from '@angular/core'; -import { IonicPage, NavParams, NavController, Content } from 'ionic-angular'; +import { Component, OnDestroy, ViewChild, Optional } from '@angular/core'; +import { IonicPage, NavParams, NavController, Content, ModalController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreEventsProvider } from '@providers/events'; import { CoreSitesProvider } from '@providers/sites'; import { AddonMessagesProvider } from '../../providers/messages'; +import { AddonMessagesOfflineProvider } from '../../providers/messages-offline'; import { AddonMessagesSyncProvider } from '../../providers/sync'; import { CoreUserProvider } from '@core/user/providers/user'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; @@ -25,6 +26,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreAppProvider } from '@providers/app'; import { coreSlideInOut } from '@classes/animations'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { Md5 } from 'ts-md5/dist/md5'; import * as moment from 'moment'; @@ -53,12 +55,16 @@ export class AddonMessagesDiscussionPage implements OnDestroy { protected syncObserver: any; protected oldContentHeight = 0; protected keyboardObserver: any; + protected scrollBottom = true; + protected viewDestroyed = false; - userId: number; + conversationId: number; // Conversation ID. Undefined if it's a new individual conversation. + conversation: any; // The conversation object (if it exists). + userId: number; // User ID you're talking to (only if group messaging not enabled or it's a new individual conversation). currentUserId: number; title: string; - profileLink: string; - showProfileLink: boolean; + showInfo: boolean; + conversationImage: string; loaded = false; showKeyboard = false; canLoadMore = false; @@ -66,26 +72,33 @@ export class AddonMessagesDiscussionPage implements OnDestroy { messages = []; showDelete = false; canDelete = false; - scrollBottom = true; - viewDestroyed = false; + groupMessagingEnabled: boolean; + isGroup = false; + members: any = {}; // Members that wrote a message, indexed by ID. constructor(private eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, navParams: NavParams, private userProvider: CoreUserProvider, private navCtrl: NavController, private messagesSync: AddonMessagesSyncProvider, private domUtils: CoreDomUtilsProvider, private messagesProvider: AddonMessagesProvider, logger: CoreLoggerProvider, - private utils: CoreUtilsProvider, private appProvider: CoreAppProvider, private translate: TranslateService) { + private utils: CoreUtilsProvider, private appProvider: CoreAppProvider, private translate: TranslateService, + @Optional() private svComponent: CoreSplitViewComponent, private messagesOffline: AddonMessagesOfflineProvider, + private modalCtrl: ModalController) { + this.siteId = sitesProvider.getCurrentSiteId(); this.currentUserId = sitesProvider.getCurrentSiteUserId(); + this.groupMessagingEnabled = this.messagesProvider.isGroupMessagingEnabled(); this.logger = logger.getInstance('AddonMessagesDiscussionPage'); + this.conversationId = navParams.get('conversationId'); this.userId = navParams.get('userId'); this.showKeyboard = navParams.get('showKeyboard'); // Refresh data if this discussion is synchronized automatically. this.syncObserver = eventsProvider.on(AddonMessagesSyncProvider.AUTO_SYNCED, (data) => { - if (data.userId == this.userId) { + if ((data.userId && data.userId == this.userId) || + (data.conversationId && data.conversationId == this.conversationId)) { // Fetch messages. - this.fetchData(); + this.fetchMessages(); // Show first warning if any. if (data.warnings && data.warnings[0]) { @@ -102,8 +115,8 @@ export class AddonMessagesDiscussionPage implements OnDestroy { * @param {boolean} [keep=true] If set the keep flag or not. */ protected addMessage(message: any, keep: boolean = true): void { - // Use smallmessage instead of message ID because ID changes when a message is read. - message.hash = Md5.hashAsciiStr(message.smallmessage) + '#' + message.timecreated + '#' + message.useridfrom; + // Use text instead of message ID because ID changes when a message is read. + message.hash = Md5.hashAsciiStr(message.text || '') + '#' + message.timecreated + '#' + message.useridfrom; if (typeof this.keepMessageMap[message.hash] === 'undefined') { // Message not added to the list. Add it now. this.messages.push(message); @@ -130,7 +143,7 @@ export class AddonMessagesDiscussionPage implements OnDestroy { const position = this.messages.findIndex((message) => { return message.hash == hash; }); - if (position > 0) { + if (position >= 0) { this.messages.splice(position, 1); } } @@ -143,42 +156,55 @@ export class AddonMessagesDiscussionPage implements OnDestroy { ionViewDidLoad(): void { // Disable the profile button if we're already coming from a profile. const backViewPage = this.navCtrl.getPrevious() && this.navCtrl.getPrevious().component.name; - this.showProfileLink = !backViewPage || backViewPage !== 'CoreUserProfilePage'; + this.showInfo = !backViewPage || backViewPage !== 'CoreUserProfilePage'; - // Get the user profile to retrieve the user fullname and image. - this.userProvider.getProfile(this.userId, undefined, true).then((user) => { - if (!this.title) { - this.title = user.fullname; - } - this.profileLink = user.profileimageurl; - }); + if (!this.groupMessagingEnabled && this.userId) { + // Get the user profile to retrieve the user fullname and image. + this.userProvider.getProfile(this.userId, undefined, true).then((user) => { + if (!this.title) { + this.title = user.fullname; + } + this.conversationImage = user.profileimageurl; + }); + } // Synchronize messages if needed. - this.messagesSync.syncDiscussion(this.userId).catch(() => { + this.messagesSync.syncDiscussion(this.conversationId, this.userId).catch(() => { // Ignore errors. }).then((warnings) => { if (warnings && warnings[0]) { this.domUtils.showErrorModal(warnings[0]); } - // Fetch the messages for the first time. - return this.fetchData().then(() => { - if (!this.title && this.messages.length) { - // Didn't receive the fullname via argument. Try to get it from messages. - // It's possible that name cannot be resolved when no messages were yet exchanged. - if (this.messages[0].useridto != this.currentUserId) { - this.title = this.messages[0].usertofullname || ''; - } else { - this.title = this.messages[0].userfromfullname || ''; + if (this.groupMessagingEnabled) { + // Get the conversation ID if it exists and we don't have it yet. + return this.getConversation(this.conversationId, this.userId).then((exists) => { + if (exists) { + // Fetch the messages for the first time. + return this.fetchMessages(); } - } - }).catch((error) => { - this.domUtils.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingmessages', true); - }).finally(() => { - this.checkCanDelete(); - this.resizeContent(); - this.loaded = true; - }); + }); + } else { + // Fetch the messages for the first time. + return this.fetchMessages().then(() => { + if (!this.title && this.messages.length) { + // Didn't receive the fullname via argument. Try to get it from messages. + // It's possible that name cannot be resolved when no messages were yet exchanged. + if (this.messages[0].useridto != this.currentUserId) { + this.title = this.messages[0].usertofullname || ''; + } else { + this.title = this.messages[0].userfromfullname || ''; + } + } + }); + } + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingmessages', true); + }).finally(() => { + this.checkCanDelete(); + this.resizeContent(); + this.loaded = true; + this.setPolling(); // Make sure we're polling messages. }); // Recalculate footer position when keyboard is shown or hidden. @@ -204,12 +230,12 @@ export class AddonMessagesDiscussionPage implements OnDestroy { /** * Convenience function to fetch messages. + * * @return {Promise} Resolved when done. */ - protected fetchData(): Promise { + protected fetchMessages(): Promise { this.loadMoreError = false; - this.logger.debug(`Polling new messages for discussion with user '${this.userId}'`); if (this.messagesBeingSent > 0) { // We do not poll while a message is being sent or we could confuse the user. // Otherwise, his message would disappear from the list, and he'd have to wait for the interval to check for messages. @@ -217,55 +243,179 @@ export class AddonMessagesDiscussionPage implements OnDestroy { } else if (this.fetching) { // Already fetching. return Promise.reject(null); + } else if (this.groupMessagingEnabled && !this.conversationId) { + // Don't have enough data to fetch messages. + return Promise.reject(null); + } + + if (this.conversationId) { + this.logger.debug(`Polling new messages for conversation '${this.conversationId}'`); + } else { + this.logger.debug(`Polling new messages for discussion with user '${this.userId}'`); } this.fetching = true; // Wait for synchronization process to finish. - return this.messagesSync.waitForSync(this.userId).then(() => { + return this.messagesSync.waitForSyncConversation(this.conversationId, this.userId).then(() => { // Fetch messages. Invalidate the cache before fetching. - return this.messagesProvider.invalidateDiscussionCache(this.userId).catch(() => { - // Ignore errors. - }); - }).then(() => { - return this.getDiscussion(this.pagesLoaded); + if (this.groupMessagingEnabled) { + return this.messagesProvider.invalidateConversationMessages(this.conversationId).catch(() => { + // Ignore errors. + }).then(() => { + return this.getConversationMessages(this.pagesLoaded); + }); + } else { + return this.messagesProvider.invalidateDiscussionCache(this.userId).catch(() => { + // Ignore errors. + }).then(() => { + return this.getDiscussionMessages(this.pagesLoaded); + }); + } }).then((messages) => { - if (this.viewDestroyed) { - return Promise.resolve(); - } - - // Check if we are at the bottom to scroll it after render. - this.scrollBottom = this.domUtils.getScrollHeight(this.content) - this.domUtils.getScrollTop(this.content) === - this.domUtils.getContentHeight(this.content); - - if (this.messagesBeingSent > 0) { - // Ignore polling due to a race condition. - return Promise.reject(null); - } - - // Add new messages to the list and mark the messages that should still be displayed. - messages.forEach((message) => { - this.addMessage(message); - }); - - // Remove messages that shouldn't be in the list anymore. - for (const hash in this.keepMessageMap) { - this.removeMessage(hash); - } - - // Sort the messages. - this.messagesProvider.sortMessages(this.messages); - - // Notify that there can be a new message. - this.notifyNewMessage(); - - // Mark retrieved messages as read if they are not. - this.markMessagesAsRead(); + this.loadMessages(messages); }).finally(() => { this.fetching = false; }); } + /** + * Format and load a list of messages into the view. + * + * @param {any[]} messages Messages to load. + */ + protected loadMessages(messages: any[]): void { + if (this.viewDestroyed) { + return; + } + + // Check if we are at the bottom to scroll it after render. + this.scrollBottom = this.domUtils.getScrollHeight(this.content) - this.domUtils.getScrollTop(this.content) === + this.domUtils.getContentHeight(this.content); + + if (this.messagesBeingSent > 0) { + // Ignore polling due to a race condition. + return; + } + + // Add new messages to the list and mark the messages that should still be displayed. + messages.forEach((message) => { + this.addMessage(message); + }); + + // Remove messages that shouldn't be in the list anymore. + for (const hash in this.keepMessageMap) { + this.removeMessage(hash); + } + + // Sort the messages. + this.messagesProvider.sortMessages(this.messages); + + // Calculate which messages need to display the date or user data. + this.messages.forEach((message, index): any => { + message.showDate = this.showDate(message, this.messages[index - 1]); + message.showUserData = this.showUserData(message, this.messages[index - 1]); + }); + + // Notify that there can be a new message. + this.notifyNewMessage(); + + // Mark retrieved messages as read if they are not. + this.markMessagesAsRead(); + } + + /** + * Get the conversation. + * + * @param {number} conversationId Conversation ID. + * @param {number} userId User ID. + * @return {Promise} Promise resolved with a boolean: whether the conversation exists or not. + */ + protected getConversation(conversationId: number, userId: number): Promise { + let promise; + + if (conversationId) { + // Retrieve the conversation. Invalidate data first to get the right unreadcount. + promise = this.messagesProvider.invalidateConversation(conversationId).then(() => { + return this.messagesProvider.getConversation(conversationId); + }); + } else { + // We don't have the conversation ID, check if it exists. + promise = this.messagesProvider.getConversationBetweenUsers(userId).catch((error) => { + + // Probably conversation does not exist or user is offline. Try to load offline messages. + return this.messagesOffline.getMessages(userId).then((messages) => { + if (messages && messages.length) { + // We have offline messages, this probably means that the conversation didn't exist. Don't display error. + messages.forEach((message) => { + message.pending = true; + message.text = message.smallmessage; + }); + + this.loadMessages(messages); + } else if (error.errorcode != 'errorconversationdoesnotexist') { + // Display the error. + return Promise.reject(error); + } + }); + }); + } + + return promise.then((conversation) => { + this.conversation = conversation; + + if (conversation) { + this.conversationId = conversation.id; + this.title = conversation.name; + this.conversationImage = conversation.imageurl; + this.isGroup = conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP; + if (!this.isGroup) { + this.userId = conversation.userid; + } + + return true; + } else { + return false; + } + }); + } + + /** + * Get the messages of the conversation. Used if group messaging is supported. + * + * @param {number} pagesToLoad Number of "pages" to load. + * @param {number} [offset=0] Offset for message list. + * @return {Promise} Promise resolved with the list of messages. + */ + protected getConversationMessages(pagesToLoad: number, offset: number = 0): Promise { + const excludePending = offset > 0; + + return this.messagesProvider.getConversationMessages(this.conversationId, excludePending, offset).then((result) => { + pagesToLoad--; + + // Treat members. Don't use CoreUtilsProvider.arrayToObject because we don't want to override the existing object. + if (result.members) { + result.members.forEach((member) => { + this.members[member.id] = member; + }); + } + + if (pagesToLoad > 0 && result.canLoadMore) { + offset += AddonMessagesProvider.LIMIT_MESSAGES; + + // Get more messages. + return this.getConversationMessages(pagesToLoad, offset).then((nextMessages) => { + return result.messages.concat(nextMessages); + }); + } else { + // No more messages to load, return them. + this.canLoadMore = result.canLoadMore; + + return result.messages; + } + }); + } + /** * Get a discussion. Can load several "pages". * @@ -276,8 +426,8 @@ export class AddonMessagesDiscussionPage implements OnDestroy { * @param {number} [lfSentRead=0] Number of read sent messages already fetched, so fetch will be done from this. * @return {Promise} Resolved when done. */ - protected getDiscussion(pagesToLoad: number, lfReceivedUnread: number = 0, lfReceivedRead: number = 0, lfSentUnread: number = 0, - lfSentRead: number = 0): Promise { + protected getDiscussionMessages(pagesToLoad: number, lfReceivedUnread: number = 0, lfReceivedRead: number = 0, + lfSentUnread: number = 0, lfSentRead: number = 0): Promise { // Only get offline messages if we're loading the first "page". const excludePending = lfReceivedUnread > 0 || lfReceivedRead > 0 || lfSentUnread > 0 || lfSentRead > 0; @@ -308,7 +458,7 @@ export class AddonMessagesDiscussionPage implements OnDestroy { }); // Get next messages. - return this.getDiscussion(pagesToLoad, lfReceivedUnread, lfReceivedRead, lfSentUnread, lfSentRead) + return this.getDiscussionMessages(pagesToLoad, lfReceivedUnread, lfReceivedRead, lfSentUnread, lfSentRead) .then((nextMessages) => { return result.messages.concat(nextMessages); }); @@ -330,23 +480,39 @@ export class AddonMessagesDiscussionPage implements OnDestroy { if (this.messagesProvider.isMarkAllMessagesReadEnabled()) { let messageUnreadFound = false; - // Mark all messages at a time if one messages is unread. - for (const x in this.messages) { - const message = this.messages[x]; - // If an unread message is found, mark all messages as read. - if (message.useridfrom != this.currentUserId && message.read == 0) { - messageUnreadFound = true; - break; + + // Mark all messages at a time if there is any unread message. + if (this.groupMessagingEnabled) { + messageUnreadFound = this.conversation && this.conversation.unreadcount > 0 && this.conversationId > 0; + } else { + for (const x in this.messages) { + const message = this.messages[x]; + // If an unread message is found, mark all messages as read. + if (message.useridfrom != this.currentUserId && message.read == 0) { + messageUnreadFound = true; + break; + } } } + if (messageUnreadFound) { this.setUnreadLabelPosition(); - promises.push(this.messagesProvider.markAllMessagesRead(this.userId).then(() => { - readChanged = true; - // Mark all messages as read. - this.messages.forEach((message) => { - message.read = 1; + + let promise; + + if (this.groupMessagingEnabled) { + promise = this.messagesProvider.markAllConversationMessagesRead(this.conversationId); + } else { + promise = this.messagesProvider.markAllMessagesRead(this.userId).then(() => { + // Mark all messages as read. + this.messages.forEach((message) => { + message.read = 1; + }); }); + } + + promises.push(promise.then(() => { + readChanged = true; })); } } else { @@ -366,6 +532,7 @@ export class AddonMessagesDiscussionPage implements OnDestroy { Promise.all(promises).finally(() => { if (readChanged) { this.eventsProvider.trigger(AddonMessagesProvider.READ_CHANGED_EVENT, { + conversationId: this.conversationId, userId: this.userId }, this.siteId); } @@ -390,6 +557,7 @@ export class AddonMessagesDiscussionPage implements OnDestroy { if (trigger) { // Update discussions last message. this.eventsProvider.trigger(AddonMessagesProvider.NEW_MESSAGE_EVENT, { + conversationId: this.conversationId, userId: this.userId, message: this.lastMessage.text, timecreated: this.lastMessage.timecreated @@ -411,21 +579,39 @@ export class AddonMessagesDiscussionPage implements OnDestroy { return; } - let previousMessageRead = false; + if (this.groupMessagingEnabled) { + // Use the unreadcount from the conversation to calculate where should the label be placed. + if (this.conversation && this.conversation.unreadcount > 0 && this.messages) { + // Iterate over messages to find the right message using the unreadcount. Skip offline messages and own messages. + let found = 0; - for (const x in this.messages) { - const message = this.messages[x]; - if (message.useridfrom != this.currentUserId) { - // Place unread from message label only once. - message.unreadFrom = message.read == 0 && previousMessageRead; - - if (message.unreadFrom) { - // Save where the label is placed. - this.unreadMessageFrom = parseInt(message.id, 10); - break; + for (let i = this.messages.length - 1; i >= 0; i--) { + const message = this.messages[i]; + if (!message.pending && message.useridfrom != this.currentUserId) { + found++; + if (found == this.conversation.unreadcount) { + this.unreadMessageFrom = parseInt(message.id, 10); + break; + } + } } + } + } else { + let previousMessageRead = false; - previousMessageRead = message.read != 0; + for (const x in this.messages) { + const message = this.messages[x]; + if (message.useridfrom != this.currentUserId) { + const unreadFrom = message.read == 0 && previousMessageRead; + + if (unreadFrom) { + // Save where the label is placed. + this.unreadMessageFrom = parseInt(message.id, 10); + break; + } + + previousMessageRead = message.read != 0; + } } } @@ -450,15 +636,6 @@ export class AddonMessagesDiscussionPage implements OnDestroy { */ protected hideUnreadLabel(): void { if (this.unreadMessageFrom > 0) { - for (const x in this.messages) { - const message = this.messages[x]; - if (message.id == this.unreadMessageFrom) { - message.unreadFrom = false; - break; - } - } - - // Label hidden. this.unreadMessageFrom = -1; } } @@ -487,10 +664,15 @@ export class AddonMessagesDiscussionPage implements OnDestroy { * Set a polling to get new messages every certain time. */ protected setPolling(): void { + if (this.groupMessagingEnabled && !this.conversationId) { + // Don't have enough data to poll messages. + return; + } + if (!this.polling) { // Start polling. this.polling = setInterval(() => { - this.fetchData().catch(() => { + this.fetchMessages().catch(() => { // Ignore errors. }); }, AddonMessagesProvider.POLL_INTERVAL); @@ -509,19 +691,19 @@ export class AddonMessagesDiscussionPage implements OnDestroy { } /** - * Copy message to clipboard + * Copy message to clipboard. * - * @param {string} text Message text to be copied. + * @param {any} message Message to be copied. */ - copyMessage(text: string): void { - this.utils.copyToClipboard(text); + copyMessage(message: any): void { + this.utils.copyToClipboard(message.smallmessage || message.text || ''); } /** * Function to delete a message. * * @param {any} message Message object to delete. - * @param {number} index Index where the mesasge is to delete it from the view. + * @param {number} index Index where the message is to delete it from the view. */ deleteMessage(message: any, index: number): void { const langKey = message.pending ? 'core.areyousure' : 'addon.messages.deletemessageconfirmation'; @@ -534,7 +716,7 @@ export class AddonMessagesDiscussionPage implements OnDestroy { this.removeMessage(message.hash); this.notifyNewMessage(); - this.fetchData(); // Re-fetch messages to update cached data. + this.fetchMessages(); // Re-fetch messages to update cached data. }).finally(() => { modal.dismiss(); }); @@ -554,9 +736,8 @@ export class AddonMessagesDiscussionPage implements OnDestroy { return this.waitForFetch().finally(() => { this.pagesLoaded++; - this.fetchData().catch((error) => { + this.fetchMessages().catch((error) => { this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. - this.pagesLoaded--; this.domUtils.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingmessages', true); }).finally(() => { @@ -606,6 +787,7 @@ export class AddonMessagesDiscussionPage implements OnDestroy { /** * Sends a message to the server. + * * @param {string} text Message text. */ sendMessage(text: string): void { @@ -624,6 +806,7 @@ export class AddonMessagesDiscussionPage implements OnDestroy { text: text, timecreated: new Date().getTime() }; + message.showDate = this.showDate(message, this.messages[this.messages.length - 1]); this.addMessage(message, false); this.messagesBeingSent++; @@ -631,14 +814,33 @@ export class AddonMessagesDiscussionPage implements OnDestroy { // If there is an ongoing fetch, wait for it to finish. // Otherwise, if a message is sent while fetching it could disappear until the next fetch. this.waitForFetch().finally(() => { - this.messagesProvider.sendMessage(this.userId, text).then((data) => { + let promise; + + if (this.conversationId) { + promise = this.messagesProvider.sendMessageToConversation(this.conversation, text); + } else { + promise = this.messagesProvider.sendMessage(this.userId, text); + } + + promise.then((data) => { let promise; this.messagesBeingSent--; if (data.sent) { - // Message was sent, fetch messages right now. - promise = this.fetchData(); + if (!this.conversationId && data.message && data.message.conversationid) { + // Message sent to a new conversation, try to load the conversation. + promise = this.getConversation(data.message.conversationid, this.userId).then(() => { + // Now fetch messages. + return this.fetchMessages(); + }).finally(() => { + // Start polling messages now that the conversation exists. + this.setPolling(); + }); + } else { + // Message was sent, fetch messages right now. + promise = this.fetchMessages(); + } } else { promise = Promise.reject(null); } @@ -681,15 +883,25 @@ export class AddonMessagesDiscussionPage implements OnDestroy { if (!prevMessage) { // First message, show it. return true; - } else if (message.pending) { - // If pending, it has no date, not show. - return false; } // Check if day has changed. return !moment(message.timecreated).isSame(prevMessage.timecreated, 'day'); } + /** + * Check if the user info should be displayed for the current message. + * User data is only displayed for group conversations if the previous message was from another user. + * + * @param {any} message Current message where to show the user info. + * @param {any} [prevMessage] Previous message. + * @return {boolean} Whether user data should be shown. + */ + showUserData(message: any, prevMessage?: any): boolean { + return this.isGroup && message.useridfrom != this.currentUserId && this.members[message.useridfrom] && + (!prevMessage || prevMessage.useridfrom != message.useridfrom || message.showDate); + } + /** * Toggles delete state. */ @@ -697,6 +909,37 @@ export class AddonMessagesDiscussionPage implements OnDestroy { this.showDelete = !this.showDelete; } + /** + * View info. If it's an individual conversation, go to the user profile. + * If it's a group conversation, view info about the group. + */ + viewInfo(): void { + if (this.isGroup) { + // Display the group information. + const modal = this.modalCtrl.create('AddonMessagesConversationInfoPage', { + conversationId: this.conversationId + }); + + modal.present(); + modal.onDidDismiss((userId) => { + if (typeof userId != 'undefined') { + // Open user conversation. + if (this.svComponent) { + // Notify the left pane to load it, this way the right conversation will be highlighted. + this.eventsProvider.trigger(AddonMessagesProvider.OPEN_CONVERSATION_EVENT, {userId: userId}, this.siteId); + } else { + // Open the discussion in a new view. + this.navCtrl.push('AddonMessagesDiscussionPage', {userId: userId}); + } + } + }); + } else { + // Open the user profile. + const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + navCtrl.push('CoreUserProfilePage', { userId: this.userId }); + } + } + /** * Page destroyed. */ diff --git a/src/addon/messages/pages/group-conversations/group-conversations.html b/src/addon/messages/pages/group-conversations/group-conversations.html index c92ff7845..a4cfd033d 100644 --- a/src/addon/messages/pages/group-conversations/group-conversations.html +++ b/src/addon/messages/pages/group-conversations/group-conversations.html @@ -8,6 +8,8 @@ + + @@ -27,13 +29,19 @@

{{ 'core.searchresults' | translate }}

{{ search.results.length }} - - - - - -

-

+ +
+ +

+

+ + +

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

+

@@ -49,7 +57,7 @@
- +

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

@@ -63,9 +71,9 @@
- + - +

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

@@ -80,7 +88,7 @@
- +

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

@@ -94,13 +102,16 @@ - - - - - + + + + + + + + +

diff --git a/src/addon/messages/pages/group-conversations/group-conversations.ts b/src/addon/messages/pages/group-conversations/group-conversations.ts index 091df7e7a..2c8cf2adc 100644 --- a/src/addon/messages/pages/group-conversations/group-conversations.ts +++ b/src/addon/messages/pages/group-conversations/group-conversations.ts @@ -13,16 +13,18 @@ // limitations under the License. import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; -import { IonicPage, Platform, NavParams } from 'ionic-angular'; +import { IonicPage, Platform, NavParams, Content } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreEventsProvider } from '@providers/events'; import { CoreSitesProvider } from '@providers/sites'; import { AddonMessagesProvider } from '../../providers/messages'; +import { AddonMessagesOfflineProvider } from '../../providers/messages-offline'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreAppProvider } from '@providers/app'; import { AddonPushNotificationsDelegate } from '@addon/pushnotifications/providers/delegate'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreUserProvider } from '@core/user/providers/user'; /** * Page that displays the list of conversations, including group conversations. @@ -34,10 +36,12 @@ import { CoreSplitViewComponent } from '@components/split-view/split-view'; }) export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; + @ViewChild(Content) content: Content; loaded = false; loadingMessage: string; - selectedConversation: number; + selectedConversationId: number; + selectedUserId: number; search = { enabled: false, showResults: false, @@ -57,6 +61,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { type: AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL, favourites: false }; + typeIndividual = AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL; protected loadingString: string; protected siteId: string; @@ -67,11 +72,13 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { protected appResumeSubscription: any; protected readChangedObserver: any; protected cronObserver: any; + protected openConversationObserver: any; constructor(private eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, translate: TranslateService, private messagesProvider: AddonMessagesProvider, private domUtils: CoreDomUtilsProvider, navParams: NavParams, private appProvider: CoreAppProvider, platform: Platform, utils: CoreUtilsProvider, - pushNotificationsDelegate: AddonPushNotificationsDelegate) { + pushNotificationsDelegate: AddonPushNotificationsDelegate, private messagesOffline: AddonMessagesOfflineProvider, + private userProvider: CoreUserProvider) { this.search.loading = translate.instant('core.searching'); this.loadingString = translate.instant('core.loading'); @@ -81,20 +88,29 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { // Update conversations when new message is received. this.newMessagesObserver = eventsProvider.on(AddonMessagesProvider.NEW_MESSAGE_EVENT, (data) => { - if (data.conversationId) { - // Search the conversation to update. - const conversation = this.findConversation(data.conversationId); + // Search the conversation to update. + const conversation = this.findConversation(data.conversationId, data.userId); - if (typeof conversation == 'undefined') { - // Probably a new conversation, refresh the list. - this.loaded = false; - this.refreshData().finally(() => { - this.loaded = true; - }); - } else { - // An existing conversation has a new message, update the last message. - conversation.lastmessage = data.message; - conversation.lastmessagedate = data.timecreated; + if (typeof conversation == 'undefined') { + // Probably a new conversation, refresh the list. + this.loaded = false; + this.refreshData().finally(() => { + this.loaded = true; + }); + } else if (conversation.lastmessage != data.message || conversation.lastmessagedate != data.timecreated / 1000) { + const isNewer = data.timecreated / 1000 > conversation.lastmessagedate; + + // 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 = this.messagesProvider.sortConversations(option.conversations); + + if (isNewer) { + // The last message is newer than the previous one, scroll to top to keep viewing the conversation. + this.domUtils.scrollToTop(this.content); } } }, this.siteId); @@ -119,6 +135,13 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { this.refreshData(); }, this.siteId); + // Load a discussion if we receive an event to do so. + this.openConversationObserver = eventsProvider.on(AddonMessagesProvider.OPEN_CONVERSATION_EVENT, (data) => { + if (data.conversationId || data.userId) { + this.gotoConversation(data.conversationId, data.userId, undefined, true); + } + }, this.siteId); + // Refresh the view when the app is resumed. this.appResumeSubscription = platform.resume.subscribe(() => { if (!this.loaded) { @@ -145,7 +168,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { ngOnInit(): void { if (this.conversationId) { // There is a discussion to load, open the discussion in a new state. - this.gotoConversation(this.conversationId, null); + this.gotoConversation(this.conversationId); } this.fetchData().then(() => { @@ -162,7 +185,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { } if (conversation) { - this.gotoConversation(conversation.id, conversation.userid); + this.gotoConversation(conversation.id); } } }); @@ -179,14 +202,39 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { // Load the first conversations of each type. const promises = []; + let offlineMessages; promises.push(this.fetchDataForOption(this.favourites, false)); promises.push(this.fetchDataForOption(this.group, false)); promises.push(this.fetchDataForOption(this.individual, false)); + promises.push(this.messagesOffline.getAllMessages().then((messages) => { + offlineMessages = messages; + })); return Promise.all(promises).then(() => { + return this.loadOfflineMessages(offlineMessages); + }).then(() => { + if (offlineMessages && offlineMessages.length) { + // Sort the conversations, the offline messages could affect the order. + this.favourites.conversations = this.messagesProvider.sortConversations(this.favourites.conversations); + this.group.conversations = this.messagesProvider.sortConversations(this.group.conversations); + this.individual.conversations = this.messagesProvider.sortConversations(this.individual.conversations); + } + if (typeof this.favourites.expanded == 'undefined') { // The expanded status hasn't been initialized. Do it now. + if (this.conversationId) { + // A certain conversation should be opened, expand its option. + const conversation = this.findConversation(this.conversationId); + if (conversation) { + const option = this.getConversationOption(conversation); + option.expanded = true; + + return; + } + } + + // No conversation specified or not found, determine which one should be expanded. this.favourites.expanded = this.favourites.count != 0; this.group.expanded = this.favourites.count == 0 && this.group.count != 0; this.individual.expanded = this.favourites.count == 0 && this.group.count == 0; @@ -206,6 +254,8 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { * @return {Promise} Promise resolved when done. */ fetchDataForOption(option: any, loadingMore?: boolean): Promise { + option.loadMoreError = false; + const limitFrom = loadingMore ? option.conversations.length : 0; return this.messagesProvider.getConversations(option.type, option.favourites, limitFrom).then((data) => { @@ -225,15 +275,22 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { * Find a conversation in the list of loaded conversations. * * @param {number} conversationId The conversation ID to search. + * @param {number} userId User ID to search (if no conversationId). * @return {any} Conversation. */ - protected findConversation(conversationId: number): any { - const conversations = (this.favourites.conversations || []).concat(this.group.conversations || []) - .concat(this.individual.conversations || []); + protected findConversation(conversationId: number, userId?: number): any { + if (conversationId) { + const conversations = (this.favourites.conversations || []).concat(this.group.conversations || []) + .concat(this.individual.conversations || []); - return conversations.find((conv) => { - return conv.id == conversationId; - }); + return conversations.find((conv) => { + return conv.id == conversationId; + }); + } else if (this.individual.conversations) { + return this.individual.conversations.find((conv) => { + return conv.userid == userId; + }); + } } /** @@ -247,19 +304,39 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { * Navigate to a particular conversation. * * @param {number} conversationId Conversation Id to load. - * @param {number} userId User of the conversation. @todo This will probably be removed when group messaging is fully supported. + * @param {number} userId User of the conversation. Only if there is no conversationId. * @param {number} [messageId] Message to scroll after loading the discussion. Used when searching. + * @param {boolean} [scrollToConversation] Whether to scroll to the conversation. */ - gotoConversation(conversationId: number, userId: number, messageId?: number): void { - this.selectedConversation = conversationId; + gotoConversation(conversationId: number, userId?: number, messageId?: number, scrollToConversation?: boolean): void { + this.selectedConversationId = conversationId; + this.selectedUserId = userId; const params = { + conversationId: conversationId, userId: userId }; if (messageId) { params['message'] = messageId; } this.splitviewCtrl.push('AddonMessagesDiscussionPage', params); + + if (scrollToConversation) { + // Search the conversation. + const conversation = this.findConversation(conversationId, userId); + if (conversation) { + // First expand the option if it isn't expanded. + const option = this.getConversationOption(conversation); + this.expandOption(option); + + // Wait for the view to expand the option. + setTimeout(() => { + // Now scroll to the conversation. + this.domUtils.scrollToElementBySelector(this.content, '#addon-message-conversation-' + + (conversation.id ? conversation.id : 'user-' + conversation.userid)); + }); + } + } } /** @@ -279,12 +356,113 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { loadMoreConversations(option: any, infiniteComplete?: any): Promise { return this.fetchDataForOption(option, true).catch((error) => { this.domUtils.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true); - option.canLoadMore = false; + option.loadMoreError = true; }).finally(() => { infiniteComplete && infiniteComplete(); }); } + /** + * Load offline messages into the conversations. + * + * @param {any[]} messages Offline messages. + * @return {Promise} Promise resolved when done. + */ + protected loadOfflineMessages(messages: any[]): Promise { + const promises = []; + + messages.forEach((message) => { + if (message.conversationid) { + // It's an existing conversation. Search it. + let conversation = this.findConversation(message.conversationid); + + 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 <= message.timecreated / 1000) { + + this.addLastOfflineMessage(conversation, message); + } + } else { + // Conversation not found, it's probably an old one. Add it. + conversation = message.conversation || {}; + conversation.id = message.conversationid; + + this.addLastOfflineMessage(conversation, message); + this.addOfflineConversation(conversation); + } + } else { + // Its 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); + + 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 <= message.timecreated / 1000) { + this.addLastOfflineMessage(conversation, message); + } + } else { + // Get the user data and create a new conversation. + promises.push(this.userProvider.getProfile(message.touserid, undefined, true).catch(() => { + // User not found. + }).then((user) => { + const conversation = { + userid: message.touserid, + name: user ? user.fullname : String(message.touserid), + imageurl: user ? user.profileimageurl : '', + type: AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL + }; + + this.addLastOfflineMessage(conversation, message); + this.addOfflineConversation(conversation); + })); + } + } + }); + + return Promise.all(promises); + } + + /** + * Add an offline conversation into the right list of conversations. + * + * @param {any} conversation Offline conversation to add. + */ + protected addOfflineConversation(conversation: any): void { + const option = this.getConversationOption(conversation); + option.conversations.unshift(conversation); + } + + /** + * Add a last offline message into a conversation. + * + * @param {any} conversation Conversation where to put the last message. + * @param {any} message Offline message to add. + */ + protected addLastOfflineMessage(conversation: any, message: any): 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 {any} conversation Conversation to check. + * @return {any} Option object. + */ + protected getConversationOption(conversation: any): any { + if (conversation.isfavourite) { + return this.favourites; + } else if (conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP) { + return this.group; + } else { + return this.individual; + } + } + /** * Refresh the data. * @@ -313,14 +491,23 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { // Already expanded, close it. option.expanded = false; } else { - // Collapse all and expand the clicked one. - this.favourites.expanded = false; - this.group.expanded = false; - this.individual.expanded = false; - option.expanded = true; + this.expandOption(option); } } + /** + * Expand a certain option. + * + * @param {any} option The option to expand. + */ + protected expandOption(option: any): void { + // Collapse all and expand the right one. + this.favourites.expanded = false; + this.group.expanded = false; + this.individual.expanded = false; + option.expanded = true; + } + /** * Clear search and show conversations again. */ @@ -358,10 +545,11 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { * Page destroyed. */ ngOnDestroy(): void { - this.newMessagesObserver && this.newMessagesObserver.unsubscribe(); + this.newMessagesObserver && this.newMessagesObserver.off(); this.appResumeSubscription && this.appResumeSubscription.unsubscribe(); this.pushObserver && this.pushObserver.unsubscribe(); this.readChangedObserver && this.readChangedObserver.off(); this.cronObserver && this.cronObserver.off(); + this.openConversationObserver && this.openConversationObserver.off(); } } diff --git a/src/addon/messages/providers/messages-offline.ts b/src/addon/messages/providers/messages-offline.ts index 5bf12837c..f746c9147 100644 --- a/src/addon/messages/providers/messages-offline.ts +++ b/src/addon/messages/providers/messages-offline.ts @@ -16,6 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreAppProvider } from '@providers/app'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; /** * Service to handle Offline messages. @@ -26,7 +27,8 @@ export class AddonMessagesOfflineProvider { protected logger; // Variables for database. - static MESSAGES_TABLE = 'addon_messages_offline_messages'; + static MESSAGES_TABLE = 'addon_messages_offline_messages'; // When group messaging isn't available or a new conversation starts. + static CONVERSATION_MESSAGES_TABLE = 'addon_messages_offline_conversation_messages'; // Conversation messages. protected tablesSchema = [ { name: AddonMessagesOfflineProvider.MESSAGES_TABLE, @@ -53,14 +55,60 @@ export class AddonMessagesOfflineProvider { } ], primaryKeys: ['touserid', 'smallmessage', 'timecreated'] + }, + { + name: AddonMessagesOfflineProvider.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'] } ]; - constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private appProvider: CoreAppProvider) { + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private appProvider: CoreAppProvider, + private textUtils: CoreTextUtilsProvider) { this.logger = logger.getInstance('AddonMessagesOfflineProvider'); this.sitesProvider.createTablesFromSchema(this.tablesSchema); } + /** + * Delete a message. + * + * @param {number} conversationId Conversation ID. + * @param {string} message The message. + * @param {number} timeCreated The time the message was created. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if stored, rejected if failure. + */ + deleteConversationMessage(conversationId: number, message: string, timeCreated: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(AddonMessagesOfflineProvider.CONVERSATION_MESSAGES_TABLE, { + conversationid: conversationId, + text: message, + timecreated: timeCreated + }); + }); + } + /** * Delete a message. * @@ -84,24 +132,20 @@ export class AddonMessagesOfflineProvider { * Get all messages where deviceoffline is set to 1. * * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved with messages. + * @return {Promise} Promise resolved with messages. */ - getAllDeviceOfflineMessages(siteId?: string): Promise { + getAllDeviceOfflineMessages(siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { - return site.getDb().getRecords(AddonMessagesOfflineProvider.MESSAGES_TABLE, {deviceoffline: 1}); - }); - } + const promises = []; - /** - * Get offline messages to send to a certain user. - * - * @param {number} toUserId User ID to get messages to. - * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved with messages. - */ - getMessages(toUserId: number, siteId?: string): Promise { - return this.sitesProvider.getSite(siteId).then((site) => { - return site.getDb().getRecords(AddonMessagesOfflineProvider.MESSAGES_TABLE, {touserid: toUserId}); + promises.push(site.getDb().getRecords(AddonMessagesOfflineProvider.MESSAGES_TABLE, {deviceoffline: 1})); + promises.push(site.getDb().getRecords(AddonMessagesOfflineProvider.CONVERSATION_MESSAGES_TABLE, {deviceoffline: 1})); + + return Promise.all(promises).then((results) => { + results[1] = this.parseConversationMessages(results[1]); + + return results[0].concat(results[1]); + }); }); } @@ -113,7 +157,59 @@ export class AddonMessagesOfflineProvider { */ getAllMessages(siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { - return site.getDb().getAllRecords(AddonMessagesOfflineProvider.MESSAGES_TABLE); + const promises = []; + + promises.push(site.getDb().getAllRecords(AddonMessagesOfflineProvider.MESSAGES_TABLE)); + promises.push(site.getDb().getAllRecords(AddonMessagesOfflineProvider.CONVERSATION_MESSAGES_TABLE)); + + return Promise.all(promises).then((results) => { + results[1] = this.parseConversationMessages(results[1]); + + return results[0].concat(results[1]); + }); + }); + } + + /** + * Get offline messages to send to a certain user. + * + * @param {number} conversationId Conversation ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with messages. + */ + getConversationMessages(conversationId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(AddonMessagesOfflineProvider.CONVERSATION_MESSAGES_TABLE, + {conversationid: conversationId}).then((messages) => { + + return this.parseConversationMessages(messages); + }); + }); + } + + /** + * Get offline messages to send to a certain user. + * + * @param {number} toUserId User ID to get messages to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with messages. + */ + getMessages(toUserId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(AddonMessagesOfflineProvider.MESSAGES_TABLE, {touserid: toUserId}); + }); + } + + /** + * Check if there are offline messages to send to a conversation. + * + * @param {number} conversationId Conversation ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: true if has offline messages, false otherwise. + */ + hasConversationMessages(conversationId: number, siteId?: string): Promise { + return this.getConversationMessages(conversationId, siteId).then((messages) => { + return !!messages.length; }); } @@ -122,14 +218,64 @@ export class AddonMessagesOfflineProvider { * * @param {number} toUserId User ID to check. * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved with boolean: true if has offline messages, false otherwise. + * @return {Promise} Promise resolved with boolean: true if has offline messages, false otherwise. */ - hasMessages(toUserId: number, siteId?: string): Promise { + hasMessages(toUserId: number, siteId?: string): Promise { return this.getMessages(toUserId, siteId).then((messages) => { return !!messages.length; }); } + /** + * Parse some fields of each offline conversation messages. + * + * @param {any[]} messages List of messages to parse. + * @return {any[]} Parsed messages. + */ + protected parseConversationMessages(messages: any[]): any[] { + if (!messages) { + return []; + } + + messages.forEach((message) => { + if (message.conversation) { + message.conversation = this.textUtils.parseJSON(message.conversation, {}); + } + }); + + return messages; + } + + /** + * Save a conversation message to be sent later. + * + * @param {any} conversation Conversation. + * @param {string} message The message to send. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if stored, rejected if failure. + */ + saveConversationMessage(conversation: any, message: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const entry = { + conversationid: conversation.id, + text: message, + timecreated: Date.now(), + deviceoffline: this.appProvider.isOnline() ? 0 : 1, + conversation: JSON.stringify({ + name: conversation.name || '', + subname: conversation.subname || '', + imageurl: conversation.imageurl || '', + isfavourite: conversation.isfavourite ? 1 : 0, + type: conversation.type + }) + }; + + return site.getDb().insertRecord(AddonMessagesOfflineProvider.CONVERSATION_MESSAGES_TABLE, entry).then(() => { + return entry; + }); + }); + } + /** * Save a message to be sent later. * @@ -169,7 +315,13 @@ export class AddonMessagesOfflineProvider { data = { deviceoffline: value ? 1 : 0 }; messages.forEach((message) => { - promises.push(db.insertRecord(AddonMessagesOfflineProvider.MESSAGES_TABLE, data)); + if (message.conversationid) { + promises.push(db.updateRecords(AddonMessagesOfflineProvider.CONVERSATION_MESSAGES_TABLE, data, + {conversationid: message.conversationid, text: message.text, timecreated: message.timecreated})); + } else { + promises.push(db.updateRecords(AddonMessagesOfflineProvider.MESSAGES_TABLE, data, + {touserid: message.touserid, smallmessage: message.smallmessage, timecreated: message.timecreated})); + } }); return Promise.all(promises); diff --git a/src/addon/messages/providers/messages.ts b/src/addon/messages/providers/messages.ts index b8df8c02e..45fbd4cba 100644 --- a/src/addon/messages/providers/messages.ts +++ b/src/addon/messages/providers/messages.ts @@ -33,6 +33,7 @@ export class AddonMessagesProvider { static NEW_MESSAGE_EVENT = 'addon_messages_new_message_event'; static READ_CHANGED_EVENT = 'addon_messages_read_changed_event'; static READ_CRON_EVENT = 'addon_messages_read_cron_event'; + static OPEN_CONVERSATION_EVENT = 'addon_messages_open_conversation_event'; // Notify that a conversation should be opened. static SPLIT_VIEW_LOAD_EVENT = 'addon_messages_split_view_load_event'; static POLL_INTERVAL = 10000; static PUSH_SIMULATION_COMPONENT = 'AddonMessagesPushSimulation'; @@ -115,7 +116,11 @@ export class AddonMessagesProvider { } // It's an offline message. - return this.messagesOffline.deleteMessage(message.touserid, message.smallmessage, message.timecreated); + if (message.conversationid) { + return this.messagesOffline.deleteConversationMessage(message.conversationid, message.text, message.timecreated); + } else { + return this.messagesOffline.deleteMessage(message.touserid, message.smallmessage, message.timecreated); + } } /** @@ -127,18 +132,56 @@ export class AddonMessagesProvider { * @return {Promise} Promise resolved when the message has been deleted. */ deleteMessageOnline(id: number, read: number, userId?: number): Promise { - userId = userId || this.sitesProvider.getCurrentSiteUserId(); - const params = { + const params: any = { messageid: id, - userid: userId, - read: read + userid: userId || this.sitesProvider.getCurrentSiteUserId() }; + if (typeof read != 'undefined') { + params.read = read; + } + return this.sitesProvider.getCurrentSite().write('core_message_delete_message', params).then(() => { return this.invalidateDiscussionCache(userId); }); } + /** + * Format a conversation. + * + * @param {any} conversation Conversation to format. + * @param {number} userId User ID viewing the conversation. + * @return {any} Formatted conversation. + */ + protected formatConversation(conversation: any, userId: number): any { + const numMessages = conversation.messages.length, + lastMessage = numMessages ? conversation.messages[numMessages - 1] : null; + + conversation.lastmessage = lastMessage ? lastMessage.text : null; + conversation.lastmessagedate = lastMessage ? lastMessage.timecreated : null; + conversation.sentfromcurrentuser = lastMessage ? lastMessage.useridfrom == userId : null; + + if (conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL) { + const otherUser = conversation.members.reduce((carry, member) => { + if (!carry && member.id != userId) { + carry = member; + } + + return carry; + }, null); + + conversation.name = conversation.name ? conversation.name : otherUser.fullname; + conversation.imageurl = conversation.imageurl ? conversation.imageurl : otherUser.profileimageurl; + 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. * @@ -187,6 +230,50 @@ export class AddonMessagesProvider { return this.ROOT_CACHE_KEY + 'discussions'; } + /** + * Get cache key for get conversations. + * + * @param {number} userId User ID. + * @param {number} conversationId Conversation ID. + * @return {string} Cache key. + */ + protected getCacheKeyForConversation(userId: number, conversationId: number): string { + return this.ROOT_CACHE_KEY + 'conversation:' + userId + ':' + conversationId; + } + + /** + * Get cache key for get conversations between users. + * + * @param {number} userId User ID. + * @param {number} otherUserId Other user ID. + * @return {string} Cache key. + */ + protected getCacheKeyForConversationBetweenUsers(userId: number, otherUserId: number): string { + return this.ROOT_CACHE_KEY + 'conversationBetweenUsers:' + userId + ':' + otherUserId; + } + + /** + * Get cache key for get conversation members. + * + * @param {number} userId User ID. + * @param {number} conversationId Conversation ID. + * @return {string} Cache key. + */ + protected getCacheKeyForConversationMembers(userId: number, conversationId: number): string { + return this.ROOT_CACHE_KEY + 'conversationMembers:' + userId + ':' + conversationId; + } + + /** + * Get cache key for get conversation messages. + * + * @param {number} userId User ID. + * @param {number} conversationId Conversation ID. + * @return {string} Cache key. + */ + protected getCacheKeyForConversationMessages(userId: number, conversationId: number): string { + return this.ROOT_CACHE_KEY + 'conversationMessages:' + userId + ':' + conversationId; + } + /** * Get cache key for get conversations. * @@ -297,6 +384,230 @@ export class AddonMessagesProvider { }); } + /** + * Get a conversation by the conversation ID. + * + * @param {number} conversationId Conversation ID to fetch. + * @param {boolean} [includeContactRequests] Include contact requests. + * @param {boolean} [includePrivacyInfo] Include privacy info. + * @param {number} [messageOffset=0] Offset for messages list. + * @param {number} [messageLimit=1] Limit of messages. Defaults to 1 (last message). + * We recommend getConversationMessages to get them. + * @param {number} [memberOffset=0] Offset for members list. + * @param {number} [memberLimit=2] Limit of members. Defaults to 2 (to be able to know the other user in individual ones). + * We recommend getConversationMembers to get them. + * @param {boolean} [newestFirst=true] Whether to order messages by newest first. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @param {number} [userId] User ID. If not defined, current user in the site. + * @return {Promise} Promise resolved with the response. + * @since 3.6 + */ + getConversation(conversationId: number, includeContactRequests?: boolean, includePrivacyInfo?: boolean, + messageOffset: number = 0, messageLimit: number = 1, memberOffset: number = 0, memberLimit: number = 2, + newestFirst: boolean = true, siteId?: string, userId?: number): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + const preSets = { + cacheKey: this.getCacheKeyForConversation(userId, conversationId) + }, + params: any = { + userid: userId, + conversationid: conversationId, + includecontactrequests: includeContactRequests ? 1 : 0, + includeprivacyinfo: includePrivacyInfo ? 1 : 0, + messageoffset: messageOffset, + messagelimit: messageLimit, + memberoffset: memberOffset, + memberlimit: memberLimit, + newestmessagesfirst: newestFirst ? 1 : 0 + }; + + return site.read('core_message_get_conversation', params, preSets).then((conversation) => { + return this.formatConversation(conversation, userId); + }); + }); + } + + /** + * Get a conversation between two users. + * + * @param {number} otherUserId The other user ID. + * @param {boolean} [includeContactRequests] Include contact requests. + * @param {boolean} [includePrivacyInfo] Include privacy info. + * @param {number} [messageOffset=0] Offset for messages list. + * @param {number} [messageLimit=1] Limit of messages. Defaults to 1 (last message). + * We recommend getConversationMessages to get them. + * @param {number} [memberOffset=0] Offset for members list. + * @param {number} [memberLimit=2] Limit of members. Defaults to 2 (to be able to know the other user in individual ones). + * We recommend getConversationMembers to get them. + * @param {boolean} [newestFirst=true] Whether to order messages by newest first. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @param {number} [userId] User ID. If not defined, current user in the site. + * @return {Promise} Promise resolved with the response. + * @since 3.6 + */ + 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): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + const preSets = { + cacheKey: this.getCacheKeyForConversationBetweenUsers(userId, otherUserId) + }, + params: any = { + userid: userId, + otheruserid: otherUserId, + includecontactrequests: includeContactRequests ? 1 : 0, + includeprivacyinfo: includePrivacyInfo ? 1 : 0, + messageoffset: messageOffset, + messagelimit: messageLimit, + memberoffset: memberOffset, + memberlimit: memberLimit, + newestmessagesfirst: newestFirst ? 1 : 0 + }; + + return site.read('core_message_get_conversation_between_users', params, preSets).then((conversation) => { + return this.formatConversation(conversation, userId); + }); + }); + } + + /** + * Get a conversation members. + * + * @param {number} conversationId Conversation ID to fetch. + * @param {number} [limitFrom=0] Offset for members list. + * @param {number} [limitTo] Limit of members. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @param {number} [userId] User ID. If not defined, current user in the site. + * @return {Promise} Promise resolved with the response. + * @since 3.6 + */ + getConversationMembers(conversationId: number, limitFrom: number = 0, limitTo?: number, includeContactRequests?: boolean, + siteId?: string, userId?: number): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + if (typeof limitTo == 'undefined' || limitTo === null) { + limitTo = this.LIMIT_MESSAGES; + } + + const preSets = { + cacheKey: this.getCacheKeyForConversationMembers(userId, conversationId) + }, + params: any = { + userid: userId, + conversationid: conversationId, + limitfrom: limitFrom, + limitnum: limitTo < 1 ? limitTo : limitTo + 1, // If there is a limit, get 1 more than requested. + includecontactrequests: includeContactRequests ? 1 : 0 + }; + + return site.read('core_message_get_conversation_members', params, preSets).then((members) => { + const result: any = {}; + + if (limitTo < 1) { + result.canLoadMore = false; + result.members = members; + } else { + result.canLoadMore = members.length > limitTo; + result.members = members.slice(0, limitTo); + } + + return result; + }); + }); + } + + /** + * Get a conversation by the conversation ID. + * + * @param {number} conversationId Conversation ID to fetch. + * @param {boolean} excludePending True to exclude messages pending to be sent. + * @param {number} [limitFrom=0] Offset for messages list. + * @param {number} [limitTo] Limit of messages. + * @param {boolean} [newestFirst=true] Whether to order messages by newest first. + * @param {number} [timeFrom] The timestamp from which the messages were created. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @param {number} [userId] User ID. If not defined, current user in the site. + * @return {Promise} Promise resolved with the response. + * @since 3.6 + */ + getConversationMessages(conversationId: number, excludePending: boolean, limitFrom: number = 0, limitTo?: number, + newestFirst: boolean = true, timeFrom: number = 0, siteId?: string, userId?: number): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + if (typeof limitTo == 'undefined' || limitTo === null) { + limitTo = this.LIMIT_MESSAGES; + } + + const preSets = { + cacheKey: this.getCacheKeyForConversationMessages(userId, conversationId) + }, + params: any = { + currentuserid: userId, + convid: conversationId, + limitfrom: limitFrom, + limitnum: limitTo < 1 ? limitTo : limitTo + 1, // If there is a limit, get 1 more than requested. + newest: newestFirst ? 1 : 0, + timefrom: timeFrom + }; + + if (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; + } + + return site.read('core_message_get_conversation_messages', params, preSets).then((result) => { + if (limitTo < 1) { + result.canLoadMore = false; + result.messages = result.messages; + } else { + result.canLoadMore = result.messages.length > limitTo; + result.messages = result.messages.slice(0, limitTo); + } + + result.messages.forEach((message) => { + // Convert time to milliseconds. + message.timecreated = message.timecreated ? message.timecreated * 1000 : 0; + }); + + if (this.appProvider.isDesktop() && params.useridto == userId && limitFrom === 0) { + // Store the last received message (we cannot know if it's unread or not). Don't block the user for this. + this.storeLastReceivedMessageIfNeeded(conversationId, result.messages[0], site.getId()); + } + + if (excludePending) { + // No need to get offline messages, return the ones we have. + return result; + } + + // Get offline messages. + return this.messagesOffline.getConversationMessages(conversationId).then((offlineMessages) => { + // Mark offline messages as pending. + offlineMessages.forEach((message) => { + message.pending = true; + message.useridfrom = userId; + }); + + 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. @@ -308,6 +619,7 @@ export class AddonMessagesProvider { * @param {string} [siteId] Site ID. If not defined, use current site. * @param {number} [userId] User ID. If not defined, current user in the site. * @return {Promise} Promise resolved with the conversations. + * @since 3.6 */ getConversations(type?: number, favourites?: boolean, limitFrom: number = 0, siteId?: string, userId?: number) : Promise<{conversations: any[], canLoadMore: boolean}> { @@ -333,32 +645,8 @@ export class AddonMessagesProvider { return site.read('core_message_get_conversations', params, preSets).then((response) => { // Format the conversations, adding some calculated fields. - const conversations = response.conversations.map((conversation) => { - const numMessages = conversation.messages.length, - lastMessage = numMessages ? conversation.messages[numMessages - 1] : null; - - conversation.lastmessage = lastMessage ? lastMessage.text : null; - conversation.lastmessagedate = lastMessage ? lastMessage.timecreated : null; - conversation.sentfromcurrentuser = lastMessage ? lastMessage.useridfrom == userId : null; - - if (conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL) { - const otherUser = conversation.members.reduce((carry, member) => { - if (!carry && member.id != userId) { - carry = member; - } - - return carry; - }, null); - - conversation.name = conversation.name ? conversation.name : otherUser.fullname; - conversation.imageurl = conversation.imageurl ? conversation.imageurl : otherUser.profileimageurl; - conversation.userid = otherUser.id; - conversation.showonlinestatus = otherUser.showonlinestatus; - conversation.isonline = otherUser.isonline; - conversation.isblocked = otherUser.isblocked; - } - - return conversation; + const conversations = response.conversations.slice(0, this.LIMIT_MESSAGES).map((conversation) => { + return this.formatConversation(conversation, userId); }); return { @@ -813,7 +1101,71 @@ export class AddonMessagesProvider { } /** - * Invalidate contacts cache. + * Invalidate conversation. + * + * @param {number} conversationId Conversation ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, current user in the site. + * @return {Promise} Resolved when done. + */ + invalidateConversation(conversationId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKey(this.getCacheKeyForConversation(conversationId, userId)); + }); + } + + /** + * Invalidate conversation between users. + * + * @param {number} otherUserId Other user ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, current user in the site. + * @return {Promise} Resolved when done. + */ + invalidateConversationBetweenUsers(otherUserId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKey(this.getCacheKeyForConversationBetweenUsers(userId, otherUserId)); + }); + } + + /** + * Invalidate conversation members cache. + * + * @param {number} conversationId Conversation ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, current user in the site. + * @return {Promise} Resolved when done. + */ + invalidateConversationMembers(conversationId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKey(this.getCacheKeyForConversationMembers(userId, conversationId)); + }); + } + + /** + * Invalidate conversation messages cache. + * + * @param {number} conversationId Conversation ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, current user in the site. + * @return {Promise} Resolved when done. + */ + invalidateConversationMessages(conversationId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKey(this.getCacheKeyForConversationMessages(userId, conversationId)); + }); + } + + /** + * Invalidate conversations cache. * * @param {string} [siteId] Site ID. If not defined, current site. * @param {number} [userId] User ID. If not defined, current user in the site. @@ -1003,6 +1355,25 @@ export class AddonMessagesProvider { return this.sitesProvider.getCurrentSite().write('core_message_mark_message_read', params); } + /** + * Mark all messages of a conversation as read. + * + * @param {number} conversationId Conversation ID. + * @returns {Promise} Promise resolved if success. + * @since 3.6 + */ + markAllConversationMessagesRead(conversationId?: number): Promise { + const params = { + userid: this.sitesProvider.getCurrentSiteUserId(), + conversationid: conversationId + }, + preSets = { + responseExpected: false + }; + + return this.sitesProvider.getCurrentSite().write('core_message_mark_all_conversation_messages_as_read', params, preSets); + } + /** * Mark all messages of a discussion as read. * @@ -1144,8 +1515,11 @@ export class AddonMessagesProvider { } // Online and no messages stored. Send it to server. - return this.sendMessageOnline(toUserId, message).then(() => { - return { sent: true }; + return this.sendMessageOnline(toUserId, message).then((result) => { + return { + sent: true, + message: result + }; }).catch((error) => { if (this.utils.isWebServiceError(error)) { // It's a WebService error, the user cannot send the message so don't store it. @@ -1185,6 +1559,8 @@ export class AddonMessagesProvider { return this.invalidateDiscussionCache(toUserId, siteId).catch(() => { // Ignore errors. + }).then(() => { + return response[0]; }); }); } @@ -1209,13 +1585,143 @@ export class AddonMessagesProvider { }); } + /** + * Send a message to a conversation. + * + * @param {any} conversation Conversation. + * @param {string} message The message to send. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} 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 + */ + sendMessageToConversation(conversation: any, message: string, siteId?: string): Promise { + // Convenience function to store a message to be synchronized later. + const storeOffline = (): Promise => { + return this.messagesOffline.saveConversationMessage(conversation, message, siteId).then((entry) => { + return { + sent: false, + message: entry + }; + }); + }; + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (!this.appProvider.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. + return this.messagesOffline.hasConversationMessages(conversation.id, siteId).catch(() => { + // Error, it's safer to assume it has messages. + return true; + }).then((hasStoredMessages) => { + if (hasStoredMessages) { + return storeOffline(); + } + + // Online and no messages stored. Send it to server. + return this.sendMessageToConversationOnline(conversation.id, message).then((result) => { + return { + sent: true, + message: result + }; + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // It's a WebService error, the user cannot send the message so don't store it. + return Promise.reject(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 {number} conversationId Conversation ID. + * @param {string} message The message to send + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success, rejected if failure. + * @since 3.6 + */ + sendMessageToConversationOnline(conversationId: number, message: string, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const messages = [ + { + text: message, + textformat: 1 + } + ]; + + return this.sendMessagesToConversationOnline(conversationId, messages, siteId).then((response) => { + return this.invalidateConversationMessages(conversationId, siteId).catch(() => { + // Ignore errors. + }).then(() => { + return response[0]; + }); + }); + } + + /** + * Send some messages to a conversation. It will fail if offline or cannot connect. + * + * @param {number} conversationId Conversation ID. + * @param {any} messages Messages to send. Each message must contain text and, optionally, textformat. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success, rejected if failure. + * @since 3.6 + */ + sendMessagesToConversationOnline(conversationId: number, messages: any, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + conversationid: conversationId, + messages: messages.map((message) => { + return { + text: message.text, + textformat: typeof message.textformat != 'undefined' ? message.textformat : 1 + }; + }) + }; + + return site.write('core_message_send_messages_to_conversation', params); + }); + } + + /** + * Helper method to sort conversations by last message time. + * + * @param {any[]} conversations Array of conversations. + * @return {any[]} Conversations sorted with most recent last. + */ + sortConversations(conversations: any[]): any[] { + return conversations.sort((a, b) => { + const timeA = parseInt(a.lastmessagedate, 10), + timeB = parseInt(b.lastmessagedate, 10); + + 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 {any} messages Array of messages containing the key 'timecreated'. - * @return {any} Messages sorted with most recent last. + * @param {any[]} messages Array of messages containing the key 'timecreated'. + * @return {any[]} Messages sorted with most recent last. */ - sortMessages(messages: any): any { + sortMessages(messages: any[]): any[] { return messages.sort((a, b) => { // Pending messages last. if (a.pending && !b.pending) { @@ -1238,17 +1744,17 @@ export class AddonMessagesProvider { /** * Store the last received message if it's newer than the last stored. * - * @param {number} userIdFrom ID of the useridfrom retrieved, 0 for all users. + * @param {number} convIdOrUserIdFrom Conversation ID (3.6+) or ID of the useridfrom retrieved (3.5-), 0 for all users. * @param {any} message Last message received. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when done. */ - protected storeLastReceivedMessageIfNeeded(userIdFrom: number, message: any, siteId?: string): Promise { + protected storeLastReceivedMessageIfNeeded(convIdOrUserIdFrom: number, message: any, siteId?: string): Promise { const component = AddonMessagesProvider.PUSH_SIMULATION_COMPONENT; // Get the last received message. return this.emulatorHelper.getLastReceivedNotification(component, siteId).then((lastMessage) => { - if (userIdFrom > 0 && (!message || !lastMessage)) { + if (convIdOrUserIdFrom > 0 && (!message || !lastMessage)) { // Seeing a single discussion. No received message or cannot know if it really is the last received message. Stop. return; } diff --git a/src/addon/messages/providers/sync.ts b/src/addon/messages/providers/sync.ts index 0a9b77ac7..0e87d42f5 100644 --- a/src/addon/messages/providers/sync.ts +++ b/src/addon/messages/providers/sync.ts @@ -42,6 +42,21 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider { super('AddonMessagesSync', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); } + /** + * Get the ID of a discussion sync. + * + * @param {number} conversationId Conversation ID. + * @param {number} userId User ID talking to (if no conversation ID). + * @return {string} Sync ID. + */ + protected getSyncId(conversationId: number, userId: number): string { + if (conversationId) { + return 'conversationid:' + conversationId; + } else { + return 'userid:' + userId; + } + } + /** * Try to synchronize all the discussions in a certain site or in all sites. * @@ -70,22 +85,39 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider { return promise.then((messages) => { const userIds = [], + conversationIds = [], promises = []; - // Get all the discussions to be synced. + // Get all the conversations to be synced. messages.forEach((message) => { - if (userIds.indexOf(message.touserid) == -1) { + if (message.conversationid) { + if (conversationIds.indexOf(message.conversationid) == -1) { + conversationIds.push(message.conversationid); + } + } else if (userIds.indexOf(message.touserid) == -1) { userIds.push(message.touserid); } }); - // Sync all discussions. - userIds.forEach((userId) => { - promises.push(this.syncDiscussion(userId, siteId).then((warnings) => { + // Sync all conversations. + conversationIds.forEach((conversationId) => { + promises.push(this.syncDiscussion(conversationId, undefined, siteId).then((warnings) => { if (typeof warnings != 'undefined') { // Sync successful, send event. this.eventsProvider.trigger(AddonMessagesSyncProvider.AUTO_SYNCED, { - userid: userId, + conversationId: conversationId, + warnings: warnings + }, siteId); + } + })); + }); + + userIds.forEach((userId) => { + promises.push(this.syncDiscussion(undefined, userId, siteId).then((warnings) => { + if (typeof warnings != 'undefined') { + // Sync successful, send event. + this.eventsProvider.trigger(AddonMessagesSyncProvider.AUTO_SYNCED, { + userId: userId, warnings: warnings }, siteId); } @@ -99,24 +131,39 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider { /** * Synchronize a discussion. * - * @param {number} userId User ID of the discussion. - * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + * @param {number} conversationId Conversation ID. + * @param {number} userId User ID talking to (if no conversation ID). + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. */ - syncDiscussion(userId: number, siteId?: string): Promise { + syncDiscussion(conversationId: number, userId: number, siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); - if (this.isSyncing(userId, siteId)) { - // There's already a sync ongoing for this SCORM, return the promise. - return this.getOngoingSync(userId, siteId); + const syncId = this.getSyncId(conversationId, userId), + groupMessagingEnabled = this.messagesProvider.isGroupMessagingEnabled(); + + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for this conversation, return the promise. + return this.getOngoingSync(syncId, siteId); } const warnings = []; - this.logger.debug(`Try to sync discussion with user '${userId}'`); + if (conversationId) { + this.logger.debug(`Try to sync conversation '${conversationId}'`); + } else { + this.logger.debug(`Try to sync discussion with user '${userId}'`); + } // Get offline messages to be sent. - const syncPromise = this.messagesOffline.getMessages(userId, siteId).then((messages) => { + let syncPromise; + + if (conversationId) { + syncPromise = this.messagesOffline.getConversationMessages(conversationId, siteId); + } else { + syncPromise = this.messagesOffline.getMessages(userId, siteId); + } + + syncPromise = syncPromise.then((messages) => { if (!messages.length) { // Nothing to sync. return []; @@ -134,12 +181,19 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider { messages = this.messagesProvider.sortMessages(messages); // Send the messages. - // We don't use AddonMessagesProvider#sendMessagesOnline because there's a problem with display order. - // @todo Use AddonMessagesProvider#sendMessagesOnline once the display order is fixed. + // Send them 1 by 1 to simulate web's behaviour and to make sure we know which message has failed. messages.forEach((message, index) => { // Chain message sending. If 1 message fails to be sent we'll stop sending. promise = promise.then(() => { - return this.messagesProvider.sendMessageOnline(userId, message.smallmessage, siteId).catch((error) => { + let subPromise; + + if (conversationId) { + subPromise = this.messagesProvider.sendMessageToConversationOnline(conversationId, message.text, siteId); + } else { + subPromise = this.messagesProvider.sendMessageOnline(userId, message.smallmessage, siteId); + } + + return subPromise.catch((error) => { if (this.utils.isWebServiceError(error)) { // Error returned by WS. Store the error to show a warning but keep sending messages. if (errors.indexOf(error) == -1) { @@ -158,22 +212,63 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider { return Promise.reject(error); }).then(() => { // Message was sent, delete it from local DB. - return this.messagesOffline.deleteMessage(userId, message.smallmessage, message.timecreated, siteId); + if (conversationId) { + return this.messagesOffline.deleteConversationMessage(conversationId, message.text, + message.timecreated, siteId); + } else { + return this.messagesOffline.deleteMessage(userId, message.smallmessage, message.timecreated, siteId); + } }).then(() => { - // All done. Wait 1 second to ensure timecreated of messages is different. - if (index < messages.length - 1) { - return setTimeout(() => {return; }, 1000); + // 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 && index < messages.length - 1) { + return new Promise((resolve, reject): any => { + setTimeout(resolve, 1000); + }); } }); }); }); - return promise.then(() => { - return errors; - }); + return promise; }).then((errors) => { - if (errors && errors.length) { - // At least an error occurred, get user full name and add errors to warnings array. + return this.handleSyncErrors(conversationId, userId, errors, warnings); + }).then(() => { + // All done, return the warnings. + return warnings; + }); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } + + /** + * Handle sync errors. + * + * @param {number} conversationId Conversation ID. + * @param {number} userId User ID talking to (if no conversation ID). + * @param {any[]} errors List of errors. + * @param {any[]} warnings Array where to place the warnings. + * @return {Promise} Promise resolved when done. + */ + protected handleSyncErrors(conversationId: number, userId: number, errors: any[], warnings: any[]): Promise { + if (errors && errors.length) { + if (conversationId) { + + // Get conversation name and add errors to warnings array. + return this.messagesProvider.getConversation(conversationId, false, false).catch(() => { + // Ignore errors. + return {}; + }).then((conversation) => { + errors.forEach((error) => { + warnings.push(this.translate.instant('addon.messages.warningconversationmessagenotsent', { + conversation: conversation.name ? conversation.name : conversationId, + error: this.textUtils.getErrorMessageFromError(error) + })); + }); + }); + } else { + + // Get user full name and add errors to warnings array. return this.userProvider.getProfile(userId, undefined, true).catch(() => { // Ignore errors. return {}; @@ -181,16 +276,26 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider { errors.forEach((error) => { warnings.push(this.translate.instant('addon.messages.warningmessagenotsent', { user: user.fullname ? user.fullname : userId, - error: error + error: this.textUtils.getErrorMessageFromError(error) })); }); }); } - }).then(() => { - // All done, return the warnings. - return warnings; - }); + } + } - return this.addOngoingSync(userId, syncPromise, siteId); + /** + * 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 {number} conversationId Conversation ID. + * @param {number} userId User ID talking to (if no conversation ID). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} 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); } } diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 591747ca6..810196869 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -165,7 +165,9 @@ "addon.messages.errorwhileretrievingcontacts": "Error while retrieving contacts from the server.", "addon.messages.errorwhileretrievingdiscussions": "Error while retrieving discussions from the server.", "addon.messages.errorwhileretrievingmessages": "Error while retrieving messages from the server.", + "addon.messages.groupinfo": "Group info", "addon.messages.groupmessages": "Group messages", + "addon.messages.info": "Info", "addon.messages.message": "Message", "addon.messages.messagenotsent": "The message was not sent. Please try again later.", "addon.messages.messagepreferences": "Message preferences", @@ -176,8 +178,10 @@ "addon.messages.nogroupmessages": "No group messages", "addon.messages.nomessages": "No messages", "addon.messages.nousersfound": "No users found", + "addon.messages.numparticipants": "{{$a}} participants", "addon.messages.removecontact": "Remove contact", "addon.messages.removecontactconfirm": "Contact will be removed from your contacts list.", + "addon.messages.showdeletemessages": "Show delete messages", "addon.messages.type_blocked": "Blocked", "addon.messages.type_offline": "Offline", "addon.messages.type_online": "Online", @@ -185,6 +189,7 @@ "addon.messages.type_strangers": "Others", "addon.messages.unblockuser": "Unblock user", "addon.messages.unblockuserconfirm": "Are you sure you want to unblock {{$a}}?", + "addon.messages.warningconversationmessagenotsent": "Couldn't send message(s) to conversation {{conversation}}. {{error}}", "addon.messages.warningmessagenotsent": "Couldn't send message(s) to user {{user}}. {{error}}", "addon.messages.you": "You:", "addon.mod_assign.acceptsubmissionstatement": "Please accept the submission statement.", diff --git a/src/components/user-avatar/user-avatar.ts b/src/components/user-avatar/user-avatar.ts index 20aab67fd..bbbb09c90 100644 --- a/src/components/user-avatar/user-avatar.ts +++ b/src/components/user-avatar/user-avatar.ts @@ -15,6 +15,7 @@ import { Component, Input, OnInit, OnChanges, SimpleChange } from '@angular/core'; import { NavController } from 'ionic-angular'; import { CoreSitesProvider } from '@providers/sites'; +import { CoreUtilsProvider } from '@providers/utils/utils'; /** * Component to display a "user avatar". @@ -41,7 +42,7 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges { protected myUser = false; protected currentUserId: number; - constructor(private navCtrl: NavController, private sitesProvider: CoreSitesProvider) { + constructor(private navCtrl: NavController, private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider) { this.currentUserId = this.sitesProvider.getCurrentSiteUserId(); } @@ -75,7 +76,7 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges { this.fullname = this.fullname || (this.user && (this.user.fullname || this.user.userfullname)); - this.userId = this.userId || (this.user && this.user.userid); + this.userId = this.userId || (this.user && (this.user.userid || this.user.id)); this.courseId = this.courseId || (this.user && this.user.courseid); // If not available we cannot ensure the avatar is from the current user. @@ -89,9 +90,18 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges { * @return boolean */ isOnline(): boolean { - const time = new Date().getTime() - this.timetoshowusers; + if (this.myUser || this.utils.isFalseOrZero(this.user.isonline)) { + return false; + } - return !this.myUser && ((this.user.lastaccess && this.user.lastaccess * 1000 >= time) || this.user.isonline); + if (this.user.lastaccess) { + // If the time has passed, don't show the online status. + const time = new Date().getTime() - this.timetoshowusers; + + return this.user.lastaccess * 1000 >= time; + } else { + return this.user.isonline; + } } /**