// (C) Copyright 2015 Moodle Pty Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; import { IonContent, IonRefresher } from '@ionic/angular'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreSites } from '@services/sites'; import { AddonMessagesProvider, AddonMessagesConversationFormatted, AddonMessages, AddonMessagesMemberInfoChangedEventData, AddonMessagesContactRequestCountEventData, AddonMessagesUnreadConversationCountsEventData, AddonMessagesReadChangedEventData, AddonMessagesUpdateConversationListEventData, AddonMessagesNewMessagedEventData, AddonMessagesOpenConversationEventData, } from '../../services/messages'; import { AddonMessagesOffline } from '../../services/messages-offline'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUser } from '@features/user/services/user'; import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate'; import { Platform, Translate } from '@singletons'; import { Subscription } from 'rxjs'; import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications'; import { ActivatedRoute, Params } from '@angular/router'; import { CoreUtils } from '@services/utils/utils'; import { CoreNavigator } from '@services/navigator'; import { AddonMessagesOfflineConversationMessagesDBRecordFormatted, AddonMessagesOfflineMessagesDBRecordFormatted, } from '@addons/messages/services/database/messages'; import { AddonMessagesSettingsHandlerService } from '@addons/messages/services/handlers/settings'; import { CoreScreen } from '@services/screen'; /** * Page that displays the list of conversations, including group conversations. */ @Component({ selector: 'page-addon-messages-group-conversations', templateUrl: 'group-conversations.html', styleUrls: ['../../messages-common.scss'], }) export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { @ViewChild(IonContent) content?: IonContent; @ViewChild('favlist') favListEl?: ElementRef; @ViewChild('grouplist') groupListEl?: ElementRef; @ViewChild('indlist') indListEl?: ElementRef; loaded = false; loadingMessage: string; selectedConversationId?: number; selectedUserId?: number; contactRequestsCount = 0; favourites: AddonMessagesGroupConversationOption = { type: undefined, favourites: true, count: 0, unread: 0, conversations: [], }; group: AddonMessagesGroupConversationOption = { type: AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP, favourites: false, count: 0, unread: 0, conversations: [], }; individual: AddonMessagesGroupConversationOption = { type: AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL, favourites: false, count: 0, unread: 0, conversations: [], }; typeGroup = AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP; currentListEl?: HTMLElement; protected siteId: string; protected currentUserId: number; protected conversationId?: number; protected discussionUserId?: number; protected newMessagesObserver: CoreEventObserver; protected pushObserver: Subscription; protected appResumeSubscription: Subscription; protected readChangedObserver: CoreEventObserver; protected cronObserver: CoreEventObserver; protected openConversationObserver: CoreEventObserver; protected updateConversationListObserver: CoreEventObserver; protected contactRequestsCountObserver: CoreEventObserver; protected memberInfoObserver: CoreEventObserver; constructor( protected route: ActivatedRoute, ) { this.loadingMessage = Translate.instance.instant('core.loading'); this.siteId = CoreSites.instance.getCurrentSiteId(); this.currentUserId = CoreSites.instance.getCurrentSiteUserId(); // Update conversations when new message is received. this.newMessagesObserver = CoreEvents.on( AddonMessagesProvider.NEW_MESSAGE_EVENT, (data) => { // Check if the new message belongs to the option that is currently expanded. const expandedOption = this.getExpandedOption(); const messageOption = this.getConversationOption(data); if (expandedOption != messageOption) { return; // Message doesn't belong to current list, stop. } // Search the conversation to update. const conversation = this.findConversation(data.conversationId, data.userId, expandedOption); if (typeof conversation == 'undefined') { // Probably a new conversation, refresh the list. this.loaded = false; this.refreshData().finally(() => { this.loaded = true; }); return; } if (conversation.lastmessage != data.message || conversation.lastmessagedate != data.timecreated / 1000) { const isNewer = data.timecreated / 1000 > (conversation.lastmessagedate || 0); // An existing conversation has a new message, update the last message. conversation.lastmessage = data.message; conversation.lastmessagedate = data.timecreated / 1000; // Sort the affected list. const option = this.getConversationOption(conversation); option.conversations = AddonMessages.instance.sortConversations(option.conversations || []); if (isNewer) { // The last message is newer than the previous one, scroll to top to keep viewing the conversation. this.content?.scrollToTop(); } } }, this.siteId, ); // Update conversations when a message is read. this.readChangedObserver = CoreEvents.on(AddonMessagesProvider.READ_CHANGED_EVENT, ( data, ) => { if (data.conversationId) { const conversation = this.findConversation(data.conversationId); if (typeof conversation != 'undefined') { // A conversation has been read reset counter. conversation.unreadcount = 0; // Conversations changed, invalidate them and refresh unread counts. AddonMessages.instance.invalidateConversations(this.siteId); AddonMessages.instance.refreshUnreadConversationCounts(this.siteId); } } }, this.siteId); // Load a discussion if we receive an event to do so. this.openConversationObserver = CoreEvents.on( AddonMessagesProvider.OPEN_CONVERSATION_EVENT, (data) => { if (data.conversationId || data.userId) { this.gotoConversation(data.conversationId, data.userId); } }, this.siteId, ); // Refresh the view when the app is resumed. this.appResumeSubscription = Platform.instance.resume.subscribe(() => { if (!this.loaded) { return; } this.loaded = false; this.refreshData().finally(() => { this.loaded = true; }); }); // Update conversations if we receive an event to do so. this.updateConversationListObserver = CoreEvents.on( AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, (data) => { if (data && data.action == 'mute') { // If the conversation is displayed, change its muted value. const expandedOption = this.getExpandedOption(); if (expandedOption && expandedOption.conversations) { const conversation = this.findConversation(data.conversationId, undefined, expandedOption); if (conversation) { conversation.ismuted = !!data.value; } } return; } this.refreshData(); }, this.siteId, ); // If a message push notification is received, refresh the view. this.pushObserver = CorePushNotificationsDelegate.instance.on('receive') .subscribe((notification) => { // New message received. If it's from current site, refresh the data. if (CoreUtils.instance.isFalseOrZero(notification.notif) && notification.site == this.siteId) { // Don't refresh unread counts, it's refreshed from the main menu handler in this case. this.refreshData(undefined, false); } }); // Update unread conversation counts. this.cronObserver = CoreEvents.on( AddonMessagesProvider.UNREAD_CONVERSATION_COUNTS_EVENT, (data) => { this.favourites.unread = data.favourites; this.individual.unread = data.individual + data.self; // Self is only returned if it's not favourite. this.group.unread = data.group; }, this.siteId, ); // Update the contact requests badge. this.contactRequestsCountObserver = CoreEvents.on( AddonMessagesProvider.CONTACT_REQUESTS_COUNT_EVENT, (data) => { this.contactRequestsCount = data.count; }, this.siteId, ); // Update block status of a user. this.memberInfoObserver = CoreEvents.on( AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT, (data) => { if (!data.userBlocked && !data.userUnblocked) { // The block status has not changed, ignore. return; } const expandedOption = this.getExpandedOption(); if (expandedOption == this.individual || expandedOption == this.favourites) { if (!expandedOption.conversations || expandedOption.conversations.length <= 0) { return; } const conversation = this.findConversation(undefined, data.userId, expandedOption); if (conversation) { conversation.isblocked = data.userBlocked; } } }, this.siteId, ); } /** * Component loaded. */ ngOnInit(): void { this.route.queryParams.subscribe(async params => { // Conversation to load. this.conversationId = params['conversationId'] ? parseInt(params['conversationId'], 10) : undefined; if (!this.conversationId) { this.discussionUserId = params['discussionUserId'] ? parseInt(params['discussionUserId'], 10) : undefined; } if (this.conversationId || this.discussionUserId) { // There is a discussion to load, open the discussion in a new state. this.gotoConversation(this.conversationId, this.discussionUserId); } await this.fetchData(); if (!this.conversationId && !this.discussionUserId && CoreScreen.instance.isTablet) { // Load the first conversation. let conversation: AddonMessagesConversationForList; const expandedOption = this.getExpandedOption(); if (expandedOption && expandedOption.conversations.length) { conversation = expandedOption.conversations[0]; if (conversation) { this.gotoConversation(conversation.id); } } } }); } /** * Fetch conversations. * * @param refreshUnreadCounts Whether to refresh unread counts. * @return Promise resolved when done. */ protected async fetchData(refreshUnreadCounts: boolean = true): Promise { // Load the amount of conversations and contact requests. const promises: Promise[] = []; promises.push(this.fetchConversationCounts()); // View updated by the events observers. promises.push(AddonMessages.instance.getContactRequestsCount(this.siteId)); if (refreshUnreadCounts) { promises.push(AddonMessages.instance.refreshUnreadConversationCounts(this.siteId)); } try { await Promise.all(promises); // The expanded status hasn't been initialized. Do it now. if (typeof this.favourites.expanded == 'undefined' && this.conversationId || this.discussionUserId) { // A certain conversation should be opened. // We don't know which option it belongs to, so we need to fetch the data for all of them. const promises: Promise[] = []; promises.push(this.fetchDataForOption(this.favourites, false)); promises.push(this.fetchDataForOption(this.group, false)); promises.push(this.fetchDataForOption(this.individual, false)); await Promise.all(promises); // All conversations have been loaded, find the one we need to load and expand its option. const conversation = this.findConversation(this.conversationId, this.discussionUserId); if (conversation) { const option = this.getConversationOption(conversation); await this.expandOption(option); this.loaded = true; return; } } // Load the data for the expanded option. await this.fetchDataForExpandedOption(); } catch (error) { CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true); } this.loaded = true; } /** * Fetch data for the expanded option. * * @return Promise resolved when done. */ protected async fetchDataForExpandedOption(): Promise { if (typeof this.favourites.expanded == 'undefined') { // Calculate which option should be expanded initially. this.favourites.expanded = this.favourites.count != 0 && !this.group.unread && !this.individual.unread; this.group.expanded = !this.favourites.expanded && this.group.count != 0 && !this.individual.unread; this.individual.expanded = !this.favourites.expanded && !this.group.expanded; } this.loadCurrentListElement(); const expandedOption = this.getExpandedOption(); if (expandedOption) { await this.fetchDataForOption(expandedOption, false); } } /** * Fetch data for a certain option. * * @param option The option to fetch data for. * @param loadingMore Whether we are loading more data or just the first ones. * @param getCounts Whether to get counts data. * @return Promise resolved when done. */ async fetchDataForOption( option: AddonMessagesGroupConversationOption, loadingMore = false, getCounts = false, ): Promise { option.loadMoreError = false; const limitFrom = loadingMore ? option.conversations.length : 0; const promises: Promise[] = []; let data: { conversations: AddonMessagesConversationForList[]; canLoadMore: boolean } = { conversations: [], canLoadMore: false, }; let offlineMessages: (AddonMessagesOfflineConversationMessagesDBRecordFormatted | AddonMessagesOfflineMessagesDBRecordFormatted)[] = []; // Get the conversations and, if needed, the offline messages. Always try to get the latest data. promises.push(AddonMessages.instance.invalidateConversations(this.siteId).then(async () => { data = await AddonMessages.instance.getConversations(option.type, option.favourites, limitFrom, this.siteId); return; })); if (!loadingMore) { promises.push(AddonMessagesOffline.instance.getAllMessages().then((messages) => { offlineMessages = messages; return; })); } if (getCounts) { promises.push(this.fetchConversationCounts()); promises.push(AddonMessages.instance.refreshUnreadConversationCounts(this.siteId)); } await Promise.all(promises); if (loadingMore) { option.conversations = option.conversations.concat(data.conversations); option.canLoadMore = data.canLoadMore; } else { option.conversations = data.conversations; option.canLoadMore = data.canLoadMore; if (offlineMessages && offlineMessages.length) { await this.loadOfflineMessages(option, offlineMessages); // Sort the conversations, the offline messages could affect the order. option.conversations = AddonMessages.instance.sortConversations(option.conversations); } } } /** * Fetch conversation counts. * * @return Promise resolved when done. */ protected async fetchConversationCounts(): Promise { // Always try to get the latest data. await AddonMessages.instance.invalidateConversationCounts(this.siteId); const counts = await AddonMessages.instance.getConversationCounts(this.siteId); this.favourites.count = counts.favourites; this.individual.count = counts.individual + counts.self; // Self is only returned if it's not favourite. this.group.count = counts.group; } /** * Find a conversation in the list of loaded conversations. * * @param conversationId The conversation ID to search. * @param userId User ID to search (if no conversationId). * @param option The option to search in. If not defined, search in all options. * @return Conversation. */ protected findConversation( conversationId?: number, userId?: number, option?: AddonMessagesGroupConversationOption, ): AddonMessagesConversationForList | undefined { if (conversationId) { const conversations: AddonMessagesConversationForList[] = option ? option.conversations : (this.favourites.conversations.concat(this.group.conversations).concat(this.individual.conversations)); return conversations.find((conv) => conv.id == conversationId); } const conversations = option ? option.conversations : this.favourites.conversations.concat(this.individual.conversations); return conversations.find((conv) => conv.userid == userId); } /** * Get the option that is currently expanded, undefined if they are all collapsed. * * @return Option currently expanded. */ protected getExpandedOption(): AddonMessagesGroupConversationOption | undefined { if (this.favourites.expanded) { return this.favourites; } else if (this.group.expanded) { return this.group; } else if (this.individual.expanded) { return this.individual; } } /** * Navigate to contacts view. */ gotoContacts(): void { CoreNavigator.instance.navigateToSitePath('contacts'); } /** * Navigate to a particular conversation. * * @param conversationId Conversation Id to load. * @param userId User of the conversation. Only if there is no conversationId. * @param messageId Message to scroll after loading the discussion. Used when searching. */ gotoConversation(conversationId?: number, userId?: number, messageId?: number): void { this.selectedConversationId = conversationId; this.selectedUserId = userId; const params: Params = {}; if (conversationId) { params.conversationId = conversationId; } if (userId) { params.userId = userId; } if (messageId) { params.message = messageId; } const splitViewLoaded = CoreNavigator.instance.isSplitViewOutletLoaded('**/messages/group-conversations/discussion'); const path = (splitViewLoaded ? '../' : '') + 'discussion'; CoreNavigator.instance.navigate(path, { params }); } /** * Navigate to message settings. */ gotoSettings(): void { CoreNavigator.instance.navigateToSitePath(AddonMessagesSettingsHandlerService.PAGE_NAME); } /** * Function to load more conversations. * * @param option The option to fetch data for. * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. * @return Promise resolved when done. */ async loadMoreConversations(option: AddonMessagesGroupConversationOption, infiniteComplete?: () => void): Promise { try { await this.fetchDataForOption(option, true); } catch (error) { CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true); option.loadMoreError = true; } infiniteComplete && infiniteComplete(); } /** * Load offline messages into the conversations. * * @param option The option where the messages should be loaded. * @param messages Offline messages. * @return Promise resolved when done. */ protected async loadOfflineMessages( option: AddonMessagesGroupConversationOption, messages: (AddonMessagesOfflineConversationMessagesDBRecordFormatted | AddonMessagesOfflineMessagesDBRecordFormatted)[], ): Promise { const promises: Promise[] = []; messages.forEach((message) => { if ('conversationid' in message) { // It's an existing conversation. Search it in the current option. let conversation = this.findConversation(message.conversationid, undefined, option); if (conversation) { // Check if it's the last message. Offline messages are considered more recent than sent messages. if (typeof conversation.lastmessage === 'undefined' || conversation.lastmessage === null || !conversation.lastmessagepending || (conversation.lastmessagedate || 0) <= message.timecreated / 1000) { this.addLastOfflineMessage(conversation, message); } } else { // Conversation not found, it could be an old one or the message could belong to another option. conversation = { id: message.conversationid, type: message.conversation?.type || AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL, membercount: message.conversation?.membercount || 0, ismuted: message.conversation?.ismuted || false, isfavourite: message.conversation?.isfavourite || false, isread: message.conversation?.isread || false, members: message.conversation?.members || [], messages: message.conversation?.messages || [], candeletemessagesforallusers: message.conversation?.candeletemessagesforallusers || false, userid: 0, // Faked data. name: message.conversation?.name, imageurl: message.conversation?.imageurl || '', }; message.conversation || {}; if (this.getConversationOption(conversation) == option) { // Message belongs to current option, add the conversation. this.addLastOfflineMessage(conversation, message); this.addOfflineConversation(conversation); } } } else if (option.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL) { // It's a new conversation. Check if we already created it (there is more than one message for the same user). const conversation = this.findConversation(undefined, message.touserid, option); message.text = message.smallmessage; if (conversation) { // Check if it's the last message. Offline messages are considered more recent than sent messages. if ((conversation.lastmessagedate || 0) <= message.timecreated / 1000) { this.addLastOfflineMessage(conversation, message); } } else { // Get the user data and create a new conversation if it belongs to the current option. promises.push(CoreUser.instance.getProfile(message.touserid, undefined, true).catch(() => { // User not found. }).then((user) => { const conversation: AddonMessagesConversationForList = { id: 0, type: AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL, membercount: 0, // Faked data. ismuted: false, // Faked data. isfavourite: false, // Faked data. isread: false, // Faked data. members: [], // Faked data. messages: [], // Faked data. candeletemessagesforallusers: false, userid: message.touserid, name: user ? user.fullname : String(message.touserid), imageurl: user ? user.profileimageurl : '', }; this.addLastOfflineMessage(conversation, message); this.addOfflineConversation(conversation); return; })); } } }); await Promise.all(promises); } /** * Add an offline conversation into the right list of conversations. * * @param conversation Offline conversation to add. */ protected addOfflineConversation(conversation: AddonMessagesConversationForList): void { const option = this.getConversationOption(conversation); option.conversations.unshift(conversation); } /** * Add a last offline message into a conversation. * * @param conversation Conversation where to put the last message. * @param message Offline message to add. */ protected addLastOfflineMessage( conversation: AddonMessagesConversationForList, message: AddonMessagesOfflineConversationMessagesDBRecordFormatted | AddonMessagesOfflineMessagesDBRecordFormatted, ): void { conversation.lastmessage = message.text; conversation.lastmessagedate = message.timecreated / 1000; conversation.lastmessagepending = true; conversation.sentfromcurrentuser = true; } /** * Given a conversation, return its option (favourites, group, individual). * * @param conversation Conversation to check. * @return Option object. */ protected getConversationOption( conversation: AddonMessagesConversationForList | AddonMessagesNewMessagedEventData, ): AddonMessagesGroupConversationOption { if (conversation.isfavourite) { return this.favourites; } if (conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP) { return this.group; } return this.individual; } /** * Refresh the data. * * @param refresher Refresher. * @param refreshUnreadCounts Whether to refresh unread counts. * @return Promise resolved when done. */ async refreshData(refresher?: CustomEvent, refreshUnreadCounts: boolean = true): Promise { // Don't invalidate conversations and so, they always try to get latest data. try { await AddonMessages.instance.invalidateContactRequestsCountCache(this.siteId); } finally { try { await this.fetchData(refreshUnreadCounts); } finally { if (refresher) { refresher?.detail.complete(); } } } } /** * Toogle the visibility of an option (expand/collapse). * * @param option The option to expand/collapse. */ toggle(option: AddonMessagesGroupConversationOption): void { if (option.expanded) { // Already expanded, close it. option.expanded = false; this.loadCurrentListElement(); } else { // Pass getCounts=true to update the counts everytime the user expands an option. this.expandOption(option, true).catch((error) => { CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true); }); } } /** * Expand a certain option. * * @param option The option to expand. * @param getCounts Whether to get counts data. * @return Promise resolved when done. */ protected async expandOption(option: AddonMessagesGroupConversationOption, getCounts = false): Promise { // Collapse all and expand the right one. this.favourites.expanded = false; this.group.expanded = false; this.individual.expanded = false; option.expanded = true; option.loading = true; try { await this.fetchDataForOption(option, false, getCounts); this.loadCurrentListElement(); } catch (error) { option.expanded = false; throw error; } finally { option.loading = false; } } /** * Load the current list element based on the expanded list. */ protected loadCurrentListElement(): void { if (this.favourites.expanded) { this.currentListEl = this.favListEl && this.favListEl.nativeElement; } else if (this.group.expanded) { this.currentListEl = this.groupListEl && this.groupListEl.nativeElement; } else if (this.individual.expanded) { this.currentListEl = this.indListEl && this.indListEl.nativeElement; } else { this.currentListEl = undefined; } } /** * Navigate to the search page. */ gotoSearch(): void { CoreNavigator.instance.navigateToSitePath('search'); } /** * Page destroyed. */ ngOnDestroy(): void { this.newMessagesObserver?.off(); this.appResumeSubscription?.unsubscribe(); this.pushObserver?.unsubscribe(); this.readChangedObserver?.off(); this.cronObserver?.off(); this.openConversationObserver?.off(); this.updateConversationListObserver?.off(); this.contactRequestsCountObserver?.off(); this.memberInfoObserver?.off(); } } /** * Conversation options. */ export type AddonMessagesGroupConversationOption = { type?: number; // Option type. favourites: boolean; // Whether it contains favourites conversations. count: number; // Number of conversations. unread?: number; // Number of unread conversations. expanded?: boolean; // Whether the option is currently expanded. loading?: boolean; // Whether the option is being loaded. canLoadMore?: boolean; // Whether it can load more data. loadMoreError?: boolean; // Whether there was an error loading more conversations. conversations: AddonMessagesConversationForList[]; // List of conversations. }; /** * Formatted conversation with some calculated data for the list. */ export type AddonMessagesConversationForList = AddonMessagesConversationFormatted & { lastmessagepending?: boolean; // Calculated in the app. Whether last message is pending to be sent. };