messaging.js 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. 'use strict';
  5. const Tweener = imports.tweener.tweener;
  6. const Gdk = imports.gi.Gdk;
  7. const GLib = imports.gi.GLib;
  8. const GObject = imports.gi.GObject;
  9. const Gtk = imports.gi.Gtk;
  10. const Pango = imports.gi.Pango;
  11. const Contacts = imports.service.ui.contacts;
  12. const Sms = imports.service.plugins.sms;
  13. const URI = imports.service.utils.uri;
  14. const _ui = imports.service.utils.ui;
  15. /*
  16. * Useful time constants
  17. */
  18. const TIME_SPAN_MINUTE = 60000;
  19. const TIME_SPAN_HOUR = 3600000;
  20. const TIME_SPAN_DAY = 86400000;
  21. const TIME_SPAN_WEEK = 604800000;
  22. // Less than an hour (eg. 42 minutes ago)
  23. const _lthLong = new Intl.RelativeTimeFormat('default', {
  24. numeric: 'auto',
  25. style: 'long',
  26. });
  27. // Less than a day ago (eg. 11:42 PM)
  28. const _ltdFormat = new Intl.DateTimeFormat('default', {
  29. hour: 'numeric',
  30. minute: 'numeric',
  31. });
  32. // Less than a week ago (eg. Monday)
  33. const _ltwLong = new Intl.DateTimeFormat('default', {
  34. weekday: 'long',
  35. });
  36. // Less than a week ago (eg. Mon)
  37. const _ltwShort = new Intl.DateTimeFormat('default', {
  38. weekday: 'short',
  39. });
  40. // Less than a year (eg. Oct 31)
  41. const _ltyShort = new Intl.DateTimeFormat('default', {
  42. day: 'numeric',
  43. month: 'short',
  44. });
  45. // Less than a year (eg. October 31)
  46. const _ltyLong = new Intl.DateTimeFormat('default', {
  47. day: 'numeric',
  48. month: 'long',
  49. });
  50. // Greater than a year (eg. October 31, 2019)
  51. const _gtyLong = new Intl.DateTimeFormat('default', {
  52. day: 'numeric',
  53. month: 'long',
  54. year: 'numeric',
  55. });
  56. // Greater than a year (eg. 10/31/2019)
  57. const _gtyShort = new Intl.DateTimeFormat('default', {
  58. day: 'numeric',
  59. month: 'numeric',
  60. year: 'numeric',
  61. });
  62. // Pretty close to strftime's %c
  63. const _cFormat = new Intl.DateTimeFormat('default', {
  64. year: 'numeric',
  65. month: 'short',
  66. day: 'numeric',
  67. weekday: 'short',
  68. hour: 'numeric',
  69. minute: 'numeric',
  70. second: 'numeric',
  71. timeZoneName: 'short',
  72. });
  73. /**
  74. * Return a human-readable timestamp, formatted for longer contexts.
  75. *
  76. * @param {number} time - Milliseconds since the epoch (local time)
  77. * @return {string} A localized timestamp similar to what Android Messages uses
  78. */
  79. function getTime(time) {
  80. const date = new Date(time);
  81. const now = new Date();
  82. const diff = now - time;
  83. // Super recent
  84. if (diff < TIME_SPAN_MINUTE)
  85. // TRANSLATORS: Less than a minute ago
  86. return _('Just now');
  87. // Under an hour (TODO: these labels aren't updated)
  88. if (diff < TIME_SPAN_HOUR)
  89. return _lthLong.format(-Math.floor(diff / TIME_SPAN_MINUTE), 'minute');
  90. // Yesterday, but less than 24 hours ago
  91. if (diff < TIME_SPAN_DAY && now.getDay() !== date.getDay())
  92. // TRANSLATORS: Yesterday, but less than 24 hours (eg. Yesterday · 11:29 PM)
  93. return _('Yesterday・%s').format(_ltdFormat.format(time));
  94. // Less than a day ago
  95. if (diff < TIME_SPAN_DAY)
  96. return _ltdFormat.format(time);
  97. // Less than a week ago
  98. if (diff < TIME_SPAN_WEEK)
  99. return _ltwLong.format(time);
  100. // Sometime this year
  101. if (date.getFullYear() === now.getFullYear())
  102. return _ltyLong.format(time);
  103. // Earlier than that
  104. return _gtyLong.format(time);
  105. }
  106. /**
  107. * Return a human-readable timestamp, formatted for shorter contexts.
  108. *
  109. * @param {number} time - Milliseconds since the epoch (local time)
  110. * @return {string} A localized timestamp similar to what Android Messages uses
  111. */
  112. function getShortTime(time) {
  113. const date = new Date(time);
  114. const now = new Date();
  115. const diff = now - time;
  116. if (diff < TIME_SPAN_MINUTE)
  117. // TRANSLATORS: Less than a minute ago
  118. return _('Just now');
  119. if (diff < TIME_SPAN_HOUR) {
  120. // TRANSLATORS: Time duration in minutes (eg. 15 minutes)
  121. return ngettext(
  122. '%d minute',
  123. '%d minutes',
  124. (diff / TIME_SPAN_MINUTE)
  125. ).format(diff / TIME_SPAN_MINUTE);
  126. }
  127. // Less than a day ago
  128. if (diff < TIME_SPAN_DAY)
  129. return _ltdFormat.format(time);
  130. // Less than a week ago
  131. if (diff < TIME_SPAN_WEEK)
  132. return _ltwShort.format(time);
  133. // Sometime this year
  134. if (date.getFullYear() === now.getFullYear())
  135. return _ltyShort.format(time);
  136. // Earlier than that
  137. return _gtyShort.format(time);
  138. }
  139. /**
  140. * Return a human-readable timestamp, similar to `strftime()` with `%c`.
  141. *
  142. * @param {number} time - Milliseconds since the epoch (local time)
  143. * @return {string} A localized timestamp
  144. */
  145. function getDetailedTime(time) {
  146. return _cFormat.format(time);
  147. }
  148. function setAvatarVisible(row, visible) {
  149. const incoming = row.message.type === Sms.MessageBox.INBOX;
  150. // Adjust the margins
  151. if (visible) {
  152. row.grid.margin_start = incoming ? 6 : 56;
  153. row.grid.margin_bottom = 6;
  154. } else {
  155. row.grid.margin_start = incoming ? 44 : 56;
  156. row.grid.margin_bottom = 0;
  157. }
  158. // Show hide the avatar
  159. if (incoming)
  160. row.avatar.visible = visible;
  161. }
  162. /**
  163. * A ListBoxRow for a preview of a conversation
  164. */
  165. const ConversationMessage = GObject.registerClass({
  166. GTypeName: 'GSConnectMessagingConversationMessage',
  167. Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/messaging-conversation-message.ui',
  168. Children: ['grid', 'avatar', 'sender-label', 'message-label'],
  169. }, class ConversationMessage extends Gtk.ListBoxRow {
  170. _init(contact, message) {
  171. super._init();
  172. this.contact = contact;
  173. this.message = message;
  174. // Sort properties
  175. this.sender = message.addresses[0].address || 'unknown';
  176. this.message_label.label = URI.linkify(message.body);
  177. this.message_label.tooltip_text = getDetailedTime(message.date);
  178. // Add avatar for incoming messages
  179. if (message.type === Sms.MessageBox.INBOX) {
  180. this.grid.margin_end = 18;
  181. this.grid.halign = Gtk.Align.START;
  182. this.avatar.contact = this.contact;
  183. this.avatar.visible = true;
  184. this.sender_label.label = contact.name;
  185. this.sender_label.visible = true;
  186. this.message_label.get_style_context().add_class('message-in');
  187. this.message_label.halign = Gtk.Align.START;
  188. } else {
  189. this.message_label.get_style_context().add_class('message-out');
  190. }
  191. }
  192. _onActivateLink(label, uri) {
  193. Gtk.show_uri_on_window(
  194. this.get_toplevel(),
  195. uri.includes('://') ? uri : `https://${uri}`,
  196. Gtk.get_current_event_time()
  197. );
  198. return true;
  199. }
  200. get date() {
  201. return this._message.date;
  202. }
  203. get thread_id() {
  204. return this._message.thread_id;
  205. }
  206. get message() {
  207. if (this._message === undefined)
  208. this._message = null;
  209. return this._message;
  210. }
  211. set message(message) {
  212. this._message = message;
  213. }
  214. });
  215. /**
  216. * A widget for displaying a conversation thread, with an entry for responding.
  217. */
  218. const Conversation = GObject.registerClass({
  219. GTypeName: 'GSConnectMessagingConversation',
  220. Properties: {
  221. 'device': GObject.ParamSpec.object(
  222. 'device',
  223. 'Device',
  224. 'The device associated with this conversation',
  225. GObject.ParamFlags.READWRITE,
  226. GObject.Object
  227. ),
  228. 'plugin': GObject.ParamSpec.object(
  229. 'plugin',
  230. 'Plugin',
  231. 'The plugin providing this conversation',
  232. GObject.ParamFlags.READWRITE,
  233. GObject.Object
  234. ),
  235. 'has-pending': GObject.ParamSpec.boolean(
  236. 'has-pending',
  237. 'Has Pending',
  238. 'Whether there are sent messages pending confirmation',
  239. GObject.ParamFlags.READABLE,
  240. false
  241. ),
  242. 'thread-id': GObject.ParamSpec.string(
  243. 'thread-id',
  244. 'Thread ID',
  245. 'The current thread',
  246. GObject.ParamFlags.READWRITE,
  247. ''
  248. ),
  249. },
  250. Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/messaging-conversation.ui',
  251. Children: [
  252. 'entry', 'list', 'scrolled',
  253. 'pending', 'pending-box',
  254. ],
  255. }, class MessagingConversation extends Gtk.Grid {
  256. _init(params) {
  257. super._init({
  258. device: params.device,
  259. plugin: params.plugin,
  260. });
  261. Object.assign(this, params);
  262. this.device.bind_property(
  263. 'connected',
  264. this.entry,
  265. 'sensitive',
  266. GObject.BindingFlags.SYNC_CREATE
  267. );
  268. // If we're disconnected pending messages might not succeed, but we'll
  269. // leave them until reconnect when we'll ask for an update
  270. this._connectedId = this.device.connect(
  271. 'notify::connected',
  272. this._onConnected.bind(this)
  273. );
  274. // Pending messages
  275. this.pending.message = {
  276. date: Number.MAX_SAFE_INTEGER,
  277. type: Sms.MessageBox.OUTBOX,
  278. };
  279. // Auto-scrolling
  280. this._vadj = this.scrolled.get_vadjustment();
  281. this._scrolledId = this._vadj.connect(
  282. 'value-changed',
  283. this._holdPosition.bind(this)
  284. );
  285. // Message List
  286. this.list.set_header_func(this._headerMessages);
  287. this.list.set_sort_func(this._sortMessages);
  288. this._populateMessages();
  289. // Cleanup on ::destroy
  290. this.connect('destroy', this._onDestroy);
  291. }
  292. get addresses() {
  293. if (this._addresses === undefined)
  294. this._addresses = [];
  295. return this._addresses;
  296. }
  297. set addresses(addresses) {
  298. if (!addresses || addresses.length === 0) {
  299. this._addresses = [];
  300. this._contacts = {};
  301. return;
  302. }
  303. // Lookup a contact for each address object, then loop back to correct
  304. // each address carried by the message.
  305. this._addresses = addresses;
  306. for (let i = 0, len = this.addresses.length; i < len; i++) {
  307. // Lookup the contact
  308. const address = this.addresses[i].address;
  309. const contact = this.device.contacts.query({number: address});
  310. // Get corrected address
  311. let number = address.toPhoneNumber();
  312. if (!number)
  313. continue;
  314. for (const contactNumber of contact.numbers) {
  315. const cnumber = contactNumber.value.toPhoneNumber();
  316. if (cnumber && (number.endsWith(cnumber) || cnumber.endsWith(number))) {
  317. number = contactNumber.value;
  318. break;
  319. }
  320. }
  321. // Store the final result
  322. this.addresses[i].address = number;
  323. this.contacts[address] = contact;
  324. }
  325. // TODO: Mark the entry as insensitive for group messages
  326. if (this.addresses.length > 1) {
  327. this.entry.placeholder_text = _('Not available');
  328. this.entry.secondary_icon_name = null;
  329. this.entry.secondary_icon_tooltip_text = null;
  330. this.entry.sensitive = false;
  331. this.entry.tooltip_text = null;
  332. }
  333. }
  334. get contacts() {
  335. if (this._contacts === undefined)
  336. this._contacts = {};
  337. return this._contacts;
  338. }
  339. get has_pending() {
  340. if (this.pending_box === undefined)
  341. return false;
  342. return (this.pending_box.get_children().length > 0);
  343. }
  344. get plugin() {
  345. if (this._plugin === undefined)
  346. this._plugin = null;
  347. return this._plugin;
  348. }
  349. set plugin(plugin) {
  350. this._plugin = plugin;
  351. }
  352. get thread_id() {
  353. if (this._thread_id === undefined)
  354. this._thread_id = null;
  355. return this._thread_id;
  356. }
  357. set thread_id(thread_id) {
  358. const thread = this.plugin.threads[thread_id];
  359. const message = (thread) ? thread[0] : null;
  360. if (message && this.addresses.length === 0) {
  361. this.addresses = message.addresses;
  362. this._thread_id = thread_id;
  363. }
  364. }
  365. _onConnected(device) {
  366. if (device.connected)
  367. this.pending_box.foreach(msg => msg.destroy());
  368. }
  369. _onDestroy(conversation) {
  370. conversation.device.disconnect(conversation._connectedId);
  371. conversation._vadj.disconnect(conversation._scrolledId);
  372. conversation.list.foreach(message => {
  373. // HACK: temporary mitigator for mysterious GtkListBox leak
  374. message.destroy();
  375. imports.system.gc();
  376. });
  377. }
  378. _onEdgeReached(scrolled_window, pos) {
  379. // Try to load more messages
  380. if (pos === Gtk.PositionType.TOP)
  381. this.logPrevious();
  382. // Release any hold to resume auto-scrolling
  383. else if (pos === Gtk.PositionType.BOTTOM)
  384. this._releasePosition();
  385. }
  386. _onEntryChanged(entry) {
  387. entry.secondary_icon_sensitive = (entry.text.length);
  388. }
  389. _onKeyPressEvent(entry, event) {
  390. const keyval = event.get_keyval()[1];
  391. const state = event.get_state()[1];
  392. const mask = state & Gtk.accelerator_get_default_mod_mask();
  393. if (keyval === Gdk.KEY_Return && (mask & Gdk.ModifierType.SHIFT_MASK)) {
  394. entry.emit('insert-at-cursor', '\n');
  395. return true;
  396. }
  397. return false;
  398. }
  399. _onSendMessage(entry, signal_id, event) {
  400. // Don't send empty texts
  401. if (!this.entry.text.trim())
  402. return;
  403. // Send the message
  404. this.plugin.sendMessage(this.addresses, this.entry.text);
  405. // Add a phony message in the pending box
  406. const message = new Gtk.Label({
  407. label: URI.linkify(this.entry.text),
  408. halign: Gtk.Align.END,
  409. selectable: true,
  410. use_markup: true,
  411. visible: true,
  412. wrap: true,
  413. wrap_mode: Pango.WrapMode.WORD_CHAR,
  414. xalign: 0,
  415. });
  416. message.get_style_context().add_class('message-out');
  417. message.date = Date.now();
  418. message.type = Sms.MessageBox.SENT;
  419. // Notify to reveal the pending box
  420. this.pending_box.add(message);
  421. this.notify('has-pending');
  422. // Clear the entry
  423. this.entry.text = '';
  424. }
  425. _onSizeAllocate(listbox, allocation) {
  426. const upper = this._vadj.get_upper();
  427. const pageSize = this._vadj.get_page_size();
  428. // If the scrolled window hasn't been filled yet, load another message
  429. if (upper <= pageSize) {
  430. this.logPrevious();
  431. this.scrolled.get_child().check_resize();
  432. // We've been asked to hold the position, so we'll reset the adjustment
  433. // value and update the hold position
  434. } else if (this.__pos) {
  435. this._vadj.set_value(upper - this.__pos);
  436. // Otherwise we probably appended a message and should scroll to it
  437. } else {
  438. this._scrollPosition(Gtk.PositionType.BOTTOM);
  439. }
  440. }
  441. /**
  442. * Create a message row, ensuring a contact object has been retrieved or
  443. * generated for the message.
  444. *
  445. * @param {Object} message - A dictionary of message data
  446. * @return {ConversationMessage} A message row
  447. */
  448. _createMessageRow(message) {
  449. // Ensure we have a contact
  450. const sender = message.addresses[0].address || 'unknown';
  451. if (this.contacts[sender] === undefined) {
  452. this.contacts[sender] = this.device.contacts.query({
  453. number: sender,
  454. });
  455. }
  456. return new ConversationMessage(this.contacts[sender], message);
  457. }
  458. _populateMessages() {
  459. this.__first = null;
  460. this.__last = null;
  461. this.__pos = 0;
  462. this.__messages = [];
  463. // Try and find a thread_id for this number
  464. if (this.thread_id === null && this.addresses.length)
  465. this._thread_id = this.plugin.getThreadIdForAddresses(this.addresses);
  466. // Make a copy of the thread and fill the window with messages
  467. if (this.plugin.threads[this.thread_id]) {
  468. this.__messages = this.plugin.threads[this.thread_id].slice(0);
  469. this.logPrevious();
  470. }
  471. }
  472. _headerMessages(row, before) {
  473. // Skip pending
  474. if (row.get_name() === 'pending')
  475. return;
  476. if (before === null)
  477. return setAvatarVisible(row, true);
  478. // Add date header if the last message was more than an hour ago
  479. let header = row.get_header();
  480. if ((row.message.date - before.message.date) > TIME_SPAN_HOUR) {
  481. if (!header) {
  482. header = new Gtk.Label({visible: true, selectable: true});
  483. header.get_style_context().add_class('dim-label');
  484. row.set_header(header);
  485. }
  486. header.label = getTime(row.message.date);
  487. // Also show the avatar
  488. setAvatarVisible(row, true);
  489. row.sender_label.visible = row.message.addresses.length > 1;
  490. // Or if the previous sender was the same, hide its avatar
  491. } else if (row.message.type === before.message.type &&
  492. row.sender.equalsPhoneNumber(before.sender)) {
  493. setAvatarVisible(before, false);
  494. setAvatarVisible(row, true);
  495. row.sender_label.visible = false;
  496. // otherwise show the avatar
  497. } else {
  498. setAvatarVisible(row, true);
  499. }
  500. }
  501. _holdPosition() {
  502. this.__pos = this._vadj.get_upper() - this._vadj.get_value();
  503. }
  504. _releasePosition() {
  505. this.__pos = 0;
  506. }
  507. _scrollPosition(pos = Gtk.PositionType.BOTTOM, animate = true) {
  508. let vpos = pos;
  509. this._vadj.freeze_notify();
  510. if (pos === Gtk.PositionType.BOTTOM)
  511. vpos = this._vadj.get_upper() - this._vadj.get_page_size();
  512. if (animate) {
  513. Tweener.addTween(this._vadj, {
  514. value: vpos,
  515. time: 0.5,
  516. transition: 'easeInOutCubic',
  517. onComplete: () => this._vadj.thaw_notify(),
  518. });
  519. } else {
  520. GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
  521. this._vadj.set_value(vpos);
  522. this._vadj.thaw_notify();
  523. });
  524. }
  525. }
  526. _sortMessages(row1, row2) {
  527. return (row1.message.date > row2.message.date) ? 1 : -1;
  528. }
  529. /**
  530. * Log the next message in the conversation.
  531. *
  532. * @param {Object} message - A message object
  533. */
  534. logNext(message) {
  535. try {
  536. // TODO: Unsupported MessageBox
  537. if (message.type !== Sms.MessageBox.INBOX &&
  538. message.type !== Sms.MessageBox.SENT)
  539. throw TypeError(`invalid message box ${message.type}`);
  540. // Append the message
  541. const row = this._createMessageRow(message);
  542. this.list.add(row);
  543. this.list.invalidate_headers();
  544. // Remove the first pending message
  545. if (this.has_pending && message.type === Sms.MessageBox.SENT) {
  546. this.pending_box.get_children()[0].destroy();
  547. this.notify('has-pending');
  548. }
  549. } catch (e) {
  550. debug(e);
  551. }
  552. }
  553. /**
  554. * Log the previous message in the thread
  555. */
  556. logPrevious() {
  557. try {
  558. const message = this.__messages.pop();
  559. if (!message)
  560. return;
  561. // TODO: Unsupported MessageBox
  562. if (message.type !== Sms.MessageBox.INBOX &&
  563. message.type !== Sms.MessageBox.SENT)
  564. throw TypeError(`invalid message box ${message.type}`);
  565. // Prepend the message
  566. const row = this._createMessageRow(message);
  567. this.list.prepend(row);
  568. this.list.invalidate_headers();
  569. } catch (e) {
  570. debug(e);
  571. }
  572. }
  573. /**
  574. * Set the contents of the message entry
  575. *
  576. * @param {string} text - The message to place in the entry
  577. */
  578. setMessage(text) {
  579. this.entry.text = text;
  580. this.entry.emit('move-cursor', 0, text.length, false);
  581. }
  582. });
  583. /**
  584. * A ListBoxRow for a preview of a conversation
  585. */
  586. const ConversationSummary = GObject.registerClass({
  587. GTypeName: 'GSConnectMessagingConversationSummary',
  588. Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/messaging-conversation-summary.ui',
  589. Children: ['avatar', 'name-label', 'time-label', 'body-label'],
  590. }, class ConversationSummary extends Gtk.ListBoxRow {
  591. _init(contacts, message) {
  592. super._init();
  593. this.contacts = contacts;
  594. this.message = message;
  595. }
  596. get date() {
  597. return this._message.date;
  598. }
  599. get thread_id() {
  600. return this._message.thread_id;
  601. }
  602. get message() {
  603. return this._message;
  604. }
  605. set message(message) {
  606. this._message = message;
  607. this._sender = message.addresses[0].address || 'unknown';
  608. // Contact Name
  609. let nameLabel = _('Unknown Contact');
  610. // Update avatar for single-recipient messages
  611. if (message.addresses.length === 1) {
  612. this.avatar.contact = this.contacts[this._sender];
  613. nameLabel = GLib.markup_escape_text(this.avatar.contact.name, -1);
  614. } else {
  615. this.avatar.contact = null;
  616. nameLabel = _('Group Message');
  617. const participants = [];
  618. message.addresses.forEach((address) => {
  619. participants.push(this.contacts[address.address].name);
  620. });
  621. this.name_label.tooltip_text = participants.join(', ');
  622. }
  623. // Contact Name & Message body
  624. let bodyLabel = message.body.split(/\r|\n/)[0];
  625. bodyLabel = GLib.markup_escape_text(bodyLabel, -1);
  626. // Ignore the 'read' flag if it's an outgoing message
  627. if (message.type === Sms.MessageBox.SENT) {
  628. // TRANSLATORS: An outgoing message body in a conversation summary
  629. bodyLabel = _('You: %s').format(bodyLabel);
  630. // Otherwise make it bold if it's unread
  631. } else if (message.read === Sms.MessageStatus.UNREAD) {
  632. nameLabel = `<b>${nameLabel}</b>`;
  633. bodyLabel = `<b>${bodyLabel}</b>`;
  634. }
  635. // Set the labels, body always smaller
  636. this.name_label.label = nameLabel;
  637. this.body_label.label = `<small>${bodyLabel}</small>`;
  638. // Time
  639. const timeLabel = `<small>${getShortTime(message.date)}</small>`;
  640. this.time_label.label = timeLabel;
  641. }
  642. /**
  643. * Update the relative time label.
  644. */
  645. update() {
  646. const timeLabel = `<small>${getShortTime(this.message.date)}</small>`;
  647. this.time_label.label = timeLabel;
  648. }
  649. });
  650. /**
  651. * A Gtk.ApplicationWindow for SMS conversations
  652. */
  653. var Window = GObject.registerClass({
  654. GTypeName: 'GSConnectMessagingWindow',
  655. Properties: {
  656. 'device': GObject.ParamSpec.object(
  657. 'device',
  658. 'Device',
  659. 'The device associated with this window',
  660. GObject.ParamFlags.READWRITE,
  661. GObject.Object
  662. ),
  663. 'plugin': GObject.ParamSpec.object(
  664. 'plugin',
  665. 'Plugin',
  666. 'The plugin providing messages',
  667. GObject.ParamFlags.READWRITE,
  668. GObject.Object
  669. ),
  670. 'thread-id': GObject.ParamSpec.string(
  671. 'thread-id',
  672. 'Thread ID',
  673. 'The current thread',
  674. GObject.ParamFlags.READWRITE,
  675. ''
  676. ),
  677. },
  678. Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/messaging-window.ui',
  679. Children: [
  680. 'headerbar', 'infobar',
  681. 'thread-list', 'stack',
  682. ],
  683. }, class MessagingWindow extends Gtk.ApplicationWindow {
  684. _init(params) {
  685. super._init(params);
  686. this.headerbar.subtitle = this.device.name;
  687. this.insert_action_group('device', this.device);
  688. // Device Status
  689. this.device.bind_property(
  690. 'connected',
  691. this.infobar,
  692. 'reveal-child',
  693. GObject.BindingFlags.INVERT_BOOLEAN
  694. );
  695. // Contacts
  696. this.contact_chooser = new Contacts.ContactChooser({
  697. device: this.device,
  698. });
  699. this.stack.add_named(this.contact_chooser, 'contact-chooser');
  700. this._numberSelectedId = this.contact_chooser.connect(
  701. 'number-selected',
  702. this._onNumberSelected.bind(this)
  703. );
  704. // Threads
  705. this.thread_list.set_sort_func(this._sortThreads);
  706. this._threadsChangedId = this.plugin.connect(
  707. 'notify::threads',
  708. this._onThreadsChanged.bind(this)
  709. );
  710. this._timestampThreadsId = GLib.timeout_add_seconds(
  711. GLib.PRIORITY_DEFAULT_IDLE,
  712. 60,
  713. this._timestampThreads.bind(this)
  714. );
  715. this._sync();
  716. this._onThreadsChanged();
  717. this.restoreGeometry('messaging');
  718. }
  719. vfunc_delete_event(event) {
  720. this.saveGeometry();
  721. GLib.source_remove(this._timestampThreadsId);
  722. this.contact_chooser.disconnect(this._numberSelectedId);
  723. this.plugin.disconnect(this._threadsChangedId);
  724. return false;
  725. }
  726. get plugin() {
  727. return this._plugin || null;
  728. }
  729. set plugin(plugin) {
  730. this._plugin = plugin;
  731. }
  732. get thread_id() {
  733. return this.stack.visible_child_name;
  734. }
  735. set thread_id(thread_id) {
  736. thread_id = `${thread_id}`; // FIXME
  737. // Reset to the empty placeholder
  738. if (!thread_id) {
  739. this.thread_list.select_row(null);
  740. this.stack.set_visible_child_name('placeholder');
  741. return;
  742. }
  743. // Create a conversation widget if there isn't one
  744. let conversation = this.stack.get_child_by_name(thread_id);
  745. const thread = this.plugin.threads[thread_id];
  746. if (conversation === null) {
  747. if (!thread) {
  748. debug(`Thread ID ${thread_id} not found`);
  749. return;
  750. }
  751. conversation = new Conversation({
  752. device: this.device,
  753. plugin: this.plugin,
  754. thread_id: thread_id,
  755. });
  756. this.stack.add_named(conversation, thread_id);
  757. }
  758. // Figure out whether this is a multi-recipient thread
  759. this._setHeaderBar(thread[0].addresses);
  760. // Select the conversation and entry active
  761. this.stack.visible_child = conversation;
  762. this.stack.visible_child.entry.has_focus = true;
  763. // There was a pending message waiting for a conversation to be chosen
  764. if (this._pendingShare) {
  765. conversation.setMessage(this._pendingShare);
  766. this._pendingShare = null;
  767. }
  768. this._thread_id = thread_id;
  769. this.notify('thread_id');
  770. }
  771. _setHeaderBar(addresses = []) {
  772. const address = addresses[0].address;
  773. const contact = this.device.contacts.query({number: address});
  774. if (addresses.length === 1) {
  775. this.headerbar.title = contact.name;
  776. this.headerbar.subtitle = Contacts.getDisplayNumber(contact, address);
  777. } else {
  778. const otherLength = addresses.length - 1;
  779. this.headerbar.title = contact.name;
  780. this.headerbar.subtitle = ngettext(
  781. 'And %d other contact',
  782. 'And %d others',
  783. otherLength
  784. ).format(otherLength);
  785. }
  786. }
  787. _sync() {
  788. this.device.contacts.fetch();
  789. this.plugin.connected();
  790. }
  791. _onNewConversation() {
  792. this._sync();
  793. this.stack.set_visible_child_name('contact-chooser');
  794. this.thread_list.select_row(null);
  795. this.contact_chooser.entry.has_focus = true;
  796. }
  797. _onNumberSelected(chooser, number) {
  798. const contacts = chooser.getSelected();
  799. const row = this._getRowForContacts(contacts);
  800. if (row)
  801. row.emit('activate');
  802. else
  803. this.setContacts(contacts);
  804. }
  805. /**
  806. * Threads
  807. */
  808. _onThreadsChanged() {
  809. // Get the last message in each thread
  810. const messages = {};
  811. for (const [thread_id, thread] of Object.entries(this.plugin.threads)) {
  812. const message = thread[thread.length - 1];
  813. // Skip messages without a body (eg. MMS messages without text)
  814. if (message.body)
  815. messages[thread_id] = thread[thread.length - 1];
  816. }
  817. // Update existing summaries and destroy old ones
  818. for (const row of this.thread_list.get_children()) {
  819. const message = messages[row.thread_id];
  820. // If it's an existing conversation, update it
  821. if (message) {
  822. // Ensure there's a contact mapping
  823. const sender = message.addresses[0].address || 'unknown';
  824. if (row.contacts[sender] === undefined) {
  825. row.contacts[sender] = this.device.contacts.query({
  826. number: sender,
  827. });
  828. }
  829. row.message = message;
  830. delete messages[row.thread_id];
  831. // Otherwise destroy it
  832. } else {
  833. // Destroy the conversation widget
  834. const conversation = this.stack.get_child_by_name(`${row.thread_id}`);
  835. if (conversation) {
  836. conversation.destroy();
  837. imports.system.gc();
  838. }
  839. // Then the summary widget
  840. row.destroy();
  841. // HACK: temporary mitigator for mysterious GtkListBox leak
  842. imports.system.gc();
  843. }
  844. }
  845. // What's left in the dictionary is new summaries
  846. for (const message of Object.values(messages)) {
  847. const contacts = this.device.contacts.lookupAddresses(message.addresses);
  848. const conversation = new ConversationSummary(contacts, message);
  849. this.thread_list.add(conversation);
  850. }
  851. // Re-sort the summaries
  852. this.thread_list.invalidate_sort();
  853. }
  854. // GtkListBox::row-activated
  855. _onThreadSelected(box, row) {
  856. // Show the conversation for this number (if applicable)
  857. if (row) {
  858. this.thread_id = row.thread_id;
  859. // Show the placeholder
  860. } else {
  861. this.headerbar.title = _('Messaging');
  862. this.headerbar.subtitle = this.device.name;
  863. }
  864. }
  865. _sortThreads(row1, row2) {
  866. return (row1.date > row2.date) ? -1 : 1;
  867. }
  868. _timestampThreads() {
  869. if (this.visible)
  870. this.thread_list.foreach(row => row.update());
  871. return GLib.SOURCE_CONTINUE;
  872. }
  873. /**
  874. * Find the thread row for @contacts
  875. *
  876. * @param {Object[]} contacts - A contact group
  877. * @return {ConversationSummary|null} The thread row or %null
  878. */
  879. _getRowForContacts(contacts) {
  880. const addresses = Object.keys(contacts).map(address => {
  881. return {address: address};
  882. });
  883. // Try to find a thread_id
  884. const thread_id = this.plugin.getThreadIdForAddresses(addresses);
  885. for (const row of this.thread_list.get_children()) {
  886. if (row.message.thread_id === thread_id)
  887. return row;
  888. }
  889. return null;
  890. }
  891. setContacts(contacts) {
  892. // Group the addresses
  893. const addresses = [];
  894. for (const address of Object.keys(contacts))
  895. addresses.push({address: address});
  896. // Try to find a thread ID for this address group
  897. let thread_id = this.plugin.getThreadIdForAddresses(addresses);
  898. if (thread_id === null)
  899. thread_id = GLib.uuid_string_random();
  900. else
  901. thread_id = thread_id.toString();
  902. // Try to find a thread row for the ID
  903. const row = this._getRowForContacts(contacts);
  904. if (row !== null) {
  905. this.thread_list.select_row(row);
  906. return;
  907. }
  908. // We're creating a new conversation
  909. const conversation = new Conversation({
  910. device: this.device,
  911. plugin: this.plugin,
  912. addresses: addresses,
  913. });
  914. // Set the headerbar
  915. this._setHeaderBar(addresses);
  916. // Select the conversation and entry active
  917. this.stack.add_named(conversation, thread_id);
  918. this.stack.visible_child = conversation;
  919. this.stack.visible_child.entry.has_focus = true;
  920. // There was a pending message waiting for a conversation to be chosen
  921. if (this._pendingShare) {
  922. conversation.setMessage(this._pendingShare);
  923. this._pendingShare = null;
  924. }
  925. this._thread_id = thread_id;
  926. this.notify('thread-id');
  927. }
  928. _includesAddress(addresses, addressObj) {
  929. const number = addressObj.address.toPhoneNumber();
  930. for (const haystackObj of addresses) {
  931. const tnumber = haystackObj.address.toPhoneNumber();
  932. if (number.endsWith(tnumber) || tnumber.endsWith(number))
  933. return true;
  934. }
  935. return false;
  936. }
  937. /**
  938. * Try and find an existing conversation widget for @message.
  939. *
  940. * @param {Object} message - A message object
  941. * @return {Conversation|null} A conversation widget or %null
  942. */
  943. getConversationForMessage(message) {
  944. // TODO: This shouldn't happen?
  945. if (message === null)
  946. return null;
  947. // First try to find a conversation by thread_id
  948. const thread_id = `${message.thread_id}`;
  949. const conversation = this.stack.get_child_by_name(thread_id);
  950. if (conversation !== null)
  951. return conversation;
  952. // Try and find one by matching addresses, which is necessary if we've
  953. // started a thread locally and haven't set the thread_id
  954. const addresses = message.addresses;
  955. for (const conversation of this.stack.get_children()) {
  956. if (conversation.addresses === undefined ||
  957. conversation.addresses.length !== addresses.length)
  958. continue;
  959. const caddrs = conversation.addresses;
  960. // If we find a match, set `thread-id` on the conversation and the
  961. // child property `name`.
  962. if (addresses.every(addr => this._includesAddress(caddrs, addr))) {
  963. conversation._thread_id = thread_id;
  964. this.stack.child_set_property(conversation, 'name', thread_id);
  965. return conversation;
  966. }
  967. }
  968. return null;
  969. }
  970. /**
  971. * Set the contents of the message entry. If @pending is %false set the
  972. * message of the currently selected conversation, otherwise mark the
  973. * message to be set for the next selected conversation.
  974. *
  975. * @param {string} message - The message to place in the entry
  976. * @param {boolean} pending - Wait for a conversation to be selected
  977. */
  978. setMessage(message, pending = false) {
  979. try {
  980. if (pending)
  981. this._pendingShare = message;
  982. else
  983. this.stack.visible_child.setMessage(message);
  984. } catch (e) {
  985. debug(e);
  986. }
  987. }
  988. });
  989. /**
  990. * A Gtk.ApplicationWindow for selecting from open conversations
  991. */
  992. var ConversationChooser = GObject.registerClass({
  993. GTypeName: 'GSConnectConversationChooser',
  994. Properties: {
  995. 'device': GObject.ParamSpec.object(
  996. 'device',
  997. 'Device',
  998. 'The device associated with this window',
  999. GObject.ParamFlags.READWRITE,
  1000. GObject.Object
  1001. ),
  1002. 'message': GObject.ParamSpec.string(
  1003. 'message',
  1004. 'Message',
  1005. 'The message to share',
  1006. GObject.ParamFlags.READWRITE,
  1007. ''
  1008. ),
  1009. 'plugin': GObject.ParamSpec.object(
  1010. 'plugin',
  1011. 'Plugin',
  1012. 'The plugin providing messages',
  1013. GObject.ParamFlags.READWRITE,
  1014. GObject.Object
  1015. ),
  1016. },
  1017. }, class ConversationChooser extends Gtk.ApplicationWindow {
  1018. _init(params) {
  1019. super._init(Object.assign({
  1020. title: _('Share Link'),
  1021. default_width: 300,
  1022. default_height: 200,
  1023. }, params));
  1024. this.set_keep_above(true);
  1025. // HeaderBar
  1026. this.headerbar = new Gtk.HeaderBar({
  1027. title: _('Share Link'),
  1028. subtitle: this.message,
  1029. show_close_button: true,
  1030. tooltip_text: this.message,
  1031. });
  1032. this.set_titlebar(this.headerbar);
  1033. const newButton = new Gtk.Button({
  1034. image: new Gtk.Image({icon_name: 'list-add-symbolic'}),
  1035. tooltip_text: _('New Conversation'),
  1036. always_show_image: true,
  1037. });
  1038. newButton.connect('clicked', this._new.bind(this));
  1039. this.headerbar.pack_start(newButton);
  1040. // Threads
  1041. const scrolledWindow = new Gtk.ScrolledWindow({
  1042. can_focus: false,
  1043. hexpand: true,
  1044. vexpand: true,
  1045. hscrollbar_policy: Gtk.PolicyType.NEVER,
  1046. });
  1047. this.add(scrolledWindow);
  1048. this.thread_list = new Gtk.ListBox({
  1049. activate_on_single_click: false,
  1050. });
  1051. this.thread_list.set_sort_func(Window.prototype._sortThreads);
  1052. this.thread_list.connect('row-activated', this._select.bind(this));
  1053. scrolledWindow.add(this.thread_list);
  1054. // Filter Setup
  1055. Window.prototype._onThreadsChanged.call(this);
  1056. this.show_all();
  1057. }
  1058. get plugin() {
  1059. return this._plugin || null;
  1060. }
  1061. set plugin(plugin) {
  1062. this._plugin = plugin;
  1063. }
  1064. _new(button) {
  1065. const message = this.message;
  1066. this.destroy();
  1067. this.plugin.sms();
  1068. this.plugin.window._onNewConversation();
  1069. this.plugin.window._pendingShare = message;
  1070. }
  1071. _select(box, row) {
  1072. this.plugin.sms();
  1073. this.plugin.window.thread_id = row.message.thread_id.toString();
  1074. this.plugin.window.setMessage(this.message);
  1075. this.destroy();
  1076. }
  1077. });