711 lines
26 KiB
TypeScript

// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnDestroy, ViewChild } from '@angular/core';
import { IonicPage, NavParams, NavController, Content } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreEventsProvider } from '@providers/events';
import { CoreSitesProvider } from '@providers/sites';
import { AddonMessagesProvider } from '../../providers/messages';
import { AddonMessagesSyncProvider } from '../../providers/sync';
import { CoreUserProvider } from '@core/user/providers/user';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreAppProvider } from '@providers/app';
import { coreSlideInOut } from '@classes/animations';
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;
protected 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 = {};
protected syncObserver: any;
protected oldContentHeight = 0;
protected keyboardObserver: any;
userId: number;
currentUserId: number;
title: string;
profileLink: string;
showProfileLink: boolean;
loaded = false;
showKeyboard = false;
canLoadMore = false;
loadMoreError = false;
messages = [];
showDelete = false;
canDelete = false;
scrollBottom = true;
viewDestroyed = false;
constructor(private eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, navParams: NavParams,
private userProvider: CoreUserProvider, private navCtrl: NavController, private messagesSync: AddonMessagesSyncProvider,
private domUtils: CoreDomUtilsProvider, private messagesProvider: AddonMessagesProvider, logger: CoreLoggerProvider,
private utils: CoreUtilsProvider, private appProvider: CoreAppProvider, private translate: TranslateService) {
this.siteId = sitesProvider.getCurrentSiteId();
this.currentUserId = sitesProvider.getCurrentSiteUserId();
this.logger = logger.getInstance('AddonMessagesDiscussionPage');
this.userId = navParams.get('userId');
this.showKeyboard = navParams.get('showKeyboard');
// Refresh data if this discussion is synchronized automatically.
this.syncObserver = eventsProvider.on(AddonMessagesSyncProvider.AUTO_SYNCED, (data) => {
if (data.userId == this.userId) {
// Fetch messages.
this.fetchData();
// Show first warning if any.
if (data.warnings && data.warnings[0]) {
this.domUtils.showErrorModal(data.warnings[0]);
}
}
}, this.siteId);
}
/**
* Adds a new message to the message list.
*
* @param {any} message Message to be added.
* @param {boolean} [keep=true] If set the keep flag or not.
*/
protected addMessage(message: any, keep: boolean = true): void {
// Use smallmessage instead of message ID because ID changes when a message is read.
message.hash = Md5.hashAsciiStr(message.smallmessage) + '#' + message.timecreated + '#' + message.useridfrom;
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 {string} hash Hash of the message to be removed.
*/
protected removeMessage(hash: any): 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.showProfileLink = !backViewPage || backViewPage !== 'CoreUserProfilePage';
// Get the user profile to retrieve the user fullname and image.
this.userProvider.getProfile(this.userId, undefined, true).then((user) => {
if (!this.title) {
this.title = user.fullname;
}
this.profileLink = user.profileimageurl;
});
// Synchronize messages if needed.
this.messagesSync.syncDiscussion(this.userId).catch(() => {
// Ignore errors.
}).then((warnings) => {
if (warnings && warnings[0]) {
this.domUtils.showErrorModal(warnings[0]);
}
// Fetch the messages for the first time.
return this.fetchData().then(() => {
if (!this.title && this.messages.length) {
// Didn't receive the fullname via argument. Try to get it from messages.
// It's possible that name cannot be resolved when no messages were yet exchanged.
if (this.messages[0].useridto != this.currentUserId) {
this.title = this.messages[0].usertofullname || '';
} else {
this.title = this.messages[0].userfromfullname || '';
}
}
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingmessages', true);
}).finally(() => {
this.checkCanDelete();
this.resizeContent();
this.loaded = true;
});
});
// Recalculate footer position when keyboard is shown or hidden.
this.keyboardObserver = this.eventsProvider.on(CoreEventsProvider.KEYBOARD_CHANGE, (kbHeight) => {
this.content.resize();
});
}
/**
* 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 {Promise<any>} Resolved when done.
*/
protected fetchData(): Promise<any> {
this.loadMoreError = false;
this.logger.debug(`Polling new messages for discussion with user '${this.userId}'`);
if (this.messagesBeingSent > 0) {
// We do not poll while a message is being sent or we could confuse the user.
// Otherwise, his message would disappear from the list, and he'd have to wait for the interval to check for messages.
return Promise.reject(null);
} else if (this.fetching) {
// Already fetching.
return Promise.reject(null);
}
this.fetching = true;
// Wait for synchronization process to finish.
return this.messagesSync.waitForSync(this.userId).then(() => {
// Fetch messages. Invalidate the cache before fetching.
return this.messagesProvider.invalidateDiscussionCache(this.userId).catch(() => {
// Ignore errors.
});
}).then(() => {
return this.getDiscussion(this.pagesLoaded);
}).then((messages) => {
if (this.viewDestroyed) {
return Promise.resolve();
}
// Check if we are at the bottom to scroll it after render.
this.scrollBottom = this.domUtils.getScrollHeight(this.content) - this.domUtils.getScrollTop(this.content) ===
this.domUtils.getContentHeight(this.content);
if (this.messagesBeingSent > 0) {
// Ignore polling due to a race condition.
return Promise.reject(null);
}
// Add new messages to the list and mark the messages that should still be displayed.
messages.forEach((message) => {
this.addMessage(message);
});
// Remove messages that shouldn't be in the list anymore.
for (const hash in this.keepMessageMap) {
this.removeMessage(hash);
}
// Sort the messages.
this.messagesProvider.sortMessages(this.messages);
// Notify that there can be a new message.
this.notifyNewMessage();
// Mark retrieved messages as read if they are not.
this.markMessagesAsRead();
}).finally(() => {
this.fetching = false;
});
}
/**
* Get a discussion. Can load several "pages".
*
* @param {number} pagesToLoad Number of pages to load.
* @param {number} [lfReceivedUnread=0] Number of unread received messages already fetched, so fetch will be done from this.
* @param {number} [lfReceivedRead=0] Number of read received messages already fetched, so fetch will be done from this.
* @param {number} [lfSentUnread=0] Number of unread sent messages already fetched, so fetch will be done from this.
* @param {number} [lfSentRead=0] Number of read sent messages already fetched, so fetch will be done from this.
* @return {Promise<any>} Resolved when done.
*/
protected getDiscussion(pagesToLoad: number, lfReceivedUnread: number = 0, lfReceivedRead: number = 0, lfSentUnread: number = 0,
lfSentRead: number = 0): Promise<any> {
// 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) => {
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.getDiscussion(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(): void {
let readChanged = false;
const promises = [];
if (this.messagesProvider.isMarkAllMessagesReadEnabled()) {
let messageUnreadFound = false;
// Mark all messages at a time if one messages is unread.
for (const x in this.messages) {
const message = this.messages[x];
// If an unread message is found, mark all messages as read.
if (message.useridfrom != this.currentUserId && message.read == 0) {
messageUnreadFound = true;
break;
}
}
if (messageUnreadFound) {
this.setUnreadLabelPosition();
promises.push(this.messagesProvider.markAllMessagesRead(this.userId).then(() => {
readChanged = true;
// Mark all messages as read.
this.messages.forEach((message) => {
message.read = 1;
});
}));
}
} 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 && message.read == 0) {
promises.push(this.messagesProvider.markMessageRead(message.id).then(() => {
readChanged = true;
message.read = 1;
}));
}
});
}
Promise.all(promises).finally(() => {
if (readChanged) {
this.eventsProvider.trigger(AddonMessagesProvider.READ_CHANGED_EVENT, {
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, {
userId: this.userId,
message: this.lastMessage.text,
timecreated: this.lastMessage.timecreated
}, 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;
}
let previousMessageRead = false;
for (const x in this.messages) {
const message = this.messages[x];
if (message.useridfrom != this.currentUserId) {
// Place unread from message label only once.
message.unreadFrom = message.read == 0 && previousMessageRead;
if (message.unreadFrom) {
// Save where the label is placed.
this.unreadMessageFrom = parseInt(message.id, 10);
break;
}
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) {
for (const x in this.messages) {
const message = this.messages[x];
if (message.id == this.unreadMessageFrom) {
message.unreadFrom = false;
break;
}
}
// Label hidden.
this.unreadMessageFrom = -1;
}
}
/**
* Wait until fetching is false.
* @return {Promise<void>} 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.polling) {
// Start polling.
this.polling = setInterval(() => {
this.fetchData().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 {string} text Message text to be copied.
*/
copyMessage(text: string): void {
this.utils.copyToClipboard(text);
}
/**
* Function to delete a message.
*
* @param {any} message Message object to delete.
* @param {number} index Index where the mesasge is to delete it from the view.
*/
deleteMessage(message: any, index: number): void {
const langKey = message.pending ? 'core.areyousure' : 'addon.messages.deletemessageconfirmation';
this.domUtils.showConfirm(this.translate.instant(langKey)).then(() => {
const modal = this.domUtils.showModalLoading('core.deleting', true);
return this.messagesProvider.deleteMessage(message).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.fetchData(); // 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 {any} [infiniteComplete] Infinite scroll complete function. Only used from core-infinite-loading.
* @return {Promise<any>} Resolved when done.
*/
loadPrevious(infiniteComplete?: any): Promise<any> {
// If there is an ongoing fetch, wait for it to finish.
return this.waitForFetch().finally(() => {
this.pagesLoaded++;
this.fetchData().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();
});
});
}
/**
* 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 {string} text Message text.
*/
sendMessage(text: string): void {
let message;
this.hideUnreadLabel();
this.showDelete = false;
this.scrollBottom = true;
message = {
pending: true,
sending: true,
useridfrom: this.currentUserId,
smallmessage: text,
text: text,
timecreated: new Date().getTime()
};
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(() => {
this.messagesProvider.sendMessage(this.userId, text).then((data) => {
let promise;
this.messagesBeingSent--;
if (data.sent) {
// Message was sent, fetch messages right now.
promise = this.fetchData();
} 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 {any} message Current message where to show the date.
* @param {any} [prevMessage] Previous message where to compare the date with.
* @return {boolean} If date has changed and should be shown.
*/
showDate(message: any, prevMessage?: any): boolean {
if (!prevMessage) {
// First message, show it.
return true;
} else if (message.pending) {
// If pending, it has no date, not show.
return false;
}
// Check if day has changed.
return !moment(message.timecreated).isSame(prevMessage.timecreated, 'day');
}
/**
* Toggles delete state.
*/
toggleDelete(): void {
this.showDelete = !this.showDelete;
}
/**
* Page destroyed.
*/
ngOnDestroy(): void {
// Unset again, just in case.
this.unsetPolling();
this.syncObserver && this.syncObserver.off();
this.keyboardObserver && this.keyboardObserver.off();
this.viewDestroyed = true;
}
}