123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642 |
- // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
- //
- // SPDX-License-Identifier: GPL-2.0-or-later
- 'use strict';
- const Gdk = imports.gi.Gdk;
- const GdkPixbuf = imports.gi.GdkPixbuf;
- const GLib = imports.gi.GLib;
- const GObject = imports.gi.GObject;
- const Gtk = imports.gi.Gtk;
- /**
- * Return a random color
- *
- * @param {*} [salt] - If not %null, will be used as salt for generating a color
- * @param {number} alpha - A value in the [0...1] range for the alpha channel
- * @return {Gdk.RGBA} A new Gdk.RGBA object generated from the input
- */
- function randomRGBA(salt = null, alpha = 1.0) {
- let red, green, blue;
- if (salt !== null) {
- const hash = new GLib.Variant('s', `${salt}`).hash();
- red = ((hash & 0xFF0000) >> 16) / 255;
- green = ((hash & 0x00FF00) >> 8) / 255;
- blue = (hash & 0x0000FF) / 255;
- } else {
- red = Math.random();
- green = Math.random();
- blue = Math.random();
- }
- return new Gdk.RGBA({red: red, green: green, blue: blue, alpha: alpha});
- }
- /**
- * Get the relative luminance of a RGB set
- * See: https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
- *
- * @param {Gdk.RGBA} rgba - A GdkRGBA object
- * @return {number} The relative luminance of the color
- */
- function relativeLuminance(rgba) {
- const {red, green, blue} = rgba;
- const R = (red > 0.03928) ? red / 12.92 : Math.pow(((red + 0.055) / 1.055), 2.4);
- const G = (green > 0.03928) ? green / 12.92 : Math.pow(((green + 0.055) / 1.055), 2.4);
- const B = (blue > 0.03928) ? blue / 12.92 : Math.pow(((blue + 0.055) / 1.055), 2.4);
- return 0.2126 * R + 0.7152 * G + 0.0722 * B;
- }
- /**
- * Get a GdkRGBA contrasted for the input
- * See: https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
- *
- * @param {Gdk.RGBA} rgba - A GdkRGBA object for the background color
- * @return {Gdk.RGBA} A GdkRGBA object for the foreground color
- */
- function getFgRGBA(rgba) {
- const bgLuminance = relativeLuminance(rgba);
- const lightContrast = (0.07275541795665634 + 0.05) / (bgLuminance + 0.05);
- const darkContrast = (bgLuminance + 0.05) / (0.0046439628482972135 + 0.05);
- const value = (darkContrast > lightContrast) ? 0.06 : 0.94;
- return new Gdk.RGBA({red: value, green: value, blue: value, alpha: 0.5});
- }
- /**
- * Get a GdkPixbuf for @path, allowing the corrupt JPEG's KDE Connect sometimes
- * sends. This function is synchronous.
- *
- * @param {string} path - A local file path
- * @param {number} size - Size in pixels
- * @param {scale} [scale] - Scale factor for the size
- * @return {Gdk.Pixbuf} A pixbuf
- */
- function getPixbufForPath(path, size, scale = 1.0) {
- let data, loader;
- // Catch missing avatar files
- try {
- data = GLib.file_get_contents(path)[1];
- } catch (e) {
- debug(e, path);
- return undefined;
- }
- // Consider errors from partially corrupt JPEGs to be warnings
- try {
- loader = new GdkPixbuf.PixbufLoader();
- loader.write(data);
- loader.close();
- } catch (e) {
- debug(e, path);
- }
- const pixbuf = loader.get_pixbuf();
- // Scale to monitor
- size = Math.floor(size * scale);
- return pixbuf.scale_simple(size, size, GdkPixbuf.InterpType.HYPER);
- }
- function getPixbufForIcon(name, size, scale, bgColor) {
- const color = getFgRGBA(bgColor);
- const theme = Gtk.IconTheme.get_default();
- const info = theme.lookup_icon_for_scale(
- name,
- size,
- scale,
- Gtk.IconLookupFlags.FORCE_SYMBOLIC
- );
- return info.load_symbolic(color, null, null, null)[0];
- }
- /**
- * Return a localized string for a phone number type
- * See: http://www.ietf.org/rfc/rfc2426.txt
- *
- * @param {string} type - An RFC2426 phone number type
- * @return {string} A localized string like 'Mobile'
- */
- function getNumberTypeLabel(type) {
- if (type.includes('fax'))
- // TRANSLATORS: A fax number
- return _('Fax');
- if (type.includes('work'))
- // TRANSLATORS: A work or office phone number
- return _('Work');
- if (type.includes('cell'))
- // TRANSLATORS: A mobile or cellular phone number
- return _('Mobile');
- if (type.includes('home'))
- // TRANSLATORS: A home phone number
- return _('Home');
- // TRANSLATORS: All other phone number types
- return _('Other');
- }
- /**
- * Get a display number from @contact for @address.
- *
- * @param {Object} contact - A contact object
- * @param {string} address - A phone number
- * @return {string} A (possibly) better display number for the address
- */
- function getDisplayNumber(contact, address) {
- const number = address.toPhoneNumber();
- for (const contactNumber of contact.numbers) {
- const cnumber = contactNumber.value.toPhoneNumber();
- if (number.endsWith(cnumber) || cnumber.endsWith(number))
- return GLib.markup_escape_text(contactNumber.value, -1);
- }
- return GLib.markup_escape_text(address, -1);
- }
- /**
- * Contact Avatar
- */
- const AvatarCache = new WeakMap();
- var Avatar = GObject.registerClass({
- GTypeName: 'GSConnectContactAvatar',
- }, class ContactAvatar extends Gtk.DrawingArea {
- _init(contact = null) {
- super._init({
- height_request: 32,
- width_request: 32,
- valign: Gtk.Align.CENTER,
- visible: true,
- });
- this.contact = contact;
- }
- get rgba() {
- if (this._rgba === undefined) {
- if (this.contact)
- this._rgba = randomRGBA(this.contact.name);
- else
- this._rgba = randomRGBA(GLib.uuid_string_random());
- }
- return this._rgba;
- }
- get contact() {
- if (this._contact === undefined)
- this._contact = null;
- return this._contact;
- }
- set contact(contact) {
- if (this.contact === contact)
- return;
- this._contact = contact;
- this._surface = undefined;
- this._rgba = undefined;
- this._offset = 0;
- }
- _loadSurface() {
- // Get the monitor scale
- const display = Gdk.Display.get_default();
- const monitor = display.get_monitor_at_window(this.get_window());
- const scale = monitor.get_scale_factor();
- // If there's a contact with an avatar, try to load it
- if (this.contact && this.contact.avatar) {
- // Check the cache
- this._surface = AvatarCache.get(this.contact);
- // Try loading the pixbuf
- if (!this._surface) {
- const pixbuf = getPixbufForPath(
- this.contact.avatar,
- this.width_request,
- scale
- );
- if (pixbuf) {
- this._surface = Gdk.cairo_surface_create_from_pixbuf(
- pixbuf,
- 0,
- this.get_window()
- );
- AvatarCache.set(this.contact, this._surface);
- }
- }
- }
- // If we still don't have a surface, load a fallback
- if (!this._surface) {
- let iconName;
- // If we were given a contact, it's direct message otherwise group
- if (this.contact)
- iconName = 'avatar-default-symbolic';
- else
- iconName = 'group-avatar-symbolic';
- // Center the icon
- this._offset = (this.width_request - 24) / 2;
- // Load the fallback
- const pixbuf = getPixbufForIcon(iconName, 24, scale, this.rgba);
- this._surface = Gdk.cairo_surface_create_from_pixbuf(
- pixbuf,
- 0,
- this.get_window()
- );
- }
- }
- vfunc_draw(cr) {
- if (!this._surface)
- this._loadSurface();
- // Clip to a circle
- const rad = this.width_request / 2;
- cr.arc(rad, rad, rad, 0, 2 * Math.PI);
- cr.clipPreserve();
- // Fill the background if the the surface is offset
- if (this._offset > 0) {
- Gdk.cairo_set_source_rgba(cr, this.rgba);
- cr.fill();
- }
- // Draw the avatar/icon
- cr.setSourceSurface(this._surface, this._offset, this._offset);
- cr.paint();
- cr.$dispose();
- return Gdk.EVENT_PROPAGATE;
- }
- });
- /**
- * A row for a contact address (usually a phone number).
- */
- const AddressRow = GObject.registerClass({
- GTypeName: 'GSConnectContactsAddressRow',
- Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/contacts-address-row.ui',
- Children: ['avatar', 'name-label', 'address-label', 'type-label'],
- }, class AddressRow extends Gtk.ListBoxRow {
- _init(contact, index = 0) {
- super._init();
- this._index = index;
- this._number = contact.numbers[index];
- this.contact = contact;
- }
- get contact() {
- if (this._contact === undefined)
- this._contact = null;
- return this._contact;
- }
- set contact(contact) {
- if (this.contact === contact)
- return;
- this._contact = contact;
- if (this._index === 0) {
- this.avatar.contact = contact;
- this.avatar.visible = true;
- this.name_label.label = GLib.markup_escape_text(contact.name, -1);
- this.name_label.visible = true;
- this.address_label.margin_start = 0;
- this.address_label.margin_end = 0;
- } else {
- this.avatar.visible = false;
- this.name_label.visible = false;
- // TODO: rtl inverts margin-start so the number don't align
- this.address_label.margin_start = 38;
- this.address_label.margin_end = 38;
- }
- this.address_label.label = GLib.markup_escape_text(this.number.value, -1);
- if (this.number.type !== undefined)
- this.type_label.label = getNumberTypeLabel(this.number.type);
- }
- get number() {
- if (this._number === undefined)
- return {value: 'unknown', type: 'unknown'};
- return this._number;
- }
- });
- /**
- * A widget for selecting contact addresses (usually phone numbers)
- */
- var ContactChooser = GObject.registerClass({
- GTypeName: 'GSConnectContactChooser',
- Properties: {
- 'device': GObject.ParamSpec.object(
- 'device',
- 'Device',
- 'The device associated with this window',
- GObject.ParamFlags.READWRITE,
- GObject.Object
- ),
- 'store': GObject.ParamSpec.object(
- 'store',
- 'Store',
- 'The contacts store',
- GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT,
- GObject.Object
- ),
- },
- Signals: {
- 'number-selected': {
- flags: GObject.SignalFlags.RUN_FIRST,
- param_types: [GObject.TYPE_STRING],
- },
- },
- Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/contact-chooser.ui',
- Children: ['entry', 'list', 'scrolled'],
- }, class ContactChooser extends Gtk.Grid {
- _init(params) {
- super._init(params);
- // Setup the contact list
- this.list._entry = this.entry.text;
- this.list.set_filter_func(this._filter);
- this.list.set_sort_func(this._sort);
- // Make sure we're using the correct contacts store
- this.device.bind_property(
- 'contacts',
- this,
- 'store',
- GObject.BindingFlags.SYNC_CREATE
- );
- // Cleanup on ::destroy
- this.connect('destroy', this._onDestroy);
- }
- get store() {
- if (this._store === undefined)
- this._store = null;
- return this._store;
- }
- set store(store) {
- if (this.store === store)
- return;
- // Unbind the old store
- if (this._store) {
- // Disconnect from the store
- this._store.disconnect(this._contactAddedId);
- this._store.disconnect(this._contactRemovedId);
- this._store.disconnect(this._contactChangedId);
- // Clear the contact list
- const rows = this.list.get_children();
- for (let i = 0, len = rows.length; i < len; i++) {
- rows[i].destroy();
- // HACK: temporary mitigator for mysterious GtkListBox leak
- imports.system.gc();
- }
- }
- // Set the store
- this._store = store;
- // Bind the new store
- if (this._store) {
- // Connect to the new store
- this._contactAddedId = store.connect(
- 'contact-added',
- this._onContactAdded.bind(this)
- );
- this._contactRemovedId = store.connect(
- 'contact-removed',
- this._onContactRemoved.bind(this)
- );
- this._contactChangedId = store.connect(
- 'contact-changed',
- this._onContactChanged.bind(this)
- );
- // Populate the list
- this._populate();
- }
- }
- /*
- * ContactStore Callbacks
- */
- _onContactAdded(store, id) {
- const contact = this.store.get_contact(id);
- this._addContact(contact);
- }
- _onContactRemoved(store, id) {
- const rows = this.list.get_children();
- for (let i = 0, len = rows.length; i < len; i++) {
- const row = rows[i];
- if (row.contact.id === id) {
- row.destroy();
- // HACK: temporary mitigator for mysterious GtkListBox leak
- imports.system.gc();
- }
- }
- }
- _onContactChanged(store, id) {
- this._onContactRemoved(store, id);
- this._onContactAdded(store, id);
- }
- _onDestroy(chooser) {
- chooser.store = null;
- }
- _onSearchChanged(entry) {
- this.list._entry = entry.text;
- let dynamic = this.list.get_row_at_index(0);
- // If the entry contains string with 2 or more digits...
- if (entry.text.replace(/\D/g, '').length >= 2) {
- // ...ensure we have a dynamic contact for it
- if (!dynamic || !dynamic.__tmp) {
- dynamic = new AddressRow({
- // TRANSLATORS: A phone number (eg. "Send to 555-5555")
- name: _('Send to %s').format(entry.text),
- numbers: [{type: 'unknown', value: entry.text}],
- });
- dynamic.__tmp = true;
- this.list.add(dynamic);
- // ...or if we already do, then update it
- } else {
- const address = entry.text;
- // Update contact object
- dynamic.contact.name = address;
- dynamic.contact.numbers[0].value = address;
- // Update UI
- dynamic.name_label.label = _('Send to %s').format(address);
- dynamic.address_label.label = address;
- }
- // ...otherwise remove any dynamic contact that's been created
- } else if (dynamic && dynamic.__tmp) {
- dynamic.destroy();
- }
- this.list.invalidate_filter();
- this.list.invalidate_sort();
- }
- // GtkListBox::row-activated
- _onNumberSelected(box, row) {
- if (row === null)
- return;
- // Emit the number
- const address = row.number.value;
- this.emit('number-selected', address);
- // Reset the contact list
- this.entry.text = '';
- this.list.select_row(null);
- this.scrolled.vadjustment.value = 0;
- }
- _filter(row) {
- // Dynamic contact always shown
- if (row.__tmp)
- return true;
- const query = row.get_parent()._entry;
- // Show contact if text is substring of name
- const queryName = query.toLocaleLowerCase();
- if (row.contact.name.toLocaleLowerCase().includes(queryName))
- return true;
- // Show contact if text is substring of number
- const queryNumber = query.toPhoneNumber();
- if (queryNumber.length) {
- for (const number of row.contact.numbers) {
- if (number.value.toPhoneNumber().includes(queryNumber))
- return true;
- }
- // Query is effectively empty
- } else if (/^0+/.test(query)) {
- return true;
- }
- return false;
- }
- _sort(row1, row2) {
- if (row1.__tmp)
- return -1;
- if (row2.__tmp)
- return 1;
- return row1.contact.name.localeCompare(row2.contact.name);
- }
- _populate() {
- // Add each contact
- const contacts = this.store.contacts;
- for (let i = 0, len = contacts.length; i < len; i++)
- this._addContact(contacts[i]);
- }
- _addContactNumber(contact, index) {
- const row = new AddressRow(contact, index);
- this.list.add(row);
- return row;
- }
- _addContact(contact) {
- try {
- // HACK: fix missing contact names
- if (contact.name === undefined)
- contact.name = _('Unknown Contact');
- if (contact.numbers.length === 1)
- return this._addContactNumber(contact, 0);
- for (let i = 0, len = contact.numbers.length; i < len; i++)
- this._addContactNumber(contact, i);
- } catch (e) {
- logError(e);
- }
- }
- /**
- * Get a dictionary of number-contact pairs for each selected phone number.
- *
- * @return {Object[]} A dictionary of contacts
- */
- getSelected() {
- try {
- const selected = {};
- for (const row of this.list.get_selected_rows())
- selected[row.number.value] = row.contact;
- return selected;
- } catch (e) {
- logError(e);
- return {};
- }
- }
- });
|