Home Reference Source

src/chat/ChatManager.js

import {List, Map} from 'immutable';

import Logs from '../Logs';
import {bindMethods} from '../utils/bindMethods';
import {EventEmitter} from '../eventemitter';
import {Chat} from './Chat';
import {ChatType} from './ChatType';
import {ChatParticipantRole} from './ChatParticipantRole';
import {uuid} from 'stanza/Utils';
import {CHAT_INVITE_REASONS} from '../wac-proxy/wac-stack/ChatService';
import {SupportChat} from './SupportChat';
import {JID} from 'stanza';

const log = Logs.instance.getLogger('SippoJS/chat/ChatManager');

function sortByTimestamp(chatA, chatB) {
	const lastA = chatA.getLastMessageTimestamp();
	const lastB = chatB.getLastMessageTimestamp();
	if (lastA < lastB) {
		return 1;
	}
	if (lastA > lastB) {
		return -1;
	}
	return 0;
}

/**
 * Provides access to methods for using chat.
 * A Chat manager object is obtained by calling the {@link Session#getChatManager} method.
 *
 * # Events
 * - `chats` - Emitted every time the chat list changes.
 * - `conferenceChats` - Emitted every time the conference chats list changes.
 * - `file` - Emitted every time a new chat message containing a file is received.
 * - `message` - Emitted every time a new chat message is received.
 * - `messageDisplayed` - Emitted every time messages are marked as displayed by this user.
 * - `supportChat` - Emitted every time a support chat is received.
 * - `supportChats` - Emitted every time the support chat list changes.
 *
 * @example <caption>Getting chats</caption>
 *   const chatManager = await session.getChatManager();
 *   const chats = chatManager.getChats();
 *
 * @example <caption>Obtaining an individual chat</caption>
 *   const chat = chatManager.getIndividualChat('alice@quobis');
 */
export class ChatManager {
	/**
	 * @protected
	 * @return {Promise<ChatManager>}
	 */
	static async newInstance(chatService, fileManager, user, sessionToken) {
		const chatManager = new ChatManager(chatService, fileManager, user, sessionToken);
		await chatManager.init();
		return chatManager;
	}

	/** @private */
	constructor(chatService, fileManager, user, sessionToken) {
		/** @private */
		this.ownAddress = user.getAddress().toLowerCase();

		/** @private */
		this.chatService = chatService;
		/** @private */
		this.fileManager = fileManager;
		/** @private */
		this.user = user;
		/** @private */
		this.sessionToken = sessionToken;
		/** @private */
		this.individualChats = new Map();
		/** @private */
		this.groupChats = new Map();
		/** @private */
		this.chats = new List();
		/** @private */
		this.conferenceChats = new List();
		/** @private */
		this.supportChats = new List();

		/** @type {EventEmitter} */
		this.emitter = new EventEmitter();

		bindMethods(this, [
			'onIndividualChatAdded',
			'onIndividualChatDeleted',
			'onMucInvite',
			'onMucEnd',
			'onChat',
			'onDisplayed',
			'onMessage',
			'onMessageSent',
			'onFile',
		]);
	}

	/** @ignore */
	async init() {
		this.bindEventHandlers();
		await this.chatService.start();
	}

	/** @ignore */
	async uninit() {
		this.unbindEventHandlers();
		await this.chatService.stop();
	}

	async suspend() {
		this.unbindEventHandlers();
		await this.chatService.suspend();
	}

	/** @private */
	async fetchIndividualChats() {
		const jids = await this.chatService.getIndividualChats();
		this.individualChats = this.deleteOutdatedChats(this.individualChats, jids);
		for (const jid of jids) {
			if (this.individualChats.has(jid)) {
				continue;
			}
			this.onIndividualChatAdded(jid);
		}
	}

	/** @private */
	async fetchSupportChats() {
		const supportChats = await this.chatService.getSupportChats();
		const allSupportChats = await Promise.all(supportChats.map(async ({chatRoomJid}) => {
			const existingChat = this.supportChats.find(chat => chat.getId() === chatRoomJid);
			return existingChat || this.tryToCreateSupportChat(chatRoomJid);
		}));
		const joinedSupportChats = allSupportChats.filter(x => !!x);
		this.supportChats = List(joinedSupportChats);
	}

