/**
* This file is part of Threema Web.
*
* Threema Web is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
* General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Threema Web. If not, see .
*/
import * as twemoji from 'twemoji';
import {extractText, hasValue, isActionTrigger, logAdapter, replaceWhitespace} from '../helpers';
import {emojify, shortnameToUnicode} from '../helpers/emoji';
import {BrowserService} from '../services/browser';
import {ReceiverService} from '../services/receiver';
import {StringService} from '../services/string';
import {TimeoutService} from '../services/timeout';
import {isElementNode, isTextNode} from '../typeguards';
/**
* The compose area where messages are written.
*/
export default [
'BrowserService',
'StringService',
'TimeoutService',
'ReceiverService',
'$timeout',
'$translate',
'$mdDialog',
'$filter',
'$log',
'$rootScope',
function(browserService: BrowserService,
stringService: StringService,
timeoutService: TimeoutService,
receiverService: ReceiverService,
$timeout: ng.ITimeoutService,
$translate: ng.translate.ITranslateService,
$mdDialog: ng.material.IDialogService,
$filter: ng.IFilterService,
$log: ng.ILogService,
$rootScope: ng.IRootScopeService) {
return {
restrict: 'EA',
scope: {
// Callback to submit text or file data
submit: '=',
// Callbacks to update typing information
startTyping: '=',
stopTyping: '=',
onTyping: '=',
onKeyDown: '=',
// Reference to initial text and draft
initialData: '=',
// Callback that is called when uploading files
onUploading: '=',
maxTextLength: '=',
receiver: ' receiverService.isBlocked(_scope.receiver),
(isBlocked: boolean, wasBlocked: boolean) => {
if (isBlocked !== wasBlocked) {
setChatBlocked(isBlocked);
}
},
);
/**
* Stop propagation of click events and hold htmlElement of the emojipicker
*/
const EmojiPickerContainer = (function() {
let instance;
function click(e) {
e.stopPropagation();
}
return {
get: function() {
if (instance === undefined) {
instance = {
htmlElement: composeArea[0].querySelector('div.twemoji-picker'),
};
// append stop propagation
angular.element(instance.htmlElement).on('click', click);
}
return instance;
},
destroy: function() {
if (instance !== undefined) {
// remove stop propagation
angular.element(instance.htmlElement).off('click', click);
instance = undefined;
}
},
};
})();
// Typing events
let stopTypingTimer: ng.IPromise = null;
function stopTyping() {
// We can only stop typing of the timer is set (meaning
// that we started typing earlier)
if (stopTypingTimer !== null) {
// Cancel timer
timeoutService.cancel(stopTypingTimer);
stopTypingTimer = null;
// Send stop typing message
scope.stopTyping();
}
}
function startTyping() {
if (stopTypingTimer === null) {
// If the timer wasn't set previously, we just
// started typing!
scope.startTyping();
} else {
// Cancel timer, we'll re-create it
timeoutService.cancel(stopTypingTimer);
}
// Define a timeout to send the stopTyping event
stopTypingTimer = timeoutService.register(stopTyping, 10000, true, 'stopTyping');
}
// Determine whether field is empty
function composeAreaIsEmpty() {
const text = extractText(composeDiv[0], logAdapter($log.warn, logTag));
return text.length === 0;
}
// Submit the text from the compose area.
//
// Emoji images are converted to their alt text in this process.
function submitText(): Promise {
const rawText = extractText(composeDiv[0], logAdapter($log.warn, logTag));
// Due to #731, and the hack introduced in #706, the
// extracted text may contain non-breaking spaces (U+00A0).
// Replace them with actual whitespace to avoid strange
// behavior when submitting the text.
//
// TODO: Remove this once we have a compose area rewrite and can
// fix the actual bug.
const text = rawText.replace(/\u00A0/g, ' ');
return new Promise((resolve, reject) => {
const submitTexts = (strings: string[]) => {
const messages: threema.TextMessageData[] = [];
for (const piece of strings) {
messages.push({
text: piece,
});
}
scope.submit('text', messages)
.then(resolve)
.catch(reject);
};
const fullText = text.trim().replace(/\r/g, '');
if (fullText.length > scope.maxTextLength) {
const pieces: string[] = stringService.byteChunk(fullText, scope.maxTextLength, 50);
const confirm = $mdDialog.confirm()
.title($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_SUBJECT'))
.textContent($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_BODY', {
max: scope.maxTextLength,
count: pieces.length,
}))
.ok($translate.instant('common.YES'))
.cancel($translate.instant('common.NO'));
$mdDialog.show(confirm).then(function() {
submitTexts(pieces);
}, () => {
reject();
});
} else {
submitTexts([fullText]);
}
});
}
function sendText(): boolean {
if (!composeAreaIsEmpty()) {
submitText().then(() => {
// Clear compose div
composeDiv[0].innerText = '';
composeDiv[0].focus();
// Send stopTyping event
stopTyping();
// Clear draft
scope.onTyping('');
updateView();
}).catch(() => {
// do nothing
$log.warn(logTag, 'Failed to submit text');
});
return true;
}
return false;
}
// Handle typing events
function onKeyDown(ev: KeyboardEvent): void {
// If enter is pressed, prevent default event from being dispatched
if (!ev.shiftKey && ev.key === 'Enter') {
ev.preventDefault();
}
// If the keydown is handled and aborted outside
if (scope.onKeyDown && scope.onKeyDown(ev) !== true) {
ev.preventDefault();
return;
}
// At link time, the element is not yet evaluated.
// Therefore add following code to end of event loop.
$timeout(() => {
// Shift + enter to insert a newline. Enter to send.
if (!ev.shiftKey && ev.key === 'Enter') {
if (sendText()) {
return;
}
}
updateView();
}, 0);
}
function onKeyUp(ev: KeyboardEvent): void {
// At link time, the element is not yet evaluated.
// Therefore add following code to end of event loop.
$timeout(() => {
// If the compose area contains only a single
, make it fully empty.
// See also: https://stackoverflow.com/q/14638887/284318
const text = extractText(composeDiv[0], logAdapter($log.warn, logTag), false);
if (text === '\n') {
composeDiv[0].innerText = '';
} else if ((ev.keyCode === 190 || ev.key === ':') && caretPosition !== null) {
// A ':' is pressed, try to parse
const currentWord = stringService.getWord(text, caretPosition.fromChar, [':']);
if (currentWord.realLength > 2 && currentWord.word.substr(0, 1) === ':') {
const trimmed = currentWord.word.substr(1, currentWord.word.length - 2);
const unicodeEmoji = shortnameToUnicode(trimmed);
if (unicodeEmoji !== null) {
return insertEmoji(unicodeEmoji,
caretPosition.from - currentWord.realLength,
caretPosition.to);
}
}
}
// Update typing information (use text instead method)
if (text.trim().length === 0 || caretPosition === null) {
stopTyping();
scope.onTyping('');
} else {
startTyping();
scope.onTyping(text.trim(), stringService.getWord(text, caretPosition.from));
}
updateView();
}, 0);
}
// Function to fetch file contents
// Resolve to ArrayBuffer or reject to ErrorEvent.
function fetchFileListContents(fileList: FileList): Promise