03a46be5c7
In telepathyClient we consider messages both Tpl.TextEvents and Tpl.Messages, and we manually create JS objects to copy the properties we care for each one. This may lead to objects not matching the interface we want. Instead, use an object with construct-only properties and two factory static methods to initialize it. Unfortunately we need to use the ChatMessageClass for the class name or calling the static methods would trigger a gjs error as per [1]. https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/1113 [1] https://gitlab.gnome.org/GNOME/gjs/-/issues/310
1020 lines
36 KiB
JavaScript
1020 lines
36 KiB
JavaScript
// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
|
|
/* exported Component */
|
|
|
|
const { Clutter, Gio, GLib, GObject, St } = imports.gi;
|
|
|
|
var Tpl = null;
|
|
var Tp = null;
|
|
try {
|
|
({ TelepathyGLib: Tp, TelepathyLogger: Tpl } = imports.gi);
|
|
} catch (e) {
|
|
log('Telepathy is not available, chat integration will be disabled.');
|
|
}
|
|
|
|
const History = imports.misc.history;
|
|
const Main = imports.ui.main;
|
|
const MessageList = imports.ui.messageList;
|
|
const MessageTray = imports.ui.messageTray;
|
|
const Params = imports.misc.params;
|
|
const Util = imports.misc.util;
|
|
|
|
const HAVE_TP = Tp != null && Tpl != null;
|
|
|
|
// See Notification.appendMessage
|
|
var SCROLLBACK_IMMEDIATE_TIME = 3 * 60; // 3 minutes
|
|
var SCROLLBACK_RECENT_TIME = 15 * 60; // 15 minutes
|
|
var SCROLLBACK_RECENT_LENGTH = 20;
|
|
var SCROLLBACK_IDLE_LENGTH = 5;
|
|
|
|
// See Source._displayPendingMessages
|
|
var SCROLLBACK_HISTORY_LINES = 10;
|
|
|
|
// See Notification._onEntryChanged
|
|
var COMPOSING_STOP_TIMEOUT = 5;
|
|
|
|
var CHAT_EXPAND_LINES = 12;
|
|
|
|
var NotificationDirection = {
|
|
SENT: 'chat-sent',
|
|
RECEIVED: 'chat-received',
|
|
};
|
|
|
|
const ChatMessage = HAVE_TP ? GObject.registerClass({
|
|
Properties: {
|
|
'message-type': GObject.ParamSpec.int(
|
|
'message-type', 'message-type', 'message-type',
|
|
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
|
|
Math.min(...Object.values(Tp.ChannelTextMessageType)),
|
|
Math.max(...Object.values(Tp.ChannelTextMessageType)),
|
|
Tp.ChannelTextMessageType.NORMAL),
|
|
'text': GObject.ParamSpec.string(
|
|
'text', 'text', 'text',
|
|
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
|
|
null),
|
|
'sender': GObject.ParamSpec.string(
|
|
'sender', 'sender', 'sender',
|
|
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
|
|
null),
|
|
'timestamp': GObject.ParamSpec.int64(
|
|
'timestamp', 'timestamp', 'timestamp',
|
|
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
|
|
0, Number.MAX_SAFE_INTEGER, 0),
|
|
'direction': GObject.ParamSpec.string(
|
|
'direction', 'direction', 'direction',
|
|
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
|
|
null),
|
|
},
|
|
}, class ChatMessageClass extends GObject.Object {
|
|
static newFromTpMessage(tpMessage, direction) {
|
|
return new ChatMessage({
|
|
'message-type': tpMessage.get_message_type(),
|
|
'text': tpMessage.to_text()[0],
|
|
'sender': tpMessage.sender.alias,
|
|
'timestamp': direction === NotificationDirection.RECEIVED
|
|
? tpMessage.get_received_timestamp() : tpMessage.get_sent_timestamp(),
|
|
direction,
|
|
});
|
|
}
|
|
|
|
static newFromTplTextEvent(tplTextEvent) {
|
|
let direction =
|
|
tplTextEvent.get_sender().get_entity_type() === Tpl.EntityType.SELF
|
|
? NotificationDirection.SENT : NotificationDirection.RECEIVED;
|
|
|
|
return new ChatMessage({
|
|
'message-type': tplTextEvent.get_message_type(),
|
|
'text': tplTextEvent.get_message(),
|
|
'sender': tplTextEvent.get_sender().get_alias(),
|
|
'timestamp': tplTextEvent.get_timestamp(),
|
|
direction,
|
|
});
|
|
}
|
|
}) : null;
|
|
|
|
|
|
var TelepathyComponent = class {
|
|
constructor() {
|
|
this._client = null;
|
|
|
|
if (!HAVE_TP)
|
|
return; // Telepathy isn't available
|
|
|
|
this._client = new TelepathyClient();
|
|
}
|
|
|
|
enable() {
|
|
if (!this._client)
|
|
return;
|
|
|
|
try {
|
|
this._client.register();
|
|
} catch (e) {
|
|
throw new Error('Could not register Telepathy client. Error: %s'.format(e.toString()));
|
|
}
|
|
|
|
if (!this._client.account_manager.is_prepared(Tp.AccountManager.get_feature_quark_core()))
|
|
this._client.account_manager.prepare_async(null, null);
|
|
}
|
|
|
|
disable() {
|
|
if (!this._client)
|
|
return;
|
|
|
|
this._client.unregister();
|
|
}
|
|
};
|
|
|
|
var TelepathyClient = HAVE_TP ? GObject.registerClass(
|
|
class TelepathyClient extends Tp.BaseClient {
|
|
_init() {
|
|
// channel path -> ChatSource
|
|
this._chatSources = {};
|
|
this._chatState = Tp.ChannelChatState.ACTIVE;
|
|
|
|
// account path -> AccountNotification
|
|
this._accountNotifications = {};
|
|
|
|
// Define features we want
|
|
this._accountManager = Tp.AccountManager.dup();
|
|
let factory = this._accountManager.get_factory();
|
|
factory.add_account_features([Tp.Account.get_feature_quark_connection()]);
|
|
factory.add_connection_features([Tp.Connection.get_feature_quark_contact_list()]);
|
|
factory.add_channel_features([Tp.Channel.get_feature_quark_contacts()]);
|
|
factory.add_contact_features([Tp.ContactFeature.ALIAS,
|
|
Tp.ContactFeature.AVATAR_DATA,
|
|
Tp.ContactFeature.PRESENCE,
|
|
Tp.ContactFeature.SUBSCRIPTION_STATES]);
|
|
|
|
// Set up a SimpleObserver, which will call _observeChannels whenever a
|
|
// channel matching its filters is detected.
|
|
// The second argument, recover, means _observeChannels will be run
|
|
// for any existing channel as well.
|
|
super._init({ name: 'GnomeShell',
|
|
account_manager: this._accountManager,
|
|
uniquify_name: true });
|
|
|
|
// We only care about single-user text-based chats
|
|
let filter = {};
|
|
filter[Tp.PROP_CHANNEL_CHANNEL_TYPE] = Tp.IFACE_CHANNEL_TYPE_TEXT;
|
|
filter[Tp.PROP_CHANNEL_TARGET_HANDLE_TYPE] = Tp.HandleType.CONTACT;
|
|
|
|
this.set_observer_recover(true);
|
|
this.add_observer_filter(filter);
|
|
this.add_approver_filter(filter);
|
|
this.add_handler_filter(filter);
|
|
|
|
// Allow other clients (such as Empathy) to pre-empt our channels if
|
|
// needed
|
|
this.set_delegated_channels_callback(
|
|
this._delegatedChannelsCb.bind(this));
|
|
}
|
|
|
|
vfunc_observe_channels(...args) {
|
|
let [account, conn, channels, dispatchOp_, requests_, context] = args;
|
|
let len = channels.length;
|
|
for (let i = 0; i < len; i++) {
|
|
let channel = channels[i];
|
|
let [targetHandle_, targetHandleType] = channel.get_handle();
|
|
|
|
if (channel.get_invalidated())
|
|
continue;
|
|
|
|
/* Only observe contact text channels */
|
|
if (!(channel instanceof Tp.TextChannel) ||
|
|
targetHandleType != Tp.HandleType.CONTACT)
|
|
continue;
|
|
|
|
this._createChatSource(account, conn, channel, channel.get_target_contact());
|
|
}
|
|
|
|
context.accept();
|
|
}
|
|
|
|
_createChatSource(account, conn, channel, contact) {
|
|
if (this._chatSources[channel.get_object_path()])
|
|
return;
|
|
|
|
let source = new ChatSource(account, conn, channel, contact, this);
|
|
|
|
this._chatSources[channel.get_object_path()] = source;
|
|
source.connect('destroy', () => {
|
|
delete this._chatSources[channel.get_object_path()];
|
|
});
|
|
}
|
|
|
|
vfunc_handle_channels(...args) {
|
|
let [account, conn, channels, requests_, userActionTime_, context] = args;
|
|
this._handlingChannels(account, conn, channels, true);
|
|
context.accept();
|
|
}
|
|
|
|
_handlingChannels(account, conn, channels, notify) {
|
|
let len = channels.length;
|
|
for (let i = 0; i < len; i++) {
|
|
let channel = channels[i];
|
|
|
|
// We can only handle text channel, so close any other channel
|
|
if (!(channel instanceof Tp.TextChannel)) {
|
|
channel.close_async(null);
|
|
continue;
|
|
}
|
|
|
|
if (channel.get_invalidated())
|
|
continue;
|
|
|
|
// 'notify' will be true when coming from an actual HandleChannels
|
|
// call, and not when from a successful Claim call. The point is
|
|
// we don't want to notify for a channel we just claimed which
|
|
// has no new messages (for example, a new channel which only has
|
|
// a delivery notification). We rely on _displayPendingMessages()
|
|
// and _messageReceived() to notify for new messages.
|
|
|
|
// But we should still notify from HandleChannels because the
|
|
// Telepathy spec states that handlers must foreground channels
|
|
// in HandleChannels calls which are already being handled.
|
|
|
|
if (notify && this.is_handling_channel(channel)) {
|
|
// We are already handling the channel, display the source
|
|
let source = this._chatSources[channel.get_object_path()];
|
|
if (source)
|
|
source.showNotification();
|
|
}
|
|
}
|
|
}
|
|
|
|
vfunc_add_dispatch_operation(...args) {
|
|
let [account, conn, channels, dispatchOp, context] = args;
|
|
let channel = channels[0];
|
|
let chanType = channel.get_channel_type();
|
|
|
|
if (channel.get_invalidated()) {
|
|
context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT,
|
|
message: 'Channel is invalidated' }));
|
|
return;
|
|
}
|
|
|
|
if (chanType == Tp.IFACE_CHANNEL_TYPE_TEXT) {
|
|
this._approveTextChannel(account, conn, channel, dispatchOp, context);
|
|
} else {
|
|
context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT,
|
|
message: 'Unsupported channel type' }));
|
|
}
|
|
}
|
|
|
|
_approveTextChannel(account, conn, channel, dispatchOp, context) {
|
|
let [targetHandle_, targetHandleType] = channel.get_handle();
|
|
|
|
if (targetHandleType != Tp.HandleType.CONTACT) {
|
|
context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT,
|
|
message: 'Unsupported handle type' }));
|
|
return;
|
|
}
|
|
|
|
// Approve private text channels right away as we are going to handle it
|
|
dispatchOp.claim_with_async(this, (o, result) => {
|
|
try {
|
|
dispatchOp.claim_with_finish(result);
|
|
this._handlingChannels(account, conn, [channel], false);
|
|
} catch (err) {
|
|
log('Failed to Claim channel: %s'.format(err.toString()));
|
|
}
|
|
});
|
|
|
|
context.accept();
|
|
}
|
|
|
|
_delegatedChannelsCb(_client, _channels) {
|
|
// Nothing to do as we don't make a distinction between observed and
|
|
// handled channels.
|
|
}
|
|
}) : null;
|
|
|
|
var ChatSource = HAVE_TP ? GObject.registerClass(
|
|
class ChatSource extends MessageTray.Source {
|
|
_init(account, conn, channel, contact, client) {
|
|
super._init(contact.get_alias());
|
|
|
|
this._account = account;
|
|
this._contact = contact;
|
|
this._client = client;
|
|
|
|
this.isChat = true;
|
|
this._pendingMessages = [];
|
|
|
|
this._conn = conn;
|
|
this._channel = channel;
|
|
this._closedId = this._channel.connect('invalidated', this._channelClosed.bind(this));
|
|
|
|
this._notifyTimeoutId = 0;
|
|
|
|
this._presence = contact.get_presence_type();
|
|
|
|
this._sentId = this._channel.connect('message-sent', this._messageSent.bind(this));
|
|
this._receivedId = this._channel.connect('message-received', this._messageReceived.bind(this));
|
|
this._pendingId = this._channel.connect('pending-message-removed', this._pendingRemoved.bind(this));
|
|
|
|
this._notifyAliasId = this._contact.connect('notify::alias', this._updateAlias.bind(this));
|
|
this._notifyAvatarId = this._contact.connect('notify::avatar-file', this._updateAvatarIcon.bind(this));
|
|
this._presenceChangedId = this._contact.connect('presence-changed', this._presenceChanged.bind(this));
|
|
|
|
// Add ourselves as a source.
|
|
Main.messageTray.add(this);
|
|
|
|
this._getLogMessages();
|
|
}
|
|
|
|
_ensureNotification() {
|
|
if (this._notification)
|
|
return;
|
|
|
|
this._notification = new ChatNotification(this);
|
|
this._notification.connect('activated', this.open.bind(this));
|
|
this._notification.connect('updated', () => {
|
|
if (this._banner && this._banner.expanded)
|
|
this._ackMessages();
|
|
});
|
|
this._notification.connect('destroy', () => {
|
|
this._notification = null;
|
|
});
|
|
this.pushNotification(this._notification);
|
|
}
|
|
|
|
_createPolicy() {
|
|
if (this._account.protocol_name == 'irc')
|
|
return new MessageTray.NotificationApplicationPolicy('org.gnome.Polari');
|
|
return new MessageTray.NotificationApplicationPolicy('empathy');
|
|
}
|
|
|
|
createBanner() {
|
|
this._banner = new ChatNotificationBanner(this._notification);
|
|
|
|
// We ack messages when the user expands the new notification
|
|
let id = this._banner.connect('expanded', this._ackMessages.bind(this));
|
|
this._banner.connect('destroy', () => {
|
|
this._banner.disconnect(id);
|
|
this._banner = null;
|
|
});
|
|
|
|
return this._banner;
|
|
}
|
|
|
|
_updateAlias() {
|
|
let oldAlias = this.title;
|
|
let newAlias = this._contact.get_alias();
|
|
|
|
if (oldAlias == newAlias)
|
|
return;
|
|
|
|
this.setTitle(newAlias);
|
|
if (this._notification)
|
|
this._notification.appendAliasChange(oldAlias, newAlias);
|
|
}
|
|
|
|
getIcon() {
|
|
let file = this._contact.get_avatar_file();
|
|
if (file)
|
|
return new Gio.FileIcon({ file });
|
|
else
|
|
return new Gio.ThemedIcon({ name: 'avatar-default' });
|
|
}
|
|
|
|
getSecondaryIcon() {
|
|
let iconName;
|
|
let presenceType = this._contact.get_presence_type();
|
|
|
|
switch (presenceType) {
|
|
case Tp.ConnectionPresenceType.AVAILABLE:
|
|
iconName = 'user-available';
|
|
break;
|
|
case Tp.ConnectionPresenceType.BUSY:
|
|
iconName = 'user-busy';
|
|
break;
|
|
case Tp.ConnectionPresenceType.OFFLINE:
|
|
iconName = 'user-offline';
|
|
break;
|
|
case Tp.ConnectionPresenceType.HIDDEN:
|
|
iconName = 'user-invisible';
|
|
break;
|
|
case Tp.ConnectionPresenceType.AWAY:
|
|
iconName = 'user-away';
|
|
break;
|
|
case Tp.ConnectionPresenceType.EXTENDED_AWAY:
|
|
iconName = 'user-idle';
|
|
break;
|
|
default:
|
|
iconName = 'user-offline';
|
|
}
|
|
return new Gio.ThemedIcon({ name: iconName });
|
|
}
|
|
|
|
_updateAvatarIcon() {
|
|
this.iconUpdated();
|
|
if (this._notifiction) {
|
|
this._notification.update(this._notification.title,
|
|
this._notification.bannerBodyText,
|
|
{ gicon: this.getIcon() });
|
|
}
|
|
}
|
|
|
|
open() {
|
|
Main.overview.hide();
|
|
Main.panel.closeCalendar();
|
|
|
|
if (this._client.is_handling_channel(this._channel)) {
|
|
// We are handling the channel, try to pass it to Empathy or Polari
|
|
// (depending on the channel type)
|
|
// We don't check if either app is available - mission control will
|
|
// fallback to something else if activation fails
|
|
|
|
let target;
|
|
if (this._channel.connection.protocol_name == 'irc')
|
|
target = 'org.freedesktop.Telepathy.Client.Polari';
|
|
else
|
|
target = 'org.freedesktop.Telepathy.Client.Empathy.Chat';
|
|
this._client.delegate_channels_async([this._channel], global.get_current_time(), target, null);
|
|
} else {
|
|
// We are not the handler, just ask to present the channel
|
|
let dbus = Tp.DBusDaemon.dup();
|
|
let cd = Tp.ChannelDispatcher.new(dbus);
|
|
|
|
cd.present_channel_async(this._channel, global.get_current_time(), null);
|
|
}
|
|
}
|
|
|
|
_getLogMessages() {
|
|
let logManager = Tpl.LogManager.dup_singleton();
|
|
let entity = Tpl.Entity.new_from_tp_contact(this._contact, Tpl.EntityType.CONTACT);
|
|
|
|
logManager.get_filtered_events_async(this._account, entity,
|
|
Tpl.EventTypeMask.TEXT, SCROLLBACK_HISTORY_LINES,
|
|
null, this._displayPendingMessages.bind(this));
|
|
}
|
|
|
|
_displayPendingMessages(logManager, result) {
|
|
let [success_, events] = logManager.get_filtered_events_finish(result);
|
|
|
|
let logMessages = events.map(e => ChatMessage.newFromTplTextEvent(e));
|
|
this._ensureNotification();
|
|
|
|
let pendingTpMessages = this._channel.get_pending_messages();
|
|
let pendingMessages = [];
|
|
|
|
for (let i = 0; i < pendingTpMessages.length; i++) {
|
|
let message = pendingTpMessages[i];
|
|
|
|
if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT)
|
|
continue;
|
|
|
|
pendingMessages.push(ChatMessage.newFromTpMessage(message,
|
|
NotificationDirection.RECEIVED));
|
|
|
|
this._pendingMessages.push(message);
|
|
}
|
|
|
|
this.countUpdated();
|
|
|
|
let showTimestamp = false;
|
|
|
|
for (let i = 0; i < logMessages.length; i++) {
|
|
let logMessage = logMessages[i];
|
|
let isPending = false;
|
|
|
|
// Skip any log messages that are also in pendingMessages
|
|
for (let j = 0; j < pendingMessages.length; j++) {
|
|
let pending = pendingMessages[j];
|
|
if (logMessage.timestamp == pending.timestamp && logMessage.text == pending.text) {
|
|
isPending = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!isPending) {
|
|
showTimestamp = true;
|
|
this._notification.appendMessage(logMessage, true, ['chat-log-message']);
|
|
}
|
|
}
|
|
|
|
if (showTimestamp)
|
|
this._notification.appendTimestamp();
|
|
|
|
for (let i = 0; i < pendingMessages.length; i++)
|
|
this._notification.appendMessage(pendingMessages[i], true);
|
|
|
|
if (pendingMessages.length > 0)
|
|
this.showNotification();
|
|
}
|
|
|
|
destroy(reason) {
|
|
if (this._client.is_handling_channel(this._channel)) {
|
|
this._ackMessages();
|
|
// The chat box has been destroyed so it can't
|
|
// handle the channel any more.
|
|
this._channel.close_async((channel, result) => {
|
|
channel.close_finish(result);
|
|
});
|
|
} else {
|
|
// Don't indicate any unread messages when the notification
|
|
// that represents them has been destroyed.
|
|
this._pendingMessages = [];
|
|
this.countUpdated();
|
|
}
|
|
|
|
// Keep source alive while the channel is open
|
|
if (reason != MessageTray.NotificationDestroyedReason.SOURCE_CLOSED)
|
|
return;
|
|
|
|
if (this._destroyed)
|
|
return;
|
|
|
|
this._destroyed = true;
|
|
this._channel.disconnect(this._closedId);
|
|
this._channel.disconnect(this._receivedId);
|
|
this._channel.disconnect(this._pendingId);
|
|
this._channel.disconnect(this._sentId);
|
|
|
|
this._contact.disconnect(this._notifyAliasId);
|
|
this._contact.disconnect(this._notifyAvatarId);
|
|
this._contact.disconnect(this._presenceChangedId);
|
|
|
|
super.destroy(reason);
|
|
}
|
|
|
|
_channelClosed() {
|
|
this.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED);
|
|
}
|
|
|
|
/* All messages are new messages for Telepathy sources */
|
|
get count() {
|
|
return this._pendingMessages.length;
|
|
}
|
|
|
|
get unseenCount() {
|
|
return this.count;
|
|
}
|
|
|
|
get countVisible() {
|
|
return this.count > 0;
|
|
}
|
|
|
|
_messageReceived(channel, message) {
|
|
if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT)
|
|
return;
|
|
|
|
this._ensureNotification();
|
|
this._pendingMessages.push(message);
|
|
this.countUpdated();
|
|
|
|
message = ChatMessage.newFromTpMessage(message,
|
|
NotificationDirection.RECEIVED);
|
|
this._notification.appendMessage(message);
|
|
|
|
// Wait a bit before notifying for the received message, a handler
|
|
// could ack it in the meantime.
|
|
if (this._notifyTimeoutId != 0)
|
|
GLib.source_remove(this._notifyTimeoutId);
|
|
this._notifyTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500,
|
|
this._notifyTimeout.bind(this));
|
|
GLib.Source.set_name_by_id(this._notifyTimeoutId, '[gnome-shell] this._notifyTimeout');
|
|
}
|
|
|
|
_notifyTimeout() {
|
|
if (this._pendingMessages.length != 0)
|
|
this.showNotification();
|
|
|
|
this._notifyTimeoutId = 0;
|
|
|
|
return GLib.SOURCE_REMOVE;
|
|
}
|
|
|
|
// This is called for both messages we send from
|
|
// our client and other clients as well.
|
|
_messageSent(channel, message, _flags, _token) {
|
|
this._ensureNotification();
|
|
message = ChatMessage.newFromTpMessage(message,
|
|
NotificationDirection.SENT);
|
|
this._notification.appendMessage(message);
|
|
}
|
|
|
|
showNotification() {
|
|
super.showNotification(this._notification);
|
|
}
|
|
|
|
respond(text) {
|
|
let type;
|
|
if (text.slice(0, 4) == '/me ') {
|
|
type = Tp.ChannelTextMessageType.ACTION;
|
|
text = text.slice(4);
|
|
} else {
|
|
type = Tp.ChannelTextMessageType.NORMAL;
|
|
}
|
|
|
|
let msg = Tp.ClientMessage.new_text(type, text);
|
|
this._channel.send_message_async(msg, 0, (src, result) => {
|
|
this._channel.send_message_finish(result);
|
|
});
|
|
}
|
|
|
|
setChatState(state) {
|
|
// We don't want to send COMPOSING every time a letter is typed into
|
|
// the entry. We send the state only when it changes. Telepathy/Empathy
|
|
// might change it behind our back if the user is using both
|
|
// gnome-shell's entry and the Empathy conversation window. We could
|
|
// keep track of it with the ChatStateChanged signal but it is good
|
|
// enough right now.
|
|
if (state != this._chatState) {
|
|
this._chatState = state;
|
|
this._channel.set_chat_state_async(state, null);
|
|
}
|
|
}
|
|
|
|
_presenceChanged(_contact, _presence, _status, _message) {
|
|
if (this._notification) {
|
|
this._notification.update(this._notification.title,
|
|
this._notification.bannerBodyText,
|
|
{ secondaryGIcon: this.getSecondaryIcon() });
|
|
}
|
|
}
|
|
|
|
_pendingRemoved(channel, message) {
|
|
let idx = this._pendingMessages.indexOf(message);
|
|
|
|
if (idx >= 0) {
|
|
this._pendingMessages.splice(idx, 1);
|
|
this.countUpdated();
|
|
}
|
|
|
|
if (this._pendingMessages.length == 0 &&
|
|
this._banner && !this._banner.expanded)
|
|
this._banner.hide();
|
|
}
|
|
|
|
_ackMessages() {
|
|
// Don't clear our messages here, tp-glib will send a
|
|
// 'pending-message-removed' for each one.
|
|
this._channel.ack_all_pending_messages_async(null);
|
|
}
|
|
}) : null;
|
|
|
|
const ChatNotificationMessage = HAVE_TP ? GObject.registerClass(
|
|
class ChatNotificationMessage extends GObject.Object {
|
|
_init(props = {}) {
|
|
super._init();
|
|
this.set(props);
|
|
}
|
|
}) : null;
|
|
|
|
var ChatNotification = HAVE_TP ? GObject.registerClass({
|
|
Signals: {
|
|
'message-removed': { param_types: [ChatNotificationMessage.$gtype] },
|
|
'message-added': { param_types: [ChatNotificationMessage.$gtype] },
|
|
'timestamp-changed': { param_types: [ChatNotificationMessage.$gtype] },
|
|
},
|
|
}, class ChatNotification extends MessageTray.Notification {
|
|
_init(source) {
|
|
super._init(source, source.title, null,
|
|
{ secondaryGIcon: source.getSecondaryIcon() });
|
|
this.setUrgency(MessageTray.Urgency.HIGH);
|
|
this.setResident(true);
|
|
|
|
this.messages = [];
|
|
this._timestampTimeoutId = 0;
|
|
}
|
|
|
|
destroy(reason) {
|
|
if (this._timestampTimeoutId)
|
|
GLib.source_remove(this._timestampTimeoutId);
|
|
this._timestampTimeoutId = 0;
|
|
super.destroy(reason);
|
|
}
|
|
|
|
/**
|
|
* appendMessage:
|
|
* @param {Object} message: An object with the properties
|
|
* {string} message.text: the body of the message,
|
|
* {Tp.ChannelTextMessageType} message.messageType: the type
|
|
* {string} message.sender: the name of the sender,
|
|
* {number} message.timestamp: the time the message was sent
|
|
* {NotificationDirection} message.direction: a #NotificationDirection
|
|
*
|
|
* @param {bool} noTimestamp: Whether to add a timestamp. If %true,
|
|
* no timestamp will be added, regardless of the difference since
|
|
* the last timestamp
|
|
*/
|
|
appendMessage(message, noTimestamp) {
|
|
let messageBody = GLib.markup_escape_text(message.text, -1);
|
|
let styles = [message.direction];
|
|
|
|
if (message.messageType == Tp.ChannelTextMessageType.ACTION) {
|
|
let senderAlias = GLib.markup_escape_text(message.sender, -1);
|
|
messageBody = '<i>%s</i> %s'.format(senderAlias, messageBody);
|
|
styles.push('chat-action');
|
|
}
|
|
|
|
if (message.direction == NotificationDirection.RECEIVED) {
|
|
this.update(this.source.title, messageBody,
|
|
{ datetime: GLib.DateTime.new_from_unix_local(message.timestamp),
|
|
bannerMarkup: true });
|
|
}
|
|
|
|
let group = message.direction == NotificationDirection.RECEIVED
|
|
? 'received' : 'sent';
|
|
|
|
this._append({ body: messageBody,
|
|
group,
|
|
styles,
|
|
timestamp: message.timestamp,
|
|
noTimestamp });
|
|
}
|
|
|
|
_filterMessages() {
|
|
if (this.messages.length < 1)
|
|
return;
|
|
|
|
let lastMessageTime = this.messages[0].timestamp;
|
|
let currentTime = Date.now() / 1000;
|
|
|
|
// Keep the scrollback from growing too long. If the most
|
|
// recent message (before the one we just added) is within
|
|
// SCROLLBACK_RECENT_TIME, we will keep
|
|
// SCROLLBACK_RECENT_LENGTH previous messages. Otherwise
|
|
// we'll keep SCROLLBACK_IDLE_LENGTH messages.
|
|
|
|
let maxLength = lastMessageTime < currentTime - SCROLLBACK_RECENT_TIME
|
|
? SCROLLBACK_IDLE_LENGTH : SCROLLBACK_RECENT_LENGTH;
|
|
|
|
let filteredHistory = this.messages.filter(item => item.realMessage);
|
|
if (filteredHistory.length > maxLength) {
|
|
let lastMessageToKeep = filteredHistory[maxLength];
|
|
let expired = this.messages.splice(this.messages.indexOf(lastMessageToKeep));
|
|
for (let i = 0; i < expired.length; i++)
|
|
this.emit('message-removed', expired[i]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* _append:
|
|
* @param {Object} props: An object with the properties:
|
|
* {string} props.body: The text of the message.
|
|
* {string} props.group: The group of the message, one of:
|
|
* 'received', 'sent', 'meta'.
|
|
* {string[]} props.styles: Style class names for the message to have.
|
|
* {number} props.timestamp: The timestamp of the message.
|
|
* {bool} props.noTimestamp: suppress timestamp signal?
|
|
*/
|
|
_append(props) {
|
|
let currentTime = Date.now() / 1000;
|
|
props = Params.parse(props, { body: null,
|
|
group: null,
|
|
styles: [],
|
|
timestamp: currentTime,
|
|
noTimestamp: false });
|
|
const { noTimestamp } = props;
|
|
delete props.noTimestamp;
|
|
|
|
// Reset the old message timeout
|
|
if (this._timestampTimeoutId)
|
|
GLib.source_remove(this._timestampTimeoutId);
|
|
this._timestampTimeoutId = 0;
|
|
|
|
let message = new ChatNotificationMessage({
|
|
realMessage: props.group !== 'meta',
|
|
showTimestamp: false,
|
|
...props,
|
|
});
|
|
|
|
this.messages.unshift(message);
|
|
this.emit('message-added', message);
|
|
|
|
if (!noTimestamp) {
|
|
let timestamp = props.timestamp;
|
|
if (timestamp < currentTime - SCROLLBACK_IMMEDIATE_TIME) {
|
|
this.appendTimestamp();
|
|
} else {
|
|
// Schedule a new timestamp in SCROLLBACK_IMMEDIATE_TIME
|
|
// from the timestamp of the message.
|
|
this._timestampTimeoutId = GLib.timeout_add_seconds(
|
|
GLib.PRIORITY_DEFAULT,
|
|
SCROLLBACK_IMMEDIATE_TIME - (currentTime - timestamp),
|
|
this.appendTimestamp.bind(this));
|
|
GLib.Source.set_name_by_id(this._timestampTimeoutId, '[gnome-shell] this.appendTimestamp');
|
|
}
|
|
}
|
|
|
|
this._filterMessages();
|
|
}
|
|
|
|
appendTimestamp() {
|
|
this._timestampTimeoutId = 0;
|
|
|
|
this.messages[0].showTimestamp = true;
|
|
this.emit('timestamp-changed', this.messages[0]);
|
|
|
|
this._filterMessages();
|
|
|
|
return GLib.SOURCE_REMOVE;
|
|
}
|
|
|
|
appendAliasChange(oldAlias, newAlias) {
|
|
oldAlias = GLib.markup_escape_text(oldAlias, -1);
|
|
newAlias = GLib.markup_escape_text(newAlias, -1);
|
|
|
|
/* Translators: this is the other person changing their old IM name to their new
|
|
IM name. */
|
|
let message = '<i>' + _("%s is now known as %s").format(oldAlias, newAlias) + '</i>';
|
|
|
|
this._append({ body: message,
|
|
group: 'meta',
|
|
styles: ['chat-meta-message'] });
|
|
|
|
this._filterMessages();
|
|
}
|
|
}) : null;
|
|
|
|
var ChatLineBox = GObject.registerClass(
|
|
class ChatLineBox extends St.BoxLayout {
|
|
vfunc_get_preferred_height(forWidth) {
|
|
let [, natHeight] = super.vfunc_get_preferred_height(forWidth);
|
|
return [natHeight, natHeight];
|
|
}
|
|
});
|
|
|
|
var ChatNotificationBanner = GObject.registerClass(
|
|
class ChatNotificationBanner extends MessageTray.NotificationBanner {
|
|
_init(notification) {
|
|
super._init(notification);
|
|
|
|
this._responseEntry = new St.Entry({ style_class: 'chat-response',
|
|
x_expand: true,
|
|
can_focus: true });
|
|
this._responseEntry.clutter_text.connect('activate', this._onEntryActivated.bind(this));
|
|
this._responseEntry.clutter_text.connect('text-changed', this._onEntryChanged.bind(this));
|
|
this.setActionArea(this._responseEntry);
|
|
|
|
this._responseEntry.clutter_text.connect('key-focus-in', () => {
|
|
this.focused = true;
|
|
});
|
|
this._responseEntry.clutter_text.connect('key-focus-out', () => {
|
|
this.focused = false;
|
|
this.emit('unfocused');
|
|
});
|
|
|
|
this._scrollArea = new St.ScrollView({ style_class: 'chat-scrollview vfade',
|
|
vscrollbar_policy: St.PolicyType.AUTOMATIC,
|
|
hscrollbar_policy: St.PolicyType.NEVER,
|
|
visible: this.expanded });
|
|
this._contentArea = new St.BoxLayout({ style_class: 'chat-body',
|
|
vertical: true });
|
|
this._scrollArea.add_actor(this._contentArea);
|
|
|
|
this.setExpandedBody(this._scrollArea);
|
|
this.setExpandedLines(CHAT_EXPAND_LINES);
|
|
|
|
this._lastGroup = null;
|
|
|
|
// Keep track of the bottom position for the current adjustment and
|
|
// force a scroll to the bottom if things change while we were at the
|
|
// bottom
|
|
this._oldMaxScrollValue = this._scrollArea.vscroll.adjustment.value;
|
|
this._scrollArea.vscroll.adjustment.connect('changed', adjustment => {
|
|
if (adjustment.value == this._oldMaxScrollValue)
|
|
this.scrollTo(St.Side.BOTTOM);
|
|
this._oldMaxScrollValue = Math.max(adjustment.lower, adjustment.upper - adjustment.page_size);
|
|
});
|
|
|
|
this._inputHistory = new History.HistoryManager({ entry: this._responseEntry.clutter_text });
|
|
|
|
this._composingTimeoutId = 0;
|
|
|
|
this._messageActors = new Map();
|
|
|
|
this._messageAddedId = this.notification.connect('message-added',
|
|
(n, message) => {
|
|
this._addMessage(message);
|
|
});
|
|
this._messageRemovedId = this.notification.connect('message-removed',
|
|
(n, message) => {
|
|
let actor = this._messageActors.get(message);
|
|
if (this._messageActors.delete(message))
|
|
actor.destroy();
|
|
});
|
|
this._timestampChangedId = this.notification.connect('timestamp-changed',
|
|
(n, message) => {
|
|
this._updateTimestamp(message);
|
|
});
|
|
|
|
for (let i = this.notification.messages.length - 1; i >= 0; i--)
|
|
this._addMessage(this.notification.messages[i]);
|
|
}
|
|
|
|
_onDestroy() {
|
|
super._onDestroy();
|
|
this.notification.disconnect(this._messageAddedId);
|
|
this.notification.disconnect(this._messageRemovedId);
|
|
this.notification.disconnect(this._timestampChangedId);
|
|
}
|
|
|
|
scrollTo(side) {
|
|
let adjustment = this._scrollArea.vscroll.adjustment;
|
|
if (side == St.Side.TOP)
|
|
adjustment.value = adjustment.lower;
|
|
else if (side == St.Side.BOTTOM)
|
|
adjustment.value = adjustment.upper;
|
|
}
|
|
|
|
hide() {
|
|
this.emit('done-displaying');
|
|
}
|
|
|
|
_addMessage(message) {
|
|
let body = new MessageList.URLHighlighter(message.body, true, true);
|
|
|
|
let styles = message.styles;
|
|
for (let i = 0; i < styles.length; i++)
|
|
body.add_style_class_name(styles[i]);
|
|
|
|
let group = message.group;
|
|
if (group != this._lastGroup) {
|
|
this._lastGroup = group;
|
|
body.add_style_class_name('chat-new-group');
|
|
}
|
|
|
|
let lineBox = new ChatLineBox();
|
|
lineBox.add(body);
|
|
this._contentArea.add_actor(lineBox);
|
|
this._messageActors.set(message, lineBox);
|
|
|
|
this._updateTimestamp(message);
|
|
}
|
|
|
|
_updateTimestamp(message) {
|
|
let actor = this._messageActors.get(message);
|
|
if (!actor)
|
|
return;
|
|
|
|
while (actor.get_n_children() > 1)
|
|
actor.get_child_at_index(1).destroy();
|
|
|
|
if (message.showTimestamp) {
|
|
let lastMessageTime = message.timestamp;
|
|
let lastMessageDate = new Date(lastMessageTime * 1000);
|
|
|
|
let timeLabel = Util.createTimeLabel(lastMessageDate);
|
|
timeLabel.style_class = 'chat-meta-message';
|
|
timeLabel.x_expand = timeLabel.y_expand = true;
|
|
timeLabel.x_align = timeLabel.y_align = Clutter.ActorAlign.END;
|
|
|
|
actor.add_actor(timeLabel);
|
|
}
|
|
}
|
|
|
|
_onEntryActivated() {
|
|
let text = this._responseEntry.get_text();
|
|
if (text == '')
|
|
return;
|
|
|
|
this._inputHistory.addItem(text);
|
|
|
|
// Telepathy sends out the Sent signal for us.
|
|
// see Source._messageSent
|
|
this._responseEntry.set_text('');
|
|
this.notification.source.respond(text);
|
|
}
|
|
|
|
_composingStopTimeout() {
|
|
this._composingTimeoutId = 0;
|
|
|
|
this.notification.source.setChatState(Tp.ChannelChatState.PAUSED);
|
|
|
|
return GLib.SOURCE_REMOVE;
|
|
}
|
|
|
|
_onEntryChanged() {
|
|
let text = this._responseEntry.get_text();
|
|
|
|
// If we're typing, we want to send COMPOSING.
|
|
// If we empty the entry, we want to send ACTIVE.
|
|
// If we've stopped typing for COMPOSING_STOP_TIMEOUT
|
|
// seconds, we want to send PAUSED.
|
|
|
|
// Remove composing timeout.
|
|
if (this._composingTimeoutId > 0) {
|
|
GLib.source_remove(this._composingTimeoutId);
|
|
this._composingTimeoutId = 0;
|
|
}
|
|
|
|
if (text != '') {
|
|
this.notification.source.setChatState(Tp.ChannelChatState.COMPOSING);
|
|
|
|
this._composingTimeoutId = GLib.timeout_add_seconds(
|
|
GLib.PRIORITY_DEFAULT,
|
|
COMPOSING_STOP_TIMEOUT,
|
|
this._composingStopTimeout.bind(this));
|
|
GLib.Source.set_name_by_id(this._composingTimeoutId, '[gnome-shell] this._composingStopTimeout');
|
|
} else {
|
|
this.notification.source.setChatState(Tp.ChannelChatState.ACTIVE);
|
|
}
|
|
}
|
|
});
|
|
|
|
var Component = TelepathyComponent;
|