	/** @private */
	hasSupportChat(chatJid) {
		return !!this.supportChats.find(c => c.getId() === chatJid);
	}

	/** @private */
	async tryToCreateSupportChat(chatJid) {
		try {
			const supportChat = await this.joinSupportChat(chatJid);
			return supportChat;
		} catch (error) {
			log.warn(`Unable to join SupportChat with JID ${chatJid}`);
			log.warn(error);
			this.onMucEnd({chatId: chatJid});
		}
	}

	/** @private */
	async joinSupportChat(chatJid) {
		await this.chatService.joinRoom(chatJid, this.ownAddress);
		const groupChat = await this.newGroupChat(chatJid);
		const supportChat = SupportChat.create(JID.getLocal(chatJid), this.chatService, groupChat);
		return supportChat;
	}

	/** @private */
	async fetchGroupChats() {
		const bookmarks = await this.chatService.getBookmarks();
		this.groupChats = this.deleteOutdatedChats(this.groupChats, bookmarks);
		await Promise.all(bookmarks.map(async (jid) => {
			try {
				await this.chatService.joinRoom(jid, this.ownAddress);
				if (this.groupChats.has(jid)) {
					return;
				}
				const chat = await this.newGroupChat(jid);
				this.groupChats = this.groupChats.set(jid, chat);
				this.chats = this.chats.unshift(chat);
			} catch (error) {
				log.warn(`Unable to join MUC ${jid}`);
				log.warn(error);
				this.onMucEnd({chatId: jid});
			}
		}));
	}

	/** @private */
	deleteOutdatedChats(immutableMap, validIds) {
		const remotelyDeletedIds = [...immutableMap.keys()].filter(id => !validIds.includes(id));
		this.chats = this.chats.filter(chat => !remotelyDeletedIds.includes(chat.getId()));
		return immutableMap.withMutations((map) => {
			for (const id of remotelyDeletedIds) {
				const chat = map.get(id);
				chat.unbindEventListeners();
				map.delete(id);
			}
		});
	}

	/** @private */
	bindEventHandlers() {
		this.chatService.emitter.on('individualChatAdded', this.onIndividualChatAdded);
		this.chatService.emitter.on('individualChatDeleted', this.onIndividualChatDeleted);
		this.chatService.emitter.on('mucInvite', this.onMucInvite);
		this.chatService.emitter.on('mucEnd', this.onMucEnd);
		this.chatService.emitter.on('chat', this.onChat);
		this.chatService.emitter.on(['displayed', '*'], this.onDisplayed);
		this.chatService.emitter.on(['message:sent', '*'], this.onMessageSent);
		this.chatService.emitter.on(['message', '*'], this.onMessage);
		this.chatService.emitter.on(['file', '*'], this.onFile);
	}

	/** @private */
	unbindEventHandlers() {
		this.chatService.emitter.off('individualChatAdded', this.onIndividualChatAdded);
		this.chatService.emitter.off('individualChatDeleted', this.onIndividualChatDeleted);
		this.chatService.emitter.off('mucInvite', this.onMucInvite);
		this.chatService.emitter.off('mucEnd', this.onMucEnd);
		this.chatService.emitter.off('chat', this.onChat);
		this.chatService.emitter.off(['displayed', '*'], this.onDisplayed);
		this.chatService.emitter.off(['message:sent', '*'], this.onMessageSent);
		this.chatService.emitter.off(['message', '*'], this.onMessage);
		this.chatService.emitter.off(['file', '*'], this.onFile);
	}

	/** @private */
	newIndividualChat(id) {
		const type = ChatType.INDIVIDUAL;
		const participants = [{
			jid: this.ownAddress,
			affiliation: ChatParticipantRole.MEMBER,
		}, {
			jid: id,
			affiliation: ChatParticipantRole.MEMBER,
		}];
		const chat = Chat.newInstance(this.chatService, this.fileManager, this.user, {id, type, participants});
		chat.bindEventListeners();
		return chat;
	}

