conversations.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. /*
  2. * vim: ts=4:sw=4:expandtab
  3. */
  4. (function () {
  5. 'use strict';
  6. window.Whisper = window.Whisper || {};
  7. // TODO: Factor out private and group subclasses of Conversation
  8. var COLORS = [
  9. 'red',
  10. 'pink',
  11. 'purple',
  12. 'deep_purple',
  13. 'indigo',
  14. 'blue',
  15. 'light_blue',
  16. 'cyan',
  17. 'teal',
  18. 'green',
  19. 'light_green',
  20. 'orange',
  21. 'deep_orange',
  22. 'amber',
  23. 'blue_grey',
  24. ];
  25. Whisper.Conversation = Backbone.Model.extend({
  26. database: Whisper.Database,
  27. storeName: 'conversations',
  28. defaults: function() {
  29. return { unreadCount : 0 };
  30. },
  31. initialize: function() {
  32. this.contactCollection = new Backbone.Collection();
  33. this.messageCollection = new Whisper.MessageCollection([], {
  34. conversation: this
  35. });
  36. this.on('change:avatar', this.updateAvatarUrl);
  37. this.on('destroy', this.revokeAvatarUrl);
  38. this.on('read', this.onReadMessage);
  39. this.fetchContacts().then(function() {
  40. this.contactCollection.each(function(contact) {
  41. textsecure.storage.protocol.on('keychange:' + contact.id, function() {
  42. this.addKeyChange(contact.id);
  43. }.bind(this));
  44. }.bind(this));
  45. }.bind(this));
  46. },
  47. addKeyChange: function(id) {
  48. var message = this.messageCollection.add({
  49. conversationId : this.id,
  50. type : 'keychange',
  51. sent_at : this.get('timestamp'),
  52. received_at : this.get('timestamp'),
  53. key_changed : id
  54. });
  55. message.save();
  56. },
  57. onReadMessage: function(message) {
  58. if (this.messageCollection.get(message.id)) {
  59. this.messageCollection.get(message.id).fetch();
  60. }
  61. return this.getUnread().then(function(unreadMessages) {
  62. this.save({unreadCount: unreadMessages.length});
  63. }.bind(this));
  64. },
  65. getUnread: function() {
  66. var conversationId = this.id;
  67. var unreadMessages = new Whisper.MessageCollection();
  68. return new Promise(function(resolve) {
  69. return unreadMessages.fetch({
  70. index: {
  71. // 'unread' index
  72. name : 'unread',
  73. lower : [conversationId],
  74. upper : [conversationId, Number.MAX_VALUE],
  75. }
  76. }).always(function() {
  77. resolve(unreadMessages);
  78. });
  79. });
  80. },
  81. validate: function(attributes, options) {
  82. var required = ['id', 'type'];
  83. var missing = _.filter(required, function(attr) { return !attributes[attr]; });
  84. if (missing.length) { return "Conversation must have " + missing; }
  85. if (attributes.type !== 'private' && attributes.type !== 'group') {
  86. return "Invalid conversation type: " + attributes.type;
  87. }
  88. var error = this.validateNumber();
  89. if (error) { return error; }
  90. this.updateTokens();
  91. },
  92. validateNumber: function() {
  93. if (this.isPrivate()) {
  94. var regionCode = storage.get('regionCode');
  95. var number = libphonenumber.util.parseNumber(this.id, regionCode);
  96. if (number.isValidNumber) {
  97. this.set({ id: number.e164 });
  98. } else {
  99. return number.error || "Invalid phone number";
  100. }
  101. }
  102. },
  103. updateTokens: function() {
  104. var tokens = [];
  105. var name = this.get('name');
  106. if (typeof name === 'string') {
  107. tokens.push(name.toLowerCase());
  108. tokens = tokens.concat(name.trim().toLowerCase().split(/[\s\-_\(\)\+]+/));
  109. }
  110. if (this.isPrivate()) {
  111. var regionCode = storage.get('regionCode');
  112. var number = libphonenumber.util.parseNumber(this.id, regionCode);
  113. tokens.push(
  114. number.nationalNumber,
  115. number.countryCode + number.nationalNumber
  116. );
  117. }
  118. this.set({tokens: tokens});
  119. },
  120. queueJob: function(callback) {
  121. var previous = this.pending || Promise.resolve();
  122. var current = this.pending = previous.then(callback, callback);
  123. current.then(function() {
  124. if (this.pending === current) {
  125. delete this.pending;
  126. }
  127. }.bind(this));
  128. return current;
  129. },
  130. sendMessage: function(body, attachments) {
  131. this.queueJob(function() {
  132. var now = Date.now();
  133. var message = this.messageCollection.add({
  134. body : body,
  135. conversationId : this.id,
  136. type : 'outgoing',
  137. attachments : attachments,
  138. sent_at : now,
  139. received_at : now,
  140. expireTimer : this.get('expireTimer')
  141. });
  142. if (this.isPrivate()) {
  143. message.set({destination: this.id});
  144. }
  145. message.save();
  146. this.save({
  147. unreadCount : 0,
  148. active_at : now,
  149. timestamp : now,
  150. lastMessage : message.getNotificationText()
  151. });
  152. var sendFunc;
  153. if (this.get('type') == 'private') {
  154. sendFunc = textsecure.messaging.sendMessageToNumber;
  155. }
  156. else {
  157. sendFunc = textsecure.messaging.sendMessageToGroup;
  158. }
  159. message.send(sendFunc(this.get('id'), body, attachments, now, this.get('expireTimer')));
  160. }.bind(this));
  161. },
  162. updateLastMessage: function() {
  163. var lastMessage = this.messageCollection.at(this.messageCollection.length - 1);
  164. if (lastMessage) {
  165. this.save({
  166. lastMessage : lastMessage.getNotificationText(),
  167. timestamp : lastMessage.get('sent_at')
  168. });
  169. } else {
  170. this.save({ lastMessage: '', timestamp: null });
  171. }
  172. },
  173. addExpirationTimerUpdate: function(expireTimer, source, received_at) {
  174. received_at = received_at || Date.now();
  175. this.save({ expireTimer: expireTimer });
  176. var message = this.messageCollection.add({
  177. conversationId : this.id,
  178. type : 'outgoing',
  179. sent_at : received_at,
  180. received_at : received_at,
  181. flags : textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
  182. expirationTimerUpdate : {
  183. expireTimer : expireTimer,
  184. source : source
  185. }
  186. });
  187. if (this.isPrivate()) {
  188. message.set({destination: this.id});
  189. }
  190. message.save();
  191. return message;
  192. },
  193. sendExpirationTimerUpdate: function(time) {
  194. var message = this.addExpirationTimerUpdate(time, textsecure.storage.user.getNumber());
  195. var sendFunc;
  196. if (this.get('type') == 'private') {
  197. sendFunc = textsecure.messaging.sendExpirationTimerUpdateToNumber;
  198. }
  199. else {
  200. sendFunc = textsecure.messaging.sendExpirationTimerUpdateToGroup;
  201. }
  202. message.send(sendFunc(this.get('id'), this.get('expireTimer'), message.get('sent_at')));
  203. },
  204. isSearchable: function() {
  205. return !this.get('left') || !!this.get('lastMessage');
  206. },
  207. endSession: function() {
  208. if (this.isPrivate()) {
  209. var now = Date.now();
  210. var message = this.messageCollection.create({
  211. conversationId : this.id,
  212. type : 'outgoing',
  213. sent_at : now,
  214. received_at : now,
  215. destination : this.id,
  216. flags : textsecure.protobuf.DataMessage.Flags.END_SESSION
  217. });
  218. message.send(textsecure.messaging.closeSession(this.id, now));
  219. }
  220. },
  221. updateGroup: function(group_update) {
  222. if (this.isPrivate()) {
  223. throw new Error("Called update group on private conversation");
  224. }
  225. if (group_update === undefined) {
  226. group_update = this.pick(['name', 'avatar', 'members']);
  227. }
  228. var now = Date.now();
  229. var message = this.messageCollection.create({
  230. conversationId : this.id,
  231. type : 'outgoing',
  232. sent_at : now,
  233. received_at : now,
  234. group_update : group_update
  235. });
  236. message.send(textsecure.messaging.updateGroup(
  237. this.id,
  238. this.get('name'),
  239. this.get('avatar'),
  240. this.get('members')
  241. ));
  242. },
  243. leaveGroup: function() {
  244. var now = Date.now();
  245. if (this.get('type') === 'group') {
  246. this.save({left: true});
  247. var message = this.messageCollection.create({
  248. group_update: { left: 'You' },
  249. conversationId : this.id,
  250. type : 'outgoing',
  251. sent_at : now,
  252. received_at : now
  253. });
  254. message.send(textsecure.messaging.leaveGroup(this.id));
  255. }
  256. },
  257. markRead: function() {
  258. if (this.get('unreadCount') > 0) {
  259. this.save({ unreadCount: 0 });
  260. var conversationId = this.id;
  261. Whisper.Notifications.remove(Whisper.Notifications.where({
  262. conversationId: conversationId
  263. }));
  264. this.getUnread().then(function(unreadMessages) {
  265. var read = unreadMessages.map(function(m) {
  266. if (this.messageCollection.get(m.id)) {
  267. m = this.messageCollection.get(m.id);
  268. }
  269. m.markRead();
  270. return {
  271. sender : m.get('source'),
  272. timestamp : m.get('sent_at')
  273. };
  274. }.bind(this));
  275. if (read.length > 0) {
  276. console.log('Sending', read.length, 'read receipts');
  277. textsecure.messaging.syncReadMessages(read);
  278. }
  279. }.bind(this));
  280. }
  281. },
  282. fetchMessages: function() {
  283. if (!this.id) { return false; }
  284. return this.messageCollection.fetchConversation(this.id);
  285. },
  286. fetchContacts: function(options) {
  287. return new Promise(function(resolve) {
  288. if (this.isPrivate()) {
  289. this.contactCollection.reset([this]);
  290. resolve();
  291. } else {
  292. var promises = [];
  293. var members = this.get('members') || [];
  294. this.contactCollection.reset(
  295. members.map(function(number) {
  296. var c = ConversationController.create({
  297. id : number,
  298. type : 'private'
  299. });
  300. promises.push(new Promise(function(resolve) {
  301. c.fetch().always(resolve);
  302. }));
  303. return c;
  304. }.bind(this))
  305. );
  306. resolve(Promise.all(promises));
  307. }
  308. }.bind(this));
  309. },
  310. destroyMessages: function() {
  311. this.messageCollection.fetch({
  312. index: {
  313. // 'conversation' index on [conversationId, received_at]
  314. name : 'conversation',
  315. lower : [this.id],
  316. upper : [this.id, Number.MAX_VALUE],
  317. }
  318. }).then(function() {
  319. var models = this.messageCollection.models;
  320. this.messageCollection.reset([]);
  321. _.each(models, function(message) { message.destroy(); });
  322. this.save({lastMessage: null, timestamp: null}); // archive
  323. }.bind(this));
  324. },
  325. getName: function() {
  326. if (this.isPrivate()) {
  327. return this.get('name');
  328. } else {
  329. return this.get('name') || 'Unknown group';
  330. }
  331. },
  332. getTitle: function() {
  333. if (this.isPrivate()) {
  334. return this.get('name') || this.getNumber();
  335. } else {
  336. return this.get('name') || 'Unknown group';
  337. }
  338. },
  339. getNumber: function() {
  340. if (!this.isPrivate()) {
  341. return '';
  342. }
  343. var number = this.id;
  344. try {
  345. var parsedNumber = libphonenumber.parse(number);
  346. var regionCode = libphonenumber.getRegionCodeForNumber(parsedNumber);
  347. if (regionCode === storage.get('regionCode')) {
  348. return libphonenumber.format(parsedNumber, libphonenumber.PhoneNumberFormat.NATIONAL);
  349. } else {
  350. return libphonenumber.format(parsedNumber, libphonenumber.PhoneNumberFormat.INTERNATIONAL);
  351. }
  352. } catch (e) {
  353. return number;
  354. }
  355. },
  356. isPrivate: function() {
  357. return this.get('type') === 'private';
  358. },
  359. revokeAvatarUrl: function() {
  360. if (this.avatarUrl) {
  361. URL.revokeObjectURL(this.avatarUrl);
  362. this.avatarUrl = null;
  363. }
  364. },
  365. updateAvatarUrl: function(silent) {
  366. this.revokeAvatarUrl();
  367. var avatar = this.get('avatar');
  368. if (avatar) {
  369. this.avatarUrl = URL.createObjectURL(
  370. new Blob([avatar.data], {type: avatar.contentType})
  371. );
  372. } else {
  373. this.avatarUrl = null;
  374. }
  375. if (!silent) {
  376. this.trigger('change');
  377. }
  378. },
  379. getColor: function() {
  380. var title = this.get('name');
  381. var color = this.get('color');
  382. if (!color) {
  383. if (this.isPrivate()) {
  384. if (title) {
  385. color = COLORS[Math.abs(this.hashCode()) % 15];
  386. } else {
  387. color = 'grey';
  388. }
  389. } else {
  390. color = 'default';
  391. }
  392. }
  393. return color;
  394. },
  395. getAvatar: function() {
  396. if (this.avatarUrl === undefined) {
  397. this.updateAvatarUrl(true);
  398. }
  399. var title = this.get('name');
  400. var color = this.getColor();
  401. if (this.avatarUrl) {
  402. return { url: this.avatarUrl, color: color };
  403. } else if (this.isPrivate()) {
  404. return {
  405. color: color,
  406. content: title ? title.trim()[0] : '#'
  407. };
  408. } else {
  409. return { url: '/images/group_default.png', color: color };
  410. }
  411. },
  412. getNotificationIcon: function() {
  413. return new Promise(function(resolve) {
  414. var avatar = this.getAvatar();
  415. if (avatar.url) {
  416. resolve(avatar.url);
  417. } else {
  418. resolve(new Whisper.IdenticonSVGView(avatar).getDataUrl());
  419. }
  420. }.bind(this));
  421. },
  422. resolveConflicts: function(conflict) {
  423. var number = conflict.number;
  424. var identityKey = conflict.identityKey;
  425. if (this.isPrivate()) {
  426. number = this.id;
  427. } else if (!_.include(this.get('members'), number)) {
  428. throw 'Tried to resolve conflicts for a unknown group member';
  429. }
  430. if (!this.messageCollection.hasKeyConflicts()) {
  431. throw 'No conflicts to resolve';
  432. }
  433. return textsecure.storage.protocol.removeIdentityKey(number).then(function() {
  434. return textsecure.storage.protocol.saveIdentity(number, identityKey).then(function() {
  435. var promise = Promise.resolve();
  436. var conflicts = this.messageCollection.filter(function(message) {
  437. return message.hasKeyConflict(number);
  438. });
  439. // group incoming & outgoing
  440. conflicts = _.groupBy(conflicts, function(m) { return m.get('type'); });
  441. // sort each group by date and concatenate outgoing after incoming
  442. conflicts = _.flatten([
  443. _.sortBy(conflicts.incoming, function(m) { return m.get('received_at'); }),
  444. _.sortBy(conflicts.outgoing, function(m) { return m.get('received_at'); }),
  445. ]).forEach(function(message) {
  446. var resolveConflict = function() {
  447. return message.resolveConflict(number);
  448. };
  449. promise = promise.then(resolveConflict, resolveConflict);
  450. });
  451. return promise;
  452. }.bind(this));
  453. }.bind(this));
  454. },
  455. notify: function(message) {
  456. if (!message.isIncoming()) {
  457. return;
  458. }
  459. if (window.isOpen() && window.isFocused()) {
  460. return;
  461. }
  462. window.drawAttention();
  463. var sender = ConversationController.create({
  464. id: message.get('source'), type: 'private'
  465. });
  466. var conversationId = this.id;
  467. sender.fetch().then(function() {
  468. sender.getNotificationIcon().then(function(iconUrl) {
  469. console.log('adding notification');
  470. Whisper.Notifications.add({
  471. title : sender.getTitle(),
  472. message : message.getNotificationText(),
  473. iconUrl : iconUrl,
  474. imageUrl : message.getImageUrl(),
  475. conversationId : conversationId,
  476. messageId : message.id
  477. });
  478. });
  479. });
  480. },
  481. hashCode: function() {
  482. if (this.hash === undefined) {
  483. var string = this.getTitle() || '';
  484. if (string.length === 0) {
  485. return 0;
  486. }
  487. var hash = 0;
  488. for (var i = 0; i < string.length; i++) {
  489. hash = ((hash<<5)-hash) + string.charCodeAt(i);
  490. hash = hash & hash; // Convert to 32bit integer
  491. }
  492. this.hash = hash;
  493. }
  494. return this.hash;
  495. }
  496. });
  497. Whisper.ConversationCollection = Backbone.Collection.extend({
  498. database: Whisper.Database,
  499. storeName: 'conversations',
  500. model: Whisper.Conversation,
  501. comparator: function(m) {
  502. return -m.get('timestamp');
  503. },
  504. destroyAll: function () {
  505. return Promise.all(this.models.map(function(m) {
  506. return new Promise(function(resolve, reject) {
  507. m.destroy().then(resolve).fail(reject);
  508. });
  509. }));
  510. },
  511. search: function(query) {
  512. query = query.trim().toLowerCase();
  513. if (query.length > 0) {
  514. query = query.replace(/[-.\(\)]*/g,'').replace(/^\+(\d*)$/, '$1');
  515. var lastCharCode = query.charCodeAt(query.length - 1);
  516. var nextChar = String.fromCharCode(lastCharCode + 1);
  517. var upper = query.slice(0, -1) + nextChar;
  518. return new Promise(function(resolve) {
  519. this.fetch({
  520. index: {
  521. name: 'search', // 'search' index on tokens array
  522. lower: query,
  523. upper: upper,
  524. excludeUpper: true
  525. }
  526. }).always(resolve);
  527. }.bind(this));
  528. }
  529. },
  530. fetchAlphabetical: function() {
  531. return new Promise(function(resolve) {
  532. this.fetch({
  533. index: {
  534. name: 'search', // 'search' index on tokens array
  535. },
  536. limit: 100
  537. }).always(resolve);
  538. }.bind(this));
  539. },
  540. fetchGroups: function(number) {
  541. return new Promise(function(resolve) {
  542. this.fetch({
  543. index: {
  544. name: 'group',
  545. only: number
  546. }
  547. }).always(resolve);
  548. }.bind(this));
  549. },
  550. fetchActive: function() {
  551. // Ensures all active conversations are included in this collection,
  552. // and updates their attributes, but removes nothing.
  553. return this.fetch({
  554. index: {
  555. name: 'inbox', // 'inbox' index on active_at
  556. order: 'desc' // ORDER timestamp DESC
  557. // TODO pagination/infinite scroll
  558. // limit: 10, offset: page*10,
  559. },
  560. remove: false
  561. });
  562. }
  563. });
  564. Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(' ');
  565. })();