compose_area.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
  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. import {ComposeArea} from '@threema/compose-area';
  18. import * as twemoji from 'twemoji';
  19. import {hasValue, isActionTrigger, logAdapter, replaceWhitespace} from '../helpers';
  20. import {emojify, emojifyNew, shortnameToUnicode} from '../helpers/emoji';
  21. import {BrowserService} from '../services/browser';
  22. import {ReceiverService} from '../services/receiver';
  23. import {StringService} from '../services/string';
  24. import {TimeoutService} from '../services/timeout';
  25. import {isElementNode, isEmojiInfo, isTextNode} from '../typeguards';
  26. /**
  27. * The compose area where messages are written.
  28. */
  29. export default [
  30. 'BrowserService',
  31. 'StringService',
  32. 'TimeoutService',
  33. 'ReceiverService',
  34. '$timeout',
  35. '$translate',
  36. '$mdDialog',
  37. '$filter',
  38. '$log',
  39. '$rootScope',
  40. 'CONFIG',
  41. function(browserService: BrowserService,
  42. stringService: StringService,
  43. timeoutService: TimeoutService,
  44. receiverService: ReceiverService,
  45. $timeout: ng.ITimeoutService,
  46. $translate: ng.translate.ITranslateService,
  47. $mdDialog: ng.material.IDialogService,
  48. $filter: ng.IFilterService,
  49. $log: ng.ILogService,
  50. $rootScope: ng.IRootScopeService,
  51. CONFIG: threema.Config) {
  52. return {
  53. restrict: 'EA',
  54. scope: {
  55. // Callback to get a reference to the initialized ComposeArea instance.
  56. onInit: '=',
  57. // Callback to submit text or file data
  58. submit: '=',
  59. // Callbacks to update typing information
  60. startTyping: '=',
  61. stopTyping: '=',
  62. onTyping: '=',
  63. onKeyDown: '=',
  64. // Reference to initial text and draft
  65. initialData: '=',
  66. // Callback that is called when uploading files
  67. onUploading: '=',
  68. maxTextLength: '=',
  69. receiver: '<receiver',
  70. },
  71. link: function(scope: any, element) {
  72. // Logging
  73. const logTag = '[Directives.ComposeArea]';
  74. // Constants
  75. const TRIGGER_ENABLED_CSS_CLASS = 'is-enabled';
  76. const TRIGGER_ACTIVE_CSS_CLASS = 'is-active';
  77. // Elements
  78. const wrapper: any = element;
  79. const composeDiv: any = angular.element(element[0].querySelector('div.compose'));
  80. const emojiTrigger: any = angular.element(element[0].querySelector('i.emoji-trigger'));
  81. const emojiKeyboard: any = angular.element(element[0].querySelector('.emoji-keyboard'));
  82. const sendTrigger: any = angular.element(element[0].querySelector('i.send-trigger'));
  83. const fileTrigger: any = angular.element(element[0].querySelector('i.file-trigger'));
  84. const fileInput: any = angular.element(element[0].querySelector('input.file-input'));
  85. // Initialize compose area lib
  86. const composeArea = ComposeArea.bind_to(composeDiv[0], CONFIG.DEBUG ? 'debug' : 'warn');
  87. if (scope.onInit) {
  88. scope.onInit(composeArea);
  89. }
  90. // Set initial text
  91. if (scope.initialData.initialText) {
  92. composeDiv[0].innerText = scope.initialData.initialText;
  93. scope.initialData.initialText = '';
  94. } else if (scope.initialData.draft !== undefined) {
  95. composeDiv[0].innerText = scope.initialData.draft;
  96. }
  97. let chatBlocked = false;
  98. // Function to update blocking state
  99. function setChatBlocked(blocked: boolean) {
  100. chatBlocked = blocked;
  101. $log.debug(logTag, 'Receiver blocked:', blocked);
  102. if (blocked) {
  103. sendTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
  104. emojiTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
  105. fileTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
  106. composeDiv.attr('contenteditable', false);
  107. if (emojiKeyboard.hasClass('active')) {
  108. hideEmojiPicker();
  109. }
  110. } else {
  111. if (composeAreaIsEmpty()) {
  112. sendTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
  113. } else {
  114. sendTrigger.addClass(TRIGGER_ENABLED_CSS_CLASS);
  115. }
  116. emojiTrigger.addClass(TRIGGER_ENABLED_CSS_CLASS);
  117. fileTrigger.addClass(TRIGGER_ENABLED_CSS_CLASS);
  118. composeDiv.attr('contenteditable', true);
  119. }
  120. }
  121. // Initialize blocking state
  122. setChatBlocked(receiverService.isBlocked(scope.receiver));
  123. // Watch `isBlocked` flag for changes
  124. scope.$watch(
  125. (_scope) => receiverService.isBlocked(_scope.receiver),
  126. (isBlocked: boolean, wasBlocked: boolean) => {
  127. if (isBlocked !== wasBlocked) {
  128. setChatBlocked(isBlocked);
  129. }
  130. },
  131. );
  132. /**
  133. * Stop propagation of click events and hold htmlElement of the emojipicker
  134. */
  135. const EmojiPickerContainer = (function() {
  136. let instance;
  137. function click(e) {
  138. e.stopPropagation();
  139. }
  140. return {
  141. get: function() {
  142. if (instance === undefined) {
  143. instance = {
  144. htmlElement: wrapper[0].querySelector('div.twemoji-picker'),
  145. };
  146. // append stop propagation
  147. angular.element(instance.htmlElement).on('click', click);
  148. }
  149. return instance;
  150. },
  151. destroy: function() {
  152. if (instance !== undefined) {
  153. // remove stop propagation
  154. angular.element(instance.htmlElement).off('click', click);
  155. instance = undefined;
  156. }
  157. },
  158. };
  159. })();
  160. // Typing events
  161. let stopTypingTimer: ng.IPromise<void> = null;
  162. function stopTyping() {
  163. // We can only stop typing of the timer is set (meaning
  164. // that we started typing earlier)
  165. if (stopTypingTimer !== null) {
  166. // Cancel timer
  167. timeoutService.cancel(stopTypingTimer);
  168. stopTypingTimer = null;
  169. // Send stop typing message
  170. scope.stopTyping();
  171. }
  172. }
  173. function startTyping() {
  174. if (stopTypingTimer === null) {
  175. // If the timer wasn't set previously, we just
  176. // started typing!
  177. scope.startTyping();
  178. } else {
  179. // Cancel timer, we'll re-create it
  180. timeoutService.cancel(stopTypingTimer);
  181. }
  182. // Define a timeout to send the stopTyping event
  183. stopTypingTimer = timeoutService.register(stopTyping, 10000, true, 'stopTyping');
  184. }
  185. // Determine whether field is empty
  186. function composeAreaIsEmpty() {
  187. return composeArea.get_text().length === 0;
  188. }
  189. // Submit the text from the compose area.
  190. //
  191. // Emoji images are converted to their alt text in this process.
  192. function submitText(): Promise<any> {
  193. const text = composeArea.get_text().replace(/\r/g, '');
  194. return new Promise((resolve, reject) => {
  195. const submitTexts = (strings: string[]) => {
  196. const messages: threema.TextMessageData[] = [];
  197. for (const piece of strings) {
  198. messages.push({
  199. text: piece,
  200. });
  201. }
  202. scope.submit('text', messages)
  203. .then(resolve)
  204. .catch(reject);
  205. };
  206. if (text.length > scope.maxTextLength) {
  207. const pieces: string[] = stringService.byteChunk(text, scope.maxTextLength, 50);
  208. const confirm = $mdDialog.confirm()
  209. .title($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_SUBJECT'))
  210. .textContent($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_BODY', {
  211. max: scope.maxTextLength,
  212. count: pieces.length,
  213. }))
  214. .ok($translate.instant('common.YES'))
  215. .cancel($translate.instant('common.NO'));
  216. $mdDialog.show(confirm).then(function() {
  217. submitTexts(pieces);
  218. }, () => {
  219. reject();
  220. });
  221. } else {
  222. submitTexts([text]);
  223. }
  224. });
  225. }
  226. function sendText(): boolean {
  227. if (!composeAreaIsEmpty()) {
  228. submitText().then(() => {
  229. // Clear compose div
  230. composeArea.clear();
  231. composeArea.focus();
  232. // Send stopTyping event
  233. stopTyping();
  234. // Clear draft
  235. scope.onTyping('');
  236. updateView();
  237. }).catch(() => {
  238. // do nothing
  239. $log.warn(logTag, 'Failed to submit text');
  240. });
  241. return true;
  242. }
  243. return false;
  244. }
  245. // Handle typing events
  246. let isComposing = false;
  247. function onCompositionStart(ev: KeyboardEvent): void {
  248. isComposing = true;
  249. }
  250. function onCompositionEnd(ev: KeyboardEvent): void {
  251. isComposing = false;
  252. }
  253. function onKeyDown(ev: KeyboardEvent): void {
  254. // If enter is pressed, prevent default event from being dispatched
  255. if (!ev.shiftKey && ev.key === 'Enter') {
  256. ev.preventDefault();
  257. }
  258. // If the keydown is handled and aborted outside
  259. if (scope.onKeyDown && scope.onKeyDown(ev) !== true) {
  260. ev.preventDefault();
  261. return;
  262. }
  263. // If the enter key is part of a composition (e.g. when
  264. // entering text with an IME), don't submit the text.
  265. // See https://github.com/threema-ch/threema-web/issues/777
  266. if ((ev as any).isComposing || isComposing) {
  267. return;
  268. }
  269. // If a : is pressed, possibly insert emoji
  270. if (ev.key === ':') {
  271. const modified = onEmojiShortcodeKeyPressed(ev, ':', false);
  272. if (modified) {
  273. ev.preventDefault();
  274. return;
  275. }
  276. }
  277. // At link time, the element is not yet evaluated.
  278. // Therefore add following code to end of event loop.
  279. $timeout(() => {
  280. // Shift + enter to insert a newline. Enter to send.
  281. if (!ev.shiftKey && ev.key === 'Enter') {
  282. if (sendText()) {
  283. return;
  284. }
  285. }
  286. updateView();
  287. }, 0);
  288. }
  289. function onKeyUp(ev: KeyboardEvent): void {
  290. // If the compose area contains only a single <br>, make it fully empty.
  291. // See also: https://stackoverflow.com/q/14638887/284318
  292. const text = composeArea.get_text(true);
  293. if (text === '\n') {
  294. composeDiv[0].innerText = '';
  295. }
  296. // Update typing information
  297. if (text.trim().length === 0) {
  298. stopTyping();
  299. scope.onTyping('');
  300. } else {
  301. startTyping();
  302. scope.onTyping(text.trim());
  303. }
  304. updateView();
  305. }
  306. // Function to fetch file contents
  307. // Resolve to ArrayBuffer or reject to ErrorEvent.
  308. function fetchFileListContents(fileList: FileList): Promise<Map<File, ArrayBuffer>> {
  309. return new Promise((resolve) => {
  310. const buffers = new Map<File, ArrayBuffer>();
  311. const fileCounter = fileList.length;
  312. const next = (file: File, res: ArrayBuffer | null, error: any) => {
  313. buffers.set(file, res);
  314. if (buffers.size >= fileCounter) {
  315. resolve(buffers);
  316. }
  317. };
  318. for (let n = 0; n < fileCounter; n++) {
  319. const reader = new FileReader();
  320. const file = fileList.item(n);
  321. reader.onload = function(ev: ProgressEvent) {
  322. next(file, this.result as ArrayBuffer, ev);
  323. };
  324. reader.onerror = function(ev: ProgressEvent) {
  325. // set a null object
  326. next(file, null, ev);
  327. };
  328. reader.onprogress = function(ev: ProgressEvent) {
  329. if (ev.lengthComputable) {
  330. const progress = ((ev.loaded / ev.total) * 100);
  331. scope.onUploading(true, progress, 100 / fileCounter * n);
  332. }
  333. };
  334. reader.readAsArrayBuffer(file);
  335. }
  336. });
  337. }
  338. function uploadFiles(fileList: FileList): void {
  339. scope.onUploading(true, 0, 0);
  340. fetchFileListContents(fileList).then((data: Map<File, ArrayBuffer>) => {
  341. const fileMessages = [];
  342. data.forEach((buffer, file) => {
  343. const fileMessageData: threema.FileMessageData = {
  344. name: file.name,
  345. fileType: file.type,
  346. size: file.size,
  347. data: buffer,
  348. };
  349. // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1240259
  350. if (browserService.getBrowser().isFirefox(false)) {
  351. if (fileMessageData.name.endsWith('.ogg') && fileMessageData.fileType === 'video/ogg') {
  352. fileMessageData.fileType = 'audio/ogg';
  353. }
  354. }
  355. fileMessages.push(fileMessageData);
  356. });
  357. scope
  358. .submit('file', fileMessages)
  359. .catch((msg) => $log.error('Could not send file:', msg));
  360. scope.onUploading(false);
  361. }).catch((ev: ErrorEvent) => {
  362. $log.error(logTag, 'Could not load file:', ev.message);
  363. });
  364. }
  365. // Handle pasting
  366. function onPaste(ev: ClipboardEvent) {
  367. ev.preventDefault();
  368. // If no clipboard data is available, do nothing.
  369. if (!ev.clipboardData) {
  370. return;
  371. }
  372. // Extract pasted items
  373. const items: DataTransferItemList = ev.clipboardData.items;
  374. if (!items) {
  375. return;
  376. }
  377. // Find available types
  378. let fileIdx: number | null = null;
  379. let textIdx: number | null = null;
  380. for (let i = 0; i < items.length; i++) {
  381. if (items[i].type.indexOf('image/') !== -1 || items[i].type === 'application/x-moz-file') {
  382. fileIdx = i;
  383. } else if (items[i].type === 'text/plain') {
  384. textIdx = i;
  385. }
  386. }
  387. // Handle pasting of files
  388. if (fileIdx !== null) {
  389. // Read clipboard data as blob
  390. const blob: Blob = items[fileIdx].getAsFile();
  391. // Convert blob to arraybuffer
  392. const reader = new FileReader();
  393. reader.onload = function(progressEvent: ProgressEvent) {
  394. const buffer: ArrayBuffer = this.result as ArrayBuffer;
  395. // Construct file name
  396. let fileName: string;
  397. if ((blob as any).name) {
  398. fileName = (blob as any).name;
  399. } else if (blob.type && blob.type.match(/^[^;]*\//) !== null) {
  400. const fileExt = blob.type.split(';')[0].split('/')[1];
  401. fileName = 'clipboard.' + fileExt;
  402. } else {
  403. $log.warn(logTag, 'Pasted file has an invalid MIME type: "' + blob.type + '"');
  404. return;
  405. }
  406. // Send data as file
  407. const fileMessageData: threema.FileMessageData = {
  408. name: fileName,
  409. fileType: blob.type,
  410. size: blob.size,
  411. data: buffer,
  412. };
  413. scope
  414. .submit('file', [fileMessageData])
  415. .catch((msg) => $log.error('Could not send file:', msg));
  416. };
  417. reader.readAsArrayBuffer(blob);
  418. // Handle pasting of text
  419. } else if (textIdx !== null) {
  420. const text = ev.clipboardData.getData('text/plain');
  421. if (text) {
  422. const tokens = emojifyNew(text);
  423. for (const token of tokens) {
  424. if (isEmojiInfo(token)) {
  425. insertEmoji(token);
  426. } else {
  427. composeArea.insert_text(token);
  428. }
  429. }
  430. updateView();
  431. }
  432. }
  433. }
  434. // Translate placeholder texts
  435. let regularPlaceholder = '';
  436. let dragoverPlaceholder = '';
  437. $translate('messenger.COMPOSE_MESSAGE').then((translated) => regularPlaceholder = translated);
  438. $translate('messenger.COMPOSE_MESSAGE_DRAGOVER').then((translated) => dragoverPlaceholder = translated);
  439. // Show emoji picker element
  440. function showEmojiPicker() {
  441. const emojiPicker: HTMLElement = EmojiPickerContainer.get().htmlElement;
  442. // Show
  443. emojiKeyboard.addClass('active');
  444. emojiTrigger.addClass(TRIGGER_ACTIVE_CSS_CLASS);
  445. // Find some selectors
  446. const allEmoji: any = angular.element(emojiPicker.querySelectorAll('.content .em'));
  447. const allEmojiTabs: any = angular.element(emojiPicker.querySelectorAll('.tab label img'));
  448. // Add event handlers
  449. allEmoji.on('click', onEmojiChosen);
  450. allEmojiTabs.on('keydown', onEmojiTabSelected);
  451. // Focus compose area again
  452. $timeout(() => composeArea.focus());
  453. }
  454. // Hide emoji picker element
  455. function hideEmojiPicker() {
  456. const emojiPicker: HTMLElement = EmojiPickerContainer.get().htmlElement;
  457. // Hide
  458. emojiKeyboard.removeClass('active');
  459. emojiTrigger.removeClass(TRIGGER_ACTIVE_CSS_CLASS);
  460. // Find some selectors
  461. const allEmoji: any = angular.element(emojiPicker.querySelectorAll('.content .em'));
  462. const allEmojiTabs: any = angular.element(emojiPicker.querySelectorAll('.tab label img'));
  463. // Remove event handlers
  464. allEmoji.off('click', onEmojiChosen);
  465. allEmojiTabs.off('keydown', onEmojiTabSelected);
  466. EmojiPickerContainer.destroy();
  467. }
  468. // Emoji trigger is clicked
  469. function onEmojiTrigger(ev: UIEvent): void {
  470. ev.stopPropagation();
  471. if (chatBlocked) {
  472. hideEmojiPicker();
  473. return;
  474. }
  475. // Toggle visibility of picker
  476. if (emojiKeyboard.hasClass('active')) {
  477. hideEmojiPicker();
  478. } else {
  479. showEmojiPicker();
  480. }
  481. }
  482. // Emoji is chosen
  483. function onEmojiChosen(ev: MouseEvent): void {
  484. ev.stopPropagation();
  485. insertSingleEmojiString(this.textContent);
  486. }
  487. // Emoji tab is selected
  488. function onEmojiTabSelected(ev: KeyboardEvent): void {
  489. if (ev.key === ' ' || ev.key === 'Enter') {
  490. // Warning: Hacky
  491. this.parentElement.previousElementSibling.checked = true;
  492. }
  493. }
  494. // Insert a single emoji, passed in as string
  495. function insertSingleEmojiString(emojiString: string): void {
  496. const tokens = emojifyNew(emojiString);
  497. if (tokens.length !== 1) {
  498. throw new Error(`Emoji parsing failed: Expected 1 element, found ${tokens.length}`);
  499. }
  500. const emoji = tokens[0];
  501. if (!isEmojiInfo(emoji)) {
  502. throw new Error(`Emoji parsing failed: Returned text, not emoji info`);
  503. }
  504. insertEmoji(emoji);
  505. }
  506. // Insert a single emoji
  507. function insertEmoji(emoji: threema.EmojiInfo): void {
  508. const img: HTMLElement = composeArea.insert_image(emoji.imgPath, emoji.emojiString, 'em');
  509. img.setAttribute('data-c', emoji.codepoint);
  510. img.draggable = false;
  511. img.ondragstart = () => false;
  512. }
  513. // The emoji shortcode trigger (:) was inserted. Return a boolean
  514. // indicating whether the compose area contents were modified.
  515. //
  516. // The `alreadyProcessed` indicates whether the key has already
  517. // been processed in the DOM (onKeyUp) or not (onKeyDown).
  518. function onEmojiShortcodeKeyPressed(ev, trigger: string, alreadyProcessed: boolean): boolean {
  519. const word = composeArea.get_word_at_caret();
  520. if (word === undefined) {
  521. return false;
  522. }
  523. let before = word.before();
  524. const after = word.after();
  525. if (!alreadyProcessed) {
  526. before += trigger;
  527. }
  528. if (after.length === 0 && before.length > 2) {
  529. if (before.startsWith(trigger) && before.endsWith(trigger)) {
  530. const trimmed = before.substr(1, before.length - 2);
  531. const unicodeEmoji = shortnameToUnicode(trimmed);
  532. if (unicodeEmoji !== null) {
  533. composeArea.select_word_at_caret();
  534. composeArea.store_selection_range();
  535. insertSingleEmojiString(unicodeEmoji);
  536. return true;
  537. }
  538. }
  539. }
  540. return false;
  541. }
  542. // TODO
  543. // function insertMention(mentionString, posFrom?: number, posTo?: number): void {
  544. // const mentionElement = ($filter('mentionify') as any)(mentionString) as string;
  545. // insertHTMLElement(mentionString, mentionElement, posFrom, posTo);
  546. // }
  547. // File trigger is clicked
  548. function onFileTrigger(ev: UIEvent): void {
  549. ev.preventDefault();
  550. ev.stopPropagation();
  551. if (chatBlocked) {
  552. return;
  553. }
  554. const input = element[0].querySelector('.file-input') as HTMLInputElement;
  555. input.click();
  556. }
  557. function onSendTrigger(ev: UIEvent): boolean {
  558. ev.preventDefault();
  559. ev.stopPropagation();
  560. if (chatBlocked) {
  561. return;
  562. }
  563. return sendText();
  564. }
  565. // File(s) are uploaded via input field
  566. function onFileSelected() {
  567. uploadFiles(this.files);
  568. fileInput.val('');
  569. }
  570. // Set all correct styles
  571. function updateView() {
  572. if (composeAreaIsEmpty()) {
  573. sendTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
  574. } else {
  575. sendTrigger.addClass(TRIGGER_ENABLED_CSS_CLASS);
  576. }
  577. }
  578. // Handle typing events
  579. composeDiv.on('compositionstart', onCompositionStart);
  580. composeDiv.on('compositionend', onCompositionEnd);
  581. composeDiv.on('keydown', onKeyDown);
  582. composeDiv.on('keyup', onKeyUp);
  583. // Handle selection change
  584. document.addEventListener('selectionchange', () => {
  585. composeArea.store_selection_range();
  586. });
  587. // Handle paste event
  588. composeDiv.on('paste', onPaste);
  589. // Handle click on emoji trigger
  590. emojiTrigger.on('click', onEmojiTrigger);
  591. emojiTrigger.on('keypress', (ev: KeyboardEvent) => {
  592. if (isActionTrigger(ev)) {
  593. onEmojiTrigger(ev);
  594. }
  595. });
  596. // Handle click on file trigger
  597. fileTrigger.on('click', onFileTrigger);
  598. fileTrigger.on('keypress', (ev: KeyboardEvent) => {
  599. if (isActionTrigger(ev)) {
  600. onFileTrigger(ev);
  601. }
  602. });
  603. // Handle file uploads
  604. fileInput.on('change', onFileSelected);
  605. // Handle click on send trigger
  606. sendTrigger.on('click', onSendTrigger);
  607. sendTrigger.on('keypress', (ev: KeyboardEvent) => {
  608. if (isActionTrigger(ev)) {
  609. onSendTrigger(ev);
  610. }
  611. });
  612. updateView();
  613. // Listen to broadcasts
  614. const unsubscribeListeners = [];
  615. unsubscribeListeners.push($rootScope.$on('onQuoted', (event: ng.IAngularEvent, args: any) => {
  616. composeArea.focus();
  617. }));
  618. // When switching chat, send stopTyping message
  619. scope.$on('$destroy', () => {
  620. unsubscribeListeners.forEach((u) => {
  621. // Unsubscribe
  622. u();
  623. });
  624. stopTyping();
  625. });
  626. },
  627. // tslint:disable:max-line-length
  628. template: `
  629. <div>
  630. <div>
  631. <i class="md-primary emoji-trigger trigger is-enabled material-icons" role="button" aria-label="emoji" tabindex="0">tag_faces</i>
  632. </div>
  633. <div>
  634. <div
  635. class="compose"
  636. id="composeDiv"
  637. contenteditable
  638. autofocus
  639. translate
  640. translate-attr-data-placeholder="messenger.COMPOSE_MESSAGE"
  641. translate-attr-aria-label="messenger.COMPOSE_MESSAGE"
  642. tabindex="0"
  643. ></div>
  644. </div>
  645. <div>
  646. <i class="md-primary send-trigger trigger material-icons" role="button" aria-label="send" tabindex="0">send</i>
  647. <i class="md-primary file-trigger trigger is-enabled material-icons" role="button" aria-label="attach file" tabindex="0">attach_file</i>
  648. <input class="file-input" type="file" style="visibility: hidden" multiple>
  649. </div>
  650. </div>
  651. <div class="emoji-keyboard">
  652. <ng-include src="'partials/emoji-picker.html'" include-replace></ng-include>
  653. </div>
  654. `,
  655. };
  656. },
  657. ];