620 lines
17 KiB
JavaScript
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 {};
|
|
}
|
|
}
|
|
});
|
|
|