	/** @private */
	async newGroupChat(id) {
		const participants = await this.chatService.getRoomMembers(id);
		const info = await this.chatService.getRoomInfo(id);
		const name = info.name;
		const subject = info.subject;
		const type = ChatType.GROUP;
		const chat = Chat.newInstance(this.chatService, this.fileManager, this.user, {id, type, name, subject, participants});
		chat.bindEventListeners();
		return chat;
	}

	/** @private */
	promoteChat({jid, type}) {
		const chat = type === 'chat' ? this.individualChats.get(jid) : this.groupChats.get(jid);
		const index = this.chats.indexOf(chat);
		if (index > 0) {
			this.chats = this.chats.delete(index).unshift(chat);
			this.emitter.emit('chats');
		}
	}

	/** @private */
	onIndividualChatAdded(jid) {
		let chat = this.individualChats.get(jid);
		if (!chat) {
			chat = this.newIndividualChat(jid);
			this.individualChats = this.individualChats.set(jid, chat);
			this.chats = this.chats.unshift(chat);
			this.emitter.emit('chats');
		}
	}

	/** @private */
	onIndividualChatDeleted(jid) {
		const chat = this.individualChats.get(jid);
		if (chat) {
			chat.unbindEventListeners();
			this.individualChats = this.individualChats.remove(jid);
			this.chats = this.chats.filter(c => c !== chat);
			this.emitter.emit('chats');
		}
	}

	/** @private */
	onDisplayed(event) {
		if (event.from === this.ownAddress) {
			this.emitter.emit('messageDisplayed');
		}
	}

	/** @private */
	onMessage(event) {
		this.emitter.emit('message', {
			chat: event.chat, // jid we use for indexing
			from: event.from,
			to: event.to,
			timestamp: event.timestamp,
			body: event.body,
			type: event.type,
		});
		this.promoteChat({jid: event.chat, type: event.type});
	}

	/** @private */
	onMessageSent(event) {
		if (event.displayed) {
			this.emitter.emit('messageDisplayed');
		} else if (event.links || event.body) {
			this.promoteChat({jid: event.chat, type: event.type});
		}
	}

	/** @private */
	onFile(event) {
		this.emitter.emit('file', {
			chat: event.chat,
			from: event.from,
			to: event.to,
			timestamp: event.timestamp,
			desc: event.desc,
			url: event.url,
			type: event.type,
		});
		this.promoteChat({jid: event.chat, type: event.type});
	}

	/** @private */
	onChat(event) {
		if (event.type === 'chat') {
			this.getIndividualChat(event.id);
		}
	}

	/** @private */
	async onMucInvite(event) {
		const id = event.chatId;
		if (event.reason === CHAT_INVITE_REASONS.SUPPORT) {
			const supportChat = await this.joinSupportChat(event.chatId);
			await supportChat.sync();
			this.supportChats = this.supportChats.unshift(supportChat);
			this.emitter.emit('supportChats', this.supportChats);
			this.emitter.emit('supportChat', supportChat);
			return;
		}
		let chat = this.groupChats.get(id);
		if (!chat) {
			await this.chatService.joinRoom(id, this.ownAddress);
			chat = await this.newGroupChat(id);
			this.groupChats = this.groupChats.set(id, chat);
			await chat.sync();
			if (event.conferenceChat) {
				this.conferenceChats = this.conferenceChats.unshift(chat);
				this.emitter.emit('conferenceChats', chat);
			} else {
				await this.chatService.addBookmark(id);
				this.chats = this.chats.unshift(chat);
				this.emitter.emit('chats', chat);
			}
		}
	}

	/** @private */
	async onMucEnd(event) {
		if (this.hasSupportChat(event.chatId)) {
			this.supportChats = this.supportChats.filter(c => c.getId() !== event.chatId);
			this.emitter.emit('supportChats', this.supportChats);
			return;
		}
		const chat = this.groupChats.get(event.chatId);
		if (!chat) {
			return;
		}
		chat.unbindEventListeners();
		this.groupChats = this.groupChats.remove(chat.getId());
		if (this.conferenceChats.includes(chat)) {
			this.conferenceChats = this.conferenceChats.filter(c => c !== chat);
			this.emitter.emit('conferenceChats', chat);
		} else {
			this.chats = this.chats.filter(c => c !== chat);
			await this.chatService.removeBookmark(event.chatId);
			this.emitter.emit('chats', chat);
		}
	}

