diff --git a/scripts/langindex.json b/scripts/langindex.json index e9720fd30..e096f7155 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", @@ -178,6 +180,7 @@ "addon.messages.nousersfound": "local_moodlemobileapp", "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", diff --git a/src/addon/messages/lang/en.json b/src/addon/messages/lang/en.json index ed076fb7d..6de944be2 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", @@ -30,6 +32,7 @@ "nousersfound": "No users found", "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", diff --git a/src/addon/messages/pages/discussion/discussion.html b/src/addon/messages/pages/discussion/discussion.html index 89e20fe8b..74e8b3e88 100644 --- a/src/addon/messages/pages/discussion/discussion.html +++ b/src/addon/messages/pages/discussion/discussion.html @@ -1,15 +1,17 @@ - + + + + - - - - + + + + + @@ -22,12 +24,12 @@ {{ message.timecreated | coreFormatDate: "LL" }} - + {{ 'addon.messages.newmessages' | translate:{$a: title} }} - +

diff --git a/src/addon/messages/pages/discussion/discussion.ts b/src/addon/messages/pages/discussion/discussion.ts index 558421290..7df042ffa 100644 --- a/src/addon/messages/pages/discussion/discussion.ts +++ b/src/addon/messages/pages/discussion/discussion.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnDestroy, ViewChild } from '@angular/core'; +import { Component, OnDestroy, ViewChild, Optional } from '@angular/core'; import { IonicPage, NavParams, NavController, Content } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreEventsProvider } from '@providers/events'; @@ -25,6 +25,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 +54,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 +71,30 @@ export class AddonMessagesDiscussionPage implements OnDestroy { messages = []; showDelete = false; canDelete = false; - scrollBottom = true; - viewDestroyed = false; + groupMessagingEnabled: boolean; + isGroup = false; 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) { 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 +111,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); @@ -143,15 +152,17 @@ 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(() => { @@ -161,24 +172,34 @@ export class AddonMessagesDiscussionPage implements OnDestroy { 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().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; }); // Recalculate footer position when keyboard is shown or hidden. @@ -204,12 +225,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,6 +238,15 @@ 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; @@ -224,11 +254,19 @@ export class AddonMessagesDiscussionPage implements OnDestroy { // Wait for synchronization process to finish. return this.messagesSync.waitForSync(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(); @@ -261,11 +299,83 @@ export class AddonMessagesDiscussionPage implements OnDestroy { // Mark retrieved messages as read if they are not. this.markMessagesAsRead(); + }).finally(() => { this.fetching = false; }); } + /** + * Get the conversation. + * + * @return {Promise} Promise resolved with a boolean: whether the conversation exists or not. + */ + protected getConversation(): Promise { + let promise; + + if (this.conversationId) { + // Retrieve the conversation. Invalidate data first to get the right unreadcount. + promise = this.messagesProvider.invalidateConversation(this.conversationId).then(() => { + return this.messagesProvider.getConversation(this.conversationId); + }); + } else { + // We don't have the conversation ID, check if it exists. + promise = this.messagesProvider.getConversationBetweenUsers(this.userId).catch((error) => { + if (error.errorcode == 'conversationdoesntexist') { + // Conversation does not exist, return undefined. + return; + } + + return Promise.reject(error); + }); + } + + return promise.then((conversation) => { + this.conversation = conversation; + if (conversation) { + 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--; + + 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 +386,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 +418,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 +440,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 +492,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 +517,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 +539,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 +596,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 +624,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,12 +651,12 @@ 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 || ''); } /** @@ -534,7 +676,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 +696,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(() => { @@ -638,7 +779,7 @@ export class AddonMessagesDiscussionPage implements OnDestroy { if (data.sent) { // Message was sent, fetch messages right now. - promise = this.fetchData(); + promise = this.fetchMessages(); } else { promise = Promise.reject(null); } @@ -697,6 +838,19 @@ 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) { + // @todo + } else { + 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..70bd51f67 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 @@ + + diff --git a/src/addon/messages/pages/group-conversations/group-conversations.ts b/src/addon/messages/pages/group-conversations/group-conversations.ts index 091df7e7a..fd3ef4b74 100644 --- a/src/addon/messages/pages/group-conversations/group-conversations.ts +++ b/src/addon/messages/pages/group-conversations/group-conversations.ts @@ -94,7 +94,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { } else { // An existing conversation has a new message, update the last message. conversation.lastmessage = data.message; - conversation.lastmessagedate = data.timecreated; + conversation.lastmessagedate = data.timecreated / 1000; } } }, this.siteId); @@ -145,7 +145,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 +162,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { } if (conversation) { - this.gotoConversation(conversation.id, conversation.userid); + this.gotoConversation(conversation.id); } } }); @@ -247,13 +247,14 @@ 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. */ - gotoConversation(conversationId: number, userId: number, messageId?: number): void { + gotoConversation(conversationId: number, userId?: number, messageId?: number): void { this.selectedConversation = conversationId; const params = { + conversationId: conversationId, userId: userId }; if (messageId) { @@ -358,7 +359,7 @@ 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(); diff --git a/src/addon/messages/providers/messages.ts b/src/addon/messages/providers/messages.ts index b8df8c02e..aaf5bba86 100644 --- a/src/addon/messages/providers/messages.ts +++ b/src/addon/messages/providers/messages.ts @@ -139,6 +139,41 @@ export class AddonMessagesProvider { }); } + /** + * 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; + } + + return conversation; + } + /** * Get the cache key for blocked contacts. * @@ -187,6 +222,39 @@ 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 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 +365,182 @@ 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 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.getMessages(userId).then((offlineMessages) => { + // Mark offline messages as pending. + offlineMessages.forEach((message) => { + message.pending = true; + message.text = message.smallmessage; + }); + + 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 +552,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 +578,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 +1034,55 @@ 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 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 +1272,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. * @@ -1238,17 +1526,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/assets/lang/en.json b/src/assets/lang/en.json index 591747ca6..29c852b9c 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", @@ -178,6 +180,7 @@ "addon.messages.nousersfound": "No users found", "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",