643 lines
18 KiB
JavaScript
643 lines
18 KiB
JavaScript
|
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
|
||
|
//
|
||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||
|
|
||
|
import Gdk from 'gi://Gdk';
|
||
|
import GdkPixbuf from 'gi://GdkPixbuf';
|
||
|
import GLib from 'gi://GLib';
|
||
|
import GObject from 'gi://GObject';
|
||
|
import Gtk from 'gi://Gtk';
|
||
|
|
||
|
import system from 'system';
|
||
|
|
||
|
|
||
|
/**
|
||
|
* 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
|
||
|
*/
|
||
|
export 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();
|
||
|
|
||
|
export const 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)
|
||
|
*/
|
||
|
export const 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
|
||
|
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
|
||
|
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 {};
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
|