1473 lines
59 KiB
TypeScript

// (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, OnDestroy, ViewChild, Optional } from '@angular/core';
import { IonicPage, NavParams, NavController, Content, ModalController } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreEventsProvider } from '@providers/events';
import { CoreSitesProvider } from '@providers/sites';
import {
AddonMessagesProvider, AddonMessagesConversationFormatted, AddonMessagesConversationMember, AddonMessagesConversationMessage,
AddonMessagesGetMessagesMessage
} from '../../providers/messages';
import { AddonMessagesOfflineProvider } from '../../providers/messages-offline';
import { AddonMessagesSyncProvider } from '../../providers/sync';
import { CoreUserProvider } from '@core/user/providers/user';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreAppProvider } from '@providers/app';
import { coreSlideInOut } from '@classes/animations';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { CoreInfiniteLoadingComponent } from '@components/infinite-loading/infinite-loading';
import { Md5 } from 'ts-md5/dist/md5';
import * as moment from 'moment';
/**
* Page that displays a message discussion page.
*/
@IonicPage({ segment: 'addon-messages-discussion' })
@Component({
selector: 'page-addon-messages-discussion',
templateUrl: 'discussion.html',
animations: [coreSlideInOut]
})
export class AddonMessagesDiscussionPage implements OnDestroy {
@ViewChild(Content) content: Content;
@ViewChild(CoreInfiniteLoadingComponent) infinite: CoreInfiniteLoadingComponent;
siteId: string;
protected fetching: boolean;
protected polling;
protected logger;
protected unreadMessageFrom = 0;
protected messagesBeingSent = 0;
protected pagesLoaded = 1;
protected lastMessage = {text: '', timecreated: 0};
protected keepMessageMap: {[hash: string]: boolean} = {};
protected syncObserver: any;
protected oldContentHeight = 0;
protected keyboardObserver: any;
protected scrollBottom = true;
protected viewDestroyed = false;
protected memberInfoObserver: any;
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: boolean;
conversationImage: string;
loaded = false;
showKeyboard = false;
canLoadMore = false;
loadMoreError = false;
messages: (AddonMessagesConversationMessageFormatted | AddonMessagesGetMessagesMessageFormatted)[] = [];
showDelete = false;
canDelete = false;
groupMessagingEnabled: boolean;
isGroup = false;
members: {[id: number]: AddonMessagesConversationMember} = {}; // Members that wrote a message, indexed by ID.
favouriteIcon = 'fa-star';
favouriteIconSlash = false;
deleteIcon = 'trash';
blockIcon = 'close-circle';
addRemoveIcon = 'person';
otherMember: AddonMessagesConversationMember; // Other member information (individual conversations only).
footerType: 'message' | 'blocked' | 'requiresContact' | 'requestSent' | 'requestReceived' | 'unable';
requestContactSent = false;
requestContactReceived = false;
isSelf = false;
muteEnabled = false;
muteIcon = 'volume-off';
constructor(private eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, navParams: NavParams,
private userProvider: CoreUserProvider, private navCtrl: NavController, private messagesSync: AddonMessagesSyncProvider,
private domUtils: CoreDomUtilsProvider, private messagesProvider: AddonMessagesProvider, logger: CoreLoggerProvider,
private utils: CoreUtilsProvider, private appProvider: CoreAppProvider, private translate: TranslateService,
@Optional() private svComponent: CoreSplitViewComponent, private messagesOffline: AddonMessagesOfflineProvider,
private modalCtrl: ModalController, private textUtils: CoreTextUtilsProvider) {
this.siteId = sitesProvider.getCurrentSiteId();
this.currentUserId = sitesProvider.getCurrentSiteUserId();
this.groupMessagingEnabled = this.messagesProvider.isGroupMessagingEnabled();
this.muteEnabled = this.messagesProvider.isMuteConversationEnabled();
this.logger = logger.getInstance('AddonMessagesDiscussionPage');
this.conversationId = navParams.get('conversationId');
this.userId = navParams.get('userId');
this.showKeyboard = navParams.get('showKeyboard');
// Refresh data if this discussion is synchronized automatically.
this.syncObserver = eventsProvider.on(AddonMessagesSyncProvider.AUTO_SYNCED, (data) => {
if ((data.userId && 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]) {
this.domUtils.showErrorModal(data.warnings[0]);
}
}
}, this.siteId);
// Refresh data if info of a mamber of the conversation have changed.
this.memberInfoObserver = eventsProvider.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);
}
/**
* Adds a new message to the message list.
*
* @param message Message to be added.
* @param keep If set the keep flag or not.
*/
protected addMessage(message: AddonMessagesConversationMessageFormatted | AddonMessagesGetMessagesMessageFormatted,
keep: boolean = true): void {
/* 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. */
message.hash = Md5.hashAsciiStr(String(message.id || message.text || '')) + '#' + message.timecreated + '#' +
message.useridfrom;
if (typeof this.keepMessageMap[message.hash] === 'undefined') {
// Message not added to the list. Add it now.
this.messages.push(message);
}
// Message needs to be kept in the list.
this.keepMessageMap[message.hash] = keep;
}
/**
* 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) => {
return message.hash == hash;
});
if (position >= 0) {
this.messages.splice(position, 1);
}
}
/**
* 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.
*/
ionViewDidLoad(): void {
// Disable the profile button if we're already coming from a profile.
const backViewPage = this.navCtrl.getPrevious() && this.navCtrl.getPrevious().component.name;
this.showInfo = !backViewPage || backViewPage !== 'CoreUserProfilePage';
// Recalculate footer position when keyboard is shown or hidden.
this.keyboardObserver = this.eventsProvider.on(CoreEventsProvider.KEYBOARD_CHANGE, (kbHeight) => {
this.content.resize();
});
this.fetchData();
}
/**
* Convenience function to fetch the conversation data.
*
* @return Resolved when done.
*/
protected fetchData(): Promise<any> {
let loader;
if (this.showLoadingModal) {
loader = this.domUtils.showModalLoading();
}
if (!this.groupMessagingEnabled && this.userId) {
// Get the user profile to retrieve the user fullname and image.
this.userProvider.getProfile(this.userId, undefined, true).then((user) => {
if (!this.title) {
this.title = user.fullname;
}
this.conversationImage = user.profileimageurl;
});
}
// Synchronize messages if needed.
return this.messagesSync.syncDiscussion(this.conversationId, this.userId).catch(() => {
// Ignore errors.
}).then((warnings): Promise<any> => {
if (warnings && warnings[0]) {
this.domUtils.showErrorModal(warnings[0]);
}
if (this.groupMessagingEnabled) {
// Get the conversation ID if it exists and we don't have it yet.
return this.getConversation(this.conversationId, this.userId).then((exists) => {
const promises = [];
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(this.messagesProvider.invalidateMemberInfo(this.userId).catch(() => {
// Shouldn't happen.
}).then(() => {
return this.messagesProvider.getMemberInfo(this.userId);
}).then((member) => {
this.otherMember = member;
if (!exists && member) {
this.conversationImage = member.profileimageurl;
this.title = member.fullname;
}
this.blockIcon = this.otherMember && this.otherMember.isblocked ? 'checkmark-circle' : 'close-circle';
}));
} else {
this.otherMember = null;
}
return Promise.all(promises);
});
} else {
this.otherMember = null;
// Fetch the messages for the first time.
return this.fetchMessages().then(() => {
if (!this.title && this.messages.length) {
// Didn't receive the fullname via argument. Try to get it from messages.
// It's possible that name cannot be resolved when no messages were yet exchanged.
const firstMessage = <AddonMessagesGetMessagesMessageFormatted> this.messages[0];
if (firstMessage.useridto != this.currentUserId) {
this.title = firstMessage.usertofullname || '';
} else {
this.title = firstMessage.userfromfullname || '';
}
}
});
}
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingmessages', true);
}).finally(() => {
this.checkCanDelete();
this.resizeContent();
this.loaded = true;
this.setPolling(); // Make sure we're polling messages.
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.
*
* @return Resolved when done.
*/
protected fetchMessages(): Promise<void> {
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.
return Promise.reject(null);
} else if (this.fetching) {
// Already fetching.
return Promise.reject(null);
} else if (this.groupMessagingEnabled && !this.conversationId) {
// Don't have enough data to fetch messages.
return Promise.reject(null);
}
if (this.conversationId) {
this.logger.debug(`Polling new messages for conversation '${this.conversationId}'`);
} else {
this.logger.debug(`Polling new messages for discussion with user '${this.userId}'`);
}
this.fetching = true;
// Wait for synchronization process to finish.
return this.messagesSync.waitForSyncConversation(this.conversationId, this.userId).then(() => {
// Fetch messages. Invalidate the cache before fetching.
if (this.groupMessagingEnabled) {
return this.messagesProvider.invalidateConversationMessages(this.conversationId).catch(() => {
// Ignore errors.
}).then(() => {
return this.getConversationMessages(this.pagesLoaded);
});
} else {
return this.messagesProvider.invalidateDiscussionCache(this.userId).catch(() => {
// Ignore errors.
}).then(() => {
return this.getDiscussionMessages(this.pagesLoaded);
});
}
}).then((messages: (AddonMessagesConversationMessageFormatted | AddonMessagesGetMessagesMessageFormatted)[]) => {
this.loadMessages(messages);
}).finally(() => {
this.fetching = false;
});
}
/**
* Format and load a list of messages into the view.
*
* @param messages Messages to load.
*/
protected loadMessages(messages: (AddonMessagesConversationMessageFormatted | AddonMessagesGetMessagesMessageFormatted)[])
: void {
if (this.viewDestroyed) {
return;
}
// Don't use domUtils.getScrollHeight because it gives an outdated value after receiving a new message.
const scrollHeight = this.content && this.content.getScrollElement() ? this.content.getScrollElement().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.domUtils.getScrollTop(this.content) -
this.domUtils.getContentHeight(this.content)) < 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.
messages.forEach((message) => {
this.addMessage(message);
});
// Remove messages that shouldn't be in the list anymore.
for (const hash in this.keepMessageMap) {
this.removeMessage(hash);
}
// Sort the messages.
this.messagesProvider.sortMessages(this.messages);
// Calculate which messages need to display the date or user data.
this.messages.forEach((message, index) => {
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.
this.content && 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],
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);
}
/**
* Get the conversation.
*
* @param conversationId Conversation ID.
* @param userId User ID.
* @return Promise resolved with a boolean: whether the conversation exists or not.
*/
protected getConversation(conversationId: number, userId: number): Promise<boolean> {
let promise: Promise<number>,
fallbackConversation: AddonMessagesConversationFormatted;
// Try to get the conversationId if we don't have it.
if (conversationId) {
promise = Promise.resolve(conversationId);
} else {
let subPromise: Promise<AddonMessagesConversationFormatted>;
if (userId == this.currentUserId && this.messagesProvider.isSelfConversationEnabled()) {
subPromise = this.messagesProvider.getSelfConversation();
} else {
subPromise = this.messagesProvider.getConversationBetweenUsers(userId, undefined, true);
}
promise = subPromise.then((conversation) => {
fallbackConversation = conversation;
return conversation.id;
});
}
return promise.then((conversationId) => {
// Retrieve the conversation. Invalidate data first to get the right unreadcount.
return this.messagesProvider.invalidateConversation(conversationId).catch(() => {
// Ignore errors.
}).then(() => {
return this.messagesProvider.getConversation(conversationId, undefined, true);
}).catch((error): any => {
// Get conversation failed, use the fallback one if we have it.
if (fallbackConversation) {
return fallbackConversation;
}
return Promise.reject(error);
}).then((conversation: AddonMessagesConversationFormatted) => {
this.conversation = conversation;
if (conversation) {
this.conversationId = conversation.id;
this.title = conversation.name;
this.conversationImage = conversation.imageurl;
this.isGroup = conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP;
this.favouriteIcon = 'fa-star';
this.favouriteIconSlash = conversation.isfavourite;
this.muteIcon = conversation.ismuted ? 'volume-up' : 'volume-off';
if (!this.isGroup) {
this.userId = conversation.userid;
}
this.isSelf = conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_SELF;
return true;
} else {
return false;
}
});
}, (error) => {
// Probably conversation does not exist or user is offline. Try to load offline messages.
this.isSelf = userId == this.currentUserId;
return this.messagesOffline.getMessages(userId).then((messages): any => {
if (messages && messages.length) {
// We have offline messages, this probably means that the conversation didn't exist. Don't display error.
messages.forEach((message) => {
message.pending = true;
message.text = message.smallmessage;
});
this.loadMessages(messages);
} else if (error.errorcode != 'errorconversationdoesnotexist') {
// Display the error.
return Promise.reject(error);
}
return 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 getConversationMessages(pagesToLoad: number, offset: number = 0)
: Promise<AddonMessagesConversationMessageFormatted[]> {
const excludePending = offset > 0;
return this.messagesProvider.getConversationMessages(this.conversationId, excludePending, offset).then((result) => {
pagesToLoad--;
// Treat members. Don't use CoreUtilsProvider.arrayToObject because we don't want to override the existing object.
if (result.members) {
result.members.forEach((member) => {
this.members[member.id] = member;
});
}
if (pagesToLoad > 0 && result.canLoadMore) {
offset += AddonMessagesProvider.LIMIT_MESSAGES;
// Get more messages.
return this.getConversationMessages(pagesToLoad, offset).then((nextMessages) => {
return result.messages.concat(nextMessages);
});
} else {
// No more messages to load, return them.
this.canLoadMore = result.canLoadMore;
return result.messages;
}
});
}
/**
* Get a discussion. Can load several "pages".
*
* @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 getDiscussionMessages(pagesToLoad: number, lfReceivedUnread: number = 0, lfReceivedRead: number = 0,
lfSentUnread: number = 0, lfSentRead: number = 0): Promise<AddonMessagesGetMessagesMessageFormatted[]> {
// Only get offline messages if we're loading the first "page".
const excludePending = lfReceivedUnread > 0 || lfReceivedRead > 0 || lfSentUnread > 0 || lfSentRead > 0;
// Get next messages.
return this.messagesProvider.getDiscussion(this.userId, excludePending, lfReceivedUnread, lfReceivedRead, lfSentUnread,
lfSentRead).then((result) => {
pagesToLoad--;
if (pagesToLoad > 0 && result.canLoadMore) {
// More pages to load. Calculate new limit froms.
result.messages.forEach((message: AddonMessagesGetMessagesMessageFormatted) => {
if (!message.pending) {
if (message.useridfrom == this.userId) {
if (message.read) {
lfReceivedRead++;
} else {
lfReceivedUnread++;
}
} else {
if (message.read) {
lfSentRead++;
} else {
lfSentUnread++;
}
}
}
});
// Get next messages.
return this.getDiscussionMessages(pagesToLoad, lfReceivedUnread, lfReceivedRead, lfSentUnread, lfSentRead)
.then((nextMessages) => {
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 markMessagesAsRead(forceMark: boolean): void {
let readChanged = false;
const promises = [];
if (this.messagesProvider.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 && this.conversation.unreadcount > 0 && this.conversationId > 0;
} else {
for (const x in this.messages) {
const message = this.messages[x];
// If an unread message is found, mark all messages as read.
if (message.useridfrom != this.currentUserId &&
(<AddonMessagesGetMessagesMessageFormatted> message).read == 0) {
messageUnreadFound = true;
break;
}
}
}
if (messageUnreadFound) {
this.setUnreadLabelPosition();
let promise;
if (this.groupMessagingEnabled) {
promise = this.messagesProvider.markAllConversationMessagesRead(this.conversationId);
} else {
promise = this.messagesProvider.markAllMessagesRead(this.userId).then(() => {
// Mark all messages as read.
this.messages.forEach((message) => {
(<AddonMessagesGetMessagesMessageFormatted> message).read = 1;
});
});
}
promises.push(promise.then(() => {
readChanged = true;
}));
}
} else {
this.setUnreadLabelPosition();
// Mark each message as read one by one.
this.messages.forEach((message) => {
// If the message is unread, call this.messagesProvider.markMessageRead.
if (message.useridfrom != this.currentUserId && (<AddonMessagesGetMessagesMessageFormatted> message).read == 0) {
promises.push(this.messagesProvider.markMessageRead(message.id).then(() => {
readChanged = true;
(<AddonMessagesGetMessagesMessageFormatted> message).read = 1;
}));
}
});
}
Promise.all(promises).finally(() => {
if (readChanged) {
this.eventsProvider.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.
this.eventsProvider.trigger(AddonMessagesProvider.NEW_MESSAGE_EVENT, {
conversationId: this.conversationId,
userId: this.userId,
message: this.lastMessage.text,
timecreated: this.lastMessage.timecreated,
isfavourite: this.conversation && this.conversation.isfavourite,
type: this.conversation && this.conversation.type
}, this.siteId);
// Update navBar links and buttons.
const newCanDelete = (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 > 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) {
found++;
if (found == this.conversation.unreadcount) {
this.unreadMessageFrom = Number(message.id);
break;
}
}
}
}
} else {
let previousMessageRead = false;
for (const x in this.messages) {
const message = <AddonMessagesGetMessagesMessageFormatted> this.messages[x];
if (message.useridfrom != this.currentUserId) {
const unreadFrom = message.read == 0 && previousMessageRead;
if (unreadFrom) {
// Save where the label is placed.
this.unreadMessageFrom = Number(message.id);
break;
}
previousMessageRead = message.read != 0;
}
}
}
// 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<void> {
if (!this.fetching) {
return Promise.resolve();
}
const deferred = this.utils.promiseDefer();
setTimeout(() => {
return 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 = 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 | AddonMessagesGetMessagesMessageFormatted): void {
const text = this.textUtils.decodeHTMLEntities(
(<AddonMessagesGetMessagesMessageFormatted> message).smallmessage || message.text || '');
this.utils.copyToClipboard(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.
*/
deleteMessage(message: AddonMessagesConversationMessageFormatted | AddonMessagesGetMessagesMessageFormatted, index: number)
: void {
const canDeleteAll = this.conversation && this.conversation.candeletemessagesforallusers,
langKey = message.pending || canDeleteAll || this.isSelf ? 'core.areyousure' :
'addon.messages.deletemessageconfirmation',
options: any = {};
if (canDeleteAll && !message.pending) {
// Show delete for all checkbox.
options.inputs = [{
type: 'checkbox',
name: 'deleteforall',
checked: false,
value: true,
label: this.translate.instant('addon.messages.deleteforeveryone')
}];
}
this.domUtils.showConfirm(this.translate.instant(langKey), undefined, undefined, undefined, options).then((data) => {
const modal = this.domUtils.showModalLoading('core.deleting', true);
return this.messagesProvider.deleteMessage(message, data && data[0]).then(() => {
// 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) => {
this.domUtils.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.
*/
loadPrevious(infiniteComplete?: any): Promise<void> {
let infiniteHeight = this.infinite ? this.infinite.getHeight() : 0;
const scrollHeight = this.domUtils.getScrollHeight(this.content);
// If there is an ongoing fetch, wait for it to finish.
return this.waitForFetch().finally(() => {
this.pagesLoaded++;
this.fetchMessages().then(() => {
// Try to keep the scroll position.
const scrollBottom = scrollHeight - this.domUtils.getScrollTop(this.content);
if (this.canLoadMore && infiniteHeight && this.infinite) {
// The height of the infinite is different while spinner is shown. Add that difference.
infiniteHeight = infiniteHeight - this.infinite.getHeight();
} 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 || (this.infinite ? this.infinite.getHeight() : 0);
}
this.keepScroll(scrollHeight, scrollBottom, infiniteHeight);
}).catch((error) => {
this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading.
this.pagesLoaded--;
this.domUtils.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?: number): void {
retries = retries || 0;
setTimeout(() => {
const newScrollHeight = this.domUtils.getScrollHeight(this.content);
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.domUtils.scrollTo(this.content, 0, scrollTo, 0);
}, 30);
}
/**
* Content or scroll has been resized. For content, only call it if it's been added on top.
*/
resizeContent(): void {
let top = this.content.getContentDimensions().scrollTop;
this.content.resize();
// Wait for new content height to be calculated.
setTimeout(() => {
// Visible content size changed, maintain the bottom position.
if (!this.viewDestroyed && this.content && this.domUtils.getContentHeight(this.content) != this.oldContentHeight) {
if (!top) {
top = this.content.getContentDimensions().scrollTop;
}
top += this.oldContentHeight - this.domUtils.getContentHeight(this.content);
this.oldContentHeight = this.domUtils.getContentHeight(this.content);
this.domUtils.scrollTo(this.content, 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.domUtils.scrollToBottom(this.content, 0);
}
});
this.scrollBottom = false;
}
}
/**
* Sends a message to the server.
*
* @param text Message text.
*/
sendMessage(text: string): void {
let message: AddonMessagesConversationMessageFormatted | AddonMessagesGetMessagesMessageFormatted;
this.hideUnreadLabel();
this.showDelete = false;
this.scrollBottom = true;
message = {
id: null,
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.
this.waitForFetch().finally(() => {
let promise: Promise<{sent: boolean, message: any}>;
if (this.conversationId) {
promise = this.messagesProvider.sendMessageToConversation(this.conversation, text);
} else {
promise = this.messagesProvider.sendMessage(this.userId, text);
}
promise.then((data) => {
let promise;
this.messagesBeingSent--;
if (data.sent) {
if (!this.conversationId && data.message && data.message.conversationid) {
// Message sent to a new conversation, try to load the conversation.
promise = this.getConversation(data.message.conversationid, this.userId).then(() => {
// Now fetch messages.
return this.fetchMessages();
}).finally(() => {
// Start polling messages now that the conversation exists.
this.setPolling();
});
} else {
// Message was sent, fetch messages right now.
promise = this.fetchMessages();
}
} else {
promise = Promise.reject(null);
}
promise.catch(() => {
// 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;
}
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.
this.appProvider.closeKeyboard();
this.domUtils.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 | AddonMessagesGetMessagesMessageFormatted,
prevMessage?: AddonMessagesConversationMessageFormatted | AddonMessagesGetMessagesMessageFormatted): 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 | AddonMessagesGetMessagesMessageFormatted,
prevMessage?: AddonMessagesConversationMessageFormatted | AddonMessagesGetMessagesMessageFormatted): boolean {
return this.isGroup && message.useridfrom != this.currentUserId && this.members[message.useridfrom] &&
(!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 | AddonMessagesGetMessagesMessageFormatted,
nextMessage?: AddonMessagesConversationMessageFormatted | AddonMessagesGetMessagesMessageFormatted): 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.
*/
viewInfo(): void {
if (this.isGroup) {
// Display the group information.
const modal = this.modalCtrl.create('AddonMessagesConversationInfoPage', {
conversationId: this.conversationId
});
modal.present();
modal.onDidDismiss((userId) => {
if (typeof userId != 'undefined') {
// Open user conversation.
if (this.svComponent) {
// Notify the left pane to load it, this way the right conversation will be highlighted.
this.eventsProvider.trigger(AddonMessagesProvider.OPEN_CONVERSATION_EVENT, {userId: userId}, this.siteId);
} else {
// Open the discussion in a new view.
this.navCtrl.push('AddonMessagesDiscussionPage', {userId: userId});
}
}
});
} else {
// Open the user profile.
const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl;
navCtrl.push('CoreUserProfilePage', { userId: this.userId });
}
}
/**
* Change the favourite state of the current conversation.
*
* @param done Function to call when done.
*/
changeFavourite(done?: () => void): void {
this.favouriteIcon = 'spinner';
this.messagesProvider.setFavouriteConversation(this.conversation.id, !this.conversation.isfavourite).then(() => {
this.conversation.isfavourite = !this.conversation.isfavourite;
// Get the conversation data so it's cached. Don't block the user for this.
this.messagesProvider.getConversation(this.conversation.id, undefined, true);
this.eventsProvider.trigger(AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, {
conversationId: this.conversation.id,
action: 'favourite',
value: this.conversation.isfavourite
}, this.siteId);
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error changing favourite state.');
}).finally(() => {
this.favouriteIcon = 'fa-star';
this.favouriteIconSlash = this.conversation.isfavourite;
done && done();
});
}
/**
* Change the mute state of the current conversation.
*
* @param done Function to call when done.
*/
changeMute(done?: () => void): void {
this.muteIcon = 'spinner';
this.messagesProvider.muteConversation(this.conversation.id, !this.conversation.ismuted).then(() => {
this.conversation.ismuted = !this.conversation.ismuted;
// Get the conversation data so it's cached. Don't block the user for this.
this.messagesProvider.getConversation(this.conversation.id, undefined, true);
this.eventsProvider.trigger(AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, {
conversationId: this.conversation.id,
action: 'mute',
value: this.conversation.ismuted
}, this.siteId);
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error changing muted state.');
}).finally(() => {
this.muteIcon = this.conversation.ismuted ? 'volume-up' : 'volume-off';
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) => {
return request.userid == this.currentUserId && request.requesteduserid == this.otherMember.id;
});
this.requestContactReceived = this.otherMember.contactrequests.some((request) => {
return 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.
*/
blockUser(): Promise<any> {
if (!this.otherMember) {
// Should never happen.
return Promise.reject(null);
}
const template = this.translate.instant('addon.messages.blockuserconfirm', {$a: this.otherMember.fullname});
const okText = this.translate.instant('addon.messages.blockuser');
return this.domUtils.showConfirm(template, undefined, okText).then(() => {
this.blockIcon = 'spinner';
const modal = this.domUtils.showModalLoading('core.sending', true);
this.showLoadingModal = true;
return this.messagesProvider.blockContact(this.otherMember.id).finally(() => {
modal.dismiss();
this.showLoadingModal = false;
});
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'core.error', true);
}).finally(() => {
this.blockIcon = this.otherMember.isblocked ? 'close-circle' : 'checkmark-circle';
});
}
/**
* Delete the conversation.
*
* @param done Function to call when done.
*/
deleteConversation(done?: () => void): void {
const confirmMessage = 'addon.messages.' + (this.isSelf ? 'deleteallselfconfirm' : 'deleteallconfirm');
this.domUtils.showDeleteConfirm(confirmMessage).then(() => {
this.deleteIcon = 'spinner';
return this.messagesProvider.deleteConversation(this.conversation.id).then(() => {
this.eventsProvider.trigger(AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, {
conversationId: this.conversation.id,
action: 'delete'
}, this.siteId);
this.messages = [];
}).finally(() => {
this.deleteIcon = 'trash';
done && done();
});
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error deleting conversation.');
});
}
/**
* Displays a confirmation modal to unblock the user of the individual conversation.
*
* @return Promise resolved when user is unblocked or dialog is cancelled.
*/
unblockUser(): Promise<any> {
if (!this.otherMember) {
// Should never happen.
return Promise.reject(null);
}
const template = this.translate.instant('addon.messages.unblockuserconfirm', {$a: this.otherMember.fullname});
const okText = this.translate.instant('addon.messages.unblockuser');
return this.domUtils.showConfirm(template, undefined, okText).then(() => {
this.blockIcon = 'spinner';
const modal = this.domUtils.showModalLoading('core.sending', true);
this.showLoadingModal = true;
return this.messagesProvider.unblockContact(this.otherMember.id).finally(() => {
modal.dismiss();
this.showLoadingModal = false;
});
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'core.error', true);
}).finally(() => {
this.blockIcon = this.otherMember.isblocked ? 'close-circle' : 'checkmark-circle';
});
}
/**
* 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.
*/
createContactRequest(): Promise<any> {
if (!this.otherMember) {
// Should never happen.
return Promise.reject(null);
}
const template = this.translate.instant('addon.messages.addcontactconfirm', { $a: this.otherMember.fullname });
const okText = this.translate.instant('core.add');
return this.domUtils.showConfirm(template, undefined, okText).then(() => {
this.addRemoveIcon = 'spinner';
const modal = this.domUtils.showModalLoading('core.sending', true);
this.showLoadingModal = true;
return this.messagesProvider.createContactRequest(this.otherMember.id).finally(() => {
modal.dismiss();
this.showLoadingModal = false;
});
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'core.error', true);
}).finally(() => {
this.addRemoveIcon = 'person';
});
}
/**
* Confirms the contact request of the other user of the individual conversation.
*
* @return Promise resolved when the request is confirmed.
*/
confirmContactRequest(): Promise<any> {
if (!this.otherMember) {
// Should never happen.
return Promise.reject(null);
}
const modal = this.domUtils.showModalLoading('core.sending', true);
this.showLoadingModal = true;
return this.messagesProvider.confirmContactRequest(this.otherMember.id).finally(() => {
modal.dismiss();
this.showLoadingModal = false;
}).catch((error) => {
this.domUtils.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.
*/
declineContactRequest(): Promise<any> {
if (!this.otherMember) {
// Should never happen.
return Promise.reject(null);
}
const modal = this.domUtils.showModalLoading('core.sending', true);
this.showLoadingModal = true;
return this.messagesProvider.declineContactRequest(this.otherMember.id).finally(() => {
modal.dismiss();
this.showLoadingModal = false;
}).catch((error) => {
this.domUtils.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.
*/
removeContact(): Promise<any> {
if (!this.otherMember) {
// Should never happen.
return Promise.reject(null);
}
const template = this.translate.instant('addon.messages.removecontactconfirm', { $a: this.otherMember.fullname });
const okText = this.translate.instant('core.remove');
return this.domUtils.showConfirm(template, undefined, okText).then(() => {
this.addRemoveIcon = 'spinner';
const modal = this.domUtils.showModalLoading('core.sending', true);
this.showLoadingModal = true;
return this.messagesProvider.removeContact(this.otherMember.id).finally(() => {
modal.dismiss();
this.showLoadingModal = false;
});
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'core.error', true);
}).finally(() => {
this.addRemoveIcon = 'person';
});
}
/**
* Page destroyed.
*/
ngOnDestroy(): void {
// Unset again, just in case.
this.unsetPolling();
this.syncObserver && this.syncObserver.off();
this.keyboardObserver && this.keyboardObserver.off();
this.memberInfoObserver && this.memberInfoObserver.off();
this.viewDestroyed = true;
}
}
/**
* Conversation message with some calculated data.
*/
type AddonMessagesConversationMessageFormatted = AddonMessagesConversationMessage & {
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.
};
/**
* Message with some calculated data.
*/
type AddonMessagesGetMessagesMessageFormatted = AddonMessagesGetMessagesMessage & {
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.
};