| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325 |
- // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
- //
- // SPDX-License-Identifier: GPL-2.0-or-later
- import Gdk from 'gi://Gdk';
- import GLib from 'gi://GLib';
- import GObject from 'gi://GObject';
- import Gtk from 'gi://Gtk';
- import Pango from 'gi://Pango';
- import system from 'system';
- import * as Contacts from './contacts.js';
- import * as Sms from '../plugins/sms.js';
- import * as URI from '../utils/uri.js';
- import '../utils/ui.js';
- const Tweener = imports.tweener.tweener;
- /*
- * Useful time constants
- */
- const TIME_SPAN_MINUTE = 60000;
- const TIME_SPAN_HOUR = 3600000;
- const TIME_SPAN_DAY = 86400000;
- const TIME_SPAN_WEEK = 604800000;
- // Less than an hour (eg. 42 minutes ago)
- const _lthLong = new Intl.RelativeTimeFormat('default', {
- numeric: 'auto',
- style: 'long',
- });
- // Less than a day ago (eg. 11:42 PM)
- const _ltdFormat = new Intl.DateTimeFormat('default', {
- hour: 'numeric',
- minute: 'numeric',
- });
- // Less than a week ago (eg. Monday)
- const _ltwLong = new Intl.DateTimeFormat('default', {
- weekday: 'long',
- });
- // Less than a week ago (eg. Mon)
- const _ltwShort = new Intl.DateTimeFormat('default', {
- weekday: 'short',
- });
- // Less than a year (eg. Oct 31)
- const _ltyShort = new Intl.DateTimeFormat('default', {
- day: 'numeric',
- month: 'short',
- });
- // Less than a year (eg. October 31)
- const _ltyLong = new Intl.DateTimeFormat('default', {
- day: 'numeric',
- month: 'long',
- });
- // Greater than a year (eg. October 31, 2019)
- const _gtyLong = new Intl.DateTimeFormat('default', {
- day: 'numeric',
- month: 'long',
- year: 'numeric',
- });
- // Greater than a year (eg. 10/31/2019)
- const _gtyShort = new Intl.DateTimeFormat('default', {
- day: 'numeric',
- month: 'numeric',
- year: 'numeric',
- });
- // Pretty close to strftime's %c
- const _cFormat = new Intl.DateTimeFormat('default', {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- weekday: 'short',
- hour: 'numeric',
- minute: 'numeric',
- second: 'numeric',
- timeZoneName: 'short',
- });
- /**
- * Return a human-readable timestamp, formatted for longer contexts.
- *
- * @param {number} time - Milliseconds since the epoch (local time)
- * @returns {string} A localized timestamp similar to what Android Messages uses
- */
- function getTime(time) {
- const date = new Date(time);
- const now = new Date();
- const diff = now - time;
- // Super recent
- if (diff < TIME_SPAN_MINUTE)
- // TRANSLATORS: Less than a minute ago
- return _('Just now');
- // Under an hour (TODO: these labels aren't updated)
- if (diff < TIME_SPAN_HOUR)
- return _lthLong.format(-Math.floor(diff / TIME_SPAN_MINUTE), 'minute');
- // Yesterday, but less than 24 hours ago
- if (diff < TIME_SPAN_DAY && now.getDay() !== date.getDay())
- // TRANSLATORS: Yesterday, but less than 24 hours (eg. Yesterday · 11:29 PM)
- return _('Yesterday・%s').format(_ltdFormat.format(time));
- // Less than a day ago
- if (diff < TIME_SPAN_DAY)
- return _ltdFormat.format(time);
- // Less than a week ago
- if (diff < TIME_SPAN_WEEK)
- return _ltwLong.format(time);
- // Sometime this year
- if (date.getFullYear() === now.getFullYear())
- return _ltyLong.format(time);
- // Earlier than that
- return _gtyLong.format(time);
- }
- /**
- * Return a human-readable timestamp, formatted for shorter contexts.
- *
- * @param {number} time - Milliseconds since the epoch (local time)
- * @returns {string} A localized timestamp similar to what Android Messages uses
- */
- function getShortTime(time) {
- const date = new Date(time);
- const now = new Date();
- const diff = now - time;
- if (diff < TIME_SPAN_MINUTE)
- // TRANSLATORS: Less than a minute ago
- return _('Just now');
- if (diff < TIME_SPAN_HOUR) {
- // TRANSLATORS: Time duration in minutes (eg. 15 minutes)
- return ngettext(
- '%d minute',
- '%d minutes',
- (diff / TIME_SPAN_MINUTE)
- ).format(diff / TIME_SPAN_MINUTE);
- }
- // Less than a day ago
- if (diff < TIME_SPAN_DAY)
- return _ltdFormat.format(time);
- // Less than a week ago
- if (diff < TIME_SPAN_WEEK)
- return _ltwShort.format(time);
- // Sometime this year
- if (date.getFullYear() === now.getFullYear())
- return _ltyShort.format(time);
- // Earlier than that
- return _gtyShort.format(time);
- }
- /**
- * Return a human-readable timestamp, similar to `strftime()` with `%c`.
- *
- * @param {number} time - Milliseconds since the epoch (local time)
- * @returns {string} A localized timestamp
- */
- function getDetailedTime(time) {
- return _cFormat.format(time);
- }
- /**
- * Make the avatar for an incoming message visible or invisible
- *
- * @param {ConversationMessage} row - The message row to modify
- * @param {boolean} visible - Whether the avatar should be visible
- */
- function setAvatarVisible(row, visible) {
- const incoming = row.message.type === Sms.MessageBox.INBOX;
- // Adjust the margins
- if (visible) {
- row.grid.margin_start = incoming ? 6 : 56;
- row.grid.margin_bottom = 6;
- } else {
- row.grid.margin_start = incoming ? 44 : 56;
- row.grid.margin_bottom = 0;
- }
- // Show hide the avatar
- if (incoming)
- row.avatar.visible = visible;
- }
- /**
- * A ListBoxRow for a preview of a conversation
- */
- const ConversationMessage = GObject.registerClass({
- GTypeName: 'GSConnectMessagingConversationMessage',
- Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/messaging-conversation-message.ui',
- Children: ['grid', 'avatar', 'sender-label', 'message-label'],
- }, class ConversationMessage extends Gtk.ListBoxRow {
- _init(contact, message) {
- super._init();
- this.contact = contact;
- this.message = message;
- // Sort properties
- this.sender = message.addresses[0].address || 'unknown';
- this.message_label.label = URI.linkify(message.body);
- this.message_label.tooltip_text = getDetailedTime(message.date);
- // Add avatar for incoming messages
- if (message.type === Sms.MessageBox.INBOX) {
- this.grid.margin_end = 18;
- this.grid.halign = Gtk.Align.START;
- this.avatar.contact = this.contact;
- this.avatar.visible = true;
- this.sender_label.label = contact.name;
- this.sender_label.visible = true;
- this.message_label.get_style_context().add_class('message-in');
- this.message_label.halign = Gtk.Align.START;
- } else {
- this.message_label.get_style_context().add_class('message-out');
- }
- }
- _onActivateLink(label, uri) {
- Gtk.show_uri_on_window(
- this.get_toplevel(),
- uri.includes('://') ? uri : `https://${uri}`,
- Gtk.get_current_event_time()
- );
- return true;
- }
- get date() {
- return this._message.date;
- }
- get thread_id() {
- return this._message.thread_id;
- }
- get message() {
- if (this._message === undefined)
- this._message = null;
- return this._message;
- }
- set message(message) {
- this._message = message;
- }
- });
- /**
- * A widget for displaying a conversation thread, with an entry for responding.
- */
- const Conversation = GObject.registerClass({
- GTypeName: 'GSConnectMessagingConversation',
- Properties: {
- 'device': GObject.ParamSpec.object(
- 'device',
- 'Device',
- 'The device associated with this conversation',
- GObject.ParamFlags.READWRITE,
- GObject.Object
- ),
- 'plugin': GObject.ParamSpec.object(
- 'plugin',
- 'Plugin',
- 'The plugin providing this conversation',
- GObject.ParamFlags.READWRITE,
- GObject.Object
- ),
- 'has-pending': GObject.ParamSpec.boolean(
- 'has-pending',
- 'Has Pending',
- 'Whether there are sent messages pending confirmation',
- GObject.ParamFlags.READABLE,
- false
- ),
- 'thread-id': GObject.ParamSpec.string(
- 'thread-id',
- 'Thread ID',
- 'The current thread',
- GObject.ParamFlags.READWRITE,
- ''
- ),
- },
- Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/messaging-conversation.ui',
- Children: [
- 'entry', 'list', 'scrolled',
- 'pending', 'pending-box',
- ],
- }, class MessagingConversation extends Gtk.Grid {
- _init(params) {
- super._init({
- device: params.device,
- plugin: params.plugin,
- });
- Object.assign(this, params);
- this.device.bind_property(
- 'connected',
- this.entry,
- 'sensitive',
- GObject.BindingFlags.SYNC_CREATE
- );
- // If we're disconnected pending messages might not succeed, but we'll
- // leave them until reconnect when we'll ask for an update
- this._connectedId = this.device.connect(
- 'notify::connected',
- this._onConnected.bind(this)
- );
- // Pending messages
- this.pending.message = {
- date: Number.MAX_SAFE_INTEGER,
- type: Sms.MessageBox.OUTBOX,
- };
- // Auto-scrolling
- this._vadj = this.scrolled.get_vadjustment();
- this._scrolledId = this._vadj.connect(
- 'value-changed',
- this._holdPosition.bind(this)
- );
- // Message List
- this.list.set_header_func(this._headerMessages);
- this.list.set_sort_func(this._sortMessages);
- this._populateMessages();
- // Cleanup on ::destroy
- this.connect('destroy', this._onDestroy);
- }
- get addresses() {
- if (this._addresses === undefined)
- this._addresses = [];
- return this._addresses;
- }
- set addresses(addresses) {
- if (!addresses || addresses.length === 0) {
- this._addresses = [];
- this._contacts = {};
- return;
- }
- // Lookup a contact for each address object, then loop back to correct
- // each address carried by the message.
- this._addresses = addresses;
- for (let i = 0, len = this.addresses.length; i < len; i++) {
- // Lookup the contact
- const address = this.addresses[i].address;
- const contact = this.device.contacts.query({number: address});
- // Get corrected address
- let number = address.toPhoneNumber();
- if (!number)
- continue;
- for (const contactNumber of contact.numbers) {
- const cnumber = contactNumber.value.toPhoneNumber();
- if (cnumber && (number.endsWith(cnumber) || cnumber.endsWith(number))) {
- number = contactNumber.value;
- break;
- }
- }
- // Store the final result
- this.addresses[i].address = number;
- this.contacts[address] = contact;
- }
- // TODO: Mark the entry as insensitive for group messages
- if (this.addresses.length > 1) {
- this.entry.placeholder_text = _('Not available');
- this.entry.secondary_icon_name = null;
- this.entry.secondary_icon_tooltip_text = null;
- this.entry.sensitive = false;
- this.entry.tooltip_text = null;
- }
- }
- get contacts() {
- if (this._contacts === undefined)
- this._contacts = {};
- return this._contacts;
- }
- get has_pending() {
- if (this.pending_box === undefined)
- return false;
- return (this.pending_box.get_children().length > 0);
- }
- get plugin() {
- if (this._plugin === undefined)
- this._plugin = null;
- return this._plugin;
- }
- set plugin(plugin) {
- this._plugin = plugin;
- }
- get thread_id() {
- if (this._thread_id === undefined)
- this._thread_id = null;
- return this._thread_id;
- }
- set thread_id(thread_id) {
- const thread = this.plugin.threads[thread_id];
- const message = (thread) ? thread[0] : null;
- if (message && this.addresses.length === 0) {
- this.addresses = message.addresses;
- this._thread_id = thread_id;
- }
- }
- _onConnected(device) {
- if (device.connected)
- this.pending_box.foreach(msg => msg.destroy());
- }
- _onDestroy(conversation) {
- conversation.device.disconnect(conversation._connectedId);
- conversation._vadj.disconnect(conversation._scrolledId);
- conversation.list.foreach(message => {
- // HACK: temporary mitigator for mysterious GtkListBox leak
- message.destroy();
- system.gc();
- });
- }
- _onEdgeReached(scrolled_window, pos) {
- // Try to load more messages
- if (pos === Gtk.PositionType.TOP)
- this.logPrevious();
- // Release any hold to resume auto-scrolling
- else if (pos === Gtk.PositionType.BOTTOM)
- this._releasePosition();
- }
- _onEntryChanged(entry) {
- entry.secondary_icon_sensitive = (entry.text.length);
- }
- _onKeyPressEvent(entry, event) {
- const keyval = event.get_keyval()[1];
- const state = event.get_state()[1];
- const mask = state & Gtk.accelerator_get_default_mod_mask();
- if (keyval === Gdk.KEY_Return && (mask & Gdk.ModifierType.SHIFT_MASK)) {
- entry.emit('insert-at-cursor', '\n');
- return true;
- }
- return false;
- }
- _onSendMessage(entry, signal_id, event) {
- // Don't send empty texts
- if (!this.entry.text.trim())
- return;
- // Send the message
- this.plugin.sendMessage(this.addresses, this.entry.text);
- // Add a phony message in the pending box
- const message = new Gtk.Label({
- label: URI.linkify(this.entry.text),
- halign: Gtk.Align.END,
- selectable: true,
- use_markup: true,
- visible: true,
- wrap: true,
- wrap_mode: Pango.WrapMode.WORD_CHAR,
- xalign: 0,
- });
- message.get_style_context().add_class('message-out');
- message.date = Date.now();
- message.type = Sms.MessageBox.SENT;
- // Notify to reveal the pending box
- this.pending_box.add(message);
- this.notify('has-pending');
- // Clear the entry
- this.entry.text = '';
- }
- _onSizeAllocate(listbox, allocation) {
- const upper = this._vadj.get_upper();
- const pageSize = this._vadj.get_page_size();
- // If the scrolled window hasn't been filled yet, load another message
- if (upper <= pageSize) {
- this.logPrevious();
- this.scrolled.get_child().check_resize();
- // We've been asked to hold the position, so we'll reset the adjustment
- // value and update the hold position
- } else if (this.__pos) {
- this._vadj.set_value(upper - this.__pos);
- // Otherwise we probably appended a message and should scroll to it
- } else {
- this._scrollPosition(Gtk.PositionType.BOTTOM);
- }
- }
- /**
- * Create a message row, ensuring a contact object has been retrieved or
- * generated for the message.
- *
- * @param {object} message - A dictionary of message data
- * @returns {ConversationMessage} A message row
- */
- _createMessageRow(message) {
- // Ensure we have a contact
- const sender = message.addresses[0].address || 'unknown';
- if (this.contacts[sender] === undefined) {
- this.contacts[sender] = this.device.contacts.query({
- number: sender,
- });
- }
- return new ConversationMessage(this.contacts[sender], message);
- }
- _populateMessages() {
- this.__first = null;
- this.__last = null;
- this.__pos = 0;
- this.__messages = [];
- // Try and find a thread_id for this number
- if (this.thread_id === null && this.addresses.length)
- this._thread_id = this.plugin.getThreadIdForAddresses(this.addresses);
- // Make a copy of the thread and fill the window with messages
- if (this.plugin.threads[this.thread_id]) {
- this.__messages = this.plugin.threads[this.thread_id].slice(0);
- this.logPrevious();
- }
- }
- _headerMessages(row, before) {
- // Skip pending
- if (row.get_name() === 'pending')
- return;
- if (before === null)
- return setAvatarVisible(row, true);
- // Add date header if the last message was more than an hour ago
- let header = row.get_header();
- if ((row.message.date - before.message.date) > TIME_SPAN_HOUR) {
- if (!header) {
- header = new Gtk.Label({visible: true, selectable: true});
- header.get_style_context().add_class('dim-label');
- row.set_header(header);
- }
- header.label = getTime(row.message.date);
- // Also show the avatar
- setAvatarVisible(row, true);
- row.sender_label.visible = row.message.addresses.length > 1;
- // Or if the previous sender was the same, hide its avatar
- } else if (row.message.type === before.message.type &&
- row.sender.equalsPhoneNumber(before.sender)) {
- setAvatarVisible(before, false);
- setAvatarVisible(row, true);
- row.sender_label.visible = false;
- // otherwise show the avatar
- } else {
- setAvatarVisible(row, true);
- }
- }
- _holdPosition() {
- this.__pos = this._vadj.get_upper() - this._vadj.get_value();
- }
- _releasePosition() {
- this.__pos = 0;
- }
- _scrollPosition(pos = Gtk.PositionType.BOTTOM, animate = true) {
- let vpos = pos;
- this._vadj.freeze_notify();
- if (pos === Gtk.PositionType.BOTTOM)
- vpos = this._vadj.get_upper() - this._vadj.get_page_size();
- if (animate) {
- Tweener.addTween(this._vadj, {
- value: vpos,
- time: 0.5,
- transition: 'easeInOutCubic',
- onComplete: () => this._vadj.thaw_notify(),
- });
- } else {
- GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
- this._vadj.set_value(vpos);
- this._vadj.thaw_notify();
- });
- }
- }
- _sortMessages(row1, row2) {
- return (row1.message.date > row2.message.date) ? 1 : -1;
- }
- /**
- * Log the next message in the conversation.
- *
- * @param {object} message - A message object
- */
- logNext(message) {
- try {
- // TODO: Unsupported MessageBox
- if (message.type !== Sms.MessageBox.INBOX &&
- message.type !== Sms.MessageBox.SENT)
- throw TypeError(`invalid message box ${message.type}`);
- // Append the message
- const row = this._createMessageRow(message);
- this.list.add(row);
- this.list.invalidate_headers();
- // Remove the first pending message
- if (this.has_pending && message.type === Sms.MessageBox.SENT) {
- this.pending_box.get_children()[0].destroy();
- this.notify('has-pending');
- }
- } catch (e) {
- debug(e);
- }
- }
- /**
- * Log the previous message in the thread
- */
- logPrevious() {
- try {
- const message = this.__messages.pop();
- if (!message)
- return;
- // TODO: Unsupported MessageBox
- if (message.type !== Sms.MessageBox.INBOX &&
- message.type !== Sms.MessageBox.SENT)
- throw TypeError(`invalid message box ${message.type}`);
- // Prepend the message
- const row = this._createMessageRow(message);
- this.list.prepend(row);
- this.list.invalidate_headers();
- } catch (e) {
- debug(e);
- }
- }
- /**
- * Set the contents of the message entry
- *
- * @param {string} text - The message to place in the entry
- */
- setMessage(text) {
- this.entry.text = text;
- this.entry.emit('move-cursor', 0, text.length, false);
- }
- });
- /**
- * A ListBoxRow for a preview of a conversation
- */
- const ConversationSummary = GObject.registerClass({
- GTypeName: 'GSConnectMessagingConversationSummary',
- Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/messaging-conversation-summary.ui',
- Children: ['avatar', 'name-label', 'time-label', 'body-label'],
- }, class ConversationSummary extends Gtk.ListBoxRow {
- _init(contacts, message) {
- super._init();
- this.contacts = contacts;
- this.message = message;
- }
- get date() {
- return this._message.date;
- }
- get thread_id() {
- return this._message.thread_id;
- }
- get message() {
- return this._message;
- }
- set message(message) {
- this._message = message;
- this._sender = message.addresses[0].address || 'unknown';
- // Contact Name
- let nameLabel = _('Unknown Contact');
- // Update avatar for single-recipient messages
- if (message.addresses.length === 1) {
- this.avatar.contact = this.contacts[this._sender];
- nameLabel = GLib.markup_escape_text(this.avatar.contact.name, -1);
- } else {
- this.avatar.contact = null;
- nameLabel = _('Group Message');
- const participants = [];
- message.addresses.forEach((address) => {
- participants.push(this.contacts[address.address].name);
- });
- this.name_label.tooltip_text = participants.join(', ');
- }
- // Contact Name & Message body
- let bodyLabel = message.body.split(/\r|\n/)[0];
- bodyLabel = GLib.markup_escape_text(bodyLabel, -1);
- // Ignore the 'read' flag if it's an outgoing message
- if (message.type === Sms.MessageBox.SENT) {
- // TRANSLATORS: An outgoing message body in a conversation summary
- bodyLabel = _('You: %s').format(bodyLabel);
- // Otherwise make it bold if it's unread
- } else if (message.read === Sms.MessageStatus.UNREAD) {
- nameLabel = `<b>${nameLabel}</b>`;
- bodyLabel = `<b>${bodyLabel}</b>`;
- }
- // Set the labels, body always smaller
- this.name_label.label = nameLabel;
- this.body_label.label = `<small>${bodyLabel}</small>`;
- // Time
- const timeLabel = `<small>${getShortTime(message.date)}</small>`;
- this.time_label.label = timeLabel;
- }
- /**
- * Update the relative time label.
- */
- update() {
- const timeLabel = `<small>${getShortTime(this.message.date)}</small>`;
- this.time_label.label = timeLabel;
- }
- });
- /**
- * A Gtk.ApplicationWindow for SMS conversations
- */
- export const Window = GObject.registerClass({
- GTypeName: 'GSConnectMessagingWindow',
- Properties: {
- 'device': GObject.ParamSpec.object(
- 'device',
- 'Device',
- 'The device associated with this window',
- GObject.ParamFlags.READWRITE,
- GObject.Object
- ),
- 'plugin': GObject.ParamSpec.object(
- 'plugin',
- 'Plugin',
- 'The plugin providing messages',
- GObject.ParamFlags.READWRITE,
- GObject.Object
- ),
- 'thread-id': GObject.ParamSpec.string(
- 'thread-id',
- 'Thread ID',
- 'The current thread',
- GObject.ParamFlags.READWRITE,
- ''
- ),
- },
- Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/messaging-window.ui',
- Children: [
- 'headerbar', 'infobar',
- 'thread-list', 'stack',
- ],
- }, class MessagingWindow extends Gtk.ApplicationWindow {
- _init(params) {
- super._init(params);
- this.headerbar.subtitle = this.device.name;
- this.insert_action_group('device', this.device);
- // Device Status
- this.device.bind_property(
- 'connected',
- this.infobar,
- 'reveal-child',
- GObject.BindingFlags.INVERT_BOOLEAN
- );
- // Contacts
- this.contact_chooser = new Contacts.ContactChooser({
- device: this.device,
- });
- this.stack.add_named(this.contact_chooser, 'contact-chooser');
- this._numberSelectedId = this.contact_chooser.connect(
- 'number-selected',
- this._onNumberSelected.bind(this)
- );
- // Threads
- this.thread_list.set_sort_func(this._sortThreads);
- this._threadsChangedId = this.plugin.connect(
- 'notify::threads',
- this._onThreadsChanged.bind(this)
- );
- this._timestampThreadsId = GLib.timeout_add_seconds(
- GLib.PRIORITY_DEFAULT_IDLE,
- 60,
- this._timestampThreads.bind(this)
- );
- this._sync();
- this._onThreadsChanged();
- this.restoreGeometry('messaging');
- }
- vfunc_delete_event(event) {
- this.saveGeometry();
- GLib.source_remove(this._timestampThreadsId);
- this.contact_chooser.disconnect(this._numberSelectedId);
- this.plugin.disconnect(this._threadsChangedId);
- return false;
- }
- get plugin() {
- return this._plugin || null;
- }
- set plugin(plugin) {
- this._plugin = plugin;
- }
- get thread_id() {
- return this.stack.visible_child_name;
- }
- set thread_id(thread_id) {
- thread_id = `${thread_id}`; // FIXME
- // Reset to the empty placeholder
- if (!thread_id) {
- this.thread_list.select_row(null);
- this.stack.set_visible_child_name('placeholder');
- return;
- }
- // Create a conversation widget if there isn't one
- let conversation = this.stack.get_child_by_name(thread_id);
- const thread = this.plugin.threads[thread_id];
- if (conversation === null) {
- if (!thread) {
- debug(`Thread ID ${thread_id} not found`);
- return;
- }
- conversation = new Conversation({
- device: this.device,
- plugin: this.plugin,
- thread_id: thread_id,
- });
- this.stack.add_named(conversation, thread_id);
- }
- // Figure out whether this is a multi-recipient thread
- this._setHeaderBar(thread[0].addresses);
- // Select the conversation and entry active
- this.stack.visible_child = conversation;
- this.stack.visible_child.entry.has_focus = true;
- // There was a pending message waiting for a conversation to be chosen
- if (this._pendingShare) {
- conversation.setMessage(this._pendingShare);
- this._pendingShare = null;
- }
- this._thread_id = thread_id;
- this.notify('thread_id');
- }
- _setHeaderBar(addresses = []) {
- const address = addresses[0].address;
- const contact = this.device.contacts.query({number: address});
- if (addresses.length === 1) {
- this.headerbar.title = contact.name;
- this.headerbar.subtitle = Contacts.getDisplayNumber(contact, address);
- } else {
- const otherLength = addresses.length - 1;
- this.headerbar.title = contact.name;
- this.headerbar.subtitle = ngettext(
- 'And %d other contact',
- 'And %d others',
- otherLength
- ).format(otherLength);
- }
- }
- _sync() {
- this.device.contacts.fetch();
- this.plugin.connected();
- }
- _onNewConversation() {
- this._sync();
- this.stack.set_visible_child_name('contact-chooser');
- this.thread_list.select_row(null);
- this.contact_chooser.entry.has_focus = true;
- }
- _onNumberSelected(chooser, number) {
- const contacts = chooser.getSelected();
- const row = this._getRowForContacts(contacts);
- if (row)
- row.emit('activate');
- else
- this.setContacts(contacts);
- }
- /**
- * Threads
- */
- _onThreadsChanged() {
- // Get the last message in each thread
- const messages = {};
- for (const [thread_id, thread] of Object.entries(this.plugin.threads)) {
- const message = thread[thread.length - 1];
- // Skip messages without a body (eg. MMS messages without text)
- if (message.body)
- messages[thread_id] = thread[thread.length - 1];
- }
- // Update existing summaries and destroy old ones
- for (const row of this.thread_list.get_children()) {
- const message = messages[row.thread_id];
- // If it's an existing conversation, update it
- if (message) {
- // Ensure there's a contact mapping
- const sender = message.addresses[0].address || 'unknown';
- if (row.contacts[sender] === undefined) {
- row.contacts[sender] = this.device.contacts.query({
- number: sender,
- });
- }
- row.message = message;
- delete messages[row.thread_id];
- // Otherwise destroy it
- } else {
- // Destroy the conversation widget
- const conversation = this.stack.get_child_by_name(`${row.thread_id}`);
- if (conversation) {
- conversation.destroy();
- system.gc();
- }
- // Then the summary widget
- row.destroy();
- // HACK: temporary mitigator for mysterious GtkListBox leak
- system.gc();
- }
- }
- // What's left in the dictionary is new summaries
- for (const message of Object.values(messages)) {
- const contacts = this.device.contacts.lookupAddresses(message.addresses);
- const conversation = new ConversationSummary(contacts, message);
- this.thread_list.add(conversation);
- }
- // Re-sort the summaries
- this.thread_list.invalidate_sort();
- }
- // GtkListBox::row-activated
- _onThreadSelected(box, row) {
- // Show the conversation for this number (if applicable)
- if (row) {
- this.thread_id = row.thread_id;
- // Show the placeholder
- } else {
- this.headerbar.title = _('Messaging');
- this.headerbar.subtitle = this.device.name;
- }
- }
- _sortThreads(row1, row2) {
- return (row1.date > row2.date) ? -1 : 1;
- }
- _timestampThreads() {
- if (this.visible)
- this.thread_list.foreach(row => row.update());
- return GLib.SOURCE_CONTINUE;
- }
- /**
- * Find the thread row for @contacts
- *
- * @param {object[]} contacts - A contact group
- * @returns {ConversationSummary|null} The thread row or %null
- */
- _getRowForContacts(contacts) {
- const addresses = Object.keys(contacts).map(address => {
- return {address: address};
- });
- // Try to find a thread_id
- const thread_id = this.plugin.getThreadIdForAddresses(addresses);
- for (const row of this.thread_list.get_children()) {
- if (row.message.thread_id === thread_id)
- return row;
- }
- return null;
- }
- setContacts(contacts) {
- // Group the addresses
- const addresses = [];
- for (const address of Object.keys(contacts))
- addresses.push({address: address});
- // Try to find a thread ID for this address group
- let thread_id = this.plugin.getThreadIdForAddresses(addresses);
- if (thread_id === null)
- thread_id = GLib.uuid_string_random();
- else
- thread_id = thread_id.toString();
- // Try to find a thread row for the ID
- const row = this._getRowForContacts(contacts);
- if (row !== null) {
- this.thread_list.select_row(row);
- return;
- }
- // We're creating a new conversation
- const conversation = new Conversation({
- device: this.device,
- plugin: this.plugin,
- addresses: addresses,
- });
- // Set the headerbar
- this._setHeaderBar(addresses);
- // Select the conversation and entry active
- this.stack.add_named(conversation, thread_id);
- this.stack.visible_child = conversation;
- this.stack.visible_child.entry.has_focus = true;
- // There was a pending message waiting for a conversation to be chosen
- if (this._pendingShare) {
- conversation.setMessage(this._pendingShare);
- this._pendingShare = null;
- }
- this._thread_id = thread_id;
- this.notify('thread-id');
- }
- _includesAddress(addresses, addressObj) {
- const number = addressObj.address.toPhoneNumber();
- for (const haystackObj of addresses) {
- const tnumber = haystackObj.address.toPhoneNumber();
- if (number.endsWith(tnumber) || tnumber.endsWith(number))
- return true;
- }
- return false;
- }
- /**
- * Try and find an existing conversation widget for @message.
- *
- * @param {object} message - A message object
- * @returns {Conversation|null} A conversation widget or %null
- */
- getConversationForMessage(message) {
- // TODO: This shouldn't happen?
- if (message === null)
- return null;
- // First try to find a conversation by thread_id
- const thread_id = `${message.thread_id}`;
- const conversation = this.stack.get_child_by_name(thread_id);
- if (conversation !== null)
- return conversation;
- // Try and find one by matching addresses, which is necessary if we've
- // started a thread locally and haven't set the thread_id
- const addresses = message.addresses;
- for (const conversation of this.stack.get_children()) {
- if (conversation.addresses === undefined ||
- conversation.addresses.length !== addresses.length)
- continue;
- const caddrs = conversation.addresses;
- // If we find a match, set `thread-id` on the conversation and the
- // child property `name`.
- if (addresses.every(addr => this._includesAddress(caddrs, addr))) {
- conversation._thread_id = thread_id;
- this.stack.child_set_property(conversation, 'name', thread_id);
- return conversation;
- }
- }
- return null;
- }
- /**
- * Set the contents of the message entry. If @pending is %false set the
- * message of the currently selected conversation, otherwise mark the
- * message to be set for the next selected conversation.
- *
- * @param {string} message - The message to place in the entry
- * @param {boolean} pending - Wait for a conversation to be selected
- */
- setMessage(message, pending = false) {
- try {
- if (pending)
- this._pendingShare = message;
- else
- this.stack.visible_child.setMessage(message);
- } catch (e) {
- debug(e);
- }
- }
- });
- /**
- * A Gtk.ApplicationWindow for selecting from open conversations
- */
- export const ConversationChooser = GObject.registerClass({
- GTypeName: 'GSConnectConversationChooser',
- Properties: {
- 'device': GObject.ParamSpec.object(
- 'device',
- 'Device',
- 'The device associated with this window',
- GObject.ParamFlags.READWRITE,
- GObject.Object
- ),
- 'message': GObject.ParamSpec.string(
- 'message',
- 'Message',
- 'The message to share',
- GObject.ParamFlags.READWRITE,
- ''
- ),
- 'plugin': GObject.ParamSpec.object(
- 'plugin',
- 'Plugin',
- 'The plugin providing messages',
- GObject.ParamFlags.READWRITE,
- GObject.Object
- ),
- },
- }, class ConversationChooser extends Gtk.ApplicationWindow {
- _init(params) {
- super._init(Object.assign({
- title: _('Share Link'),
- default_width: 300,
- default_height: 200,
- }, params));
- this.set_keep_above(true);
- // HeaderBar
- this.headerbar = new Gtk.HeaderBar({
- title: _('Share Link'),
- subtitle: this.message,
- show_close_button: true,
- tooltip_text: this.message,
- });
- this.set_titlebar(this.headerbar);
- const newButton = new Gtk.Button({
- image: new Gtk.Image({icon_name: 'list-add-symbolic'}),
- tooltip_text: _('New Conversation'),
- always_show_image: true,
- });
- newButton.connect('clicked', this._new.bind(this));
- this.headerbar.pack_start(newButton);
- // Threads
- const scrolledWindow = new Gtk.ScrolledWindow({
- can_focus: false,
- hexpand: true,
- vexpand: true,
- hscrollbar_policy: Gtk.PolicyType.NEVER,
- });
- this.add(scrolledWindow);
- this.thread_list = new Gtk.ListBox({
- activate_on_single_click: false,
- });
- this.thread_list.set_sort_func(Window.prototype._sortThreads);
- this.thread_list.connect('row-activated', this._select.bind(this));
- scrolledWindow.add(this.thread_list);
- // Filter Setup
- Window.prototype._onThreadsChanged.call(this);
- this.show_all();
- }
- get plugin() {
- return this._plugin || null;
- }
- set plugin(plugin) {
- this._plugin = plugin;
- }
- _new(button) {
- const message = this.message;
- this.destroy();
- this.plugin.sms();
- this.plugin.window._onNewConversation();
- this.plugin.window._pendingShare = message;
- }
- _select(box, row) {
- this.plugin.sms();
- this.plugin.window.thread_id = row.message.thread_id.toString();
- this.plugin.window.setMessage(this.message);
- this.destroy();
- }
- });
|