From 18cb43aa5ecaf933b183aa09eee346ba2939ced7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 22 Jan 2021 17:06:30 +0100 Subject: [PATCH] MOBILE-3631 messages: Add discussion page --- .../services/coursecompletion.ts | 7 +- src/addons/messages/messages-lazy.module.ts | 5 + .../messages/pages/discussion/discussion.html | 150 ++ .../pages/discussion/discussion.module.ts | 46 + .../pages/discussion/discussion.page.ts | 1684 +++++++++++++++++ .../messages/pages/discussion/discussion.scss | 300 +++ .../pages/discussions-35/discussions.page.ts | 56 +- .../group-conversations.page.ts | 128 +- src/addons/messages/pages/index-35/index.html | 9 +- src/addons/messages/services/messages.ts | 76 +- .../services/handlers/mainmenu.ts | 3 +- .../components/user-avatar/user-avatar.scss | 7 + src/core/features/tag/services/tag.ts | 5 +- src/core/services/navigator.ts | 24 +- src/theme/app.scss | 9 + src/theme/variables.scss | 17 + 16 files changed, 2429 insertions(+), 97 deletions(-) create mode 100644 src/addons/messages/pages/discussion/discussion.html create mode 100644 src/addons/messages/pages/discussion/discussion.module.ts create mode 100644 src/addons/messages/pages/discussion/discussion.page.ts create mode 100644 src/addons/messages/pages/discussion/discussion.scss diff --git a/src/addons/coursecompletion/services/coursecompletion.ts b/src/addons/coursecompletion/services/coursecompletion.ts index 6ae4d2b92..dd6f7205e 100644 --- a/src/addons/coursecompletion/services/coursecompletion.ts +++ b/src/addons/coursecompletion/services/coursecompletion.ts @@ -20,6 +20,7 @@ import { CoreCourses } from '@features/courses/services/courses'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@services/ws'; import { makeSingleton } from '@singletons'; +import { CoreError } from '@classes/errors/error'; const ROOT_CACHE_KEY = 'mmaCourseCompletion:'; @@ -118,7 +119,7 @@ export class AddonCourseCompletionProvider { return result.completionstatus; } - throw null; + throw new CoreError('Cannot fetch course completion status'); } /** @@ -165,7 +166,7 @@ export class AddonCourseCompletionProvider { */ async isPluginViewEnabledForCourse(courseId: number, preferCache: boolean = true): Promise { if (!courseId) { - throw null; + throw new CoreError('No courseId provided'); } const course = await CoreCourses.instance.getUserCourse(courseId, preferCache); @@ -260,7 +261,7 @@ export class AddonCourseCompletionProvider { const response = await site.write('core_completion_mark_course_self_completed', params); if (!response.status) { - throw null; + throw new CoreError('Cannot mark course as self completed'); } } diff --git a/src/addons/messages/messages-lazy.module.ts b/src/addons/messages/messages-lazy.module.ts index dedc69c34..94c97e62d 100644 --- a/src/addons/messages/messages-lazy.module.ts +++ b/src/addons/messages/messages-lazy.module.ts @@ -31,6 +31,11 @@ function buildRoutes(injector: Injector): Routes { loadChildren: () => import('./pages/group-conversations/group-conversations.module') .then(m => m.AddonMessagesGroupConversationsPageModule), }, + { + path: 'discussion', + loadChildren: () => import('./pages/discussion/discussion.module') + .then(m => m.AddonMessagesDiscussionPageModule), + }, { path: 'search', loadChildren: () => import('./pages/search/search.module') diff --git a/src/addons/messages/pages/discussion/discussion.html b/src/addons/messages/pages/discussion/discussion.html new file mode 100644 index 000000000..fef58336a --- /dev/null +++ b/src/addons/messages/pages/discussion/discussion.html @@ -0,0 +1,150 @@ + + + + + + +
+ + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + +
+ + + + + + + +

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

+

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

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

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

+ + +

+ +

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

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

+
+

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

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

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

+

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

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

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

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

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

+

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

