@jsxc/jsxc
Version:
Real-time XMPP chat application with video calls, file transfer and encrypted communication
746 lines (570 loc) • 22 kB
text/typescript
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');
}
}
}