messenger.ts 48 KB

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