UNPKG

@jsxc/jsxc

Version:

Real-time XMPP chat application with video calls, file transfer and encrypted communication

746 lines (570 loc) 22 kB
import Log from '../util/Log'; import Contact from '../Contact'; import Menu from './util/Menu'; import Message from '../Message'; import { IMessage } from '../Message.interface'; import Client from '../Client'; import Account from '../Account'; import Emoticons from '../Emoticons'; import AvatarSet from './AvatarSet'; import { startCall } from './actions/call'; import { Presence } from '../connection/AbstractConnection'; import { EncryptionState } from '../plugin/AbstractPlugin'; import ElementHandler from './util/ElementHandler'; import ChatWindowMessage from './ChatWindowMessage'; import Transcript from '../Transcript'; import FileTransferHandler from './ChatWindowFileTransferHandler'; import Attachment from '../Attachment'; import { IJID } from '@src/JID.interface'; import { JINGLE_FEATURES } from '@src/JingleAbstractSession'; import Location from '@util/Location'; import interact from 'interactjs'; import Translation from '../util/Translation'; import MultiUserContact from '@src/MultiUserContact'; let chatWindowTemplate = require('../../template/chatWindow.hbs'); const ENTER_KEY = 13; const ESC_KEY = 27; export enum State { Open, Minimized, Closed, } export default class ChatWindow { protected element: JQuery<HTMLElement>; private inputElement: JQuery<HTMLElement>; private inputBlurTimeout: number; private readTimeout: number; private readonly INPUT_RESIZE_DELAY = 1200; private readonly HIGHTLIGHT_DURATION = 600; private readonly READ_DELAY = 2000; private chatWindowMessages: { [id: string]: ChatWindowMessage } = {}; private attachmentDeposition: Attachment; private settingsMenu: Menu; private encryptionMenu: Menu; constructor(protected contact: Contact) { let template = chatWindowTemplate({ accountUid: this.getAccount().getUid(), contactId: contact.getId(), contactJid: contact.getJid().bare, name: contact.getName(), }); this.element = $(template); this.inputElement = this.element.find('.jsxc-message-input'); Menu.init(this.element.find('.jsxc-menu')); this.settingsMenu = this.element.find('.jsxc-menu-settings').data('object'); this.encryptionMenu = this.element.find('.jsxc-menu.jsxc-transfer').data('object'); this.initResizableWindow(); this.initEmoticonMenu(); this.restoreLocalHistory(); this.registerHandler(); this.registerInputHandler(); this.initDroppable(); new FileTransferHandler(this); this.element.find('.jsxc-window').css('bottom', -1 * this.element.find('.jsxc-window-fade').height()); let avatar = AvatarSet.get(contact); avatar.addElement(this.element.find('.jsxc-bar--window .jsxc-avatar')); this.initEncryptionIcon(); this.registerHooks(); this.element.attr('data-presence', Presence[this.contact.getPresence()]); this.element.attr('data-subscription', this.contact.getSubscription()); this.element.attr('data-type', this.contact.getType()); if (this.contact.isGroupChat()) { const setMembersOnly = () => this.element.attr('data-membersonly', (this.contact as MultiUserContact).isMembersOnly().toString()); const setNonAnonymous = () => this.element.attr('data-nonanonymous', (this.contact as MultiUserContact).isNonAnonymous().toString()); this.contact.registerHook('features', () => { setMembersOnly(); setNonAnonymous(); }); setMembersOnly(); setNonAnonymous(); } this.getAccount().triggerChatWindowInitializedHook(this, contact); } public getTranscript(): Transcript { return this.contact.getTranscript(); } public getChatWindowMessage(message: IMessage) { let id = message.getUid(); if (!this.chatWindowMessages[id]) { this.chatWindowMessages[id] = new ChatWindowMessage(message, this); } return this.chatWindowMessages[id]; } public getId() { return this.contact.getId(); } public getAccount(): Account { return this.contact.getAccount(); } public getContact(jid?: IJID) { return jid ? this.getAccount().getContact(jid) : this.contact; } public getDom() { return this.element; } public close() { this.element.detach(); } public minimize() { this.element.removeClass('jsxc-normal').addClass('jsxc-minimized'); } public open() { this.element.removeClass('jsxc-minimized').addClass('jsxc-normal'); this.scrollMessageAreaToBottom(); } public focus() { this.element.find('.jsxc-message-input').focus(); } public clear() { this.chatWindowMessages = {}; this.getTranscript().clear(); this.element.find('.jsxc-message-area').empty().trigger('scroll'); this.getAccount().triggerChatWindowClearedHook(this, this.contact); } public highlight() { let element = this.element; if (!element.hasClass('jsxc-highlight')) { element.addClass('jsxc-highlight'); setTimeout(function () { element.removeClass('jsxc-highlight'); }, this.HIGHTLIGHT_DURATION); } } public setBarText(text: string) { this.element.find('.jsxc-bar__caption__secondary').text(text); } public getInput(): string { return this.inputElement.val().toString(); } public setInput(text: string) { this.inputElement.val(text); this.inputElement.trigger('focus'); this.resizeInputArea(); } public appendTextToInput(text: string = '') { let value = this.inputElement.val(); this.inputElement.val((value + ' ' + text).trim()); this.inputElement.focus(); } public postMessage(message: IMessage): ChatWindowMessage { if (message.getDirection() === Message.DIRECTION.IN && this.inputElement.is(':focus') && Client.isVisible()) { message.read(); } let chatWindowMessage = this.getChatWindowMessage(message); let messageElement = chatWindowMessage.getElement(); if (message.getDOM().length > 0) { message.getDOM().replaceWith(messageElement); } else { this.element.find('.jsxc-message-area').prepend(messageElement); } chatWindowMessage.restoreNextMessage(); setTimeout(() => this.scrollMessageAreaToBottom(), 500); return chatWindowMessage; } public addActionEntry(className: string, cb: (ev) => void, child?: JQuery<HTMLElement>) { let element = $('<div>'); element.addClass('jsxc-bar__action-entry'); element.addClass(className); element.on('click', cb); if (child) { element.append(child); } this.element.find('.jsxc-bar__action-entry.jsxc-js-close').before(element); } public addMenuEntry(className: string, label: string, cb: (ev) => void) { return this.settingsMenu.addEntry(label, cb, className); } public getAttachment(): Attachment { return this.attachmentDeposition; } public setAttachment(attachment: Attachment) { this.attachmentDeposition = attachment; let previewElement = this.element.find('.jsxc-preview'); previewElement.empty(); previewElement.append('<p class="jsxc-waiting">Processing...</p>'); previewElement.empty().append(attachment.getElement()); let deleteElement = $('<div>'); deleteElement.text('×'); deleteElement.addClass('jsxc-delete-handle'); deleteElement.click(() => { this.clearAttachment(); }); previewElement.children().first().append(deleteElement); this.scrollMessageAreaToBottom(); } public clearAttachment() { this.attachmentDeposition = undefined; let previewElement = this.element.find('.jsxc-preview'); previewElement.empty(); } public getOverlay(): JQuery<HTMLElement> { return this.getDom().find('.jsxc-window__overlay__content'); } public showOverlay() { this.getDom().find('.jsxc-window__overlay').addClass('jsxc-window__overlay--show'); } public hideOverlay() { this.getDom().find('.jsxc-window__overlay__content').empty(); this.getDom().find('.jsxc-window__overlay').removeClass('jsxc-window__overlay--show'); } protected initDroppable() { let enterCounter = 0; let windowElement = this.element.find('.jsxc-window'); windowElement.addClass('jsxc-droppable'); windowElement.on('dragenter', ev => { enterCounter++; windowElement.addClass('jsxc-dragover'); }); windowElement.on('dragleave', ev => { enterCounter--; if (enterCounter === 0) { windowElement.removeClass('jsxc-dragover'); } }); windowElement.on('dragover', ev => { ev.preventDefault(); (<any>ev.originalEvent).dataTransfer.dropEffect = 'copy'; }); } private getController() { return this.contact.getChatWindowController(); } private registerHandler() { let self = this; let contact = this.contact; let elementHandler = new ElementHandler(contact); elementHandler.add(this.element.find('.jsxc-fingerprints')[0], function () { // showFingerprintsDialog(contact); }); elementHandler.add(this.element.find('.jsxc-bar--window')[0], () => { this.toggle(); }); elementHandler.add(this.element.find('.jsxc-js-close')[0], ev => { ev.stopPropagation(); this.getController().close(); }); elementHandler.add(this.element.find('.jsxc-clear')[0], () => { this.clear(); }); if (this.contact.isChat()) { elementHandler.add( this.element.find('.jsxc-video')[0], ev => { ev.stopPropagation(); startCall(contact, this.getAccount()); }, JINGLE_FEATURES.video ); elementHandler.add( this.element.find('.jsxc-audio')[0], ev => { ev.stopPropagation(); startCall(contact, this.getAccount(), 'audio'); }, JINGLE_FEATURES.audio ); elementHandler.add( this.element.find('.jsxc-share-screen')[0], ev => { ev.stopPropagation(); startCall(contact, this.getAccount(), 'screen'); }, JINGLE_FEATURES.screen ); } elementHandler.add(this.element.find('.jsxc-send-location')[0], ev => { Location.getCurrentLocationAsGeoUri() .then(uri => { this.sendOutgoingMessage(uri); }) .catch(err => { Log.warn('Could not get current location', err); this.getContact().addSystemMessage('Could not get your current location.'); }); }); elementHandler.add(this.element.find('.jsxc-message-area')[0], function () { // check if user clicks element or selects text if (typeof getSelection === 'function' && !getSelection().toString()) { self.inputElement.focus(); } }); } private registerInputHandler() { let self = this; let inputElement = this.inputElement; inputElement.keyup(self.onInputKeyUp); inputElement.keypress(self.onInputKeyPress); inputElement.focus(this.onInputFocus); inputElement.blur(this.onInputBlur); inputElement .mouseenter(function () { $('#jsxc-window-list').data('isHover', true); }) .mouseleave(function () { $('#jsxc-window-list').data('isHover', false); }); } private onInputKeyUp = ev => { ev.stopPropagation(); // let message = <string> $(ev.target).val(); if (ev.which === ENTER_KEY && !ev.shiftKey) { // message = ''; } else { this.resizeInputArea(); } if (ev.which === ESC_KEY) { this.getController().close(); } let selectionStart = ev.target.selectionStart; let selectionEnd = ev.target.selectionEnd; if (selectionStart === selectionEnd) { // let lastSpaceIndex = message.lastIndexOf(' ') + 1; // let lastNewlineIndex = message.lastIndexOf('\n') + 1; // let lastWord = message.slice(Math.max(lastSpaceIndex, lastNewlineIndex), selectionStart); //@TODO auto complete } }; private onInputKeyPress = ev => { ev.stopPropagation(); let message: string = <string>$(ev.target).val(); if (ev.which !== ENTER_KEY || ev.shiftKey || (!message && !this.attachmentDeposition)) { return; } this.getAccount() .getCommandRepository() .execute(message, this.contact) .then(result => { if (result === false) { this.sendOutgoingMessage(message); } }) .catch(err => { this.contact.addSystemMessage(err.message || Translation.t('Command_failed')); }); $(ev.target).val(''); this.resizeInputArea(); ev.preventDefault(); }; private onInputFocus = () => { if (this.inputBlurTimeout) { clearTimeout(this.inputBlurTimeout); } this.readTimeout = window.setTimeout(() => { this.getTranscript().markAllMessagesAsRead(); }, this.READ_DELAY); this.resizeInputArea(); }; private onInputBlur = ev => { if (this.readTimeout) { clearTimeout(this.readTimeout); } this.inputBlurTimeout = window.setTimeout(function () { $(ev.target).css('height', ''); }, this.INPUT_RESIZE_DELAY); }; private sendOutgoingMessage(messageString: string) { let message = new Message({ peer: this.contact.getJid(), direction: Message.DIRECTION.OUT, type: this.contact.getType(), plaintextMessage: messageString, attachment: this.attachmentDeposition, unread: false, }); this.getTranscript().pushMessage(message); this.clearAttachment(); let pipe = this.getAccount().getPipe('preSendMessage'); pipe .run(this.contact, message) .then(([contact, message]) => { this.getAccount().getConnection().sendMessage(message); }) .catch(err => { Log.warn('Error during preSendMessage pipe', err); }); if (messageString === '?' && Client.getOption('theAnswerToAnything') !== false) { if (typeof Client.getOption('theAnswerToAnything') === 'undefined' || (Math.random() * 100) % 42 < 1) { Client.setOption('theAnswerToAnything', true); this.getContact().addSystemMessage('42'); } } } private toggle = (ev?) => { if (this.element.hasClass('jsxc-minimized')) { this.getController().open(); } else { this.getController().minimize(); } }; private updateEncryptionState = encryptionState => { Log.debug('update window encryption state to ' + EncryptionState[encryptionState]); let transferElement = this.encryptionMenu.getElement(); transferElement.removeClass('jsxc-fin jsxc-enc jsxc-trust'); switch (encryptionState) { case EncryptionState.Plaintext: break; case EncryptionState.UnverifiedEncrypted: transferElement.addClass('jsxc-enc'); break; case EncryptionState.VerifiedEncrypted: transferElement.addClass('jsxc-enc jsxc-trust'); break; case EncryptionState.Ended: transferElement.addClass('jsxc-fin'); break; default: Log.warn('Unknown encryption state'); } }; private resizeInputArea() { let inputElement = this.inputElement; if (!inputElement.data('originalScrollHeight')) { inputElement.data('originalScrollHeight', inputElement[0].scrollHeight); } if (inputElement.val()) { inputElement.parent().addClass('jsxc-contains-val'); } else { inputElement.parent().removeClass('jsxc-contains-val'); } this.element.removeClass('jsxc-large-send-area'); if (inputElement.data('originalScrollHeight') < inputElement[0].scrollHeight && inputElement.val()) { this.element.addClass('jsxc-large-send-area'); } } private initResizableWindow() { let element = this.element; let fadeElement = element.find('.jsxc-window-fade'); interact(fadeElement.get(0)) .resizable({ edges: { top: true, left: true, bottom: false, right: false, }, }) .on('resizestart', () => { fadeElement.addClass('jsxc-window-fade--resizing'); }) .on('resizemove', ev => { let barHeight = element.find('.jsxc-bar--window').height(); let windowHeight = $(window).height(); let newHeight = Math.min(windowHeight - barHeight, ev.rect.height); fadeElement.css({ width: ev.rect.width + 'px', height: newHeight + 'px', }); element.find('.jsxc-bar--window').css('width', fadeElement.width() + 'px'); }) .on('resizeend', () => { fadeElement.removeClass('jsxc-window-fade--resizing'); }); $(window).on('resize', () => { fadeElement.css({ width: '', height: '', }); element.find('.jsxc-bar--window').css('width', ''); }); } private initEmoticonMenu() { let emoticonListElement = this.element.find('.jsxc-menu--emoticons ul'); let emoticonList = Emoticons.getDefaultEmoticonList(); emoticonList.forEach(emoticon => { let li = $('<li>'); li.append(Emoticons.toImage(emoticon)); li.find('div').attr('title', emoticon); li.click(() => { let inputElement = this.element.find('.jsxc-message-input'); let inputValue = <string>inputElement.val() || ''; let selectionStart = (<HTMLInputElement>inputElement[0]).selectionStart; let selectionEnd = (<HTMLInputElement>inputElement[0]).selectionEnd; let inputStart = inputValue.slice(0, selectionStart); let inputEnd = inputValue.slice(selectionEnd); let newValue = inputStart; newValue += inputStart.length && inputStart.slice(-1) !== ' ' ? ' ' : ''; newValue += emoticon; newValue += inputEnd.length && inputEnd.slice(0, 1) !== ' ' ? ' ' : ''; newValue += inputEnd; inputElement.val(newValue); inputElement.focus(); }); emoticonListElement.prepend(li); }); } private restoreLocalHistory() { let firstMessage = this.getTranscript().getFirstOriginalMessage(); if (!firstMessage) { return; } let chatWindowMessage = this.getChatWindowMessage(firstMessage); this.element.find('.jsxc-message-area').append(chatWindowMessage.getElement()); chatWindowMessage.restoreNextMessage(); } private scrollMessageAreaToBottom() { let messageArea = this.element.find('.jsxc-message-area'); messageArea[0].scrollTop = messageArea[0].scrollHeight; } private registerHooks() { this.contact.registerHook('encryptionState', this.updateEncryptionState); this.contact.registerHook('presence', newPresence => { this.element.attr('data-presence', Presence[newPresence]); }); this.contact.registerHook('subscription', () => { this.element.attr('data-subscription', this.contact.getSubscription()); }); this.contact.registerHook('name', newName => { this.element.find('.jsxc-bar__caption__primary').text(newName); }); this.getTranscript().registerHook('firstMessageId', firstMessageId => { if (!firstMessageId) { return; } let message = this.getTranscript().getMessage(firstMessageId); if (!message.isReplacement()) { this.postMessage(message); } }); } private initEncryptionIcon() { this.updateEncryptionState(this.contact.getEncryptionState()); let pluginRepository = this.getAccount().getPluginRepository(); if (!pluginRepository.hasEncryptionPlugin()) { return; } let encryptionPlugins = pluginRepository.getAllEncryptionPlugins(); this.encryptionMenu.getButtonElement().on('click', ev => { if (!this.contact.isEncrypted()) { return; } let encryptionPluginName = this.contact.getEncryptionPluginId(); pluginRepository.getEncryptionPlugin(encryptionPluginName).toggleTransfer(this.contact); ev.preventDefault(); ev.stopPropagation(); return false; }); let menu = this.encryptionMenu; for (let plugin of encryptionPlugins) { let label = (<any>plugin.constructor).getName().toUpperCase(); menu.addEntry(label, () => { let buttonElement = this.encryptionMenu.getButtonElement(); buttonElement.addClass('jsxc-transfer--loading'); plugin .toggleTransfer(this.contact) .catch(err => { Log.warn('Toggle transfer error:', err); this.getContact().addSystemMessage(err.toString()); }) .then(() => { buttonElement.removeClass('jsxc-transfer--loading'); }); }); } let menuElement = this.encryptionMenu.getElement(); if (menuElement.find('li').length > 0) { menuElement.removeClass('jsxc-disabled'); } } }