+
+ +
+
diff --git a/src/addons/messages/pages/discussion/discussion.module.ts b/src/addons/messages/pages/discussion/discussion.module.ts new file mode 100644 index 000000000..4d19beffd --- /dev/null +++ b/src/addons/messages/pages/discussion/discussion.module.ts @@ -0,0 +1,46 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; + +import { CoreSharedModule } from '@/core/shared.module'; + +import { AddonMessagesDiscussionPage } from './discussion.page'; + +const routes: Routes = [ + { + path: '', + component: AddonMessagesDiscussionPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + ], + declarations: [ + AddonMessagesDiscussionPage, + ], + exports: [RouterModule], +}) +export class AddonMessagesDiscussionPageModule {} + diff --git a/src/addons/messages/pages/discussion/discussion.page.ts b/src/addons/messages/pages/discussion/discussion.page.ts new file mode 100644 index 000000000..41a5a34c1 --- /dev/null +++ b/src/addons/messages/pages/discussion/discussion.page.ts @@ -0,0 +1,1684 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { IonContent } from '@ionic/angular'; +import { AlertOptions } from '@ionic/core'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { + AddonMessagesProvider, + AddonMessagesConversationFormatted, + AddonMessagesConversationMember, + AddonMessagesGetMessagesMessage, + AddonMessages, + AddonMessagesMemberInfoChangedEventData, + AddonMessagesSendInstantMessagesMessage, + AddonMessagesSendMessagesToConversationMessage, + AddonMessagesReadChangedEventData, + AddonMessagesNewMessagedEventData, + AddonMessagesUpdateConversationListEventData, + AddonMessagesConversationMessageFormatted, +} from '../../services/messages'; +import { AddonMessagesOffline } from '../../services/messages-offline'; +import { AddonMessagesSync, AddonMessagesSyncEvents, AddonMessagesSyncProvider } from '../../services/messages-sync'; +import { CoreUser } from '@features/user/services/user'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreLogger } from '@singletons/logger'; +import { CoreApp } from '@services/app'; +import { CoreInfiniteLoadingComponent } from '@components/infinite-loading/infinite-loading'; +import { Md5 } from 'ts-md5/dist/md5'; +import moment from 'moment'; +import { CoreAnimations } from '@components/animations'; +import { CoreError } from '@classes/errors/error'; +import { ModalController, Translate } from '@singletons'; +import { CoreNavigator } from '@services/navigator'; +import { CoreIonLoadingElement } from '@classes/ion-loading'; +import { ActivatedRoute } from '@angular/router'; +import { + AddonMessagesOfflineMessagesDBRecordFormatted, +} from '@addons/messages/services/database/messages'; +// @todo import { CoreSplitViewComponent } from '@components/split-view/split-view'; + +/** + * Page that displays a message discussion page. + */ +@Component({ + selector: 'page-addon-messages-discussion', + templateUrl: 'discussion.html', + animations: [CoreAnimations.SLIDE_IN_OUT], + styleUrls: ['discussion.scss', '../../../../theme/messages.scss'], +}) +export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterViewInit { + + @ViewChild(IonContent) content?: IonContent; + @ViewChild(CoreInfiniteLoadingComponent) infinite?: CoreInfiniteLoadingComponent; + + siteId: string; + protected fetching = false; + protected polling?: number; + protected logger: CoreLogger; + + protected messagesBeingSent = 0; + protected pagesLoaded = 1; + protected lastMessage = { text: '', timecreated: 0 }; + protected keepMessageMap: {[hash: string]: boolean} = {}; + protected syncObserver: CoreEventObserver; + protected oldContentHeight = 0; + protected keyboardObserver: CoreEventObserver; + protected scrollBottom = true; + protected viewDestroyed = false; + protected memberInfoObserver: CoreEventObserver; + protected showLoadingModal = false; // Whether to show a loading modal while fetching data. + + conversationId?: number; // Conversation ID. Undefined if it's a new individual conversation. + conversation?: AddonMessagesConversationFormatted; // The conversation object (if it exists). + userId?: number; // User ID you're talking to (only if group messaging not enabled or it's a new individual conversation). + currentUserId: number; + title?: string; + showInfo = false; + conversationImage?: string; + loaded = false; + showKeyboard = false; + canLoadMore = false; + loadMoreError = false; + messages: AddonMessagesConversationMessageFormatted[] = []; + showDelete = false; + canDelete = false; + groupMessagingEnabled: boolean; + isGroup = false; + members: {[id: number]: AddonMessagesConversationMember} = {}; // Members that wrote a message, indexed by ID. + favouriteIcon = 'fa-star'; + deleteIcon = 'fas-trash'; + blockIcon = 'fas-user-lock'; + addRemoveIcon = 'fas-user-plus'; + muteIcon = 'fas-bell-slash'; + favouriteIconSlash = false; + muteEnabled = false; + otherMember?: AddonMessagesConversationMember; // Other member information (individual conversations only). + footerType: 'message' | 'blocked' | 'requiresContact' | 'requestSent' | 'requestReceived' | 'unable' = 'unable'; + requestContactSent = false; + requestContactReceived = false; + isSelf = false; + newMessages = 0; + scrollElement?: HTMLElement; + unreadMessageFrom = 0; + + constructor( + protected route: ActivatedRoute, + // @todo @Optional() private svComponent: CoreSplitViewComponent, + ) { + + this.siteId = CoreSites.instance.getCurrentSiteId(); + this.currentUserId = CoreSites.instance.getCurrentSiteUserId(); + this.groupMessagingEnabled = AddonMessages.instance.isGroupMessagingEnabled(); + this.muteEnabled = AddonMessages.instance.isMuteConversationEnabled(); + + this.logger = CoreLogger.getInstance('AddonMessagesDiscussionPage'); + + // Refresh data if this discussion is synchronized automatically. + this.syncObserver = CoreEvents.on(AddonMessagesSyncProvider.AUTO_SYNCED, (data) => { + if ((data.userId && data.userId == this.userId) || + (data.conversationId && data.conversationId == this.conversationId)) { + // Fetch messages. + this.fetchMessages(); + + // Show first warning if any. + if (data.warnings && data.warnings[0]) { + CoreDomUtils.instance.showErrorModal(data.warnings[0]); + } + } + }, this.siteId); + + // Refresh data if info of a mamber of the conversation have changed. + this.memberInfoObserver = CoreEvents.on( + AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT, + (data) => { + if (data.userId && (this.members[data.userId] || this.otherMember && data.userId == this.otherMember.id)) { + this.fetchData(); + } + }, + this.siteId, + ); + + // Recalculate footer position when keyboard is shown or hidden. + this.keyboardObserver = CoreEvents.on(CoreEvents.KEYBOARD_CHANGE, () => { + // @todo this.content.resize(); + }); + } + + /** + * Runs when the page has loaded. This event only happens once per page being created. + * If a page leaves but is cached, then this event will not fire again on a subsequent viewing. + * Setup code for the page. + */ + async ngOnInit(): Promise { + + this.route.queryParams.subscribe(params => { + // Disable the profile button if we're already coming from a profile. + const backViewPage = CoreNavigator.instance.getPreviousPath(); + this.showInfo = !backViewPage || !CoreTextUtils.instance.matchesGlob(backViewPage, '**/user/profile'); + + this.conversationId = params['conversationId'] || undefined; + this.userId = params['userId'] || undefined; + this.showKeyboard = !!params['showKeyboard']; + + this.fetchData(); + }); + } + + /** + * View has been initialized. + */ + async ngAfterViewInit(): Promise { + this.scrollElement = await this.content?.getScrollElement(); + } + + /** + * Adds a new message to the message list. + * + * @param message Message to be added. + * @param keep If set the keep flag or not. + * @return If message is not mine and was recently added. + */ + protected addMessage( + message: AddonMessagesConversationMessageFormatted, + keep: boolean = true, + ): boolean { + + /* Create a hash to identify the message. The text of online messages isn't reliable because it can have random data + like VideoJS ID. Try to use id and fallback to text for offline messages. */ + const id = 'id' in message ? message.id : ''; + message.hash = Md5.hashAsciiStr(String(id || message.text || '')) + '#' + message.timecreated + '#' + + message.useridfrom; + + let added = false; + if (typeof this.keepMessageMap[message.hash] === 'undefined') { + // Message not added to the list. Add it now. + this.messages.push(message); + added = message.useridfrom != this.currentUserId; + } + // Message needs to be kept in the list. + this.keepMessageMap[message.hash] = keep; + + return added; + } + + /** + * Remove a message if it shouldn't be in the list anymore. + * + * @param hash Hash of the message to be removed. + */ + protected removeMessage(hash: string): void { + if (this.keepMessageMap[hash]) { + // Selected to keep it, clear the flag. + this.keepMessageMap[hash] = false; + + return; + } + + delete this.keepMessageMap[hash]; + + const position = this.messages.findIndex((message) => message.hash == hash); + if (position >= 0) { + this.messages.splice(position, 1); + } + } + + /** + * Convenience function to fetch the conversation data. + * + * @return Resolved when done. + */ + protected async fetchData(): Promise { + let loader: CoreIonLoadingElement | undefined; + if (this.showLoadingModal) { + loader = await CoreDomUtils.instance.showModalLoading(); + } + + if (!this.groupMessagingEnabled && this.userId) { + // Get the user profile to retrieve the user fullname and image. + CoreUser.instance.getProfile(this.userId, undefined, true).then((user) => { + if (!this.title) { + this.title = user.fullname; + } + this.conversationImage = user.profileimageurl; + + return; + }).catch(() => { + // Ignore errors. + }); + } + + // Synchronize messages if needed. + try { + const syncResult = await AddonMessagesSync.instance.syncDiscussion(this.conversationId, this.userId); + if (syncResult.warnings && syncResult.warnings[0]) { + CoreDomUtils.instance.showErrorModal(syncResult.warnings[0]); + } + } catch { + // Ignore errors; + } + + try { + if (this.groupMessagingEnabled) { + // Get the conversation ID if it exists and we don't have it yet. + const exists = await this.getConversation(this.conversationId, this.userId); + + const promises: Promise[] = []; + if (exists) { + // Fetch the messages for the first time. + promises.push(this.fetchMessages()); + } + + if (this.userId) { + // Get the member info. Invalidate first to make sure we get the latest status. + promises.push(AddonMessages.instance.invalidateMemberInfo(this.userId).then(() => + AddonMessages.instance.getMemberInfo(this.userId!)).then((member) => { + this.otherMember = member; + if (!exists && member) { + this.conversationImage = member.profileimageurl; + this.title = member.fullname; + } + this.blockIcon = this.otherMember?.isblocked ? 'fas-user-lock' : 'fas-user-check'; + + return; + })); + } else { + this.otherMember = undefined; + } + + await Promise.all(promises); + } else { + this.otherMember = undefined; + + // Fetch the messages for the first time. + await this.fetchMessages(); + + if (!this.title && this.messages.length) { + // Didn't receive the fullname via argument. Try to get it from messages. + // It's possible that name cannot be resolved when no messages were yet exchanged. + const firstMessage = this.messages[0]; + if ('usertofullname' in firstMessage) { + if (firstMessage.useridto != this.currentUserId) { + this.title = firstMessage.usertofullname || ''; + } else { + this.title = firstMessage.userfromfullname || ''; + } + } + } + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingmessages', true); + } finally { + this.checkCanDelete(); + this.resizeContent(); + this.loaded = true; + this.setPolling(); // Make sure we're polling messages. + this.setContactRequestInfo(); + this.setFooterType(); + loader && loader.dismiss(); + } + } + + /** + * Runs when the page has fully entered and is now the active page. + * This event will fire, whether it was the first load or a cached page. + */ + ionViewDidEnter(): void { + this.setPolling(); + } + + /** + * Runs when the page is about to leave and no longer be the active page. + */ + ionViewWillLeave(): void { + this.unsetPolling(); + } + + /** + * Convenience function to fetch messages. + * + * @param messagesAreNew If messages loaded are new messages. + * @return Resolved when done. + */ + protected async fetchMessages(messagesAreNew: boolean = true): Promise { + this.loadMoreError = false; + + if (this.messagesBeingSent > 0) { + // We do not poll while a message is being sent or we could confuse the user. + // Otherwise, his message would disappear from the list, and he'd have to wait for the interval to check for messages. + throw null; + } else if (this.fetching) { + // Already fetching. + throw null; + } else if (this.groupMessagingEnabled && !this.conversationId) { + // Don't have enough data to fetch messages. + throw new CoreError('No enough data provided to fetch messages'); + } + + if (this.conversationId) { + this.logger.debug(`Polling new messages for conversation '${this.conversationId}'`); + } else if (this.userId) { + this.logger.debug(`Polling new messages for discussion with user '${this.userId}'`); + } else { + // Should not happen. + throw new CoreError('No enough data provided to fetch messages'); + } + + this.fetching = true; + + try { + // Wait for synchronization process to finish. + await AddonMessagesSync.instance.waitForSyncConversation(this.conversationId, this.userId); + + let messages: AddonMessagesConversationMessageFormatted[] = []; + // Fetch messages. Invalidate the cache before fetching. + if (this.groupMessagingEnabled) { + await AddonMessages.instance.invalidateConversationMessages(this.conversationId!); + messages = await this.getConversationMessages(this.pagesLoaded); + } else { + await AddonMessages.instance.invalidateDiscussionCache(this.userId!); + messages = await this.getDiscussionMessages(this.pagesLoaded); + } + + this.loadMessages(messages, messagesAreNew); + + } finally { + this.fetching = false; + } + } + + /** + * Format and load a list of messages into the view. + * + * @param messagesAreNew If messages loaded are new messages. + * @param messages Messages to load. + */ + protected loadMessages( + messages: AddonMessagesConversationMessageFormatted[], + messagesAreNew: boolean = true, + ): void { + + if (this.viewDestroyed) { + return; + } + + // Don't use domUtils.getScrollHeight because it gives an outdated value after receiving a new message. + const scrollHeight = this.scrollElement ? this.scrollElement.scrollHeight : 0; + + // Check if we are at the bottom to scroll it after render. + // Use a 5px error margin because in iOS there is 1px difference for some reason. + this.scrollBottom = Math.abs(scrollHeight - (this.scrollElement?.scrollTop || 0) - + (this.scrollElement?.clientHeight || 0)) < 5; + + if (this.messagesBeingSent > 0) { + // Ignore polling due to a race condition. + return; + } + + // Add new messages to the list and mark the messages that should still be displayed. + const newMessages = messages.reduce((val, message) => val + (this.addMessage(message) ? 1 : 0), 0); + + // Set the new badges message if we're loading new messages. + if (messagesAreNew) { + this.setNewMessagesBadge(this.newMessages + newMessages); + } + + // Remove messages that shouldn't be in the list anymore. + for (const hash in this.keepMessageMap) { + this.removeMessage(hash); + } + + // Sort the messages. + AddonMessages.instance.sortMessages(this.messages); + + // Calculate which messages need to display the date or user data. + this.messages.forEach((message, index) => { + message.showDate = this.showDate(message, this.messages[index - 1]); + message.showUserData = this.showUserData(message, this.messages[index - 1]); + message.showTail = this.showTail(message, this.messages[index + 1]); + }); + + // Call resize to recalculate the dimensions. + // @todo this.content!.resize(); + + // If we received a new message while using group messaging, force mark messages as read. + const last = this.messages[this.messages.length - 1]; + const forceMark = this.groupMessagingEnabled && last && last.useridfrom != this.currentUserId && this.lastMessage.text != '' + && (last.text !== this.lastMessage.text || last.timecreated !== this.lastMessage.timecreated); + + // Notify that there can be a new message. + this.notifyNewMessage(); + + // Mark retrieved messages as read if they are not. + this.markMessagesAsRead(forceMark); + } + + /** + * Set the new message badge number and set scroll listener if needed. + * + * @param addMessages NUmber of messages still to be read. + */ + protected setNewMessagesBadge(addMessages: number): void { + if (this.newMessages == 0 && addMessages > 0) { + // Setup scrolling. + this.content!.scrollEvents = true; + + this.scrollFunction(); + } else if (this.newMessages > 0 && addMessages == 0) { + // Remove scrolling. + this.content!.scrollEvents = false; + } + + this.newMessages = addMessages; + } + + /** + * The scroll was moved. Update new messages count. + */ + scrollFunction(): void { + if (this.newMessages > 0) { + const scrollBottom = (this.scrollElement?.scrollTop || 0) + (this.scrollElement?.clientHeight || 0); + const scrollHeight = (this.scrollElement?.scrollHeight || 0); + if (scrollBottom > scrollHeight - 40) { + // At the bottom, reset. + this.setNewMessagesBadge(0); + + return; + } + + const scrollElRect = this.scrollElement?.getBoundingClientRect(); + const scrollBottomPos = (scrollElRect && scrollElRect.bottom) || 0; + + if (scrollBottomPos == 0) { + return; + } + + const messages = Array.from(document.querySelectorAll('.addon-message-not-mine')).slice(-this.newMessages).reverse(); + + const newMessagesUnread = messages.findIndex((message) => { + const elementRect = message.getBoundingClientRect(); + if (!elementRect) { + return false; + } + + return elementRect.bottom <= scrollBottomPos; + }); + + if (newMessagesUnread > 0 && newMessagesUnread < this.newMessages) { + this.setNewMessagesBadge(newMessagesUnread); + } + } + } + + /** + * Get the conversation. + * + * @param conversationId Conversation ID. + * @param userId User ID. + * @return Promise resolved with a boolean: whether the conversation exists or not. + */ + protected async getConversation(conversationId?: number, userId?: number): Promise { + let fallbackConversation: AddonMessagesConversationFormatted | undefined; + + // Try to get the conversationId if we don't have it. + if (!conversationId && userId) { + try { + if (userId == this.currentUserId && AddonMessages.instance.isSelfConversationEnabled()) { + fallbackConversation = await AddonMessages.instance.getSelfConversation(); + } else { + fallbackConversation = await AddonMessages.instance.getConversationBetweenUsers(userId, undefined, true); + } + conversationId = fallbackConversation.id; + } catch (error) { + // Probably conversation does not exist or user is offline. Try to load offline messages. + this.isSelf = userId == this.currentUserId; + + const messages = await AddonMessagesOffline.instance.getMessages(userId); + + if (messages && messages.length) { + // We have offline messages, this probably means that the conversation didn't exist. Don't display error. + messages.forEach((message) => { + message.pending = true; + message.text = message.smallmessage; + }); + + this.loadMessages(messages); + } else if (error.errorcode != 'errorconversationdoesnotexist') { + // Display the error. + throw error; + } + + return false; + } + } + + + // Retrieve the conversation. Invalidate data first to get the right unreadcount. + await AddonMessages.instance.invalidateConversation(conversationId!); + + try { + this.conversation = await AddonMessages.instance.getConversation(conversationId!, undefined, true); + } catch (error) { + // Get conversation failed, use the fallback one if we have it. + if (fallbackConversation) { + this.conversation = fallbackConversation; + } else { + throw error; + } + } + + if (this.conversation) { + this.conversationId = this.conversation.id; + this.title = this.conversation.name; + this.conversationImage = this.conversation.imageurl; + this.isGroup = this.conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP; + this.favouriteIcon = 'fas-star'; + this.favouriteIconSlash = this.conversation.isfavourite; + this.muteIcon = this.conversation.ismuted ? 'fas-bell' : 'fas-bell-slash'; + if (!this.isGroup) { + this.userId = this.conversation.userid; + } + this.isSelf = this.conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_SELF; + + return true; + } else { + return false; + } + + } + + /** + * Get the messages of the conversation. Used if group messaging is supported. + * + * @param pagesToLoad Number of "pages" to load. + * @param offset Offset for message list. + * @return Promise resolved with the list of messages. + */ + protected async getConversationMessages( + pagesToLoad: number, + offset: number = 0, + ): Promise { + + if (!this.conversationId) { + return []; + } + + const excludePending = offset > 0; + + const result = await AddonMessages.instance.getConversationMessages(this.conversationId, { + excludePending: excludePending, + limitFrom: offset, + }); + + pagesToLoad--; + + // Treat members. Don't use CoreUtilsProvider.arrayToObject because we don't want to override the existing object. + if (result.members) { + result.members.forEach((member) => { + this.members[member.id] = member; + }); + } + + const messages: AddonMessagesConversationMessageFormatted[] = result.messages; + + if (pagesToLoad > 0 && result.canLoadMore) { + offset += AddonMessagesProvider.LIMIT_MESSAGES; + + // Get more messages. + const nextMessages = await this.getConversationMessages(pagesToLoad, offset); + + return messages.concat(nextMessages); + } + + // No more messages to load, return them. + this.canLoadMore = !!result.canLoadMore; + + return messages; + + } + + /** + * Get a discussion. Can load several "pages". + * + * @param pagesToLoad Number of pages to load. + * @param lfReceivedUnread Number of unread received messages already fetched, so fetch will be done from this. + * @param lfReceivedRead Number of read received messages already fetched, so fetch will be done from this. + * @param lfSentUnread Number of unread sent messages already fetched, so fetch will be done from this. + * @param lfSentRead Number of read sent messages already fetched, so fetch will be done from this. + * @return Resolved when done. + */ + protected async getDiscussionMessages( + pagesToLoad: number, + lfReceivedUnread: number = 0, + lfReceivedRead: number = 0, + lfSentUnread: number = 0, + lfSentRead: number = 0, + ): Promise<(AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[]> { + + // Only get offline messages if we're loading the first "page". + const excludePending = lfReceivedUnread > 0 || lfReceivedRead > 0 || lfSentUnread > 0 || lfSentRead > 0; + + // Get next messages. + const result = await AddonMessages.instance.getDiscussion( + this.userId!, + excludePending, + lfReceivedUnread, + lfReceivedRead, + lfSentUnread, + lfSentRead, + ); + + pagesToLoad--; + if (pagesToLoad > 0 && result.canLoadMore) { + // More pages to load. Calculate new limit froms. + result.messages.forEach((message) => { + if (!message.pending && 'read' in message) { + if (message.useridfrom == this.userId) { + if (message.read) { + lfReceivedRead++; + } else { + lfReceivedUnread++; + } + } else { + if (message.read) { + lfSentRead++; + } else { + lfSentUnread++; + } + } + } + }); + + // Get next messages. + const nextMessages = + await this.getDiscussionMessages(pagesToLoad, lfReceivedUnread, lfReceivedRead, lfSentUnread, lfSentRead); + + return result.messages.concat(nextMessages); + } else { + // No more messages to load, return them. + this.canLoadMore = result.canLoadMore; + + return result.messages; + } + } + + /** + * Mark messages as read. + */ + protected async markMessagesAsRead(forceMark: boolean): Promise { + let readChanged = false; + + if (AddonMessages.instance.isMarkAllMessagesReadEnabled()) { + let messageUnreadFound = false; + + // Mark all messages at a time if there is any unread message. + if (forceMark) { + messageUnreadFound = true; + } else if (this.groupMessagingEnabled) { + messageUnreadFound = !!((this.conversation?.unreadcount && this.conversation?.unreadcount > 0) && + (this.conversationId && this.conversationId > 0)); + } else { + // If an unread message is found, mark all messages as read. + messageUnreadFound = this.messages.some((message) => + message.useridfrom != this.currentUserId && ('read' in message && !message.read)); + } + + if (messageUnreadFound) { + this.setUnreadLabelPosition(); + + if (this.groupMessagingEnabled) { + await AddonMessages.instance.markAllConversationMessagesRead(this.conversationId!); + } else { + await AddonMessages.instance.markAllMessagesRead(this.userId); + + // Mark all messages as read. + this.messages.forEach((message) => { + if ('read' in message) { + message.read = true; + } + }); + } + + readChanged = true; + } + } else { + this.setUnreadLabelPosition(); + const promises: Promise[] = []; + + // Mark each message as read one by one. + this.messages.forEach((message) => { + // If the message is unread, call AddonMessages.instance.markMessageRead. + if (message.useridfrom != this.currentUserId && 'read' in message && !message.read) { + promises.push(AddonMessages.instance.markMessageRead(message.id).then(() => { + readChanged = true; + message.read = true; + + return; + })); + } + }); + + await Promise.all(promises); + } + + if (readChanged) { + CoreEvents.trigger(AddonMessagesProvider.READ_CHANGED_EVENT, { + conversationId: this.conversationId, + userId: this.userId, + }, this.siteId); + } + } + + /** + * Notify the last message found so discussions list controller can tell if last message should be updated. + */ + protected notifyNewMessage(): void { + const last = this.messages[this.messages.length - 1]; + + let trigger = false; + + if (!last) { + this.lastMessage = { text: '', timecreated: 0 }; + trigger = true; + } else if (last.text !== this.lastMessage.text || last.timecreated !== this.lastMessage.timecreated) { + this.lastMessage = { text: last.text || '', timecreated: last.timecreated }; + trigger = true; + } + + if (trigger) { + // Update discussions last message. + CoreEvents.trigger(AddonMessagesProvider.NEW_MESSAGE_EVENT, { + conversationId: this.conversationId, + userId: this.userId, + message: this.lastMessage.text, + timecreated: this.lastMessage.timecreated, + isfavourite: !!this.conversation?.isfavourite, + type: this.conversation?.type, + }, this.siteId); + + // Update navBar links and buttons. + const newCanDelete = (last && 'id' in last && last.id && this.messages.length == 1) || this.messages.length > 1; + if (this.canDelete != newCanDelete) { + this.checkCanDelete(); + } + } + } + + /** + * Set the place where the unread label position has to be. + */ + protected setUnreadLabelPosition(): void { + if (this.unreadMessageFrom != 0) { + return; + } + + if (this.groupMessagingEnabled) { + // Use the unreadcount from the conversation to calculate where should the label be placed. + if (this.conversation && (this.conversation?.unreadcount && this.conversation?.unreadcount > 0) && this.messages) { + // Iterate over messages to find the right message using the unreadcount. Skip offline messages and own messages. + let found = 0; + + for (let i = this.messages.length - 1; i >= 0; i--) { + const message = this.messages[i]; + if (!message.pending && message.useridfrom != this.currentUserId && 'id' in message) { + found++; + if (found == this.conversation.unreadcount) { + this.unreadMessageFrom = Number(message.id); + break; + } + } + } + } + } else { + let previousMessageRead = false; + + for (const x in this.messages) { + const message = this.messages[x]; + if (message.useridfrom != this.currentUserId && 'read' in message) { + const unreadFrom = !message.read && previousMessageRead; + + if (unreadFrom) { + // Save where the label is placed. + this.unreadMessageFrom = Number(message.id); + break; + } + + previousMessageRead = !!message.read; + } + } + } + + // Do not update the message unread from label on next refresh. + if (this.unreadMessageFrom == 0) { + // Using negative to indicate the label is not placed but should not be placed. + this.unreadMessageFrom = -1; + } + } + + /** + * Check if there's any message in the list that can be deleted. + */ + protected checkCanDelete(): void { + // All messages being sent should be at the end of the list. + const first = this.messages[0]; + this.canDelete = first && !first.sending; + } + + /** + * Hide unread label when sending messages. + */ + protected hideUnreadLabel(): void { + if (this.unreadMessageFrom > 0) { + this.unreadMessageFrom = -1; + } + } + + /** + * Wait until fetching is false. + * + * @return Resolved when done. + */ + protected waitForFetch(): Promise { + if (!this.fetching) { + return Promise.resolve(); + } + + const deferred = CoreUtils.instance.promiseDefer(); + + setTimeout(() => this.waitForFetch().finally(() => { + deferred.resolve(); + }), 400); + + return deferred.promise; + } + + /** + * Set a polling to get new messages every certain time. + */ + protected setPolling(): void { + if (this.groupMessagingEnabled && !this.conversationId) { + // Don't have enough data to poll messages. + return; + } + + if (!this.polling) { + // Start polling. + this.polling = window.setInterval(() => { + this.fetchMessages().catch(() => { + // Ignore errors. + }); + }, AddonMessagesProvider.POLL_INTERVAL); + } + } + + /** + * Unset polling. + */ + protected unsetPolling(): void { + if (this.polling) { + this.logger.debug(`Cancelling polling for conversation with user '${this.userId}'`); + clearInterval(this.polling); + this.polling = undefined; + } + } + + /** + * Copy message to clipboard. + * + * @param message Message to be copied. + */ + copyMessage(message: AddonMessagesConversationMessageFormatted): void { + const text = 'smallmessage' in message ? message.smallmessage || message.text || '' : message.text || ''; + CoreUtils.instance.copyToClipboard(CoreTextUtils.instance.decodeHTMLEntities(text)); + } + + /** + * Function to delete a message. + * + * @param message Message object to delete. + * @param index Index where the message is to delete it from the view. + */ + async deleteMessage( + message: AddonMessagesConversationMessageFormatted, + index: number, + ): Promise { + + const canDeleteAll = this.conversation && this.conversation.candeletemessagesforallusers; + const langKey = message.pending || canDeleteAll || this.isSelf ? 'core.areyousure' : + 'addon.messages.deletemessageconfirmation'; + const options: AlertOptions = {}; + + if (canDeleteAll && !message.pending) { + // Show delete for all checkbox. + options.inputs = [{ + type: 'checkbox', + name: 'deleteforall', + checked: false, + value: true, + label: Translate.instance.instant('addon.messages.deleteforeveryone'), + }]; + } + + try { + const data: boolean[] = await CoreDomUtils.instance.showConfirm( + Translate.instance.instant(langKey), + undefined, + undefined, + undefined, + options, + ); + + const modal = await CoreDomUtils.instance.showModalLoading('core.deleting', true); + + try { + await AddonMessages.instance.deleteMessage(message, data && data[0]); + // Remove message from the list without having to wait for re-fetch. + this.messages.splice(index, 1); + this.removeMessage(message.hash!); + this.notifyNewMessage(); + + this.fetchMessages(); // Re-fetch messages to update cached data. + } finally { + modal.dismiss(); + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errordeletemessage', true); + } + } + + /** + * Function to load previous messages. + * + * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. + * @return Resolved when done. + */ + async loadPrevious(infiniteComplete?: () => void): Promise { + let infiniteHeight = this.infinite?.infiniteEl?.nativeElement.getBoundingClientRect().height || 0; + const scrollHeight = (this.scrollElement?.scrollHeight || 0); + + // If there is an ongoing fetch, wait for it to finish. + try { + await this.waitForFetch(); + } finally { + this.pagesLoaded++; + + try { + await this.fetchMessages(false); + + // Try to keep the scroll position. + const scrollBottom = scrollHeight - (this.scrollElement?.scrollTop || 0); + + const height = this.infinite?.infiniteEl?.nativeElement.getBoundingClientRect().height || 0; + if (this.canLoadMore && infiniteHeight && this.infinite) { + // The height of the infinite is different while spinner is shown. Add that difference. + infiniteHeight = infiniteHeight - height; + } else if (!this.canLoadMore) { + // Can't load more, take into account the full height of the infinite loading since it will disappear now. + infiniteHeight = infiniteHeight || height; + } + + this.keepScroll(scrollHeight, scrollBottom, infiniteHeight); + } catch (error) { + this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. + this.pagesLoaded--; + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingmessages', true); + } finally { + infiniteComplete && infiniteComplete(); + } + } + } + + /** + * Keep scroll position after loading previous messages. + * We don't use resizeContent because the approach used is different and it isn't easy to calculate these positions. + */ + protected keepScroll(oldScrollHeight: number, oldScrollBottom: number, infiniteHeight: number, retries = 0): void { + + setTimeout(() => { + const newScrollHeight = (this.scrollElement?.scrollHeight || 0); + + if (newScrollHeight == oldScrollHeight) { + // Height hasn't changed yet. Retry if max retries haven't been reached. + if (retries <= 10) { + this.keepScroll(oldScrollHeight, oldScrollBottom, infiniteHeight, retries + 1); + } + + return; + } + + const scrollTo = newScrollHeight - oldScrollBottom + infiniteHeight; + + this.content!.scrollToPoint(0, scrollTo, 0); + }, 30); + } + + /** + * Content or scroll has been resized. For content, only call it if it's been added on top. + */ + resizeContent(): void { + /* @todo + let top = this.content!.getContentDimensions().scrollTop; + // @todo this.content.resize(); + + // Wait for new content height to be calculated. + setTimeout(() => { + // Visible content size changed, maintain the bottom position. + if (!this.viewDestroyed && (this.scrollElement?.clientHeight || 0) != this.oldContentHeight) { + if (!top) { + top = this.content!.getContentDimensions().scrollTop; + } + + top += this.oldContentHeight - (this.scrollElement?.clientHeight || 0); + this.oldContentHeight = (this.scrollElement?.clientHeight || 0); + + this.content!.scrollToPoint(0, top, 0); + } + }); + */ + } + + /** + * Scroll bottom when render has finished. + */ + scrollToBottom(): void { + // Check if scroll is at bottom. If so, scroll bottom after rendering since there might be something new. + if (this.scrollBottom) { + // Need a timeout to leave time to the view to be rendered. + setTimeout(() => { + if (!this.viewDestroyed) { + this.content!.scrollToBottom(0); + } + }); + this.scrollBottom = false; + + // Reset the badge. + this.setNewMessagesBadge(0); + } + } + + /** + * Scroll to the first new unread message. + */ + scrollToFirstUnreadMessage(): void { + if (this.newMessages > 0) { + const messages = Array.from(document.querySelectorAll('.addon-message-not-mine')); + + CoreDomUtils.instance.scrollToElement(this.content!, messages[messages.length - this.newMessages]); + } + } + + /** + * Sends a message to the server. + * + * @param text Message text. + */ + async sendMessage(text: string): Promise { + this.hideUnreadLabel(); + + this.showDelete = false; + this.scrollBottom = true; + this.setNewMessagesBadge(0); + + const message: AddonMessagesConversationMessageFormatted = { + id: -1, + pending: true, + sending: true, + useridfrom: this.currentUserId, + smallmessage: text, + text: text, + timecreated: new Date().getTime(), + }; + message.showDate = this.showDate(message, this.messages[this.messages.length - 1]); + this.addMessage(message, false); + + this.messagesBeingSent++; + + // If there is an ongoing fetch, wait for it to finish. + // Otherwise, if a message is sent while fetching it could disappear until the next fetch. + try { + await this.waitForFetch(); + } finally { + + try { + let data: { + sent: boolean; + message: AddonMessagesSendMessagesToConversationMessage | AddonMessagesSendInstantMessagesMessage; + }; + if (this.conversationId) { + data = await AddonMessages.instance.sendMessageToConversation(this.conversation!, text); + } else { + data = await AddonMessages.instance.sendMessage(this.userId!, text); + } + + + this.messagesBeingSent--; + let failure = false; + if (data.sent) { + try { + + if (!this.conversationId && data.message && 'conversationid' in data.message) { + // Message sent to a new conversation, try to load the conversation. + await this.getConversation(data.message.conversationid, this.userId); + // Now fetch messages. + try { + await this.fetchMessages(); + } finally { + // Start polling messages now that the conversation exists. + this.setPolling(); + } + } else { + // Message was sent, fetch messages right now. + await this.fetchMessages(); + } + } catch { + failure = true; + } + } + + if (failure || !data.sent) { + // Fetch failed or is offline message, mark the message as sent. + // If fetch is successful there's no need to mark it because the fetch will already show the message received. + message.sending = false; + if (data.sent) { + // Message sent to server, not pending anymore. + message.pending = false; + } else if (data.message) { + message.timecreated = data.message.timecreated || 0; + } + + this.notifyNewMessage(); + } + + } catch (error) { + this.messagesBeingSent--; + + // Only close the keyboard if an error happens. + // We want the user to be able to send multiple messages without the keyboard being closed. + CoreApp.instance.closeKeyboard(); + + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.messagenotsent', true); + this.removeMessage(message.hash!); + } + } + } + + /** + * Check date should be shown on message list for the current message. + * If date has changed from previous to current message it should be shown. + * + * @param message Current message where to show the date. + * @param prevMessage Previous message where to compare the date with. + * @return If date has changed and should be shown. + */ + showDate( + message: AddonMessagesConversationMessageFormatted, + prevMessage?: AddonMessagesConversationMessageFormatted, + ): boolean { + + if (!prevMessage) { + // First message, show it. + return true; + } + + // Check if day has changed. + return !moment(message.timecreated).isSame(prevMessage.timecreated, 'day'); + } + + /** + * Check if the user info should be displayed for the current message. + * User data is only displayed for group conversations if the previous message was from another user. + * + * @param message Current message where to show the user info. + * @param prevMessage Previous message. + * @return Whether user data should be shown. + */ + showUserData( + message: AddonMessagesConversationMessageFormatted, + prevMessage?: AddonMessagesConversationMessageFormatted, + ): boolean { + + return this.isGroup && message.useridfrom != this.currentUserId && this.members[(message.useridfrom || 0)] && + (!prevMessage || prevMessage.useridfrom != message.useridfrom || !!message.showDate); + } + + /** + * Check if a css tail should be shown. + * + * @param message Current message where to show the user info. + * @param nextMessage Next message. + * @return Whether user data should be shown. + */ + showTail( + message: AddonMessagesConversationMessageFormatted, + nextMessage?: AddonMessagesConversationMessageFormatted, + ): boolean { + return !nextMessage || nextMessage.useridfrom != message.useridfrom || !!nextMessage.showDate; + } + + /** + * Toggles delete state. + */ + toggleDelete(): void { + this.showDelete = !this.showDelete; + } + + /** + * View info. If it's an individual conversation, go to the user profile. + * If it's a group conversation, view info about the group. + */ + async viewInfo(): Promise { + if (this.isGroup) { + // Display the group information. + const modal = await ModalController.instance.create({ + component: 'AddonMessagesConversationInfoPage', // @todo + componentProps: { + conversationId: this.conversationId, + }, + }); + + await modal.present(); + + const userId = await modal.onDidDismiss(); + + if (typeof userId != 'undefined') { + // Open user conversation. + /* @todo if (this.svComponent) { + // Notify the left pane to load it, this way the right conversation will be highlighted. + CoreEvents.trigger( + AddonMessagesProvider.OPEN_CONVERSATION_EVENT, + { userId: userId }, + this.siteId); + } else {*/ + // Open the discussion in a new view. + CoreNavigator.instance.navigateToSitePath('/messages/discussion', { params: { userId } }); + // } + } + } else { + // Open the user profile. + // @todo const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + CoreNavigator.instance.navigateToSitePath('/user/profile', { params: { userId: this.userId } }); + } + } + + /** + * Change the favourite state of the current conversation. + * + * @param done Function to call when done. + */ + async changeFavourite(done?: () => void): Promise { + if (!this.conversation) { + return; + } + + this.favouriteIcon = 'spinner'; + + try { + await AddonMessages.instance.setFavouriteConversation(this.conversation.id, !this.conversation.isfavourite); + + this.conversation.isfavourite = !this.conversation.isfavourite; + + // Get the conversation data so it's cached. Don't block the user for this. + AddonMessages.instance.getConversation(this.conversation.id, undefined, true); + + CoreEvents.trigger(AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, { + conversationId: this.conversation.id, + action: 'favourite', + value: this.conversation.isfavourite, + }, this.siteId); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error changing favourite state.'); + } finally { + this.favouriteIcon = 'fas-star'; + this.favouriteIconSlash = this.conversation.isfavourite; + done && done(); + } + } + + /** + * Change the mute state of the current conversation. + * + * @param done Function to call when done. + */ + async changeMute(done?: () => void): Promise { + if (!this.conversation) { + return; + } + + this.muteIcon = 'spinner'; + + try { + await AddonMessages.instance.muteConversation(this.conversation.id, !this.conversation.ismuted); + this.conversation.ismuted = !this.conversation.ismuted; + + // Get the conversation data so it's cached. Don't block the user for this. + AddonMessages.instance.getConversation(this.conversation.id, undefined, true); + + CoreEvents.trigger(AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, { + conversationId: this.conversation.id, + action: 'mute', + value: this.conversation.ismuted, + }, this.siteId); + + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error changing muted state.'); + } finally { + this.muteIcon = this.conversation.ismuted ? 'fas-bell' : 'fas-bell-slash'; + done && done(); + } + } + + /** + * Calculate whether there are pending contact requests. + */ + protected setContactRequestInfo(): void { + this.requestContactSent = false; + this.requestContactReceived = false; + if (this.otherMember && !this.otherMember.iscontact) { + this.requestContactSent = !!this.otherMember.contactrequests?.some((request) => + request.userid == this.currentUserId && request.requesteduserid == this.otherMember!.id); + this.requestContactReceived = !!this.otherMember.contactrequests?.some((request) => + request.userid == this.otherMember!.id && request.requesteduserid == this.currentUserId); + } + } + + /** + * Calculate what to display in the footer. + */ + protected setFooterType(): void { + if (!this.otherMember) { + // Group conversation or group messaging not available. + this.footerType = 'message'; + } else if (this.otherMember.isblocked) { + this.footerType = 'blocked'; + } else if (this.requestContactReceived) { + this.footerType = 'requestReceived'; + } else if (this.otherMember.canmessage) { + this.footerType = 'message'; + } else if (this.requestContactSent) { + this.footerType = 'requestSent'; + } else if (this.otherMember.requirescontact) { + this.footerType = 'requiresContact'; + } else { + this.footerType = 'unable'; + } + } + + /** + * Displays a confirmation modal to block the user of the individual conversation. + * + * @return Promise resolved when user is blocked or dialog is cancelled. + */ + async blockUser(): Promise { + if (!this.otherMember) { + // Should never happen. + throw new CoreError('No member selected to be blocked.'); + } + + const template = Translate.instance.instant('addon.messages.blockuserconfirm', { $a: this.otherMember.fullname }); + const okText = Translate.instance.instant('addon.messages.blockuser'); + + try { + await CoreDomUtils.instance.showConfirm(template, undefined, okText); + this.blockIcon = 'spinner'; + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + this.showLoadingModal = true; + + try { + try { + await AddonMessages.instance.blockContact(this.otherMember.id); + } finally { + modal.dismiss(); + this.showLoadingModal = false; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } finally { + this.blockIcon = this.otherMember.isblocked ? 'fas-user-lock' : 'fas-user-check'; + } + } catch { + // User cancelled. + } + } + + /** + * Delete the conversation. + * + * @param done Function to call when done. + */ + async deleteConversation(done?: () => void): Promise { + if (!this.conversation) { + return; + } + + const confirmMessage = 'addon.messages.' + (this.isSelf ? 'deleteallselfconfirm' : 'deleteallconfirm'); + + try { + await CoreDomUtils.instance.showDeleteConfirm(confirmMessage); + this.deleteIcon = 'spinner'; + + try { + try { + await AddonMessages.instance.deleteConversation(this.conversation.id); + + CoreEvents.trigger( + AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, + { + conversationId: this.conversation.id, + action: 'delete', + }, + this.siteId, + ); + + this.messages = []; + } finally { + done && done(); + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error deleting conversation.'); + } finally { + this.deleteIcon = 'fas-trash'; + } + } catch { + // User cancelled. + } + } + + /** + * Displays a confirmation modal to unblock the user of the individual conversation. + * + * @return Promise resolved when user is unblocked or dialog is cancelled. + */ + async unblockUser(): Promise { + if (!this.otherMember) { + // Should never happen. + throw new CoreError('No member selected to be unblocked.'); + } + + const template = Translate.instance.instant('addon.messages.unblockuserconfirm', { $a: this.otherMember.fullname }); + const okText = Translate.instance.instant('addon.messages.unblockuser'); + + try { + await CoreDomUtils.instance.showConfirm(template, undefined, okText); + + this.blockIcon = 'spinner'; + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + this.showLoadingModal = true; + + try { + try { + await AddonMessages.instance.unblockContact(this.otherMember.id); + } finally { + modal.dismiss(); + this.showLoadingModal = false; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } finally { + this.blockIcon = this.otherMember.isblocked ? 'fas-user-lock' : 'fas-user-check'; + } + } catch { + // User cancelled. + } + } + + /** + * Displays a confirmation modal to send a contact request to the other user of the individual conversation. + * + * @return Promise resolved when the request is sent or the dialog is cancelled. + */ + async createContactRequest(): Promise { + if (!this.otherMember) { + // Should never happen. + throw new CoreError('No member selected to be requested.'); + } + + const template = Translate.instance.instant('addon.messages.addcontactconfirm', { $a: this.otherMember.fullname }); + const okText = Translate.instance.instant('core.add'); + + try { + await CoreDomUtils.instance.showConfirm(template, undefined, okText); + + this.addRemoveIcon = 'spinner'; + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + this.showLoadingModal = true; + + try { + try { + await AddonMessages.instance.createContactRequest(this.otherMember.id); + } finally { + modal.dismiss(); + this.showLoadingModal = false; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } finally { + this.addRemoveIcon = 'fas-user-plus'; + } + } catch { + // User cancelled. + } + } + + /** + * Confirms the contact request of the other user of the individual conversation. + * + * @return Promise resolved when the request is confirmed. + */ + async confirmContactRequest(): Promise { + if (!this.otherMember) { + // Should never happen. + throw new CoreError('No member selected to be confirmed.'); + } + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + this.showLoadingModal = true; + + try { + try { + await AddonMessages.instance.confirmContactRequest(this.otherMember.id); + } finally { + modal.dismiss(); + this.showLoadingModal = false; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } + } + + /** + * Declines the contact request of the other user of the individual conversation. + * + * @return Promise resolved when the request is confirmed. + */ + async declineContactRequest(): Promise { + if (!this.otherMember) { + // Should never happen. + throw new CoreError('No member selected to be declined.'); + } + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + this.showLoadingModal = true; + + try { + try { + await AddonMessages.instance.declineContactRequest(this.otherMember.id); + } finally { + modal.dismiss(); + this.showLoadingModal = false; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } + } + + /** + * Displays a confirmation modal to remove the other user of the conversation from contacts. + * + * @return Promise resolved when the request is sent or the dialog is cancelled. + */ + async removeContact(): Promise { + if (!this.otherMember) { + // Should never happen. + throw new CoreError('No member selected to be removed.'); + } + + const template = Translate.instance.instant('addon.messages.removecontactconfirm', { $a: this.otherMember.fullname }); + const okText = Translate.instance.instant('core.remove'); + + try { + await CoreDomUtils.instance.showConfirm(template, undefined, okText); + + this.addRemoveIcon = 'spinner'; + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + this.showLoadingModal = true; + + try { + try { + await AddonMessages.instance.removeContact(this.otherMember.id); + } finally { + modal.dismiss(); + this.showLoadingModal = false; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } finally { + this.addRemoveIcon = 'fas-user-plus'; + } + } catch { + // User cancelled. + } + + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + // Unset again, just in case. + this.unsetPolling(); + this.syncObserver?.off(); + this.keyboardObserver?.off(); + this.memberInfoObserver?.off(); + this.viewDestroyed = true; + } + +} diff --git a/src/addons/messages/pages/discussion/discussion.scss b/src/addons/messages/pages/discussion/discussion.scss new file mode 100644 index 000000000..67086643d --- /dev/null +++ b/src/addons/messages/pages/discussion/discussion.scss @@ -0,0 +1,300 @@ +:host { + ion-content { + background-color: var(--background-lighter); + + &::part(scroll) { + padding-bottom: 0 !important; + } + } + + .addon-messages-discussion-container { + display: flex; + flex-direction: column; + padding-bottom: 15px; + background: var(--background-lighter); + } + + .addon-messages-date { + font-weight: normal; + font-size: 0.9rem; + } + + .addon-messages-unreadfrom { + color: var(--core-color); + background-color: transparent; + margin-top: 6px; + ion-icon { + color: var(--core-color); + background-color: transparent; + } + } + + // Message item. + ion-item.addon-message { + border: 0; + border-radius: 4px; + padding: 8px; + margin: 8px 8px 0 8px; + --background: var(--addon-messages-message-bg); + background: var(--background); + align-self: flex-start; + width: 90%; + max-width: 90%; + min-height: 0; + position: relative; + -webkit-transition: width 500ms ease-in-out; + transition: width 500ms ease-in-out; + // This is needed to display bubble tails. + overflow: visible; + + &::part(native) { + --inner-border-width: 0; + --inner-padding-end: 0; + padding: 0; + margin: 0; + } + + core-format-text > p:only-child { + display: inline; + } + + .addon-message-user { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-bottom: .5rem; + margin-top: 0; + color: var(--ion-text-color); + + core-user-avatar { + display: block; + --core-avatar-size: var(--addon-messages-avatar-size); + margin: 0; + } + + div { + font-weight: 500; + flex-grow: 1; + padding-right: .5rem; + padding-left: .5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + ion-note { + text-align: end;; + color: var(--addon-messages-message-note-text); + font-size: var(--addon-messages-message-note-font-size); + } + } + + &.addon-message-no-user .addon-message-user ion-note { + width: 100%; + } + + &:active { + --background: var(--addon-messages-message-activated-bg); + } + + ion-label { + margin: 0; + padding: 0; + } + + .addon-message-text { + display: inline-flex; + * { + color: var(--ion-text-color); + } + } + + .addon-messages-delete-button { + min-height: initial; + line-height: initial; + margin: 0 0 0 10px; + height: 1.6em !important; + -webkit-align-self: flex-end; + align-self: flex-end; + vertical-align: middle; + float: inline-end; + + ion-icon { + font-size: 1.4em; + line-height: initial; + color: var(--ion-color-danger); + } + } + + .tail { + content: ''; + width: 0; + height: 0; + border: 0.5rem solid transparent; + position: absolute; + touch-action: none; + } + + // Defines when an item-message is the user's. + &.addon-message-mine { + --background: var(--addon-messages-message-mine-bg); + align-self: flex-end; + + &:active { + --background: var(--addon-messages-message-mine-activated-bg); + } + + .spinner { + float: inline-end; + margin: 2px, -3px, -2px, 5px; + + svg { + width: 16px; + height: 16px; + } + } + + .tail { + right: -8px; + bottom: -8px; + margin-right: -0.5rem; + border-bottom-color: var(--addon-messages-message-mine-bg); + } + + &:active .tail { + border-bottom-color: var(--addon-messages-message-mine-activated-bg); + } + } + + &.addon-message-not-mine .tail { + border-bottom-color: var(--addon-messages-message-bg); + bottom: -8px; + left: -8px; + margin-left: -0.5rem; + } + + &.addon-message-not-mine.activated .tail { + border-bottom-color: var(--addon-messages-message-activated-bg); + } + } + + ion-item.addon-message.addon-message-mine + ion-item.addon-message.addon-message-no-user.addon-message-mine, + ion-item.addon-message.addon-message-not-mine + ion-item.addon-message.addon-message-no-user.addon-message-not-mine { + h2 { + margin-bottom: 0; + } + margin-top: -8px; + padding-top: 0; + border-top-right-radius: 0; + border-top-left-radius: 0; + } + + .has-fab .scroll-content { + padding-bottom: 0; + } + + ion-fab ion-fab-button { + &::part(native) { + contain: unset; + overflow: visible; + } + + .core-discussion-messages-badge { + position: absolute; + border-radius: 50%; + color: var(--addon-messages-discussion-badge-text); + background-color: var(--addon-messages-discussion-badge); + display: block; + line-height: 20px; + height: 20px; + width: 20px; + right: -6px; + top: -6px; + + } + } + + ion-header ion-toolbar .toolbar-title { + display: flex; + align-items: center; + padding: 0; + + .core-bar-button-image { + margin-right: 6px; + } + + core-format-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 1; + } + + ion-icon { + margin-left: 6px; + } + } +} + +:host-context([dir=rtl]) { + ion-header ion-toolbar .toolbar-title { + .core-bar-button-image { + margin-left: 6px; + margin-right: 0; + } + + ion-icon { + margin-right: 6px; + margin-left: 0; + } + } + + // Message item. + ion-item.addon-message { + + .addon-messages-delete-button { + margin-right: 10px; + margin-left: 0; + } + + &.addon-message-mine { + .spinner { + margin-right: 5px; + margin-left: -3px; + } + + .tail { + right: unset; + left: -8px; + margin-right: unset; + margin-left: 0; + } + } + + &.addon-message-not-mine .tail { + right: -8px; + margin-right: -0.5rem; + left: unset; + margin-left: 0; + } + } + + ion-fab button { + .core-discussion-messages-badge { + left: -6px; + right: unset; + } + } +} + +:host-context(.ios) { + ion-header ion-toolbar .toolbar-title { + justify-content: center; + } + + ion-footer .toolbar:last-child { + padding-bottom: 4px; + min-height: 0; + } +} diff --git a/src/addons/messages/pages/discussions-35/discussions.page.ts b/src/addons/messages/pages/discussions-35/discussions.page.ts index 5f3650ca4..411ae91b7 100644 --- a/src/addons/messages/pages/discussions-35/discussions.page.ts +++ b/src/addons/messages/pages/discussions-35/discussions.page.ts @@ -19,7 +19,9 @@ import { AddonMessages, AddonMessagesDiscussion, AddonMessagesMessageAreaContact, + AddonMessagesNewMessagedEventData, AddonMessagesProvider, + AddonMessagesReadChangedEventData, AddonMessagesSplitViewLoadIndexEventData, } from '../../services/messages'; import { CoreDomUtils } from '@services/utils/dom'; @@ -78,38 +80,46 @@ export class AddonMessagesDiscussions35Page implements OnInit, OnDestroy { this.siteId = CoreSites.instance.getCurrentSiteId(); // Update discussions when new message is received. - this.newMessagesObserver = CoreEvents.on(AddonMessagesProvider.NEW_MESSAGE_EVENT, (data: any) => { - if (data.userId && this.discussions) { - const discussion = this.discussions.find((disc) => disc.message!.user == data.userId); + this.newMessagesObserver = CoreEvents.on( + AddonMessagesProvider.NEW_MESSAGE_EVENT, + (data) => { + if (data.userId && this.discussions) { + const discussion = this.discussions.find((disc) => disc.message!.user == data.userId); - if (typeof discussion == 'undefined') { - this.loaded = false; - this.refreshData().finally(() => { - this.loaded = true; - }); - } else { + if (typeof discussion == 'undefined') { + this.loaded = false; + this.refreshData().finally(() => { + this.loaded = true; + }); + } else { // An existing discussion has a new message, update the last message. - discussion.message!.message = data.message; - discussion.message!.timecreated = data.timecreated; + discussion.message!.message = data.message; + discussion.message!.timecreated = data.timecreated; + } } - } - }, this.siteId); + }, + this.siteId, + ); // Update discussions when a message is read. - this.readChangedObserver = CoreEvents.on(AddonMessagesProvider.READ_CHANGED_EVENT, (data: any) => { - if (data.userId && this.discussions) { - const discussion = this.discussions.find((disc) => disc.message!.user == data.userId); + this.readChangedObserver = CoreEvents.on( + AddonMessagesProvider.READ_CHANGED_EVENT, + (data) => { + if (data.userId && this.discussions) { + const discussion = this.discussions.find((disc) => disc.message!.user == data.userId); - if (typeof discussion != 'undefined') { + if (typeof discussion != 'undefined') { // A discussion has been read reset counter. - discussion.unread = false; + discussion.unread = false; - // Conversations changed, invalidate them and refresh unread counts. - AddonMessages.instance.invalidateConversations(this.siteId); - AddonMessages.instance.refreshUnreadConversationCounts(this.siteId); + // Conversations changed, invalidate them and refresh unread counts. + AddonMessages.instance.invalidateConversations(this.siteId); + AddonMessages.instance.refreshUnreadConversationCounts(this.siteId); + } } - } - }, this.siteId); + }, + this.siteId, + ); // Update unread conversation counts. this.cronObserver = CoreEvents.on(AddonMessagesProvider.UNREAD_CONVERSATION_COUNTS_EVENT, () => { diff --git a/src/addons/messages/pages/group-conversations/group-conversations.page.ts b/src/addons/messages/pages/group-conversations/group-conversations.page.ts index df925f4bf..d180847e8 100644 --- a/src/addons/messages/pages/group-conversations/group-conversations.page.ts +++ b/src/addons/messages/pages/group-conversations/group-conversations.page.ts @@ -23,6 +23,10 @@ import { AddonMessagesMemberInfoChangedEventData, AddonMessagesContactRequestCountEventData, AddonMessagesUnreadConversationCountsEventData, + AddonMessagesReadChangedEventData, + AddonMessagesUpdateConversationListEventData, + AddonMessagesNewMessagedEventData, + AddonMessagesOpenConversationEventData, } from '../../services/messages'; import { AddonMessagesOffline } from '../../services/messages-offline'; import { CoreDomUtils } from '@services/utils/dom'; @@ -112,47 +116,53 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { this.currentUserId = CoreSites.instance.getCurrentSiteUserId(); // Update conversations when new message is received. - this.newMessagesObserver = CoreEvents.on(AddonMessagesProvider.NEW_MESSAGE_EVENT, (data: any) => { + this.newMessagesObserver = CoreEvents.on( + AddonMessagesProvider.NEW_MESSAGE_EVENT, + (data) => { // Check if the new message belongs to the option that is currently expanded. - const expandedOption = this.getExpandedOption(); - const messageOption = this.getConversationOption(data); + const expandedOption = this.getExpandedOption(); + const messageOption = this.getConversationOption(data); - if (expandedOption != messageOption) { - return; // Message doesn't belong to current list, stop. - } - - // Search the conversation to update. - const conversation = this.findConversation(data.conversationId, data.userId, expandedOption); - - if (typeof conversation == 'undefined') { - // Probably a new conversation, refresh the list. - this.loaded = false; - this.refreshData().finally(() => { - this.loaded = true; - }); - - return; - } - if (conversation.lastmessage != data.message || conversation.lastmessagedate != data.timecreated / 1000) { - const isNewer = data.timecreated / 1000 > (conversation.lastmessagedate || 0); - - // An existing conversation has a new message, update the last message. - conversation.lastmessage = data.message; - conversation.lastmessagedate = data.timecreated / 1000; - - // Sort the affected list. - const option = this.getConversationOption(conversation); - option.conversations = AddonMessages.instance.sortConversations(option.conversations || []); - - if (isNewer) { - // The last message is newer than the previous one, scroll to top to keep viewing the conversation. - this.content?.scrollToTop(); + if (expandedOption != messageOption) { + return; // Message doesn't belong to current list, stop. } - } - }, this.siteId); + + // Search the conversation to update. + const conversation = this.findConversation(data.conversationId, data.userId, expandedOption); + + if (typeof conversation == 'undefined') { + // Probably a new conversation, refresh the list. + this.loaded = false; + this.refreshData().finally(() => { + this.loaded = true; + }); + + return; + } + if (conversation.lastmessage != data.message || conversation.lastmessagedate != data.timecreated / 1000) { + const isNewer = data.timecreated / 1000 > (conversation.lastmessagedate || 0); + + // An existing conversation has a new message, update the last message. + conversation.lastmessage = data.message; + conversation.lastmessagedate = data.timecreated / 1000; + + // Sort the affected list. + const option = this.getConversationOption(conversation); + option.conversations = AddonMessages.instance.sortConversations(option.conversations || []); + + if (isNewer) { + // The last message is newer than the previous one, scroll to top to keep viewing the conversation. + this.content?.scrollToTop(); + } + } + }, + this.siteId, + ); // Update conversations when a message is read. - this.readChangedObserver = CoreEvents.on(AddonMessagesProvider.READ_CHANGED_EVENT, (data: any) => { + this.readChangedObserver = CoreEvents.on(AddonMessagesProvider.READ_CHANGED_EVENT, ( + data, + ) => { if (data.conversationId) { const conversation = this.findConversation(data.conversationId); @@ -168,11 +178,15 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { }, this.siteId); // Load a discussion if we receive an event to do so. - this.openConversationObserver = CoreEvents.on(AddonMessagesProvider.OPEN_CONVERSATION_EVENT, (data: any) => { - if (data.conversationId || data.userId) { - this.gotoConversation(data.conversationId, data.userId); - } - }, this.siteId); + this.openConversationObserver = CoreEvents.on( + AddonMessagesProvider.OPEN_CONVERSATION_EVENT, + (data) => { + if (data.conversationId || data.userId) { + this.gotoConversation(data.conversationId, data.userId); + } + }, + this.siteId, + ); // Refresh the view when the app is resumed. this.appResumeSubscription = Platform.instance.resume.subscribe(() => { @@ -186,24 +200,28 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { }); // Update conversations if we receive an event to do so. - this.updateConversationListObserver = CoreEvents.on(AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, (data: any) => { - if (data && data.action == 'mute') { + this.updateConversationListObserver = CoreEvents.on( + AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, + (data) => { + if (data && data.action == 'mute') { // If the conversation is displayed, change its muted value. - const expandedOption = this.getExpandedOption(); + const expandedOption = this.getExpandedOption(); - if (expandedOption && expandedOption.conversations) { - const conversation = this.findConversation(data.conversationId, undefined, expandedOption); - if (conversation) { - conversation.ismuted = data.value; + if (expandedOption && expandedOption.conversations) { + const conversation = this.findConversation(data.conversationId, undefined, expandedOption); + if (conversation) { + conversation.ismuted = !!data.value; + } } + + return; } - return; - } + this.refreshData(); - this.refreshData(); - - }, this.siteId); + }, + this.siteId, + ); // If a message push notification is received, refresh the view. this.pushObserver = CorePushNotificationsDelegate.instance.on('receive') @@ -667,7 +685,9 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { * @param conversation Conversation to check. * @return Option object. */ - protected getConversationOption(conversation: AddonMessagesConversationForList): AddonMessagesGroupConversationOption { + protected getConversationOption( + conversation: AddonMessagesConversationForList | AddonMessagesNewMessagedEventData, + ): AddonMessagesGroupConversationOption { if (conversation.isfavourite) { return this.favourites; } diff --git a/src/addons/messages/pages/index-35/index.html b/src/addons/messages/pages/index-35/index.html index 606bd99a3..445f9490e 100644 --- a/src/addons/messages/pages/index-35/index.html +++ b/src/addons/messages/pages/index-35/index.html @@ -10,7 +10,8 @@ - - - - + + + + + diff --git a/src/addons/messages/services/messages.ts b/src/addons/messages/services/messages.ts index a6f7e9dc0..9d8fc2123 100644 --- a/src/addons/messages/services/messages.ts +++ b/src/addons/messages/services/messages.ts @@ -251,19 +251,18 @@ export class AddonMessagesProvider { * @param deleteForAll Whether the message should be deleted for all users. * @return Promise resolved when the message has been deleted. */ - deleteMessage(message: any, deleteForAll?: boolean): Promise { - // @todo Add message type. - if (message.id) { + deleteMessage(message: AddonMessagesConversationMessageFormatted, deleteForAll?: boolean): Promise { + if ('id' in message) { // Message has ID, it means it has been sent to the server. if (deleteForAll) { return this.deleteMessageForAllOnline(message.id); } else { - return this.deleteMessageOnline(message.id, message.read); + return this.deleteMessageOnline(message.id, !!('read' in message && message.read)); } } // It's an offline message. - if (!message.conversationid) { + if (!('conversationid' in message)) { return AddonMessagesOffline.instance.deleteMessage(message.touserid, message.smallmessage, message.timecreated); } @@ -1433,7 +1432,7 @@ export class AddonMessagesProvider { const response: AddonMessagesGetMessagesResult = await site.read('core_message_get_messages', params, preSets); response.messages.forEach((message) => { - message.read = !params.read ? 0 : 1; + message.read = !!params.read; // Convert times to milliseconds. message.timecreated = message.timecreated ? message.timecreated * 1000 : 0; message.timeread = message.timeread ? message.timeread * 1000 : 0; @@ -2786,6 +2785,9 @@ export class AddonMessagesProvider { * @param messages Array of messages containing the key 'timecreated'. * @return Messages sorted with most recent last. */ + sortMessages( + messages: AddonMessagesConversationMessageFormatted[], + ): AddonMessagesConversationMessageFormatted[]; sortMessages( messages: (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[], ): (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[]; @@ -2794,9 +2796,11 @@ export class AddonMessagesProvider { ): (AddonMessagesOfflineMessagesDBRecordFormatted | AddonMessagesOfflineConversationMessagesDBRecordFormatted)[]; sortMessages( messages: (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[] | - (AddonMessagesOfflineMessagesDBRecordFormatted | AddonMessagesOfflineConversationMessagesDBRecordFormatted)[], + (AddonMessagesOfflineMessagesDBRecordFormatted | AddonMessagesOfflineConversationMessagesDBRecordFormatted)[] | + AddonMessagesConversationMessageFormatted[], ): (AddonMessagesGetMessagesMessage | AddonMessagesOfflineMessagesDBRecordFormatted)[] | - (AddonMessagesOfflineMessagesDBRecordFormatted | AddonMessagesOfflineConversationMessagesDBRecordFormatted)[] { + (AddonMessagesOfflineMessagesDBRecordFormatted | AddonMessagesOfflineConversationMessagesDBRecordFormatted)[] | + AddonMessagesConversationMessageFormatted[] { return messages.sort((a, b) => { // Pending messages last. if (a.pending && !b.pending) { @@ -3041,6 +3045,23 @@ export type AddonMessagesConversationMessage = { timecreated: number; // The timecreated timestamp for the message. }; +/** + * Conversation message with some calculated data. + */ +export type AddonMessagesConversationMessageFormatted = + (AddonMessagesConversationMessage + | AddonMessagesGetMessagesMessage + | AddonMessagesOfflineMessagesDBRecordFormatted + | AddonMessagesOfflineConversationMessagesDBRecordFormatted) & { + pending?: boolean; // Calculated in the app. Whether the message is pending to be sent. + sending?: boolean; // Calculated in the app. Whether the message is being sent right now. + hash?: string; // Calculated in the app. A hash to identify the message. + showDate?: boolean; // Calculated in the app. Whether to show the date before the message. + showUserData?: boolean; // Calculated in the app. Whether to show the user data in the message. + showTail?: boolean; // Calculated in the app. Whether to show a "tail" in the message. + }; + + /** * Data returned by core_message_get_user_message_preferences WS. */ @@ -3417,7 +3438,7 @@ export type AddonMessagesMessagePreferencesCalculatedData = { */ export type AddonMessagesGetMessagesMessageCalculatedData = { pending?: boolean; // Calculated in the app. Whether the message is pending to be sent. - read?: number; // Calculated in the app. Whether the message has been read. + read?: boolean; // Calculated in the app. Whether the message has been read. }; /** @@ -3691,3 +3712,40 @@ export type AddonMessagesSplitViewLoadContactsEventData = { userId: number; onInit: boolean; }; + +/** + * Data sent by READ_CHANGED_EVENT event. + */ +export type AddonMessagesReadChangedEventData = { + userId?: number; + conversationId?: number; +}; + +/** + * Data sent by NEW_MESSAGE_EVENT event. + */ +export type AddonMessagesNewMessagedEventData = { + conversationId?: number; + userId?: number; + message: string; + timecreated: number; + isfavourite: boolean; + type?: number; +}; + +/** + * Data sent by UPDATE_CONVERSATION_LIST_EVENT event. + */ +export type AddonMessagesUpdateConversationListEventData = { + conversationId: number; + action: string; + value?: boolean; +}; + +/** + * Data sent by OPEN_CONVERSATION_EVENT event. + */ +export type AddonMessagesOpenConversationEventData = { + userId?: number; + conversationId?: number; +}; diff --git a/src/addons/notifications/services/handlers/mainmenu.ts b/src/addons/notifications/services/handlers/mainmenu.ts index ee646c8c2..8ece315fe 100644 --- a/src/addons/notifications/services/handlers/mainmenu.ts +++ b/src/addons/notifications/services/handlers/mainmenu.ts @@ -22,6 +22,7 @@ import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '@features/mainmenu import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications'; import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate'; import { AddonNotifications, AddonNotificationsProvider } from '../notifications'; +import { AddonMessagesReadChangedEventData } from '@addons/messages/services/messages'; /** * Handler to inject an option into main menu. @@ -48,7 +49,7 @@ export class AddonNotificationsMainMenuHandlerService implements CoreMainMenuHan * Initialize the handler. */ initialize(): void { - CoreEvents.on(AddonNotificationsProvider.READ_CHANGED_EVENT, (data: CoreEventSiteData) => { + CoreEvents.on(AddonNotificationsProvider.READ_CHANGED_EVENT, (data) => { this.updateBadge(data.siteId); }); diff --git a/src/core/components/user-avatar/user-avatar.scss b/src/core/components/user-avatar/user-avatar.scss index 94b738f0e..9b993d60d 100644 --- a/src/core/components/user-avatar/user-avatar.scss +++ b/src/core/components/user-avatar/user-avatar.scss @@ -6,6 +6,13 @@ width: var(--core-avatar-size); height: var(--core-avatar-size); } + &.core-bar-button-image img { + padding: 0; + width: var(--core-toolbar-button-image-width); + height: var(--core-toolbar-button-image-width); + max-width: var(--core-toolbar-button-image-width); + border-radius: 50%; + } .contact-status { position: absolute; diff --git a/src/core/features/tag/services/tag.ts b/src/core/features/tag/services/tag.ts index 9cdca6790..0c25ee61d 100644 --- a/src/core/features/tag/services/tag.ts +++ b/src/core/features/tag/services/tag.ts @@ -17,6 +17,7 @@ import { CoreSites } from '@services/sites'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreWSExternalWarning } from '@services/ws'; import { makeSingleton, Translate } from '@singletons'; +import { CoreError } from '@classes/errors/error'; const ROOT_CACHE_KEY = 'CoreTag:'; @@ -121,7 +122,7 @@ export class CoreTagProvider { const response: CoreTagCollections = await site.read('core_tag_get_tag_collections', null, preSets); if (!response || !response.collections) { - throw null; + throw new CoreError('Cannot fetch tag collections'); } return response.collections; @@ -185,7 +186,7 @@ export class CoreTagProvider { } if (!response) { - throw null; + throw new CoreError('Cannot fetch tag index per area'); } return response; diff --git a/src/core/services/navigator.ts b/src/core/services/navigator.ts index 86af33ebe..f14ab895b 100644 --- a/src/core/services/navigator.ts +++ b/src/core/services/navigator.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { ActivatedRoute, Params } from '@angular/router'; +import { ActivatedRoute, NavigationStart, Params, Router as RouterService } from '@angular/router'; import { NavigationOptions } from '@ionic/angular/providers/nav-controller'; @@ -27,6 +27,8 @@ import { CoreUtils } from '@services/utils/utils'; import { CoreUrlUtils } from '@services/utils/url'; import { CoreTextUtils } from '@services/utils/text'; import { makeSingleton, NavController, Router } from '@singletons'; +import { CoreScreen } from './screen'; +import { filter } from 'rxjs/operators'; const DEFAULT_MAIN_MENU_TAB = CoreMainMenuHomeHandlerService.PAGE_NAME; @@ -55,6 +57,17 @@ export class CoreNavigatorService { protected storedParams: Record = {}; protected lastParamId = 0; + protected currentPath?: string; + protected previousPath?: string; + + // @todo Param router is an optional param to let the mocking work. + constructor(router?: RouterService) { + router?.events.pipe(filter(event => event instanceof NavigationStart)).subscribe((routerEvent: NavigationStart) => { + // Using NavigationStart instead of NavigationEnd so it can be check on ngOnInit. + this.previousPath = this.currentPath; + this.currentPath = routerEvent.url; + }); + } /** * Check whether the active route is using the given path. @@ -194,6 +207,15 @@ export class CoreNavigatorService { return CoreUrlUtils.instance.removeUrlParams(Router.instance.url); } + /** + * Get the previous navigation route path. + * + * @return Previous path. + */ + getPreviousPath(): string { + return CoreUrlUtils.instance.removeUrlParams(this.previousPath || ''); + } + /** * Get a parameter for the current route. * Please notice that objects can only be retrieved once. You must call this function only once per page and parameter, diff --git a/src/theme/app.scss b/src/theme/app.scss index 386b5c404..d2f65a5d1 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -309,6 +309,15 @@ ion-item img.core-module-icon[slot="start"] { margin-left: 32px; } +ion-toolbar ion-title img.core-bar-button-image, +ion-toolbar ion-title .core-bar-button-image img { + padding: 0; + width: var(--core-toolbar-button-image-width); + height: var(--core-toolbar-button-image-width); + max-width: var(--core-toolbar-button-image-width); + border-radius: 50%; +} + // Action sheet. .md ion-action-sheet { .action-sheet-group-cancel { diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 256933500..49093e206 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -97,6 +97,7 @@ ion-content { --background: var(--gray-light); + --background-lighter: var(--gray-lighter); --contrast-background: var(--white); } @@ -190,6 +191,11 @@ --core-avatar-size: var(--custom-avatar-size, 40px); + --core-toolbar-button-image-width: var(--custom-toolbar-button-image-width, 32px); + + --core-send-message-input-background: var(--custom-send-message-input-background, var(--gray)); + --core-send-message-input-color: var(--custom-send-message-input-color, var(--black)); + --addon-calendar-event-category-color: var(--custom-calendar-event-category-color, var(--purple)); --addon-calendar-event-course-color: var(--custom-calendar-event-course-color, var(--red)); --addon-calendar-event-group-color: var(--custom-calendar-event-group-color, var(--yellow)); @@ -198,6 +204,16 @@ --addon-calendar-today-bgcolor: var(--custom-calendar-today-bgcolor, var(--core-color)); --addon-calendar-today-color: var(--custom-calendar-today-color, var(--white)); --addon-calendar-border-color: var(--custom-calendar-border-color, var(--gray)); + + --addon-messages-message-bg: var(--custom-messages-message-bg, var(--white)); + --addon-messages-message-activated-bg: var(--custom-messages-message-activated-bg, var(--gray-light)); + --addon-messages-message-note-text: var(--custom-messages-message-note-text, var(--gray-dark)); + --addon-messages-message-note-font-size: var(--custom-messages-message-note-font-size, 75%); + --addon-messages-message-mine-bg: var(--custom-messages-message-mine-bg, var(--gray-light)); + --addon-messages-message-mine-activated-bg: var(--custom-messages-message-mine-activated-bg, var(--gray)); + --addon-messages-avatar-size: var(--custom-messages-avatar-size, 30px); + --addon-messages-discussion-badge: var(--custom-messages-discussion-badge, var(--core-color)); + --addon-messages-discussion-badge-text: var(--custom-messages-discussion-badge-text, var(--white)); } /* @@ -255,6 +271,7 @@ ion-content { --background: var(--ion-background-color); + --background-lighter: var(--gray-darker); --contrast-background: var(--ion-background-color); }