	/**
	 * Sets the Push Notifications service type and token.
	 * @param {Object} config
	 * @param {string} config.jid The bare JID of the Push Server (e.g.: push.sippo).
	 * @param {"fcm"|"apn"} config.type The type of the Push Server. Supported values are fcm and apn.
	 * @param {string} config.token The token obtained from the Push Service.
	 */
	setPushConfig({jid, type, token}) {
		const node = this.sessionToken;
		/** @private */
		this.pushConfig = {jid, type, token, node};
	}

	/**
	 * Enables push notifications.
	 * @return {Promise}
	 */
	enablePushNotifications() {
		return this.chatService.enableNotifications(this.pushConfig);
	}

	/**
	 * Disables push notifications.
	 * @return {Promise}
	 */
	disablePushNotifications() {
		return this.chatService.disableNotifications(this.pushConfig);
	}

	/**
	 * Creates a new group chat.
	 * @param {string} [name=''] The name of the new group chat.
	 * @param {string} [subject=''] The subject of the new group chat.
	 * @returns {Promise<Chat>}
	 */
	async createChat(name = '', subject = '') {
		const id = await this.chatService.createRoom(this.ownAddress, name, subject);
		await this.chatService.addBookmark(id);
		const chat = await this.newGroupChat(id);
		this.groupChats = this.groupChats.set(id, chat);
		this.chats = this.chats.unshift(chat);
		this.emitter.emit('chats', chat);
		return chat;
	}

	/**
	 * Obtains an individual chat with the specified user.
	 * @param {string} participant Address of the remote participant of the chat.
	 * @return {Chat}
	 */
	getIndividualChat(participant) {
		participant = participant.toLowerCase();
		let chat = this.individualChats.get(participant);
		if (!chat) {
			chat = this.newIndividualChat(participant);
			this.individualChats = this.individualChats.set(participant, chat);
			this.chatService.addIndividualChat(participant);
			this.chats = this.chats.unshift(chat);
			this.emitter.emit('chats');
		}
		return chat;
	}

	/**
	 * Obtains the specified group chat.
	 * @param {String} id Address of the group chat.
	 * @return {Chat}
	 */
	getGroupChat(id) {
		let chat = this.groupChats.get(id);
		if (!chat) {
			throw Error(`There is no group with id: ${id}`);
		}
		return chat;
	}

	/**
	 * Returns a list with all the chats except the associated with conference rooms.
	 * @return {ImmutableList<Chat>}
	 */
	getChats() {
		return this.chats;
	}

	/**
	 * Returns a list with chats associated to conference rooms.
	 * @return {ImmutableList<Chat>}
	 */
	getConferenceChats() {
		return this.conferenceChats;
	}

	/**
	 * Returns a list with chats associated with support chats.
	 * @return {ImmutableList<Chat>}
	 */
	getSupportChats() {
		return this.supportChats;
	}

	/**
	 * Synchronizes latest chat messages from the sever and reorders the chat list.
	 * This method needs to be called after initialization and every time session is
	 * resumed.
	 */
	async sync() {
		await Promise.all([
			this.fetchIndividualChats(),
			this.fetchGroupChats(),
			this.fetchSupportChats(),
		]);
		await this.chatService.syncMucInvitations(this.ownAddress);
		await Promise.all(this.chats.map(chat => chat.sync()));
		this.chats = this.chats.sort(sortByTimestamp);
		this.emitter.emit('chats');
	}

	/**
	 * Creates a support chat, which is a group chat where a agent is added in the
	 * moment of the creation.
	 * @param {object} context Context of supportChat
	 * @return {Promise<Chat>}
	 */
	async createSupportChat(context = {}) {
		return new Promise((resolve, reject) => {
			const id = uuid();
			const onSupportChat = (chat) => {
				resolve(chat);
				this.emitter.off('supportChat', onSupportChat);
			};
			this.emitter.on('supportChat', onSupportChat);
			this.emitter.on('suppe', reject);
			this.chatService.createSupportChat(id, context).catch(reject);
		});
	}
}