643 lines
18 KiB
JavaScript
Raw Normal View History

2024-07-08 22:46:35 +02:00
// 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 {};
}
}
});