contacts.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. 'use strict';
  5. const Gdk = imports.gi.Gdk;
  6. const GdkPixbuf = imports.gi.GdkPixbuf;
  7. const GLib = imports.gi.GLib;
  8. const GObject = imports.gi.GObject;
  9. const Gtk = imports.gi.Gtk;
  10. /**
  11. * Return a random color
  12. *
  13. * @param {*} [salt] - If not %null, will be used as salt for generating a color
  14. * @param {number} alpha - A value in the [0...1] range for the alpha channel
  15. * @return {Gdk.RGBA} A new Gdk.RGBA object generated from the input
  16. */
  17. function randomRGBA(salt = null, alpha = 1.0) {
  18. let red, green, blue;
  19. if (salt !== null) {
  20. const hash = new GLib.Variant('s', `${salt}`).hash();
  21. red = ((hash & 0xFF0000) >> 16) / 255;
  22. green = ((hash & 0x00FF00) >> 8) / 255;
  23. blue = (hash & 0x0000FF) / 255;
  24. } else {
  25. red = Math.random();
  26. green = Math.random();
  27. blue = Math.random();
  28. }
  29. return new Gdk.RGBA({red: red, green: green, blue: blue, alpha: alpha});
  30. }
  31. /**
  32. * Get the relative luminance of a RGB set
  33. * See: https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
  34. *
  35. * @param {Gdk.RGBA} rgba - A GdkRGBA object
  36. * @return {number} The relative luminance of the color
  37. */
  38. function relativeLuminance(rgba) {
  39. const {red, green, blue} = rgba;
  40. const R = (red > 0.03928) ? red / 12.92 : Math.pow(((red + 0.055) / 1.055), 2.4);
  41. const G = (green > 0.03928) ? green / 12.92 : Math.pow(((green + 0.055) / 1.055), 2.4);
  42. const B = (blue > 0.03928) ? blue / 12.92 : Math.pow(((blue + 0.055) / 1.055), 2.4);
  43. return 0.2126 * R + 0.7152 * G + 0.0722 * B;
  44. }
  45. /**
  46. * Get a GdkRGBA contrasted for the input
  47. * See: https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
  48. *
  49. * @param {Gdk.RGBA} rgba - A GdkRGBA object for the background color
  50. * @return {Gdk.RGBA} A GdkRGBA object for the foreground color
  51. */
  52. function getFgRGBA(rgba) {
  53. const bgLuminance = relativeLuminance(rgba);
  54. const lightContrast = (0.07275541795665634 + 0.05) / (bgLuminance + 0.05);
  55. const darkContrast = (bgLuminance + 0.05) / (0.0046439628482972135 + 0.05);
  56. const value = (darkContrast > lightContrast) ? 0.06 : 0.94;
  57. return new Gdk.RGBA({red: value, green: value, blue: value, alpha: 0.5});
  58. }
  59. /**
  60. * Get a GdkPixbuf for @path, allowing the corrupt JPEG's KDE Connect sometimes
  61. * sends. This function is synchronous.
  62. *
  63. * @param {string} path - A local file path
  64. * @param {number} size - Size in pixels
  65. * @param {scale} [scale] - Scale factor for the size
  66. * @return {Gdk.Pixbuf} A pixbuf
  67. */
  68. function getPixbufForPath(path, size, scale = 1.0) {
  69. let data, loader;
  70. // Catch missing avatar files
  71. try {
  72. data = GLib.file_get_contents(path)[1];
  73. } catch (e) {
  74. debug(e, path);
  75. return undefined;
  76. }
  77. // Consider errors from partially corrupt JPEGs to be warnings
  78. try {
  79. loader = new GdkPixbuf.PixbufLoader();
  80. loader.write(data);
  81. loader.close();
  82. } catch (e) {
  83. debug(e, path);
  84. }
  85. const pixbuf = loader.get_pixbuf();
  86. // Scale to monitor
  87. size = Math.floor(size * scale);
  88. return pixbuf.scale_simple(size, size, GdkPixbuf.InterpType.HYPER);
  89. }
  90. function getPixbufForIcon(name, size, scale, bgColor) {
  91. const color = getFgRGBA(bgColor);
  92. const theme = Gtk.IconTheme.get_default();
  93. const info = theme.lookup_icon_for_scale(
  94. name,
  95. size,
  96. scale,
  97. Gtk.IconLookupFlags.FORCE_SYMBOLIC
  98. );
  99. return info.load_symbolic(color, null, null, null)[0];
  100. }
  101. /**
  102. * Return a localized string for a phone number type
  103. * See: http://www.ietf.org/rfc/rfc2426.txt
  104. *
  105. * @param {string} type - An RFC2426 phone number type
  106. * @return {string} A localized string like 'Mobile'
  107. */
  108. function getNumberTypeLabel(type) {
  109. if (type.includes('fax'))
  110. // TRANSLATORS: A fax number
  111. return _('Fax');
  112. if (type.includes('work'))
  113. // TRANSLATORS: A work or office phone number
  114. return _('Work');
  115. if (type.includes('cell'))
  116. // TRANSLATORS: A mobile or cellular phone number
  117. return _('Mobile');
  118. if (type.includes('home'))
  119. // TRANSLATORS: A home phone number
  120. return _('Home');
  121. // TRANSLATORS: All other phone number types
  122. return _('Other');
  123. }
  124. /**
  125. * Get a display number from @contact for @address.
  126. *
  127. * @param {Object} contact - A contact object
  128. * @param {string} address - A phone number
  129. * @return {string} A (possibly) better display number for the address
  130. */
  131. function getDisplayNumber(contact, address) {
  132. const number = address.toPhoneNumber();
  133. for (const contactNumber of contact.numbers) {
  134. const cnumber = contactNumber.value.toPhoneNumber();
  135. if (number.endsWith(cnumber) || cnumber.endsWith(number))
  136. return GLib.markup_escape_text(contactNumber.value, -1);
  137. }
  138. return GLib.markup_escape_text(address, -1);
  139. }
  140. /**
  141. * Contact Avatar
  142. */
  143. const AvatarCache = new WeakMap();
  144. var Avatar = GObject.registerClass({
  145. GTypeName: 'GSConnectContactAvatar',
  146. }, class ContactAvatar extends Gtk.DrawingArea {
  147. _init(contact = null) {
  148. super._init({
  149. height_request: 32,
  150. width_request: 32,
  151. valign: Gtk.Align.CENTER,
  152. visible: true,
  153. });
  154. this.contact = contact;
  155. }
  156. get rgba() {
  157. if (this._rgba === undefined) {
  158. if (this.contact)
  159. this._rgba = randomRGBA(this.contact.name);
  160. else
  161. this._rgba = randomRGBA(GLib.uuid_string_random());
  162. }
  163. return this._rgba;
  164. }
  165. get contact() {
  166. if (this._contact === undefined)
  167. this._contact = null;
  168. return this._contact;
  169. }
  170. set contact(contact) {
  171. if (this.contact === contact)
  172. return;
  173. this._contact = contact;
  174. this._surface = undefined;
  175. this._rgba = undefined;
  176. this._offset = 0;
  177. }
  178. _loadSurface() {
  179. // Get the monitor scale
  180. const display = Gdk.Display.get_default();
  181. const monitor = display.get_monitor_at_window(this.get_window());
  182. const scale = monitor.get_scale_factor();
  183. // If there's a contact with an avatar, try to load it
  184. if (this.contact && this.contact.avatar) {
  185. // Check the cache
  186. this._surface = AvatarCache.get(this.contact);
  187. // Try loading the pixbuf
  188. if (!this._surface) {
  189. const pixbuf = getPixbufForPath(
  190. this.contact.avatar,
  191. this.width_request,
  192. scale
  193. );
  194. if (pixbuf) {
  195. this._surface = Gdk.cairo_surface_create_from_pixbuf(
  196. pixbuf,
  197. 0,
  198. this.get_window()
  199. );
  200. AvatarCache.set(this.contact, this._surface);
  201. }
  202. }
  203. }
  204. // If we still don't have a surface, load a fallback
  205. if (!this._surface) {
  206. let iconName;
  207. // If we were given a contact, it's direct message otherwise group
  208. if (this.contact)
  209. iconName = 'avatar-default-symbolic';
  210. else
  211. iconName = 'group-avatar-symbolic';
  212. // Center the icon
  213. this._offset = (this.width_request - 24) / 2;
  214. // Load the fallback
  215. const pixbuf = getPixbufForIcon(iconName, 24, scale, this.rgba);
  216. this._surface = Gdk.cairo_surface_create_from_pixbuf(
  217. pixbuf,
  218. 0,
  219. this.get_window()
  220. );
  221. }
  222. }
  223. vfunc_draw(cr) {
  224. if (!this._surface)
  225. this._loadSurface();
  226. // Clip to a circle
  227. const rad = this.width_request / 2;
  228. cr.arc(rad, rad, rad, 0, 2 * Math.PI);
  229. cr.clipPreserve();
  230. // Fill the background if the the surface is offset
  231. if (this._offset > 0) {
  232. Gdk.cairo_set_source_rgba(cr, this.rgba);
  233. cr.fill();
  234. }
  235. // Draw the avatar/icon
  236. cr.setSourceSurface(this._surface, this._offset, this._offset);
  237. cr.paint();
  238. cr.$dispose();
  239. return Gdk.EVENT_PROPAGATE;
  240. }
  241. });
  242. /**
  243. * A row for a contact address (usually a phone number).
  244. */
  245. const AddressRow = GObject.registerClass({
  246. GTypeName: 'GSConnectContactsAddressRow',
  247. Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/contacts-address-row.ui',
  248. Children: ['avatar', 'name-label', 'address-label', 'type-label'],
  249. }, class AddressRow extends Gtk.ListBoxRow {
  250. _init(contact, index = 0) {
  251. super._init();
  252. this._index = index;
  253. this._number = contact.numbers[index];
  254. this.contact = contact;
  255. }
  256. get contact() {
  257. if (this._contact === undefined)
  258. this._contact = null;
  259. return this._contact;
  260. }
  261. set contact(contact) {
  262. if (this.contact === contact)
  263. return;
  264. this._contact = contact;
  265. if (this._index === 0) {
  266. this.avatar.contact = contact;
  267. this.avatar.visible = true;
  268. this.name_label.label = GLib.markup_escape_text(contact.name, -1);
  269. this.name_label.visible = true;
  270. this.address_label.margin_start = 0;
  271. this.address_label.margin_end = 0;
  272. } else {
  273. this.avatar.visible = false;
  274. this.name_label.visible = false;
  275. // TODO: rtl inverts margin-start so the number don't align
  276. this.address_label.margin_start = 38;
  277. this.address_label.margin_end = 38;
  278. }
  279. this.address_label.label = GLib.markup_escape_text(this.number.value, -1);
  280. if (this.number.type !== undefined)
  281. this.type_label.label = getNumberTypeLabel(this.number.type);
  282. }
  283. get number() {
  284. if (this._number === undefined)
  285. return {value: 'unknown', type: 'unknown'};
  286. return this._number;
  287. }
  288. });
  289. /**
  290. * A widget for selecting contact addresses (usually phone numbers)
  291. */
  292. var ContactChooser = GObject.registerClass({
  293. GTypeName: 'GSConnectContactChooser',
  294. Properties: {
  295. 'device': GObject.ParamSpec.object(
  296. 'device',
  297. 'Device',
  298. 'The device associated with this window',
  299. GObject.ParamFlags.READWRITE,
  300. GObject.Object
  301. ),
  302. 'store': GObject.ParamSpec.object(
  303. 'store',
  304. 'Store',
  305. 'The contacts store',
  306. GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT,
  307. GObject.Object
  308. ),
  309. },
  310. Signals: {
  311. 'number-selected': {
  312. flags: GObject.SignalFlags.RUN_FIRST,
  313. param_types: [GObject.TYPE_STRING],
  314. },
  315. },
  316. Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/contact-chooser.ui',
  317. Children: ['entry', 'list', 'scrolled'],
  318. }, class ContactChooser extends Gtk.Grid {
  319. _init(params) {
  320. super._init(params);
  321. // Setup the contact list
  322. this.list._entry = this.entry.text;
  323. this.list.set_filter_func(this._filter);
  324. this.list.set_sort_func(this._sort);
  325. // Make sure we're using the correct contacts store
  326. this.device.bind_property(
  327. 'contacts',
  328. this,
  329. 'store',
  330. GObject.BindingFlags.SYNC_CREATE
  331. );
  332. // Cleanup on ::destroy
  333. this.connect('destroy', this._onDestroy);
  334. }
  335. get store() {
  336. if (this._store === undefined)
  337. this._store = null;
  338. return this._store;
  339. }
  340. set store(store) {
  341. if (this.store === store)
  342. return;
  343. // Unbind the old store
  344. if (this._store) {
  345. // Disconnect from the store
  346. this._store.disconnect(this._contactAddedId);
  347. this._store.disconnect(this._contactRemovedId);
  348. this._store.disconnect(this._contactChangedId);
  349. // Clear the contact list
  350. const rows = this.list.get_children();
  351. for (let i = 0, len = rows.length; i < len; i++) {
  352. rows[i].destroy();
  353. // HACK: temporary mitigator for mysterious GtkListBox leak
  354. imports.system.gc();
  355. }
  356. }
  357. // Set the store
  358. this._store = store;
  359. // Bind the new store
  360. if (this._store) {
  361. // Connect to the new store
  362. this._contactAddedId = store.connect(
  363. 'contact-added',
  364. this._onContactAdded.bind(this)
  365. );
  366. this._contactRemovedId = store.connect(
  367. 'contact-removed',
  368. this._onContactRemoved.bind(this)
  369. );
  370. this._contactChangedId = store.connect(
  371. 'contact-changed',
  372. this._onContactChanged.bind(this)
  373. );
  374. // Populate the list
  375. this._populate();
  376. }
  377. }
  378. /*
  379. * ContactStore Callbacks
  380. */
  381. _onContactAdded(store, id) {
  382. const contact = this.store.get_contact(id);
  383. this._addContact(contact);
  384. }
  385. _onContactRemoved(store, id) {
  386. const rows = this.list.get_children();
  387. for (let i = 0, len = rows.length; i < len; i++) {
  388. const row = rows[i];
  389. if (row.contact.id === id) {
  390. row.destroy();
  391. // HACK: temporary mitigator for mysterious GtkListBox leak
  392. imports.system.gc();
  393. }
  394. }
  395. }
  396. _onContactChanged(store, id) {
  397. this._onContactRemoved(store, id);
  398. this._onContactAdded(store, id);
  399. }
  400. _onDestroy(chooser) {
  401. chooser.store = null;
  402. }
  403. _onSearchChanged(entry) {
  404. this.list._entry = entry.text;
  405. let dynamic = this.list.get_row_at_index(0);
  406. // If the entry contains string with 2 or more digits...
  407. if (entry.text.replace(/\D/g, '').length >= 2) {
  408. // ...ensure we have a dynamic contact for it
  409. if (!dynamic || !dynamic.__tmp) {
  410. dynamic = new AddressRow({
  411. // TRANSLATORS: A phone number (eg. "Send to 555-5555")
  412. name: _('Send to %s').format(entry.text),
  413. numbers: [{type: 'unknown', value: entry.text}],
  414. });
  415. dynamic.__tmp = true;
  416. this.list.add(dynamic);
  417. // ...or if we already do, then update it
  418. } else {
  419. const address = entry.text;
  420. // Update contact object
  421. dynamic.contact.name = address;
  422. dynamic.contact.numbers[0].value = address;
  423. // Update UI
  424. dynamic.name_label.label = _('Send to %s').format(address);
  425. dynamic.address_label.label = address;
  426. }
  427. // ...otherwise remove any dynamic contact that's been created
  428. } else if (dynamic && dynamic.__tmp) {
  429. dynamic.destroy();
  430. }
  431. this.list.invalidate_filter();
  432. this.list.invalidate_sort();
  433. }
  434. // GtkListBox::row-activated
  435. _onNumberSelected(box, row) {
  436. if (row === null)
  437. return;
  438. // Emit the number
  439. const address = row.number.value;
  440. this.emit('number-selected', address);
  441. // Reset the contact list
  442. this.entry.text = '';
  443. this.list.select_row(null);
  444. this.scrolled.vadjustment.value = 0;
  445. }
  446. _filter(row) {
  447. // Dynamic contact always shown
  448. if (row.__tmp)
  449. return true;
  450. const query = row.get_parent()._entry;
  451. // Show contact if text is substring of name
  452. const queryName = query.toLocaleLowerCase();
  453. if (row.contact.name.toLocaleLowerCase().includes(queryName))
  454. return true;
  455. // Show contact if text is substring of number
  456. const queryNumber = query.toPhoneNumber();
  457. if (queryNumber.length) {
  458. for (const number of row.contact.numbers) {
  459. if (number.value.toPhoneNumber().includes(queryNumber))
  460. return true;
  461. }
  462. // Query is effectively empty
  463. } else if (/^0+/.test(query)) {
  464. return true;
  465. }
  466. return false;
  467. }
  468. _sort(row1, row2) {
  469. if (row1.__tmp)
  470. return -1;
  471. if (row2.__tmp)
  472. return 1;
  473. return row1.contact.name.localeCompare(row2.contact.name);
  474. }
  475. _populate() {
  476. // Add each contact
  477. const contacts = this.store.contacts;
  478. for (let i = 0, len = contacts.length; i < len; i++)
  479. this._addContact(contacts[i]);
  480. }
  481. _addContactNumber(contact, index) {
  482. const row = new AddressRow(contact, index);
  483. this.list.add(row);
  484. return row;
  485. }
  486. _addContact(contact) {
  487. try {
  488. // HACK: fix missing contact names
  489. if (contact.name === undefined)
  490. contact.name = _('Unknown Contact');
  491. if (contact.numbers.length === 1)
  492. return this._addContactNumber(contact, 0);
  493. for (let i = 0, len = contact.numbers.length; i < len; i++)
  494. this._addContactNumber(contact, i);
  495. } catch (e) {
  496. logError(e);
  497. }
  498. }
  499. /**
  500. * Get a dictionary of number-contact pairs for each selected phone number.
  501. *
  502. * @return {Object[]} A dictionary of contacts
  503. */
  504. getSelected() {
  505. try {
  506. const selected = {};
  507. for (const row of this.list.get_selected_rows())
  508. selected[row.number.value] = row.contact;
  509. return selected;
  510. } catch (e) {
  511. logError(e);
  512. return {};
  513. }
  514. }
  515. });