dot/.local/share/gnome-shell/extensions/gsconnect@andyholmes.github.io/service/ui/contacts.js

620 lines
17 KiB
JavaScript

'use strict';
const Gdk = imports.gi.Gdk;
const GdkPixbuf = imports.gi.GdkPixbuf;
const Gio = imports.gi.Gio;
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) {
let 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 {Number} r - A number in the [0.0, 1.0] range for the red value
* @param {Number} g - A number in the [0.0, 1.0] range for the green value
* @param {Number} b - A number in the [0.0, 1.0] range for the blue value
* @return {Number} - ...
*/
function relativeLuminance(rgba) {
let {red, green, blue} = rgba;
let R = (red > 0.03928) ? red / 12.92 : Math.pow(((red + 0.055) / 1.055), 2.4);
let G = (green > 0.03928) ? green / 12.92 : Math.pow(((green + 0.055) / 1.055), 2.4);
let 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 Gdk.RGBA contrasted for the input
* See: https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
*
* @param {Gdk.RGBA} - A Gdk.RGBA object for the background color
* @return {Gdk.RGBA} - A Gdk.RGBA object for the foreground color
*/
function getFgRGBA(rgba) {
let bgLuminance = relativeLuminance(rgba);
let lightContrast = (0.07275541795665634 + 0.05) / (bgLuminance + 0.05);
let darkContrast = (bgLuminance + 0.05) / (0.0046439628482972135 + 0.05);
let value = (darkContrast > lightContrast) ? 0.06 : 0.94;
return new Gdk.RGBA({red: value, green: value, blue: value, alpha: 0.5});
}
/**
* Get Gdk.Pixbuf for @path, allowing the corrupt JPEG's KDE Connect sometimes
* sends. This function is synchronous.
*
* @param {string} path - A local file path
*/
function getPixbufForPath(path, size = null, scale) {
let data, loader, pixbuf;
// 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);
}
pixbuf = loader.get_pixbuf();
// Scale to monitor
size = size * scale;
return pixbuf.scale_simple(size, size, GdkPixbuf.InterpType.HYPER);
}
function getPixbufForIcon(name, size, scale, bgColor) {
let color = getFgRGBA(bgColor);
let theme = Gtk.IconTheme.get_default();
let 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 and type
* See: http://www.ietf.org/rfc/rfc2426.txt
*
* @param {string} number - A phone number and RFC2426 phone number type
* @return {string} - A string like '555-5555・Mobile'
*/
function getNumberLabel(number) {
if (!number.type) return _('%s・Other').format(number.value);
switch (true) {
case number.type.includes('fax'):
// TRANSLATORS: A fax number
return _('%s・Fax').format(number.value);
case number.type.includes('work'):
// TRANSLATORS: A work phone number
return _('%s・Work'.format(number.value));
case number.type.includes('cell'):
// TRANSLATORS: A mobile or cellular phone number
return _('%s・Mobile').format(number.value);
case number.type.includes('home'):
// TRANSLATORS: A home phone number
return _('%s・Home').format(number.value);
default:
// TRANSLATORS: All other phone number types
return _('%s・Other').format(number.value);
}
}
/**
* Get a display number from @contact for @address.
*
* @param {object} contact - A contact object
* @param {string} address - A phone number
*/
function getDisplayNumber(contact, address) {
let number = address.toPhoneNumber();
for (let contactNumber of contact.numbers) {
let 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 Avatar 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) {
this._contact = contact;
this._surface = undefined;
this._rgba = undefined;
this._offset = 0;
}
}
_loadSurface() {
// Get the monitor scale
let display = Gdk.Display.get_default();
let monitor = display.get_monitor_at_window(this.get_window());
let 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) {
let 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
if (this.contact) {
iconName = 'avatar-default-symbolic';
// Otherwise it's a group message
} else {
iconName = 'group-avatar-symbolic';
}
this._offset = (this.width_request - 24) / 2;
// Load the fallback
let 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
let 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;
}
});
var ContactChooser = GObject.registerClass({
GTypeName: 'GSConnectContactChooser',
Properties: {
'device': GObject.ParamSpec.object(
'device',
'Device',
'The device associated with this window',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
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) {
// Do nothing if the store hasn't changed
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
let 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) {
let contact = this.store.get_contact(id);
this._addContact(contact);
}
_onContactRemoved(store, id) {
let rows = this.list.get_children();
for (let i = 0, len = rows.length; i < len; i++) {
let 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 = this._addContact({
// 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;
// ...or if we already do, then update it
} else {
// Update contact object
dynamic.contact.name = entry.text;
dynamic.contact.numbers[0].value = entry.text;
// Update UI
let grid = dynamic.get_child();
let nameLabel = grid.get_child_at(1, 0);
nameLabel.label = _('Send to %s').format(entry.text);
let numLabel = grid.get_child_at(1, 1);
numLabel.label = getNumberLabel(dynamic.contact.numbers[0]);
}
// ...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-selected
_onNumberSelected(box, row) {
if (row === null) return;
// Emit the number
let 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;
let query = row.get_parent()._entry;
let queryName = query.toLocaleLowerCase();
let queryNumber = query.toPhoneNumber();
// Show contact if text is substring of name
if (row.contact.name.toLocaleLowerCase().includes(queryName)) {
return true;
// Show contact if text is substring of number
} else if (queryNumber.length) {
for (let 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;
} else if (row2.__tmp) {
return 1;
}
return row1.contact.name.localeCompare(row2.contact.name);
}
_populate() {
// Add each contact
let contacts = this.store.contacts;
for (let i = 0, len = contacts.length; i < len; i++) {
this._addContact(contacts[i]);
}
}
_addContactNumber(contact, index) {
let row = new Gtk.ListBoxRow({
activatable: true,
selectable: true,
visible: true
});
row.contact = contact;
row.number = contact.numbers[index];
this.list.add(row);
let grid = new Gtk.Grid({
margin: 6,
column_spacing: 6,
visible: true
});
row.add(grid);
if (index === 0) {
let avatar = new Avatar(contact);
avatar.valign = Gtk.Align.CENTER;
grid.attach(avatar, 0, 0, 1, 2);
let nameLabel = new Gtk.Label({
label: GLib.markup_escape_text(contact.name, -1),
halign: Gtk.Align.START,
hexpand: true,
visible: true
});
grid.attach(nameLabel, 1, 0, 1, 1);
}
let numLabel = new Gtk.Label({
label: getNumberLabel(row.number),
halign: Gtk.Align.START,
hexpand: true,
// TODO: rtl inverts margin-start so the number don't align
margin_start: (index > 0) ? 38 : 0,
margin_end: (index > 0) ? 38 : 0,
visible: true
});
numLabel.get_style_context().add_class('dim-label');
grid.attach(numLabel, 1, 1, 1, 1);
return row;
}
_addContact(contact) {
try {
// HACK: fix missing contact names
contact.name = 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.
*/
getSelected() {
try {
let selected = {};
for (let row of this.list.get_selected_rows()) {
selected[row.number.value] = row.contact;
}
return selected;
} catch (e) {
logError(e);
return {};
}
}
});