compose_area.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. /**
  2. * This file is part of Threema Web.
  3. *
  4. * Threema Web is free software: you can redistribute it and/or modify it
  5. * under the terms of the GNU Affero General Public License as published by
  6. * the Free Software Foundation, either version 3 of the License, or (at
  7. * your option) any later version.
  8. *
  9. * This program is distributed in the hope that it will be useful, but
  10. * WITHOUT ANY WARRANTY; without even the implied warranty of
  11. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
  12. * General Public License for more details.
  13. *
  14. * You should have received a copy of the GNU Affero General Public License
  15. * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  16. */
  17. /**
  18. * The compose area where messages are written.
  19. */
  20. export default [
  21. 'BrowserService',
  22. 'StringService',
  23. '$window',
  24. '$timeout',
  25. '$translate',
  26. '$mdDialog',
  27. '$filter',
  28. '$log',
  29. function(browserService: threema.BrowserService,
  30. stringService: threema.StringService,
  31. $window, $timeout: ng.ITimeoutService,
  32. $translate: ng.translate.ITranslateService,
  33. $mdDialog: ng.material.IDialogService,
  34. $filter: ng.IFilterService,
  35. $log: ng.ILogService) {
  36. return {
  37. restrict: 'EA',
  38. scope: {
  39. // Callback to submit text or file data
  40. submit: '=',
  41. // Callbacks to update typing information
  42. startTyping: '=',
  43. stopTyping: '=',
  44. onTyping: '=',
  45. // Reference to drafts variable
  46. draft: '=',
  47. // Callback that is called when uploading files
  48. onUploading: '=',
  49. maxTextLength: '=',
  50. },
  51. link(scope: any, element) {
  52. // Logging
  53. const logTag = '[Directives.ComposeArea]';
  54. // Constants
  55. const TRIGGER_ENABLED_CSS_CLASS = 'is-enabled';
  56. const TRIGGER_ACTIVE_CSS_CLASS = 'is-active';
  57. // Elements
  58. const composeArea: any = element;
  59. const composeDiv: any = angular.element(element[0].querySelector('div.compose'));
  60. const emojiTrigger: any = angular.element(element[0].querySelector('i.emoji-trigger'));
  61. const emojiKeyboard: any = angular.element(element[0].querySelector('.emoji-keyboard'));
  62. const sendTrigger: any = angular.element(element[0].querySelector('i.send-trigger'));
  63. const fileTrigger: any = angular.element(element[0].querySelector('i.file-trigger'));
  64. const fileInput: any = angular.element(element[0].querySelector('input.file-input'));
  65. // Restore drafts
  66. if (scope.draft !== undefined) {
  67. composeDiv[0].innerText = scope.draft;
  68. }
  69. let caretPosition: {from?: number, to?: number} = null;
  70. /**
  71. * Stop propagation of click events and hold htmlElement of the emojipicker
  72. */
  73. let EmoijPickerContainer = (function(){
  74. let instance;
  75. function click(e) {
  76. e.stopPropagation();
  77. }
  78. return {
  79. get: function() {
  80. if (instance === undefined) {
  81. instance = {
  82. htmlElement: composeArea[0].querySelector('div.emojione-picker'),
  83. };
  84. // append stop propagation
  85. angular.element(instance.htmlElement).on('click', click);
  86. }
  87. return instance;
  88. },
  89. destroy: function() {
  90. if (instance !== undefined) {
  91. // remove stop propagation
  92. angular.element(instance.htmlElement).off('click', click);
  93. instance = undefined;
  94. }
  95. },
  96. };
  97. })();
  98. // Submit the text from the compose area.
  99. //
  100. // Emoji images are converted to their alt text in this process.
  101. function submitText(): Promise<any> {
  102. let text = '';
  103. // tslint:disable-next-line: prefer-for-of (see #98)
  104. for (let i = 0; i < composeDiv[0].childNodes.length; i++) {
  105. const node = composeDiv[0].childNodes[i];
  106. switch (node.nodeType) {
  107. case Node.TEXT_NODE:
  108. text += node.nodeValue;
  109. break;
  110. case Node.ELEMENT_NODE:
  111. const tag = node.tagName.toLowerCase();
  112. if (tag === 'img') {
  113. text += node.alt;
  114. break;
  115. } else if (tag === 'br') {
  116. text += '\n';
  117. break;
  118. }
  119. default:
  120. $log.warn(logTag, 'Unhandled node:', node);
  121. }
  122. }
  123. return new Promise((resolve, reject) => {
  124. let submitTexts = (strings: string[]) => {
  125. let messages: threema.TextMessageData[] = [];
  126. for (let piece of strings) {
  127. messages.push({
  128. text: piece,
  129. });
  130. }
  131. scope.submit('text', messages)
  132. .then(resolve)
  133. .catch(reject);
  134. };
  135. let fullText = text.trim();
  136. if (fullText.length > scope.maxTextLength) {
  137. let pieces: string[] = stringService.byteChunk(fullText, scope.maxTextLength, 50);
  138. let confirm = $mdDialog.confirm()
  139. .title($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_SUBJECT'))
  140. .textContent($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_BODY', {
  141. max: scope.maxTextLength,
  142. count: pieces.length,
  143. }))
  144. .ok($translate.instant('common.YES'))
  145. .cancel($translate.instant('common.NO'));
  146. $mdDialog.show(confirm).then(function () {
  147. submitTexts(pieces);
  148. }, () => {
  149. reject();
  150. });
  151. } else {
  152. submitTexts([fullText]);
  153. }
  154. });
  155. }
  156. function sendText(): boolean {
  157. if (composeDiv[0].innerHTML.length > 0) {
  158. submitText().then(() => {
  159. // Clear compose div
  160. composeDiv[0].innerText = '';
  161. // Send stopTyping event
  162. scope.stopTyping();
  163. scope.onTyping('');
  164. updateView();
  165. }).catch(() => {
  166. // do nothing
  167. this.$log.warn('failed to submit text');
  168. });
  169. return true;
  170. }
  171. return false;
  172. }
  173. // Handle typing events
  174. function onTyping(ev: KeyboardEvent): void {
  175. // If enter is pressed, prevent default event from being dispatched
  176. if (!ev.shiftKey && ev.which === 13) {
  177. ev.preventDefault();
  178. }
  179. // At link time, the element is not yet evaluated.
  180. // Therefore add following code to end of event loop.
  181. $timeout(() => {
  182. // Shift + enter to insert a newline. Enter to send.
  183. if (!ev.shiftKey && ev.which === 13) {
  184. if (sendText()) {
  185. return;
  186. }
  187. }
  188. // Update typing information
  189. if (composeDiv[0].innerText.length === 0) {
  190. scope.stopTyping();
  191. } else {
  192. scope.startTyping(composeDiv[0].innerText);
  193. }
  194. // Notify about typing event
  195. scope.onTyping(composeDiv[0].innerText);
  196. updateView();
  197. }, 0);
  198. }
  199. // Function to fetch file contents
  200. // Resolve to ArrayBuffer or reject to ErrorEvent.
  201. function fetchFileListContents(fileList: FileList): Promise<Map<File, ArrayBuffer>> {
  202. return new Promise((resolve) => {
  203. let buffers = new Map<File, ArrayBuffer>();
  204. let next = (file: File, res: ArrayBuffer | null, error: any) => {
  205. buffers.set(file, res);
  206. if (buffers.size >= fileList.length) {
  207. resolve(buffers);
  208. }
  209. };
  210. for (let n = 0; n < fileList.length; n++) {
  211. const reader = new FileReader();
  212. const file = fileList.item(n);
  213. reader.onload = (ev: Event) => {
  214. next(file, (ev.target as FileReader).result, ev);
  215. };
  216. reader.onerror = (ev: ErrorEvent) => {
  217. // set a null object
  218. next(file, null, ev);
  219. };
  220. reader.onprogress = function(data) {
  221. if (data.lengthComputable) {
  222. let progress = ((data.loaded / data.total) * 100);
  223. scope.onUploading(true, progress, 100 / fileList.length * n);
  224. }
  225. };
  226. reader.readAsArrayBuffer(file);
  227. }
  228. });
  229. }
  230. function uploadFiles(fileList: FileList): void {
  231. scope.onUploading(true, 0, 0);
  232. fetchFileListContents(fileList).then((data: Map<File, ArrayBuffer>) => {
  233. let fileMessages = [];
  234. data.forEach((buffer, file) => {
  235. const fileMessageData: threema.FileMessageData = {
  236. name: file.name,
  237. fileType: file.type,
  238. size: file.size,
  239. data: buffer,
  240. };
  241. fileMessages.push(fileMessageData);
  242. });
  243. scope.submit('file', fileMessages);
  244. scope.onUploading(false);
  245. }).catch((ev: ErrorEvent) => {
  246. $log.error(logTag, 'Could not load file:', ev.message);
  247. });
  248. }
  249. // Handle pasting
  250. function onPaste(ev: ClipboardEvent) {
  251. ev.preventDefault();
  252. // If no clipboard data is available, do nothing.
  253. if (!ev.clipboardData) {
  254. return;
  255. }
  256. // Extract pasted items
  257. const items: DataTransferItemList = ev.clipboardData.items;
  258. if (!items) {
  259. return;
  260. }
  261. // Find available types
  262. let fileIdx: number | null = null;
  263. let textIdx: number | null = null;
  264. for (let i = 0; i < items.length; i++) {
  265. if (items[i].type.indexOf('image/') !== -1 || items[i].type === 'application/x-moz-file') {
  266. fileIdx = i;
  267. } else if (items[i].type === 'text/plain') {
  268. textIdx = i;
  269. }
  270. }
  271. // Handle pasting of files
  272. if (fileIdx !== null) {
  273. // Read clipboard data as blob
  274. const blob: Blob = items[fileIdx].getAsFile();
  275. // Convert blob to arraybuffer
  276. const reader = new FileReader();
  277. reader.onload = function() {
  278. let buffer: ArrayBuffer = this.result;
  279. // Construct file name
  280. let fileName: string;
  281. if ((blob as any).name) {
  282. fileName = (blob as any).name;
  283. } else if (blob.type && blob.type.match(/^[^;]*\//) !== null) {
  284. const fileExt = blob.type.split(';')[0].split('/')[1];
  285. fileName = 'clipboard.' + fileExt;
  286. } else {
  287. $log.warn(logTag, 'Pasted file has an invalid MIME type: "' + blob.type + '"');
  288. return;
  289. }
  290. // Send data as file
  291. const fileMessageData: threema.FileMessageData = {
  292. name: fileName,
  293. fileType: blob.type,
  294. size: blob.size,
  295. data: buffer,
  296. };
  297. scope.submit('file', [fileMessageData]);
  298. };
  299. reader.readAsArrayBuffer(blob);
  300. // Handle pasting of text
  301. } else if (textIdx !== null) {
  302. const text = ev.clipboardData.getData('text/plain');
  303. // Look up some filter functions
  304. const escapeHtml = $filter('escapeHtml') as (a: string) => string;
  305. const emojify = $filter('emojify') as (a: string, b?: boolean) => string;
  306. const nlToBr = $filter('nlToBr') as (a: string, b?: boolean) => string;
  307. // Escape HTML markup
  308. const escaped = escapeHtml(text);
  309. // Apply filters (emojify, convert newline, etc)
  310. const formatted = nlToBr(emojify(escaped, true), true);
  311. // Insert resulting HTML
  312. document.execCommand('insertHTML', false, formatted);
  313. cleanupComposeContent();
  314. updateView();
  315. }
  316. }
  317. // Translate placeholder texts
  318. let regularPlaceholder = '';
  319. let dragoverPlaceholder = '';
  320. $translate('messenger.COMPOSE_MESSAGE').then((translated) => regularPlaceholder = translated);
  321. $translate('messenger.COMPOSE_MESSAGE_DRAGOVER').then((translated) => dragoverPlaceholder = translated);
  322. // Show emoji picker element
  323. function showEmojiPicker() {
  324. const emojiPicker: HTMLElement = EmoijPickerContainer.get().htmlElement;
  325. // Show
  326. emojiKeyboard.addClass('active');
  327. emojiTrigger.addClass(TRIGGER_ACTIVE_CSS_CLASS);
  328. // Find all emoji
  329. const allEmoji: any = angular.element(emojiPicker.querySelectorAll('.content .e1'));
  330. // Add event handlers
  331. allEmoji.on('click', onEmojiChosen);
  332. // set focus to fix chat scroll bug
  333. $timeout(() => {
  334. composeDiv[0].focus();
  335. });
  336. }
  337. // Hide emoji picker element
  338. function hideEmojiPicker() {
  339. // Hide
  340. emojiKeyboard.removeClass('active');
  341. emojiTrigger.removeClass(TRIGGER_ACTIVE_CSS_CLASS);
  342. // Find all emoji
  343. const allEmoji: any = angular.element(
  344. EmoijPickerContainer.get().htmlElement.querySelectorAll('.content .e1'));
  345. // Remove event handlers
  346. allEmoji.off('click', onEmojiChosen);
  347. EmoijPickerContainer.destroy();
  348. }
  349. // Emoji trigger is clicked
  350. function onEmojiTrigger(ev: MouseEvent): void {
  351. ev.stopPropagation();
  352. // Toggle visibility of picker
  353. if (emojiKeyboard.hasClass('active')) {
  354. hideEmojiPicker();
  355. } else {
  356. showEmojiPicker();
  357. }
  358. }
  359. // Emoji is chosen
  360. function onEmojiChosen(ev: MouseEvent): void {
  361. ev.stopPropagation();
  362. const emoji = this.textContent; // Unicode character
  363. const formatted = ($filter('emojify') as any)(emoji, true);
  364. // Firefox inserts a <br> after editing content editable fields.
  365. // Remove the last <br> to fix this.
  366. let currentHTML = '';
  367. for (let i = 0; i < composeDiv[0].childNodes.length; i++) {
  368. const node = composeDiv[0].childNodes[i];
  369. if (node.nodeType === node.TEXT_NODE) {
  370. currentHTML += node.textContent;
  371. } else if (node.nodeType === node.ELEMENT_NODE) {
  372. let tag = node.tagName.toLowerCase();
  373. if (tag === 'img') {
  374. currentHTML += getOuterHtml(node);
  375. } else if (tag === 'br') {
  376. // not not append br if the br is the LAST element
  377. if (i < composeDiv[0].childNodes.length - 1) {
  378. currentHTML += getOuterHtml(node);
  379. }
  380. }
  381. }
  382. }
  383. if (caretPosition !== null) {
  384. currentHTML = currentHTML.substr(0, caretPosition.from)
  385. + formatted
  386. + currentHTML.substr(caretPosition.to);
  387. // change caret position
  388. caretPosition.from += formatted.length - 1;
  389. caretPosition.to = caretPosition.from;
  390. } else {
  391. // insert at the end of line
  392. currentHTML += formatted;
  393. caretPosition = {
  394. from: currentHTML.length,
  395. to: currentHTML.length,
  396. };
  397. }
  398. composeDiv[0].innerHTML = currentHTML;
  399. cleanupComposeContent();
  400. setCaretPosition(caretPosition.from);
  401. updateView();
  402. }
  403. // File trigger is clicked
  404. function onFileTrigger(ev: MouseEvent): void {
  405. ev.preventDefault();
  406. ev.stopPropagation();
  407. const input = element[0].querySelector('.file-input') as HTMLInputElement;
  408. input.click();
  409. }
  410. function onSendTrigger(ev: MouseEvent): boolean {
  411. ev.preventDefault();
  412. ev.stopPropagation();
  413. return sendText();
  414. }
  415. // File(s) are uploaded via input field
  416. function onFileSelected() {
  417. uploadFiles(this.files);
  418. fileInput.val('');
  419. }
  420. // Disable content editable and dragging for contained images (emoji)
  421. function cleanupComposeContent() {
  422. for (let img of composeDiv[0].getElementsByTagName('img')) {
  423. img.ondragstart = () => false;
  424. }
  425. if (browserService.getBrowser().firefox) {
  426. // disable object resizing is the only way to disable resizing of
  427. // emoji (contenteditable must be true, otherwise the emoji can not
  428. // be removed with backspace (in FF))
  429. document.execCommand('enableObjectResizing', false, false);
  430. }
  431. }
  432. // Set all correct styles
  433. function updateView() {
  434. if (composeDiv[0].innerHTML.length === 0) {
  435. sendTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
  436. } else {
  437. sendTrigger.addClass(TRIGGER_ENABLED_CSS_CLASS);
  438. }
  439. }
  440. // return the outer html of a node element
  441. function getOuterHtml(node: Node): string {
  442. let pseudoElement = document.createElement('pseudo');
  443. pseudoElement.appendChild(node.cloneNode());
  444. return pseudoElement.innerHTML;
  445. }
  446. // return the html code position of the container element
  447. function getHTMLPosition(offset: number, container: Node) {
  448. let pos = null;
  449. if (composeDiv[0].contains(container)) {
  450. let selectedElement;
  451. if (container === composeDiv[0]) {
  452. if (offset === 0) {
  453. return 0;
  454. }
  455. selectedElement = composeDiv[0].childNodes[offset - 1];
  456. pos = 0;
  457. } else {
  458. selectedElement = container.previousSibling;
  459. pos = offset;
  460. }
  461. while (selectedElement !== null) {
  462. if (selectedElement.nodeType === Node.TEXT_NODE) {
  463. pos += selectedElement.textContent.length;
  464. } else {
  465. pos += getOuterHtml(selectedElement).length;
  466. }
  467. selectedElement = selectedElement.previousSibling;
  468. }
  469. }
  470. return pos;
  471. }
  472. // define position of caret
  473. function updateCaretPosition() {
  474. caretPosition = null;
  475. if (window.getSelection && composeDiv[0].innerHTML.length > 0) {
  476. const selection = window.getSelection();
  477. if (selection.rangeCount) {
  478. const range = selection.getRangeAt(0);
  479. let from = getHTMLPosition(range.startOffset, range.startContainer);
  480. if (from !== null && from >= 0) {
  481. caretPosition = {
  482. from: from,
  483. to: getHTMLPosition(range.endOffset, range.endContainer),
  484. };
  485. }
  486. }
  487. }
  488. }
  489. // set the correct cart position in the content editable div, position
  490. // is the position in the html content (not plain text)
  491. function setCaretPosition(pos: number) {
  492. let rangeAt = (node: Node, offset?: number) => {
  493. let range = document.createRange();
  494. range.collapse(false);
  495. if (offset !== undefined) {
  496. range.setStart(node, offset);
  497. } else {
  498. range.setStartAfter(node);
  499. }
  500. let sel = window.getSelection();
  501. sel.removeAllRanges();
  502. sel.addRange(range);
  503. };
  504. for (let i = 0; i < composeDiv[0].childNodes.length; i++) {
  505. const node = composeDiv[0].childNodes[i];
  506. let size;
  507. let offset;
  508. switch (node.nodeType) {
  509. case Node.TEXT_NODE:
  510. size = node.textContent.length;
  511. offset = pos;
  512. break;
  513. case Node.ELEMENT_NODE:
  514. size = getOuterHtml(node).length ;
  515. break;
  516. default:
  517. $log.warn(logTag, 'Unhandled node:', node);
  518. }
  519. if (pos < size) {
  520. // use this node
  521. rangeAt(node, offset);
  522. this.stop = true;
  523. } else if (i === composeDiv[0].childNodes.length - 1) {
  524. rangeAt(node);
  525. }
  526. pos -= size;
  527. }
  528. }
  529. // Handle typing events
  530. composeDiv.on('keydown', onTyping);
  531. composeDiv.on('keyup mouseup', updateCaretPosition);
  532. composeDiv.on('selectionchange', updateCaretPosition);
  533. // When switching chat, send stopTyping message
  534. scope.$on('$destroy', scope.stopTyping);
  535. // Handle paste event
  536. composeDiv.on('paste', onPaste);
  537. // Handle click on emoji trigger
  538. emojiTrigger.on('click', onEmojiTrigger);
  539. // Handle click on file trigger
  540. fileTrigger.on('click', onFileTrigger);
  541. // Handle file uploads
  542. fileInput.on('change', onFileSelected);
  543. // Handle click on send trigger
  544. sendTrigger.on('click', onSendTrigger);
  545. updateView();
  546. },
  547. // tslint:disable:max-line-length
  548. template: `
  549. <div>
  550. <div>
  551. <i class="md-primary emoji-trigger trigger is-enabled material-icons">tag_faces</i>
  552. </div>
  553. <div>
  554. <div class="compose" contenteditable translate translate-attr-data-placeholder="messenger.COMPOSE_MESSAGE" autofocus></div>
  555. </div>
  556. <div>
  557. <i class="md-primary send-trigger trigger material-icons">send</i>
  558. <i class="md-primary file-trigger trigger is-enabled material-icons">attach_file</i>
  559. <input class="file-input" type="file" style="visibility: hidden" multiple>
  560. </div>
  561. </div>
  562. <div class="emoji-keyboard">
  563. <ng-include src="'partials/emoji-picker.html'" include-replace></ng-include>
  564. </div>
  565. `,
  566. };
  567. },
  568. ];