messaging.js 38 KB

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