messenger.ts 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224
  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 {ContactControllerModel} from '../controller_model/contact';
  18. import {supportsPassive, throttle} from '../helpers';
  19. import {ContactService} from '../services/contact';
  20. import {ControllerService} from '../services/controller';
  21. import {ControllerModelService} from '../services/controller_model';
  22. import {ExecuteService} from '../services/execute';
  23. import {FingerPrintService} from '../services/fingerprint';
  24. import {TrustedKeyStoreService} from '../services/keystore';
  25. import {MimeService} from '../services/mime';
  26. import {NotificationService} from '../services/notification';
  27. import {ReceiverService} from '../services/receiver';
  28. import {SettingsService} from '../services/settings';
  29. import {StateService} from '../services/state';
  30. import {WebClientService} from '../services/webclient';
  31. import {ControllerModelMode} from '../types/enums';
  32. class DialogController {
  33. public static $inject = ['$mdDialog'];
  34. public $mdDialog: ng.material.IDialogService;
  35. public activeElement: HTMLElement | null;
  36. constructor($mdDialog: ng.material.IDialogService) {
  37. this.$mdDialog = $mdDialog;
  38. this.activeElement = document.activeElement as HTMLElement;
  39. }
  40. public cancel(): void {
  41. this.$mdDialog.cancel();
  42. this.done();
  43. }
  44. protected hide(data: any): void {
  45. this.$mdDialog.hide(data);
  46. this.done();
  47. }
  48. private done(): void {
  49. if (this.resumeFocusOnClose() === true && this.activeElement !== null) {
  50. // reset focus
  51. this.activeElement.focus();
  52. }
  53. }
  54. /**
  55. * If true, the focus on the active element (before opening the dialog)
  56. * will be restored. Default `true`, override if desired.
  57. */
  58. protected resumeFocusOnClose(): boolean {
  59. return true;
  60. }
  61. }
  62. /**
  63. * Handle sending of files.
  64. */
  65. class SendFileController extends DialogController {
  66. public caption: string;
  67. public sendAsFile: boolean = false;
  68. public send(): void {
  69. this.hide({
  70. caption: this.caption,
  71. sendAsFile: this.sendAsFile,
  72. });
  73. }
  74. public keypress($event: KeyboardEvent): void {
  75. if ($event.key === 'Enter') { // see https://developer.mozilla.org/de/docs/Web/API/KeyboardEvent/key/Key_Values
  76. this.send();
  77. }
  78. }
  79. }
  80. /**
  81. * Handle settings
  82. */
  83. class SettingsController {
  84. public static $inject = ['$mdDialog', '$window', 'SettingsService', 'NotificationService'];
  85. public $mdDialog: ng.material.IDialogService;
  86. public $window: ng.IWindowService;
  87. public settingsService: SettingsService;
  88. private notificationService: NotificationService;
  89. public activeElement: HTMLElement | null;
  90. private desktopNotifications: boolean;
  91. private notificationApiAvailable: boolean;
  92. private notificationPermission: boolean;
  93. private notificationPreview: boolean;
  94. private notificationSound: boolean;
  95. constructor($mdDialog: ng.material.IDialogService,
  96. $window: ng.IWindowService,
  97. settingsService: SettingsService,
  98. notificationService: NotificationService) {
  99. this.$mdDialog = $mdDialog;
  100. this.$window = $window;
  101. this.settingsService = settingsService;
  102. this.notificationService = notificationService;
  103. this.activeElement = document.activeElement as HTMLElement;
  104. this.desktopNotifications = notificationService.getWantsNotifications();
  105. this.notificationApiAvailable = notificationService.isNotificationApiAvailable();
  106. this.notificationPermission = notificationService.getNotificationPermission();
  107. this.notificationPreview = notificationService.getWantsPreview();
  108. this.notificationSound = notificationService.getWantsSound();
  109. }
  110. public cancel(): void {
  111. this.$mdDialog.cancel();
  112. this.done();
  113. }
  114. protected hide(data: any): void {
  115. this.$mdDialog.hide(data);
  116. this.done();
  117. }
  118. private done(): void {
  119. if (this.activeElement !== null) {
  120. // Reset focus
  121. this.activeElement.focus();
  122. }
  123. }
  124. public setWantsNotifications(desktopNotifications: boolean) {
  125. this.notificationService.setWantsNotifications(desktopNotifications);
  126. }
  127. public setWantsPreview(notificationPreview: boolean) {
  128. this.notificationService.setWantsPreview(notificationPreview);
  129. }
  130. public setWantsSound(notificationSound: boolean) {
  131. this.notificationService.setWantsSound(notificationSound);
  132. }
  133. }
  134. class ConversationController {
  135. public name = 'navigation';
  136. // Angular services
  137. private $stateParams;
  138. private $timeout: ng.ITimeoutService;
  139. private $state: ng.ui.IStateService;
  140. private $log: ng.ILogService;
  141. private $scope: ng.IScope;
  142. // Own services
  143. private webClientService: WebClientService;
  144. private receiverService: ReceiverService;
  145. private stateService: StateService;
  146. private mimeService: MimeService;
  147. // Third party services
  148. private $mdDialog: ng.material.IDialogService;
  149. private $mdToast: ng.material.IToastService;
  150. // DOM Elements
  151. private domChatElement: HTMLElement;
  152. // Scrolling
  153. public showScrollJump: boolean = false;
  154. private stopTypingTimer: ng.IPromise<void> = null;
  155. public receiver: threema.Receiver;
  156. public type: threema.ReceiverType;
  157. public message: string = '';
  158. public lastReadMsgId: number = 0;
  159. public msgReadReportPending = false;
  160. private hasMore = true;
  161. private latestRefMsgId: number = null;
  162. private messages: threema.Message[];
  163. private draft: string;
  164. private $translate: ng.translate.ITranslateService;
  165. private locked = false;
  166. public maxTextLength: number;
  167. public isTyping = (): boolean => false;
  168. private uploading = {
  169. enabled: false,
  170. value1: 0,
  171. value2: 0,
  172. };
  173. public static $inject = [
  174. '$stateParams', '$state', '$timeout', '$log', '$scope', '$rootScope',
  175. '$mdDialog', '$mdToast', '$location', '$translate',
  176. 'WebClientService', 'StateService', 'ReceiverService', 'MimeService',
  177. ];
  178. constructor($stateParams,
  179. $state: ng.ui.IStateService,
  180. $timeout: ng.ITimeoutService,
  181. $log: ng.ILogService,
  182. $scope: ng.IScope,
  183. $rootScope: ng.IRootScopeService,
  184. $mdDialog: ng.material.IDialogService,
  185. $mdToast: ng.material.IToastService,
  186. $location,
  187. $translate: ng.translate.ITranslateService,
  188. webClientService: WebClientService,
  189. stateService: StateService,
  190. receiverService: ReceiverService,
  191. mimeService: MimeService) {
  192. this.$stateParams = $stateParams;
  193. this.$timeout = $timeout;
  194. this.$log = $log;
  195. this.webClientService = webClientService;
  196. this.receiverService = receiverService;
  197. this.stateService = stateService;
  198. this.mimeService = mimeService;
  199. this.$state = $state;
  200. this.$scope = $scope;
  201. this.$mdDialog = $mdDialog;
  202. this.$mdToast = $mdToast;
  203. this.$translate = $translate;
  204. // Close any showing dialogs
  205. this.$mdDialog.cancel();
  206. this.maxTextLength = this.webClientService.getMaxTextLength();
  207. // On every navigation event, close all dialogs.
  208. // Note: Deprecated. When migrating ui-router ($state),
  209. // replace with transition hooks.
  210. $rootScope.$on('$stateChangeStart', () => this.$mdDialog.cancel());
  211. // Redirect to welcome if necessary
  212. if (stateService.state === 'error') {
  213. $log.debug('ConversationController: WebClient not yet running, redirecting to welcome screen');
  214. $state.go('welcome');
  215. return;
  216. }
  217. if (!this.locked) {
  218. // Get DOM references
  219. this.domChatElement = document.querySelector('#conversation-chat') as HTMLElement;
  220. // Add custom event handlers
  221. this.domChatElement.addEventListener('scroll', throttle(() => {
  222. $rootScope.$apply(() => {
  223. this.updateScrollJump();
  224. });
  225. }, 100, this), supportsPassive() ? {passive: true} as any : false);
  226. }
  227. // Set receiver and type
  228. try {
  229. this.receiver = webClientService.receivers.getData($stateParams);
  230. this.type = $stateParams.type;
  231. if (this.receiver.type === undefined) {
  232. this.receiver.type = this.type;
  233. }
  234. // initial set locked state
  235. this.locked = this.receiver.locked;
  236. this.receiverService.setActive(this.receiver);
  237. if (!this.receiver.locked) {
  238. let latestHeight = 0;
  239. // update unread count
  240. this.webClientService.messages.updateFirstUnreadMessage(this.receiver);
  241. this.messages = this.webClientService.messages.register(
  242. this.receiver,
  243. this.$scope,
  244. (e, allMessages: threema.Message[], hasMore: boolean) => {
  245. this.messages = allMessages;
  246. this.hasMore = hasMore;
  247. if (this.latestRefMsgId !== null) {
  248. // scroll to div..
  249. this.domChatElement.scrollTop =
  250. this.domChatElement.scrollHeight - latestHeight;
  251. this.latestRefMsgId = null;
  252. }
  253. latestHeight = this.domChatElement.scrollHeight;
  254. },
  255. );
  256. this.draft = webClientService.getDraft(this.receiver);
  257. if (this.receiver.type === 'contact') {
  258. this.isTyping = () => this.webClientService.isTyping(this.receiver as threema.ContactReceiver);
  259. }
  260. }
  261. } catch (error) {
  262. $log.error('Could not set receiver and type');
  263. $log.debug(error.stack);
  264. $state.go('messenger.home');
  265. }
  266. // reload controller if locked state was changed
  267. $scope.$watch(() => {
  268. return this.receiver.locked;
  269. }, () => {
  270. if (this.locked !== this.receiver.locked) {
  271. $state.reload();
  272. }
  273. });
  274. }
  275. public isEnabled(): boolean {
  276. return this.type !== 'group'
  277. || !(this.receiver as threema.GroupReceiver).disabled;
  278. }
  279. public isQuoting(): boolean {
  280. return this.getQuote() !== undefined;
  281. }
  282. public getQuote(): threema.Quote {
  283. return this.webClientService.getQuote(this.receiver);
  284. }
  285. public cancelQuoting(): void {
  286. // clear curren quote
  287. this.webClientService.setQuote(this.receiver);
  288. }
  289. public showError(errorMessage: string, toastLength = 4000) {
  290. if (errorMessage === undefined || errorMessage.length === 0) {
  291. errorMessage = this.$translate.instant('error.ERROR_OCCURRED');
  292. }
  293. this.$mdToast.show(
  294. this.$mdToast.simple()
  295. .textContent(errorMessage)
  296. .position('bottom center'));
  297. }
  298. /**
  299. * Submit function for input field. Can contain text or file data.
  300. * Return whether sending was successful.
  301. */
  302. public submit = (type: threema.MessageContentType, contents: threema.MessageData[]): Promise<any> => {
  303. // Validate whether a connection is available
  304. return new Promise((resolve, reject) => {
  305. if (this.stateService.state !== 'ok') {
  306. // Invalid connection, show toast and abort
  307. this.showError(this.$translate.instant('error.NO_CONNECTION'));
  308. return reject();
  309. }
  310. let success = true;
  311. let nextCallback = (index: number) => {
  312. if (index === contents.length - 1) {
  313. if (success) {
  314. resolve();
  315. } else {
  316. reject();
  317. }
  318. }
  319. };
  320. switch (type) {
  321. case 'file':
  322. // Determine file type
  323. let showSendAsFileCheckbox = false;
  324. for (let msg of contents) {
  325. const mime = (msg as threema.FileMessageData).fileType;
  326. if (this.mimeService.isImage(mime)
  327. || this.mimeService.isAudio(mime)
  328. || this.mimeService.isVideo(mime)) {
  329. showSendAsFileCheckbox = true;
  330. break;
  331. }
  332. }
  333. // Eager translations
  334. const title = this.$translate.instant('messenger.CONFIRM_FILE_SEND', {
  335. senderName: this.receiver.displayName,
  336. });
  337. const placeholder = this.$translate.instant('messenger.CONFIRM_FILE_CAPTION');
  338. const confirmSendAsFile = this.$translate.instant('messenger.CONFIRM_SEND_AS_FILE');
  339. // Show confirmation dialog
  340. this.$mdDialog.show({
  341. clickOutsideToClose: false,
  342. controller: 'SendFileController',
  343. controllerAs: 'ctrl',
  344. // tslint:disable:max-line-length
  345. template: `
  346. <md-dialog class="send-file-dialog">
  347. <md-dialog-content class="md-dialog-content">
  348. <h2 class="md-title">${title}</h2>
  349. <md-input-container md-no-float class="input-caption md-prompt-input-container">
  350. <input md-autofocus ng-keypress="ctrl.keypress($event)" ng-model="ctrl.caption" placeholder="${placeholder}" aria-label="${placeholder}">
  351. </md-input-container>
  352. <md-input-container md-no-float class="input-send-as-file md-prompt-input-container" ng-show="${showSendAsFileCheckbox}">
  353. <md-checkbox ng-model="ctrl.sendAsFile" aria-label="${confirmSendAsFile}">
  354. ${confirmSendAsFile}
  355. </md-checkbox>
  356. </md-input-container>
  357. </md-dialog-content>
  358. <md-dialog-actions>
  359. <button class="md-primary md-cancel-button md-button" md-ink-ripple type="button" ng-click="ctrl.cancel()">
  360. <span translate>common.CANCEL</span>
  361. </button>
  362. <button class="md-primary md-cancel-button md-button" md-ink-ripple type="button" ng-click="ctrl.send()">
  363. <span translate>common.SEND</span>
  364. </button>
  365. </md-dialog-actions>
  366. </md-dialog>
  367. `,
  368. // tslint:enable:max-line-length
  369. }).then((data) => {
  370. const caption = data.caption;
  371. const sendAsFile = data.sendAsFile;
  372. contents.forEach((msg: threema.FileMessageData, index: number) => {
  373. if (caption !== undefined && caption.length > 0) {
  374. msg.caption = caption;
  375. }
  376. msg.sendAsFile = sendAsFile;
  377. this.webClientService.sendMessage(this.$stateParams, type, msg)
  378. .then(() => {
  379. nextCallback(index);
  380. })
  381. .catch((error) => {
  382. this.$log.error(error);
  383. this.showError(error);
  384. success = false;
  385. nextCallback(index);
  386. });
  387. });
  388. }, angular.noop);
  389. break;
  390. case 'text':
  391. // do not show confirmation, send directly
  392. contents.forEach((msg: threema.MessageData, index: number) => {
  393. msg.quote = this.webClientService.getQuote(this.receiver);
  394. // remove quote
  395. this.webClientService.setQuote(this.receiver);
  396. // send message
  397. this.webClientService.sendMessage(this.$stateParams, type, msg)
  398. .then(() => {
  399. nextCallback(index);
  400. })
  401. .catch((error) => {
  402. this.$log.error(error);
  403. this.showError(error);
  404. success = false;
  405. nextCallback(index);
  406. });
  407. });
  408. return;
  409. default:
  410. this.$log.warn('Invalid message type:', type);
  411. reject();
  412. }
  413. });
  414. }
  415. /**
  416. * Something was typed.
  417. *
  418. * In contrast to startTyping, this method is is always called, not just if
  419. * the text field is non-empty.
  420. */
  421. public onTyping = (text: string) => {
  422. // Update draft
  423. this.webClientService.setDraft(this.receiver, text);
  424. }
  425. public onUploading = (inProgress: boolean, percentCurrent: number = null, percentFull: number = null) => {
  426. this.uploading.enabled = inProgress;
  427. this.uploading.value1 = Number(percentCurrent);
  428. this.uploading.value2 = Number(percentCurrent);
  429. }
  430. /**
  431. * We started typing.
  432. */
  433. public startTyping = (text: string) => {
  434. if (this.stopTypingTimer === null) {
  435. // Notify app
  436. this.webClientService.sendMeIsTyping(this.$stateParams, true);
  437. } else {
  438. // Stop existing timer
  439. this.$timeout.cancel(this.stopTypingTimer);
  440. }
  441. // Define a timeout to send the stopTyping event
  442. this.stopTypingTimer = this.$timeout(() => {
  443. this.stopTyping();
  444. }, 10000);
  445. }
  446. /**
  447. * We stopped typing.
  448. */
  449. public stopTyping = () => {
  450. // Cancel timer if present
  451. if (this.stopTypingTimer !== null) {
  452. this.$timeout.cancel(this.stopTypingTimer);
  453. this.stopTypingTimer = null;
  454. }
  455. // Notify app
  456. this.webClientService.sendMeIsTyping(this.$stateParams, false);
  457. }
  458. /**
  459. * User scrolled to the top of the chat.
  460. */
  461. public topOfChat(): void {
  462. this.requestMessages();
  463. }
  464. public requestMessages(): void {
  465. let refMsgId = this.webClientService.requestMessages(this.$stateParams);
  466. if (refMsgId !== null
  467. && refMsgId !== undefined) {
  468. // new message are requested, scroll to refMsgId
  469. this.latestRefMsgId = refMsgId;
  470. } else {
  471. this.latestRefMsgId = null;
  472. }
  473. }
  474. public showReceiver(ev): void {
  475. this.$state.go('messenger.home.detail', this.receiver);
  476. }
  477. public hasMoreMessages(): boolean {
  478. return this.hasMore;
  479. }
  480. /**
  481. * A message has been seen. Report it to the app, with a small delay to
  482. * avoid sending too many messages at once.
  483. */
  484. public msgRead(msgId: number): void {
  485. if (msgId > this.lastReadMsgId) {
  486. this.lastReadMsgId = msgId;
  487. }
  488. if (!this.msgReadReportPending) {
  489. this.msgReadReportPending = true;
  490. const receiver = angular.copy(this.receiver);
  491. receiver.type = this.type;
  492. this.$timeout(() => {
  493. this.webClientService.requestRead(receiver, this.lastReadMsgId);
  494. this.msgReadReportPending = false;
  495. }, 500);
  496. }
  497. }
  498. public goBack(): void {
  499. this.receiverService.setActive(undefined);
  500. // redirect to messenger home
  501. this.$state.go('messenger.home');
  502. }
  503. /**
  504. * Scroll to bottom of chat.
  505. */
  506. public scrollDown(): void {
  507. this.domChatElement.scrollTop = this.domChatElement.scrollHeight;
  508. }
  509. /**
  510. * Only show the scroll to bottom button if user scrolled more than 10px
  511. * away from bottom.
  512. */
  513. private updateScrollJump(): void {
  514. const chat = this.domChatElement;
  515. this.showScrollJump = chat.scrollHeight - (chat.scrollTop + chat.offsetHeight) > 10;
  516. }
  517. }
  518. class NavigationController {
  519. public name = 'navigation';
  520. private webClientService: WebClientService;
  521. private receiverService: ReceiverService;
  522. private stateService: StateService;
  523. private trustedKeyStoreService: TrustedKeyStoreService;
  524. private activeTab: 'contacts' | 'conversations' = 'conversations';
  525. private searchVisible = false;
  526. private searchText: string = '';
  527. private $mdDialog;
  528. private $translate: ng.translate.ITranslateService;
  529. private $state: ng.ui.IStateService;
  530. public static $inject = [
  531. '$log', '$state', '$mdDialog', '$translate',
  532. 'WebClientService', 'StateService', 'ReceiverService', 'TrustedKeyStore',
  533. ];
  534. constructor($log: ng.ILogService, $state: ng.ui.IStateService,
  535. $mdDialog: ng.material.IDialogService, $translate: ng.translate.ITranslateService,
  536. webClientService: WebClientService, stateService: StateService,
  537. receiverService: ReceiverService,
  538. trustedKeyStoreService: TrustedKeyStoreService) {
  539. // Redirect to welcome if necessary
  540. if (stateService.state === 'error') {
  541. $log.debug('NavigationController: WebClient not yet running, redirecting to welcome screen');
  542. $state.go('welcome');
  543. return;
  544. }
  545. this.webClientService = webClientService;
  546. this.receiverService = receiverService;
  547. this.stateService = stateService;
  548. this.trustedKeyStoreService = trustedKeyStoreService;
  549. this.$mdDialog = $mdDialog;
  550. this.$translate = $translate;
  551. this.$state = $state;
  552. }
  553. public contacts(): threema.ContactReceiver[] {
  554. return Array.from(this.webClientService.contacts.values()) as threema.ContactReceiver[];
  555. }
  556. /**
  557. * Search for `needle` in the `haystack`. The search is case insensitive.
  558. */
  559. private matches(haystack: string, needle: string): boolean {
  560. return haystack.toLowerCase().replace('\n', ' ').indexOf(needle.trim().toLowerCase()) !== -1;
  561. }
  562. /**
  563. * Predicate function used for conversation filtering.
  564. *
  565. * Match by contact name *or* id *or* last message text.
  566. */
  567. private searchConversation = (value: threema.Conversation, index, array): boolean => {
  568. return this.searchText === ''
  569. || this.matches(value.receiver.displayName, this.searchText)
  570. || (value.latestMessage && value.latestMessage.body
  571. && this.matches(value.latestMessage.body, this.searchText))
  572. || (value.receiver.id.length === 8 && this.matches(value.receiver.id, this.searchText));
  573. }
  574. /**
  575. * Predicate function used for contact filtering.
  576. *
  577. * Match by contact name *or* id.
  578. */
  579. private searchContact = (value, index, array): boolean => {
  580. return this.searchText === ''
  581. || value.displayName.toLowerCase().indexOf(this.searchText.toLowerCase()) !== -1
  582. || value.id.toLowerCase().indexOf(this.searchText.toLowerCase()) !== -1;
  583. }
  584. public isVisible(conversation: threema.Conversation) {
  585. return conversation.receiver.visible;
  586. }
  587. public conversations(): threema.Conversation[] {
  588. return this.webClientService.conversations.get();
  589. }
  590. public isActive(value: threema.Conversation): boolean {
  591. return this.receiverService.isConversationActive(value);
  592. }
  593. /**
  594. * Show dialog.
  595. */
  596. public showDialog(name, ev) {
  597. this.$mdDialog.show({
  598. controller: DialogController,
  599. controllerAs: 'ctrl',
  600. templateUrl: 'partials/dialog.' + name + '.html',
  601. parent: angular.element(document.body),
  602. targetEvent: ev,
  603. clickOutsideToClose: true,
  604. fullscreen: true,
  605. });
  606. }
  607. /**
  608. * Show about dialog.
  609. */
  610. public about(ev): void {
  611. this.showDialog('about', ev);
  612. }
  613. /**
  614. * Show settings dialog.
  615. */
  616. public settings(ev): void {
  617. this.$mdDialog.show({
  618. controller: SettingsController,
  619. controllerAs: 'ctrl',
  620. templateUrl: 'partials/dialog.settings.html',
  621. parent: angular.element(document.body),
  622. targetEvent: ev,
  623. clickOutsideToClose: true,
  624. fullscreen: true,
  625. });
  626. }
  627. /**
  628. * Return whether a trusted key is available.
  629. */
  630. public isPersistent(): boolean {
  631. return this.trustedKeyStoreService.hasTrustedKey();
  632. }
  633. /**
  634. * Close the session.
  635. */
  636. public closeSession(ev): void {
  637. const confirm = this.$mdDialog.confirm()
  638. .title(this.$translate.instant('common.SESSION_CLOSE'))
  639. .textContent(this.$translate.instant('common.CONFIRM_CLOSE_BODY'))
  640. .targetEvent(ev)
  641. .ok(this.$translate.instant('common.YES'))
  642. .cancel(this.$translate.instant('common.CANCEL'));
  643. this.$mdDialog.show(confirm).then(() => {
  644. const deleteStoredData = false;
  645. const resetPush = true;
  646. const redirect = true;
  647. this.webClientService.stop(true, deleteStoredData, resetPush, redirect);
  648. }, () => {
  649. // do nothing
  650. });
  651. }
  652. /**
  653. * Close and delete the session.
  654. */
  655. public deleteSession(ev): void {
  656. const confirm = this.$mdDialog.confirm()
  657. .title(this.$translate.instant('common.SESSION_DELETE'))
  658. .textContent(this.$translate.instant('common.CONFIRM_DELETE_CLOSE_BODY'))
  659. .targetEvent(ev)
  660. .ok(this.$translate.instant('common.YES'))
  661. .cancel(this.$translate.instant('common.CANCEL'));
  662. this.$mdDialog.show(confirm).then(() => {
  663. const deleteStoredData = true;
  664. const resetPush = true;
  665. const redirect = true;
  666. this.webClientService.stop(true, deleteStoredData, resetPush, redirect);
  667. }, () => {
  668. // do nothing
  669. });
  670. }
  671. public addContact(ev): void {
  672. this.$state.go('messenger.home.create', {
  673. type: 'contact',
  674. });
  675. }
  676. public createGroup(ev): void {
  677. this.$state.go('messenger.home.create', {
  678. type: 'group',
  679. });
  680. }
  681. public createDistributionList(ev): void {
  682. this.$state.go('messenger.home.create', {
  683. type: 'distributionList',
  684. });
  685. }
  686. /**
  687. * Toggle search bar.
  688. */
  689. public toggleSearch(): void {
  690. this.searchVisible = !this.searchVisible;
  691. }
  692. public getMyIdentity(): threema.Identity {
  693. return this.webClientService.getMyIdentity();
  694. }
  695. public showMyIdentity(): boolean {
  696. return this.getMyIdentity() !== undefined;
  697. }
  698. }
  699. class MessengerController {
  700. public name = 'messenger';
  701. private receiverService: ReceiverService;
  702. private $state;
  703. private webClientService: WebClientService;
  704. public static $inject = [
  705. '$scope', '$state', '$log', '$mdDialog', '$translate',
  706. 'StateService', 'ReceiverService', 'WebClientService', 'ControllerService',
  707. ];
  708. constructor($scope, $state, $log: ng.ILogService, $mdDialog: ng.material.IDialogService,
  709. $translate: ng.translate.ITranslateService,
  710. stateService: StateService, receiverService: ReceiverService,
  711. webClientService: WebClientService, controllerService: ControllerService) {
  712. // Redirect to welcome if necessary
  713. if (stateService.state === 'error') {
  714. $log.debug('MessengerController: WebClient not yet running, redirecting to welcome screen');
  715. $state.go('welcome');
  716. return;
  717. }
  718. controllerService.setControllerName('messenger');
  719. this.receiverService = receiverService;
  720. this.$state = $state;
  721. this.webClientService = webClientService;
  722. // watch for alerts
  723. $scope.$watch(() => webClientService.alerts, (alerts: threema.Alert[]) => {
  724. if (alerts.length > 0) {
  725. angular.forEach(alerts, (alert: threema.Alert) => {
  726. $mdDialog.show(
  727. $mdDialog.alert()
  728. .clickOutsideToClose(true)
  729. .title(alert.type)
  730. .textContent(alert.message)
  731. .ok($translate.instant('common.OK')));
  732. });
  733. // clean array
  734. webClientService.alerts = [];
  735. }
  736. }, true);
  737. this.webClientService.setReceiverListener({
  738. onRemoved(receiver: threema.Receiver) {
  739. switch ($state.current.name) {
  740. case 'messenger.home.conversation':
  741. case 'messenger.home.detail':
  742. case 'messenger.home.edit':
  743. if ($state.params !== undefined
  744. && $state.params.type !== undefined
  745. && $state.params.id !== undefined) {
  746. if ($state.params.type === receiver.type
  747. && $state.params.id === receiver.id) {
  748. // conversation or sub form is open, redirect to home!
  749. this.$state.go('messenger.home', null, {location: 'replace'});
  750. }
  751. }
  752. break;
  753. default:
  754. $log.warn('Ignored onRemoved event for state', $state.current.name);
  755. }
  756. },
  757. });
  758. }
  759. public showDetail(): boolean {
  760. return !this.$state.is('messenger.home');
  761. }
  762. }
  763. class ReceiverDetailController {
  764. public $mdDialog: any;
  765. public $state: ng.ui.IStateService;
  766. public receiver: threema.Receiver;
  767. public title: string;
  768. public fingerPrint?: string;
  769. private fingerPrintService: FingerPrintService;
  770. private contactService: ContactService;
  771. private showGroups = false;
  772. private showDistributionLists = false;
  773. private inGroups: threema.GroupReceiver[] = [];
  774. private inDistributionLists: threema.DistributionListReceiver[] = [];
  775. private hasSystemEmails = false;
  776. private hasSystemPhones = false;
  777. private controllerModel: threema.ControllerModel;
  778. public static $inject = [
  779. '$log', '$stateParams', '$state', '$mdDialog',
  780. 'WebClientService', 'FingerPrintService', 'ContactService', 'ControllerModelService',
  781. ];
  782. constructor($log: ng.ILogService, $stateParams, $state: ng.ui.IStateService, $mdDialog: ng.material.IDialogService,
  783. webClientService: WebClientService, fingerPrintService: FingerPrintService,
  784. contactService: ContactService, controllerModelService: ControllerModelService) {
  785. this.$mdDialog = $mdDialog;
  786. this.$state = $state;
  787. this.fingerPrintService = fingerPrintService;
  788. this.contactService = contactService;
  789. this.receiver = webClientService.receivers.getData($stateParams);
  790. // append members
  791. if (this.receiver.type === 'contact') {
  792. let contactReceiver = (<threema.ContactReceiver> this.receiver);
  793. this.contactService.requiredDetails(contactReceiver)
  794. .then(() => {
  795. this.hasSystemEmails = contactReceiver.systemContact.emails.length > 0;
  796. this.hasSystemPhones = contactReceiver.systemContact.phoneNumbers.length > 0;
  797. })
  798. .catch(() => {
  799. // do nothing
  800. });
  801. this.fingerPrint = this.fingerPrintService.generate(contactReceiver.publicKey);
  802. webClientService.groups.forEach((groupReceiver: threema.GroupReceiver) => {
  803. // check if my identity is a member
  804. if (groupReceiver.members.indexOf(contactReceiver.id) !== -1) {
  805. this.inGroups.push(groupReceiver);
  806. this.showGroups = true;
  807. }
  808. });
  809. webClientService.distributionLists.forEach(
  810. (distributionListReceiver: threema.DistributionListReceiver) => {
  811. // check if my identity is a member
  812. if (distributionListReceiver.members.indexOf(contactReceiver.id) !== -1) {
  813. this.inDistributionLists.push(distributionListReceiver);
  814. this.showDistributionLists = true;
  815. }
  816. },
  817. );
  818. }
  819. switch (this.receiver.type) {
  820. case 'contact':
  821. this.controllerModel = controllerModelService
  822. .contact(this.receiver as threema.ContactReceiver, ControllerModelMode.VIEW);
  823. break;
  824. case 'group':
  825. this.controllerModel = controllerModelService
  826. .group(this.receiver as threema.GroupReceiver, ControllerModelMode.VIEW);
  827. break;
  828. case 'distributionList':
  829. this.controllerModel = controllerModelService
  830. .distributionList(this.receiver as threema.DistributionListReceiver, ControllerModelMode.VIEW);
  831. break;
  832. default:
  833. $log.warn('Invalid receiver type:', this.receiver.type);
  834. }
  835. // if this receiver was removed, navigation to "home" view
  836. this.controllerModel.setOnRemoved((receiverId: string) => {
  837. // go "home"
  838. this.$state.go('messenger.home', null, {location: 'replace'});
  839. });
  840. }
  841. public chat(): void {
  842. this.$state.go('messenger.home.conversation', this.receiver);
  843. }
  844. public edit(): void {
  845. if (!this.controllerModel.canEdit()) {
  846. return;
  847. }
  848. this.$state.go('messenger.home.edit', this.receiver);
  849. }
  850. public goBack(): void {
  851. window.history.back();
  852. }
  853. }
  854. /**
  855. * Control edit a group or a contact
  856. * fields, validate and save routines are implemented in the specific ControllerModel
  857. */
  858. class ReceiverEditController {
  859. public $mdDialog: any;
  860. public $state: ng.ui.IStateService;
  861. private $translate: ng.translate.ITranslateService;
  862. public title: string;
  863. private $timeout: ng.ITimeoutService;
  864. private execute: ExecuteService;
  865. public loading = false;
  866. private controllerModel: threema.ControllerModel;
  867. public type: string;
  868. public static $inject = [
  869. '$log', '$stateParams', '$state', '$mdDialog',
  870. '$timeout', '$translate', 'WebClientService', 'ControllerModelService',
  871. ];
  872. constructor($log: ng.ILogService, $stateParams, $state: ng.ui.IStateService,
  873. $mdDialog, $timeout: ng.ITimeoutService, $translate: ng.translate.ITranslateService,
  874. webClientService: WebClientService, controllerModelService: ControllerModelService) {
  875. this.$mdDialog = $mdDialog;
  876. this.$state = $state;
  877. this.$timeout = $timeout;
  878. this.$translate = $translate;
  879. const receiver = webClientService.receivers.getData($stateParams);
  880. switch (receiver.type) {
  881. case 'contact':
  882. this.controllerModel = controllerModelService.contact(
  883. receiver as threema.ContactReceiver,
  884. ControllerModelMode.EDIT,
  885. );
  886. break;
  887. case 'group':
  888. this.controllerModel = controllerModelService.group(
  889. receiver as threema.GroupReceiver,
  890. ControllerModelMode.EDIT,
  891. );
  892. break;
  893. case 'distributionList':
  894. this.controllerModel = controllerModelService.distributionList(
  895. receiver as threema.DistributionListReceiver,
  896. ControllerModelMode.EDIT,
  897. );
  898. break;
  899. default:
  900. $log.warn('Invalid receiver type:', receiver.type);
  901. }
  902. this.execute = new ExecuteService($log, $timeout, 1000);
  903. this.type = receiver.type;
  904. }
  905. public save(): void {
  906. // show loading
  907. this.loading = true;
  908. // validate first
  909. this.execute.execute(this.controllerModel.save())
  910. .then((receiver: threema.Receiver) => {
  911. this.goBack();
  912. })
  913. .catch((errorCode) => {
  914. this.showError(errorCode);
  915. });
  916. }
  917. public isSaving(): boolean {
  918. return this.execute.isRunning();
  919. }
  920. public showError(errorCode): void {
  921. this.$mdDialog.show(
  922. this.$mdDialog.alert()
  923. .clickOutsideToClose(true)
  924. .title(this.controllerModel.subject)
  925. .textContent(this.$translate.instant('validationError.editReceiver.' + errorCode))
  926. .ok(this.$translate.instant('common.OK')));
  927. }
  928. public goBack(): void {
  929. window.history.back();
  930. }
  931. }
  932. /**
  933. * Control creating a group or adding contact
  934. * fields, validate and save routines are implemented in the specific ControllerModel
  935. */
  936. class ReceiverCreateController {
  937. public static $inject = ['$stateParams', '$mdDialog', '$mdToast', '$translate',
  938. '$timeout', '$state', '$log', 'ControllerModelService'];
  939. public $mdDialog: any;
  940. private loading = false;
  941. private $timeout: ng.ITimeoutService;
  942. private $log: ng.ILogService;
  943. private $state: ng.ui.IStateService;
  944. private $mdToast: any;
  945. public identity = '';
  946. private $translate: any;
  947. public type: string;
  948. private execute: ExecuteService;
  949. public controllerModel: threema.ControllerModel;
  950. constructor($stateParams: threema.CreateReceiverStateParams, $mdDialog, $mdToast, $translate,
  951. $timeout: ng.ITimeoutService, $state: ng.ui.IStateService, $log: ng.ILogService,
  952. controllerModelService: ControllerModelService) {
  953. this.$mdDialog = $mdDialog;
  954. this.$timeout = $timeout;
  955. this.$state = $state;
  956. this.$log = $log;
  957. this.$mdToast = $mdToast;
  958. this.$translate = $translate;
  959. this.type = $stateParams.type;
  960. switch (this.type) {
  961. case 'contact':
  962. this.controllerModel = controllerModelService.contact(null, ControllerModelMode.NEW);
  963. if ($stateParams.initParams !== null) {
  964. (this.controllerModel as ContactControllerModel)
  965. .identity = $stateParams.initParams.identity;
  966. }
  967. break;
  968. case 'group':
  969. this.controllerModel = controllerModelService.group(null, ControllerModelMode.NEW);
  970. break;
  971. case 'distributionList':
  972. this.controllerModel = controllerModelService.distributionList(null, ControllerModelMode.NEW);
  973. break;
  974. default:
  975. this.$log.error('invalid type', this.type);
  976. }
  977. this.execute = new ExecuteService($log, $timeout, 1000);
  978. }
  979. public isSaving(): boolean {
  980. return this.execute.isRunning();
  981. }
  982. public goBack(): void {
  983. if (!this.isSaving()) {
  984. window.history.back();
  985. }
  986. }
  987. private showAddError(errorCode: String): void {
  988. if (errorCode === undefined) {
  989. errorCode = 'invalid_entry';
  990. }
  991. this.$mdDialog.show(
  992. this.$mdDialog.alert()
  993. .clickOutsideToClose(true)
  994. .title(this.controllerModel.subject)
  995. .textContent(this.$translate.instant('validationError.createReceiver.' + errorCode))
  996. .ok(this.$translate.instant('common.OK')),
  997. );
  998. }
  999. public create(): void {
  1000. // show loading
  1001. this.loading = true;
  1002. // validate first
  1003. this.execute.execute(this.controllerModel.save())
  1004. .then((receiver: threema.Receiver) => {
  1005. this.$state.go('messenger.home.detail', receiver, {location: 'replace'});
  1006. })
  1007. .catch((errorCode) => {
  1008. this.showAddError(errorCode);
  1009. });
  1010. }
  1011. }
  1012. angular.module('3ema.messenger', ['ngMaterial'])
  1013. .config(['$stateProvider', function($stateProvider: ng.ui.IStateProvider) {
  1014. $stateProvider
  1015. .state('messenger', {
  1016. abstract: true,
  1017. templateUrl: 'partials/messenger.html',
  1018. controller: 'MessengerController',
  1019. controllerAs: 'ctrl',
  1020. })
  1021. .state('messenger.home', {
  1022. url: '/messenger',
  1023. views: {
  1024. navigation: {
  1025. templateUrl: 'partials/messenger.navigation.html',
  1026. controller: 'NavigationController',
  1027. controllerAs: 'ctrl',
  1028. },
  1029. content: {
  1030. // Required because navigation should not be changed,
  1031. template: '<div ui-view></div>',
  1032. },
  1033. },
  1034. })
  1035. .state('messenger.home.conversation', {
  1036. url: '/conversation/{type}/{id}',
  1037. templateUrl: 'partials/messenger.conversation.html',
  1038. controller: 'ConversationController',
  1039. controllerAs: 'ctrl',
  1040. })
  1041. .state('messenger.home.detail', {
  1042. url: '/conversation/{type}/{id}/detail',
  1043. templateUrl: 'partials/messenger.receiver.html',
  1044. controller: 'ReceiverDetailController',
  1045. controllerAs: 'ctrl',
  1046. })
  1047. .state('messenger.home.edit', {
  1048. url: '/conversation/{type}/{id}/detail/edit',
  1049. templateUrl: 'partials/messenger.receiver.edit.html',
  1050. controller: 'ReceiverEditController',
  1051. controllerAs: 'ctrl',
  1052. })
  1053. .state('messenger.home.create', {
  1054. url: '/receiver/create/{type}',
  1055. templateUrl: 'partials/messenger.receiver.create.html',
  1056. controller: 'ReceiverCreateController',
  1057. controllerAs: 'ctrl',
  1058. params: {initParams: null},
  1059. })
  1060. ;
  1061. }])
  1062. .controller('SendFileController', SendFileController)
  1063. .controller('MessengerController', MessengerController)
  1064. .controller('ConversationController', ConversationController)
  1065. .controller('NavigationController', NavigationController)
  1066. .controller('ReceiverDetailController', ReceiverDetailController)
  1067. .controller('ReceiverEditController', ReceiverEditController)
  1068. .controller('ReceiverCreateController', ReceiverCreateController)
  1069. ;