conversations.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  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. '#EF5350', // red
  10. '#EC407A', // pink
  11. '#AB47BC', // purple
  12. '#7E57C2', // deep purple
  13. '#5C6BC0', // indigo
  14. '#2196F3', // blue
  15. '#03A9F4', // light blue
  16. '#00BCD4', // cyan
  17. '#009688', // teal
  18. '#4CAF50', // green
  19. '#7CB342', // light green
  20. '#FF9800', // orange
  21. '#FF5722', // deep orange
  22. '#FFB300', // amber
  23. '#607D8B', // blue grey
  24. ];
  25. Whisper.Conversation = Backbone.Model.extend({
  26. database: Whisper.Database,
  27. storeName: 'conversations',
  28. defaults: function() {
  29. var timestamp = new Date().getTime();
  30. return {
  31. unreadCount : 0,
  32. timestamp : timestamp,
  33. };
  34. },
  35. initialize: function() {
  36. this.contactCollection = new Backbone.Collection();
  37. this.messageCollection = new Whisper.MessageCollection([], {
  38. conversation: this
  39. });
  40. this.on('change:avatar', this.updateAvatarUrl);
  41. this.on('destroy', this.revokeAvatarUrl);
  42. },
  43. validate: function(attributes, options) {
  44. var required = ['id', 'type'];
  45. var missing = _.filter(required, function(attr) { return !attributes[attr]; });
  46. if (missing.length) { return "Conversation must have " + missing; }
  47. if (attributes.type !== 'private' && attributes.type !== 'group') {
  48. return "Invalid conversation type: " + attributes.type;
  49. }
  50. var error = this.validateNumber();
  51. if (error) { return error; }
  52. this.updateTokens();
  53. },
  54. validateNumber: function() {
  55. if (this.isPrivate()) {
  56. var regionCode = storage.get('regionCode');
  57. var number = libphonenumber.util.parseNumber(this.id, regionCode);
  58. if (number.isValidNumber) {
  59. this.set({ id: number.e164 });
  60. } else {
  61. return number.error || "Invalid phone number";
  62. }
  63. }
  64. },
  65. updateTokens: function() {
  66. var tokens = [];
  67. var name = this.get('name');
  68. if (typeof name === 'string') {
  69. tokens.push(name.toLowerCase());
  70. tokens = tokens.concat(name.trim().toLowerCase().split(/[\s\-_\(\)\+]+/));
  71. }
  72. if (this.isPrivate()) {
  73. var regionCode = storage.get('regionCode');
  74. var number = libphonenumber.util.parseNumber(this.id, regionCode);
  75. tokens.push(
  76. number.nationalNumber,
  77. number.countryCode + number.nationalNumber
  78. );
  79. }
  80. this.set({tokens: tokens});
  81. },
  82. sendMessage: function(body, attachments) {
  83. var now = Date.now();
  84. var message = this.messageCollection.add({
  85. body : body,
  86. conversationId : this.id,
  87. type : 'outgoing',
  88. attachments : attachments,
  89. sent_at : now,
  90. received_at : now
  91. });
  92. if (this.isPrivate()) {
  93. message.set({destination: this.id});
  94. }
  95. message.save();
  96. this.save({
  97. unreadCount : 0,
  98. active_at : now,
  99. timestamp : now,
  100. lastMessage : message.getNotificationText()
  101. });
  102. var sendFunc;
  103. if (this.get('type') == 'private') {
  104. sendFunc = textsecure.messaging.sendMessageToNumber;
  105. }
  106. else {
  107. sendFunc = textsecure.messaging.sendMessageToGroup;
  108. }
  109. message.send(sendFunc(this.get('id'), body, attachments, now));
  110. },
  111. endSession: function() {
  112. if (this.isPrivate()) {
  113. var now = Date.now();
  114. var message = this.messageCollection.create({
  115. conversationId : this.id,
  116. type : 'outgoing',
  117. sent_at : now,
  118. received_at : now,
  119. flags : textsecure.protobuf.DataMessage.Flags.END_SESSION
  120. });
  121. message.send(textsecure.messaging.closeSession(this.id, now));
  122. }
  123. },
  124. updateGroup: function(group_update) {
  125. if (this.isPrivate()) {
  126. throw new Error("Called update group on private conversation");
  127. }
  128. if (group_update === undefined) {
  129. group_update = this.pick(['name', 'avatar', 'members']);
  130. }
  131. var now = Date.now();
  132. var message = this.messageCollection.create({
  133. conversationId : this.id,
  134. type : 'outgoing',
  135. sent_at : now,
  136. received_at : now,
  137. group_update : group_update
  138. });
  139. message.send(textsecure.messaging.updateGroup(
  140. this.id,
  141. this.get('name'),
  142. this.get('avatar'),
  143. this.get('members')
  144. ));
  145. },
  146. leaveGroup: function() {
  147. var now = Date.now();
  148. if (this.get('type') === 'group') {
  149. var message = this.messageCollection.create({
  150. group_update: { left: 'You' },
  151. conversationId : this.id,
  152. type : 'outgoing',
  153. sent_at : now,
  154. received_at : now
  155. });
  156. message.send(textsecure.messaging.leaveGroup(this.id));
  157. }
  158. },
  159. markRead: function() {
  160. if (this.get('unreadCount') > 0) {
  161. this.save({unreadCount: 0});
  162. var conversationId = this.id;
  163. Whisper.Notifications.remove(
  164. Whisper.Notifications.models.filter(
  165. function(model) {
  166. return model.attributes.conversationId===conversationId;
  167. }));
  168. }
  169. },
  170. fetchMessages: function() {
  171. if (!this.id) { return false; }
  172. return this.messageCollection.fetchConversation(this.id);
  173. },
  174. fetchContacts: function(options) {
  175. return new Promise(function(resolve) {
  176. if (this.isPrivate()) {
  177. this.contactCollection.reset([this]);
  178. resolve();
  179. } else {
  180. var promises = [];
  181. var members = this.get('members') || [];
  182. this.contactCollection.reset(
  183. members.map(function(number) {
  184. var c = ConversationController.create({
  185. id : number,
  186. type : 'private'
  187. });
  188. promises.push(new Promise(function(resolve) {
  189. c.fetch().always(resolve);
  190. }));
  191. return c;
  192. }.bind(this))
  193. );
  194. resolve(Promise.all(promises));
  195. }
  196. }.bind(this));
  197. },
  198. destroyMessages: function() {
  199. this.messageCollection.fetch({
  200. index: {
  201. // 'conversation' index on [conversationId, received_at]
  202. name : 'conversation',
  203. lower : [this.id],
  204. upper : [this.id, Number.MAX_VALUE],
  205. }
  206. }).then(function() {
  207. var models = this.messageCollection.models;
  208. this.messageCollection.reset([]);
  209. _.each(models, function(message) { message.destroy(); });
  210. this.save({active_at: null, lastMessage: ''}); // archive
  211. }.bind(this));
  212. },
  213. getTitle: function() {
  214. if (this.isPrivate()) {
  215. return this.get('name') || this.getNumber();
  216. } else {
  217. return this.get('name') || 'Unknown group';
  218. }
  219. },
  220. getNumber: function() {
  221. if (!this.isPrivate()) {
  222. return '';
  223. }
  224. var number = this.id;
  225. try {
  226. var parsedNumber = libphonenumber.parse(number);
  227. var regionCode = libphonenumber.getRegionCodeForNumber(parsedNumber);
  228. if (regionCode === storage.get('regionCode')) {
  229. return libphonenumber.format(parsedNumber, libphonenumber.PhoneNumberFormat.NATIONAL);
  230. } else {
  231. return libphonenumber.format(parsedNumber, libphonenumber.PhoneNumberFormat.INTERNATIONAL);
  232. }
  233. } catch (e) {
  234. return number;
  235. }
  236. },
  237. isPrivate: function() {
  238. return this.get('type') === 'private';
  239. },
  240. revokeAvatarUrl: function() {
  241. if (this.avatarUrl) {
  242. URL.revokeObjectURL(this.avatarUrl);
  243. this.avatarUrl = null;
  244. }
  245. },
  246. updateAvatarUrl: function(silent) {
  247. this.revokeAvatarUrl();
  248. var avatar = this.get('avatar');
  249. if (avatar) {
  250. this.avatarUrl = URL.createObjectURL(
  251. new Blob([avatar.data], {type: avatar.contentType})
  252. );
  253. } else {
  254. this.avatarUrl = null;
  255. }
  256. if (!silent) {
  257. this.trigger('change');
  258. }
  259. },
  260. getAvatar: function() {
  261. if (this.avatarUrl === undefined) {
  262. this.updateAvatarUrl(true);
  263. }
  264. if (this.avatarUrl) {
  265. return { url: this.avatarUrl };
  266. } else if (this.isPrivate()) {
  267. var title = this.get('name');
  268. if (!title) {
  269. return { content: '#', color: '#999999' };
  270. }
  271. var initials = title.trim()[0];
  272. return {
  273. color: COLORS[Math.abs(this.hashCode()) % 15],
  274. content: initials
  275. };
  276. } else {
  277. return { url: '/images/group_default.png', color: 'gray' };
  278. }
  279. },
  280. getNotificationIcon: function() {
  281. return new Promise(function(resolve) {
  282. var avatar = this.getAvatar();
  283. if (avatar.url) {
  284. resolve(avatar.url);
  285. } else {
  286. resolve(new Whisper.IdenticonSVGView(avatar).getDataUrl());
  287. }
  288. }.bind(this));
  289. },
  290. resolveConflicts: function(conflict) {
  291. var number = conflict.number;
  292. var identityKey = conflict.identityKey;
  293. if (this.isPrivate()) {
  294. number = this.id;
  295. } else if (!_.include(this.get('members'), number)) {
  296. throw 'Tried to resolve conflicts for a unknown group member';
  297. }
  298. if (!this.messageCollection.hasKeyConflicts()) {
  299. throw 'No conflicts to resolve';
  300. }
  301. return textsecure.storage.axolotl.removeIdentityKey(number).then(function() {
  302. return textsecure.storage.axolotl.putIdentityKey(number, identityKey).then(function() {
  303. var promise = Promise.resolve();
  304. this.messageCollection.each(function(message) {
  305. if (message.hasKeyConflict(number)) {
  306. var resolveConflict = function() {
  307. return message.resolveConflict(number);
  308. };
  309. promise = promise.then(resolveConflict, resolveConflict);
  310. }
  311. });
  312. return promise;
  313. }.bind(this));
  314. }.bind(this));
  315. },
  316. notify: function(message) {
  317. if (!message.isIncoming()) {
  318. this.markRead();
  319. return;
  320. }
  321. if (window.isOpen() && window.isFocused()) {
  322. return;
  323. }
  324. window.drawAttention();
  325. var sender = ConversationController.create({
  326. id: message.get('source'), type: 'private'
  327. });
  328. var conversationId = this.id;
  329. sender.fetch().then(function() {
  330. sender.getNotificationIcon().then(function(iconUrl) {
  331. Whisper.Notifications.add({
  332. title : sender.getTitle(),
  333. message : message.getNotificationText(),
  334. iconUrl : iconUrl,
  335. imageUrl : message.getImageUrl(),
  336. conversationId : conversationId
  337. });
  338. });
  339. });
  340. },
  341. hashCode: function() {
  342. if (this.hash === undefined) {
  343. var string = this.getTitle() || '';
  344. if (string.length === 0) {
  345. return 0;
  346. }
  347. var hash = 0;
  348. for (var i = 0; i < string.length; i++) {
  349. hash = ((hash<<5)-hash) + string.charCodeAt(i);
  350. hash = hash & hash; // Convert to 32bit integer
  351. }
  352. this.hash = hash;
  353. }
  354. return this.hash;
  355. }
  356. });
  357. Whisper.ConversationCollection = Backbone.Collection.extend({
  358. database: Whisper.Database,
  359. storeName: 'conversations',
  360. model: Whisper.Conversation,
  361. comparator: function(m) {
  362. return -m.get('timestamp');
  363. },
  364. destroyAll: function () {
  365. return Promise.all(this.models.map(function(m) {
  366. return new Promise(function(resolve, reject) {
  367. m.destroy().then(resolve).fail(reject);
  368. });
  369. }));
  370. },
  371. search: function(query) {
  372. query = query.trim().toLowerCase();
  373. if (query.length > 0) {
  374. query = query.replace(/[-.\(\)]*/g,'').replace(/^\+(\d*)$/, '$1');
  375. var lastCharCode = query.charCodeAt(query.length - 1);
  376. var nextChar = String.fromCharCode(lastCharCode + 1);
  377. var upper = query.slice(0, -1) + nextChar;
  378. return new Promise(function(resolve) {
  379. this.fetch({
  380. index: {
  381. name: 'search', // 'search' index on tokens array
  382. lower: query,
  383. upper: upper,
  384. excludeUpper: true
  385. }
  386. }).always(resolve);
  387. }.bind(this));
  388. }
  389. },
  390. fetchAlphabetical: function() {
  391. return new Promise(function(resolve) {
  392. this.fetch({
  393. index: {
  394. name: 'search', // 'search' index on tokens array
  395. },
  396. limit: 100
  397. }).always(resolve);
  398. }.bind(this));
  399. },
  400. fetchGroups: function(number) {
  401. return this.fetch({
  402. index: {
  403. name: 'group',
  404. only: number
  405. }
  406. });
  407. },
  408. fetchActive: function() {
  409. // Ensures all active conversations are included in this collection,
  410. // and updates their attributes, but removes nothing.
  411. return this.fetch({
  412. index: {
  413. name: 'inbox', // 'inbox' index on active_at
  414. order: 'desc' // ORDER timestamp DESC
  415. // TODO pagination/infinite scroll
  416. // limit: 10, offset: page*10,
  417. },
  418. remove: false
  419. });
  420. }
  421. });
  422. })();