1
// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
3
const Gio = imports.gi.Gio;
4
const GLib = imports.gi.GLib;
5
const Lang = imports.lang;
6
const Mainloop = imports.mainloop;
7
const Shell = imports.gi.Shell;
8
const Signals = imports.signals;
9
const St = imports.gi.St;
10
const Tpl = imports.gi.TelepathyLogger;
11
const Tp = imports.gi.TelepathyGLib;
13
const History = imports.misc.history;
14
const Main = imports.ui.main;
15
const MessageTray = imports.ui.messageTray;
16
const Params = imports.misc.params;
17
const PopupMenu = imports.ui.popupMenu;
19
// See Notification.appendMessage
20
const SCROLLBACK_IMMEDIATE_TIME = 60; // 1 minute
21
const SCROLLBACK_RECENT_TIME = 15 * 60; // 15 minutes
22
const SCROLLBACK_RECENT_LENGTH = 20;
23
const SCROLLBACK_IDLE_LENGTH = 5;
25
// See Source._displayPendingMessages
26
const SCROLLBACK_HISTORY_LINES = 10;
28
// See Notification._onEntryChanged
29
const COMPOSING_STOP_TIMEOUT = 5;
31
const NotificationDirection = {
33
RECEIVED: 'chat-received'
36
function makeMessageFromTpMessage(tpMessage, direction) {
37
let [text, flags] = tpMessage.to_text();
39
let timestamp = tpMessage.get_sent_timestamp();
41
timestamp = tpMessage.get_received_timestamp();
44
messageType: tpMessage.get_message_type(),
46
sender: tpMessage.sender.alias,
53
function makeMessageFromTplEvent(event) {
54
let sent = event.get_sender().get_entity_type() == Tpl.EntityType.SELF;
55
let direction = sent ? NotificationDirection.SENT : NotificationDirection.RECEIVED;
58
messageType: event.get_message_type(),
59
text: event.get_message(),
60
sender: event.get_sender().get_alias(),
61
timestamp: event.get_timestamp(),
66
const TelepathyClient = new Lang.Class({
67
Name: 'TelepathyClient',
70
// channel path -> ChatSource
71
this._chatSources = {};
72
this._chatState = Tp.ChannelChatState.ACTIVE;
74
// account path -> AccountNotification
75
this._accountNotifications = {};
77
// Define features we want
78
this._accountManager = Tp.AccountManager.dup();
79
let factory = this._accountManager.get_factory();
80
factory.add_account_features([Tp.Account.get_feature_quark_connection()]);
81
factory.add_connection_features([Tp.Connection.get_feature_quark_contact_list()]);
82
factory.add_channel_features([Tp.Channel.get_feature_quark_contacts()]);
83
factory.add_contact_features([Tp.ContactFeature.ALIAS,
84
Tp.ContactFeature.AVATAR_DATA,
85
Tp.ContactFeature.PRESENCE,
86
Tp.ContactFeature.SUBSCRIPTION_STATES]);
88
// Set up a SimpleObserver, which will call _observeChannels whenever a
89
// channel matching its filters is detected.
90
// The second argument, recover, means _observeChannels will be run
91
// for any existing channel as well.
92
this._tpClient = new Shell.TpClient({ name: 'GnomeShell',
93
account_manager: this._accountManager,
94
uniquify_name: true });
95
this._tpClient.set_observe_channels_func(
96
Lang.bind(this, this._observeChannels));
97
this._tpClient.set_approve_channels_func(
98
Lang.bind(this, this._approveChannels));
99
this._tpClient.set_handle_channels_func(
100
Lang.bind(this, this._handleChannels));
102
// Watch subscription requests and connection errors
103
this._subscriptionSource = null;
104
this._accountSource = null;
106
// Workaround for gjs not supporting GPtrArray in signals.
107
// See BGO bug #653941 for context.
108
this._tpClient.set_contact_list_changed_func(
109
Lang.bind(this, this._contactListChanged));
111
// Allow other clients (such as Empathy) to pre-empt our channels if
113
this._tpClient.set_delegated_channels_callback(
114
Lang.bind(this, this._delegatedChannelsCb));
119
this._tpClient.register();
121
throw new Error('Couldn\'t register Telepathy client. Error: \n' + e);
124
this._accountManagerValidityChangedId = this._accountManager.connect('account-validity-changed',
125
Lang.bind(this, this._accountValidityChanged));
127
if (!this._accountManager.is_prepared(Tp.AccountManager.get_feature_quark_core()))
128
this._accountManager.prepare_async(null, Lang.bind(this, this._accountManagerPrepared));
131
disable: function() {
132
this._tpClient.unregister();
133
this._accountManager.disconnect(this._accountManagerValidityChangedId);
134
this._accountManagerValidityChangedId = 0;
137
_observeChannels: function(observer, account, conn, channels,
138
dispatchOp, requests, context) {
139
let len = channels.length;
140
for (let i = 0; i < len; i++) {
141
let channel = channels[i];
142
let [targetHandle, targetHandleType] = channel.get_handle();
144
if (channel.get_invalidated())
147
/* Only observe contact text channels */
148
if ((!(channel instanceof Tp.TextChannel)) ||
149
targetHandleType != Tp.HandleType.CONTACT)
152
this._createChatSource(account, conn, channel, channel.get_target_contact());
158
_createChatSource: function(account, conn, channel, contact) {
159
if (this._chatSources[channel.get_object_path()])
162
let source = new ChatSource(account, conn, channel, contact, this._tpClient);
164
this._chatSources[channel.get_object_path()] = source;
165
source.connect('destroy', Lang.bind(this,
167
if (this._tpClient.is_handling_channel(channel)) {
168
// The chat box has been destroyed so it can't
169
// handle the channel any more.
170
channel.close_async(function(src, result) {
171
channel.close_finish(result);
175
delete this._chatSources[channel.get_object_path()];
179
_handleChannels: function(handler, account, conn, channels,
180
requests, user_action_time, context) {
181
this._handlingChannels(account, conn, channels, true);
185
_handlingChannels: function(account, conn, channels, notify) {
186
let len = channels.length;
187
for (let i = 0; i < len; i++) {
188
let channel = channels[i];
190
// We can only handle text channel, so close any other channel
191
if (!(channel instanceof Tp.TextChannel)) {
192
channel.close_async(null);
196
if (channel.get_invalidated())
199
// 'notify' will be true when coming from an actual HandleChannels
200
// call, and not when from a successful Claim call. The point is
201
// we don't want to notify for a channel we just claimed which
202
// has no new messages (for example, a new channel which only has
203
// a delivery notification). We rely on _displayPendingMessages()
204
// and _messageReceived() to notify for new messages.
206
// But we should still notify from HandleChannels because the
207
// Telepathy spec states that handlers must foreground channels
208
// in HandleChannels calls which are already being handled.
210
if (notify && this._tpClient.is_handling_channel(channel)) {
211
// We are already handling the channel, display the source
212
let source = this._chatSources[channel.get_object_path()];
219
_displayRoomInvitation: function(conn, channel, dispatchOp, context) {
220
// We can only approve the rooms if we have been invited to it
221
let selfContact = channel.group_get_self_contact();
222
if (selfContact == null) {
223
context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT,
224
message: 'Not invited to the room' }));
228
let [invited, inviter, reason, msg] = channel.group_get_local_pending_contact_info(selfContact);
230
context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT,
231
message: 'Not invited to the room' }));
235
// FIXME: We don't have a 'chat room' icon (bgo #653737) use
236
// system-users for now as Empathy does.
237
let source = new ApproverSource(dispatchOp, _("Invitation"),
238
Gio.icon_new_for_string('system-users'));
239
Main.messageTray.add(source);
241
let notif = new RoomInviteNotification(source, dispatchOp, channel, inviter);
242
source.notify(notif);
246
_approveChannels: function(approver, account, conn, channels,
247
dispatchOp, context) {
248
let channel = channels[0];
249
let chanType = channel.get_channel_type();
251
if (channel.get_invalidated()) {
252
context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT,
253
message: 'Channel is invalidated' }));
257
if (chanType == Tp.IFACE_CHANNEL_TYPE_TEXT)
258
this._approveTextChannel(account, conn, channel, dispatchOp, context);
259
else if (chanType == Tp.IFACE_CHANNEL_TYPE_CALL)
260
this._approveCall(account, conn, channel, dispatchOp, context);
261
else if (chanType == Tp.IFACE_CHANNEL_TYPE_FILE_TRANSFER)
262
this._approveFileTransfer(account, conn, channel, dispatchOp, context);
264
context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT,
265
message: 'Unsupported channel type' }));
268
_approveTextChannel: function(account, conn, channel, dispatchOp, context) {
269
let [targetHandle, targetHandleType] = channel.get_handle();
271
if (targetHandleType == Tp.HandleType.CONTACT) {
272
// Approve private text channels right away as we are going to handle it
273
dispatchOp.claim_with_async(this._tpClient,
274
Lang.bind(this, function(dispatchOp, result) {
276
dispatchOp.claim_with_finish(result);
277
this._handlingChannels(account, conn, [channel], false);
279
throw new Error('Failed to Claim channel: ' + err);
284
this._displayRoomInvitation(conn, channel, dispatchOp, context);
288
_approveCall: function(account, conn, channel, dispatchOp, context) {
291
let props = channel.borrow_immutable_properties();
293
if (props[Tp.PROP_CHANNEL_TYPE_CALL_INITIAL_VIDEO])
296
// We got the TpContact
297
let source = new ApproverSource(dispatchOp, _("Call"), isVideo ?
298
Gio.icon_new_for_string('camera-web') :
299
Gio.icon_new_for_string('audio-input-microphone'));
300
Main.messageTray.add(source);
302
let notif = new AudioVideoNotification(source, dispatchOp, channel,
303
channel.get_target_contact(), isVideo);
304
source.notify(notif);
308
_approveFileTransfer: function(account, conn, channel, dispatchOp, context) {
309
// Use the icon of the file being transferred
310
let gicon = Gio.content_type_get_icon(channel.get_mime_type());
312
// We got the TpContact
313
let source = new ApproverSource(dispatchOp, _("File Transfer"), gicon);
314
Main.messageTray.add(source);
316
let notif = new FileTransferNotification(source, dispatchOp, channel,
317
channel.get_target_contact());
318
source.notify(notif);
322
_delegatedChannelsCb: function(client, channels) {
323
// Nothing to do as we don't make a distinction between observed and
327
_accountManagerPrepared: function(am, result) {
328
am.prepare_finish(result);
330
let accounts = am.get_valid_accounts();
331
for (let i = 0; i < accounts.length; i++) {
332
this._accountValidityChanged(am, accounts[i], true);
336
_accountValidityChanged: function(am, account, valid) {
340
// It would be better to connect to "status-changed" but we cannot.
341
// See discussion in https://bugzilla.gnome.org/show_bug.cgi?id=654159
342
account.connect("notify::connection-status",
343
Lang.bind(this, this._accountConnectionStatusNotifyCb));
345
account.connect('notify::connection',
346
Lang.bind(this, this._connectionChanged));
347
this._connectionChanged(account);
350
_connectionChanged: function(account) {
351
let conn = account.get_connection();
355
this._tpClient.grab_contact_list_changed(conn);
356
if (conn.get_contact_list_state() == Tp.ContactListState.SUCCESS) {
357
this._contactListChanged(conn, conn.dup_contact_list(), []);
361
_contactListChanged: function(conn, added, removed) {
362
for (let i = 0; i < added.length; i++) {
363
let contact = added[i];
365
contact.connect('subscription-states-changed',
366
Lang.bind(this, this._subscriptionStateChanged));
367
this._subscriptionStateChanged(contact);
371
_subscriptionStateChanged: function(contact) {
372
if (contact.get_publish_state() != Tp.SubscriptionState.ASK)
375
/* Implicitly accept publish requests if contact is already subscribed */
376
if (contact.get_subscribe_state() == Tp.SubscriptionState.YES ||
377
contact.get_subscribe_state() == Tp.SubscriptionState.ASK) {
379
contact.authorize_publication_async(function(src, result) {
380
src.authorize_publication_finish(result)});
385
/* Display notification to ask user to accept/reject request */
386
let source = this._ensureSubscriptionSource();
388
let notif = new SubscriptionRequestNotification(source, contact);
389
source.notify(notif);
392
_ensureSubscriptionSource: function() {
393
if (this._subscriptionSource == null) {
394
this._subscriptionSource = new MessageTray.Source(_("Subscription request"),
395
'gtk-dialog-question');
396
Main.messageTray.add(this._subscriptionSource);
397
this._subscriptionSource.connect('destroy', Lang.bind(this, function () {
398
this._subscriptionSource = null;
402
return this._subscriptionSource;
405
_accountConnectionStatusNotifyCb: function(account) {
406
let connectionError = account.connection_error;
408
if (account.connection_status != Tp.ConnectionStatus.DISCONNECTED ||
409
connectionError == Tp.error_get_dbus_name(Tp.Error.CANCELLED)) {
413
let notif = this._accountNotifications[account.get_object_path()];
417
/* Display notification that account failed to connect */
418
let source = this._ensureAccountSource();
420
notif = new AccountNotification(source, account, connectionError);
421
this._accountNotifications[account.get_object_path()] = notif;
422
notif.connect('destroy', Lang.bind(this, function() {
423
delete this._accountNotifications[account.get_object_path()];
425
source.notify(notif);
428
_ensureAccountSource: function() {
429
if (this._accountSource == null) {
430
this._accountSource = new MessageTray.Source(_("Connection error"),
432
Main.messageTray.add(this._accountSource);
433
this._accountSource.connect('destroy', Lang.bind(this, function () {
434
this._accountSource = null;
438
return this._accountSource;
442
const ChatSource = new Lang.Class({
444
Extends: MessageTray.Source,
446
_init: function(account, conn, channel, contact, client) {
447
this._account = account;
448
this._contact = contact;
449
this._client = client;
451
this.parent(contact.get_alias());
454
this._pendingMessages = [];
457
this._channel = channel;
458
this._closedId = this._channel.connect('invalidated', Lang.bind(this, this._channelClosed));
460
this._notification = new ChatNotification(this);
461
this._notification.setUrgency(MessageTray.Urgency.HIGH);
462
this._notifyTimeoutId = 0;
464
// We ack messages when the user expands the new notification or views the summary
465
// notification, in which case the notification is also expanded.
466
this._notification.connect('expanded', Lang.bind(this, this._ackMessages));
468
this._presence = contact.get_presence_type();
470
this._sentId = this._channel.connect('message-sent', Lang.bind(this, this._messageSent));
471
this._receivedId = this._channel.connect('message-received', Lang.bind(this, this._messageReceived));
472
this._pendingId = this._channel.connect('pending-message-removed', Lang.bind(this, this._pendingRemoved));
474
this._notifyAliasId = this._contact.connect('notify::alias', Lang.bind(this, this._updateAlias));
475
this._notifyAvatarId = this._contact.connect('notify::avatar-file', Lang.bind(this, this._updateAvatarIcon));
476
this._presenceChangedId = this._contact.connect('presence-changed', Lang.bind(this, this._presenceChanged));
478
// Add ourselves as a source.
479
Main.messageTray.add(this);
480
this.pushNotification(this._notification);
482
this._getLogMessages();
485
buildRightClickMenu: function() {
488
let rightClickMenu = this.parent();
489
item = new PopupMenu.PopupMenuItem('');
490
item.actor.connect('notify::mapped', Lang.bind(this, function() {
491
item.label.set_text(this.isMuted ? _("Unmute") : _("Mute"));
493
item.connect('activate', Lang.bind(this, function() {
494
this.setMuted(!this.isMuted);
495
this.emit('done-displaying-content');
497
rightClickMenu.add(item.actor);
498
return rightClickMenu;
501
_updateAlias: function() {
502
let oldAlias = this.title;
503
let newAlias = this._contact.get_alias();
505
if (oldAlias == newAlias)
508
this.setTitle(newAlias);
509
this._notification.appendAliasChange(oldAlias, newAlias);
512
createIcon: function(size) {
513
this._iconBox = new St.Bin({ style_class: 'avatar-box' });
514
this._iconBox._size = size;
515
let textureCache = St.TextureCache.get_default();
516
let file = this._contact.get_avatar_file();
519
let uri = file.get_uri();
520
this._iconBox.child = textureCache.load_uri_async(uri, this._iconBox._size, this._iconBox._size);
522
this._iconBox.child = new St.Icon({ icon_name: 'avatar-default',
523
icon_size: this._iconBox._size });
526
return this._iconBox;
529
createSecondaryIcon: function() {
530
let iconBox = new St.Bin();
531
iconBox.child = new St.Icon({ style_class: 'secondary-icon' });
532
let presenceType = this._contact.get_presence_type();
534
switch (presenceType) {
535
case Tp.ConnectionPresenceType.AVAILABLE:
536
iconBox.child.icon_name = 'user-available';
538
case Tp.ConnectionPresenceType.BUSY:
539
iconBox.child.icon_name = 'user-busy';
541
case Tp.ConnectionPresenceType.OFFLINE:
542
iconBox.child.icon_name = 'user-offline';
544
case Tp.ConnectionPresenceType.HIDDEN:
545
iconBox.child.icon_name = 'user-invisible';
547
case Tp.ConnectionPresenceType.AWAY:
548
iconBox.child.icon_name = 'user-away';
550
case Tp.ConnectionPresenceType.EXTENDED_AWAY:
551
iconBox.child.icon_name = 'user-idle';
554
iconBox.child.icon_name = 'user-offline';
559
_updateAvatarIcon: function() {
561
this._notification.update(this._notification.title, null, { customContent: true });
564
open: function(notification) {
565
if (this._client.is_handling_channel(this._channel)) {
566
// We are handling the channel, try to pass it to Empathy
567
this._client.delegate_channels_async([this._channel], global.get_current_time(), '', null);
570
// We are not the handler, just ask to present the channel
571
let dbus = Tp.DBusDaemon.dup();
572
let cd = Tp.ChannelDispatcher.new(dbus);
574
cd.present_channel_async(this._channel, global.get_current_time(), null);
578
_getLogMessages: function() {
579
let logManager = Tpl.LogManager.dup_singleton();
580
let entity = Tpl.Entity.new_from_tp_contact(this._contact, Tpl.EntityType.CONTACT);
582
logManager.get_filtered_events_async(this._account, entity,
583
Tpl.EventTypeMask.TEXT, SCROLLBACK_HISTORY_LINES,
584
null, Lang.bind(this, this._displayPendingMessages));
587
_displayPendingMessages: function(logManager, result) {
588
let [success, events] = logManager.get_filtered_events_finish(result);
590
let logMessages = events.map(makeMessageFromTplEvent);
592
let pendingTpMessages = this._channel.get_pending_messages();
593
let pendingMessages = [];
595
for (let i = 0; i < pendingTpMessages.length; i++) {
596
let message = pendingTpMessages[i];
598
if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT)
601
pendingMessages.push(makeMessageFromTpMessage(message, NotificationDirection.RECEIVED));
603
this._pendingMessages.push(message);
608
let showTimestamp = false;
610
for (let i = 0; i < logMessages.length; i++) {
611
let logMessage = logMessages[i];
612
let isPending = false;
614
// Skip any log messages that are also in pendingMessages
615
for (let j = 0; j < pendingMessages.length; j++) {
616
let pending = pendingMessages[j];
617
if (logMessage.timestamp == pending.timestamp && logMessage.text == pending.text) {
624
showTimestamp = true;
625
this._notification.appendMessage(logMessage, true, ['chat-log-message']);
630
this._notification.appendTimestamp();
632
for (let i = 0; i < pendingMessages.length; i++)
633
this._notification.appendMessage(pendingMessages[i], true);
635
if (pendingMessages.length > 0)
639
_channelClosed: function() {
640
this._channel.disconnect(this._closedId);
641
this._channel.disconnect(this._receivedId);
642
this._channel.disconnect(this._pendingId);
643
this._channel.disconnect(this._sentId);
645
this._contact.disconnect(this._notifyAliasId);
646
this._contact.disconnect(this._notifyAvatarId);
647
this._contact.disconnect(this._presenceChangedId);
652
/* All messages are new messages for Telepathy sources */
654
return this._pendingMessages.length;
662
return this.count > 0;
665
_messageReceived: function(channel, message) {
666
if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT)
669
this._pendingMessages.push(message);
672
message = makeMessageFromTpMessage(message, NotificationDirection.RECEIVED);
673
this._notification.appendMessage(message);
675
// Wait a bit before notifying for the received message, a handler
676
// could ack it in the meantime.
677
if (this._notifyTimeoutId != 0)
678
Mainloop.source_remove(this._notifyTimeoutId);
679
this._notifyTimeoutId = Mainloop.timeout_add(500,
680
Lang.bind(this, this._notifyTimeout));
683
_notifyTimeout: function() {
684
if (this._pendingMessages.length != 0)
687
this._notifyTimeoutId = 0;
692
// This is called for both messages we send from
693
// our client and other clients as well.
694
_messageSent: function(channel, message, flags, token) {
695
message = makeMessageFromTpMessage(message, NotificationDirection.SENT);
696
this._notification.appendMessage(message);
700
this.parent(this._notification);
703
respond: function(text) {
705
if (text.slice(0, 4) == '/me ') {
706
type = Tp.ChannelTextMessageType.ACTION;
707
text = text.slice(4);
709
type = Tp.ChannelTextMessageType.NORMAL;
712
let msg = Tp.ClientMessage.new_text(type, text);
713
this._channel.send_message_async(msg, 0, Lang.bind(this, function (src, result) {
714
this._channel.send_message_finish(result);
718
setChatState: function(state) {
719
// We don't want to send COMPOSING every time a letter is typed into
720
// the entry. We send the state only when it changes. Telepathy/Empathy
721
// might change it behind our back if the user is using both
722
// gnome-shell's entry and the Empathy conversation window. We could
723
// keep track of it with the ChatStateChanged signal but it is good
725
if (state != this._chatState) {
726
this._chatState = state;
727
this._channel.set_chat_state_async(state, null);
731
_presenceChanged: function (contact, presence, status, message) {
734
title = GLib.markup_escape_text(this.title, -1);
736
this._notification.update(this._notification.title, null, { customContent: true, secondaryIcon: this.createSecondaryIcon() });
739
msg += ' <i>(' + GLib.markup_escape_text(message, -1) + ')</i>';
742
_pendingRemoved: function(channel, message) {
743
let idx = this._pendingMessages.indexOf(message);
746
this._pendingMessages.splice(idx, 1);
751
_ackMessages: function() {
752
// Don't clear our messages here, tp-glib will send a
753
// 'pending-message-removed' for each one.
754
this._channel.ack_all_pending_messages_async(Lang.bind(this, function(src, result) {
755
this._channel.ack_all_pending_messages_finish(result);}));
759
const ChatNotification = new Lang.Class({
760
Name: 'ChatNotification',
761
Extends: MessageTray.Notification,
763
_init: function(source) {
764
this.parent(source, source.title, null, { customContent: true, secondaryIcon: source.createSecondaryIcon() });
765
this.setResident(true);
767
this._responseEntry = new St.Entry({ style_class: 'chat-response',
769
this._responseEntry.clutter_text.connect('activate', Lang.bind(this, this._onEntryActivated));
770
this._responseEntry.clutter_text.connect('text-changed', Lang.bind(this, this._onEntryChanged));
771
this.setActionArea(this._responseEntry);
773
this._responseEntry.clutter_text.connect('key-focus-in', Lang.bind(this, function() {
776
this._responseEntry.clutter_text.connect('key-focus-out', Lang.bind(this, function() {
777
this.focused = false;
778
this.emit('unfocused');
781
this._oldMaxScrollAdjustment = 0;
782
this._createScrollArea();
783
this._lastGroup = null;
784
this._lastGroupActor = null;
786
this._scrollArea.vscroll.adjustment.connect('changed', Lang.bind(this, function(adjustment) {
787
let currentValue = adjustment.value + adjustment.page_size;
788
if (currentValue == this._oldMaxScrollAdjustment)
789
this.scrollTo(St.Side.BOTTOM);
790
this._oldMaxScrollAdjustment = adjustment.upper;
793
this._inputHistory = new History.HistoryManager({ entry: this._responseEntry.clutter_text });
796
this._timestampTimeoutId = 0;
797
this._composingTimeoutId = 0;
802
* @message: An object with the properties:
803
* text: the body of the message,
804
* messageType: a #Tp.ChannelTextMessageType,
805
* sender: the name of the sender,
806
* timestamp: the time the message was sent
807
* direction: a #NotificationDirection
809
* @noTimestamp: Whether to add a timestamp. If %true, no timestamp
810
* will be added, regardless of the difference since the
813
appendMessage: function(message, noTimestamp) {
814
let messageBody = GLib.markup_escape_text(message.text, -1);
815
let styles = [message.direction];
817
if (message.messageType == Tp.ChannelTextMessageType.ACTION) {
818
let senderAlias = GLib.markup_escape_text(message.sender, -1);
819
messageBody = '<i>%s</i> %s'.format(senderAlias, messageBody);
820
styles.push('chat-action');
823
if (message.direction == NotificationDirection.RECEIVED) {
824
this.update(this.source.title, messageBody, { customContent: true,
825
bannerMarkup: true });
828
let group = (message.direction == NotificationDirection.RECEIVED ?
829
'received' : 'sent');
831
this._append({ body: messageBody,
834
timestamp: message.timestamp,
835
noTimestamp: noTimestamp });
838
_filterMessages: function() {
839
if (this._history.length < 1)
842
let lastMessageTime = this._history[0].time;
843
let currentTime = (Date.now() / 1000);
845
// Keep the scrollback from growing too long. If the most
846
// recent message (before the one we just added) is within
847
// SCROLLBACK_RECENT_TIME, we will keep
848
// SCROLLBACK_RECENT_LENGTH previous messages. Otherwise
849
// we'll keep SCROLLBACK_IDLE_LENGTH messages.
851
let maxLength = (lastMessageTime < currentTime - SCROLLBACK_RECENT_TIME) ?
852
SCROLLBACK_IDLE_LENGTH : SCROLLBACK_RECENT_LENGTH;
854
let filteredHistory = this._history.filter(function(item) { return item.realMessage });
855
if (filteredHistory.length > maxLength) {
856
let lastMessageToKeep = filteredHistory[maxLength];
857
let expired = this._history.splice(this._history.indexOf(lastMessageToKeep));
858
for (let i = 0; i < expired.length; i++)
859
expired[i].actor.destroy();
862
let groups = this._contentArea.get_children();
863
for (let i = 0; i < groups.length; i++) {
864
let group = groups[i];
865
if (group.get_n_children() == 0)
872
* @props: An object with the properties:
873
* body: The text of the message.
874
* group: The group of the message, one of:
875
* 'received', 'sent', 'meta'.
876
* styles: Style class names for the message to have.
877
* timestamp: The timestamp of the message.
878
* noTimestamp: suppress timestamp signal?
879
* childProps: props to add the actor with.
881
_append: function(props) {
882
let currentTime = (Date.now() / 1000);
883
props = Params.parse(props, { body: null,
886
timestamp: currentTime,
890
// Reset the old message timeout
891
if (this._timestampTimeoutId)
892
Mainloop.source_remove(this._timestampTimeoutId);
894
let highlighter = new MessageTray.URLHighlighter(props.body,
896
true); // allow markup?
898
let body = highlighter.actor;
900
let styles = props.styles;
901
for (let i = 0; i < styles.length; i++)
902
body.add_style_class_name(styles[i]);
904
let group = props.group;
905
if (group != this._lastGroup) {
906
let style = 'chat-group-' + group;
907
this._lastGroup = group;
908
this._lastGroupActor = new St.BoxLayout({ style_class: style,
910
this.addActor(this._lastGroupActor);
913
this._lastGroupActor.add(body, props.childProps);
917
let timestamp = props.timestamp;
918
this._history.unshift({ actor: body, time: timestamp,
919
realMessage: group != 'meta' });
921
if (!props.noTimestamp) {
922
if (timestamp < currentTime - SCROLLBACK_IMMEDIATE_TIME)
923
this.appendTimestamp();
925
// Schedule a new timestamp in SCROLLBACK_IMMEDIATE_TIME
926
// from the timestamp of the message.
927
this._timestampTimeoutId = Mainloop.timeout_add_seconds(
928
SCROLLBACK_IMMEDIATE_TIME - (currentTime - timestamp),
929
Lang.bind(this, this.appendTimestamp));
932
this._filterMessages();
935
_formatTimestamp: function(date) {
936
let now = new Date();
938
var daysAgo = (now.getTime() - date.getTime()) / (24 * 60 * 60 * 1000);
942
// Show a week day and time if date is in the last week
943
if (daysAgo < 1 || (daysAgo < 7 && now.getDay() != date.getDay())) {
944
/* Translators: this is a time format string followed by a date.
945
If applicable, replace %X with a strftime format valid for your
946
locale, without seconds. */
947
// xgettext:no-c-format
948
format = _("Sent at <b>%X</b> on <b>%A</b>");
950
} else if (date.getYear() == now.getYear()) {
951
/* Translators: this is a time format in the style of "Wednesday, May 25",
952
shown when you get a chat message in the same year. */
953
// xgettext:no-c-format
954
format = _("Sent on <b>%A</b>, <b>%B %d</b>");
956
/* Translators: this is a time format in the style of "Wednesday, May 25, 2012",
957
shown when you get a chat message in a different year. */
958
// xgettext:no-c-format
959
format = _("Sent on <b>%A</b>, <b>%B %d</b>, %Y");
962
return date.toLocaleFormat(format);
965
appendTimestamp: function() {
966
let lastMessageTime = this._history[0].time;
967
let lastMessageDate = new Date(lastMessageTime * 1000);
969
let timeLabel = this._append({ body: this._formatTimestamp(lastMessageDate),
971
styles: ['chat-meta-message'],
972
childProps: { expand: true, x_fill: false,
973
x_align: St.Align.END },
975
timestamp: lastMessageTime });
977
this._filterMessages();
982
appendAliasChange: function(oldAlias, newAlias) {
983
oldAlias = GLib.markup_escape_text(oldAlias, -1);
984
newAlias = GLib.markup_escape_text(newAlias, -1);
986
/* Translators: this is the other person changing their old IM name to their new
988
let message = '<i>' + _("%s is now known as %s").format(oldAlias, newAlias) + '</i>';
990
let label = this._append({ body: message,
992
styles: ['chat-meta-message'] });
994
this.update(newAlias, null, { customContent: true });
996
this._filterMessages();
999
_onEntryActivated: function() {
1000
let text = this._responseEntry.get_text();
1004
this._inputHistory.addItem(text);
1006
// Telepathy sends out the Sent signal for us.
1007
// see Source._messageSent
1008
this._responseEntry.set_text('');
1009
this.source.respond(text);
1012
_composingStopTimeout: function() {
1013
this._composingTimeoutId = 0;
1015
this.source.setChatState(Tp.ChannelChatState.PAUSED);
1020
_onEntryChanged: function() {
1021
let text = this._responseEntry.get_text();
1023
// If we're typing, we want to send COMPOSING.
1024
// If we empty the entry, we want to send ACTIVE.
1025
// If we've stopped typing for COMPOSING_STOP_TIMEOUT
1026
// seconds, we want to send PAUSED.
1028
// Remove composing timeout.
1029
if (this._composingTimeoutId > 0) {
1030
Mainloop.source_remove(this._composingTimeoutId);
1031
this._composingTimeoutId = 0;
1035
this.source.setChatState(Tp.ChannelChatState.COMPOSING);
1037
this._composingTimeoutId = Mainloop.timeout_add_seconds(
1038
COMPOSING_STOP_TIMEOUT,
1039
Lang.bind(this, this._composingStopTimeout));
1041
this.source.setChatState(Tp.ChannelChatState.ACTIVE);
1046
const ApproverSource = new Lang.Class({
1047
Name: 'ApproverSource',
1048
Extends: MessageTray.Source,
1050
_init: function(dispatchOp, text, gicon) {
1051
this._gicon = gicon;
1055
this._dispatchOp = dispatchOp;
1057
// Destroy the source if the channel dispatch operation is invalidated
1058
// as we can't approve any more.
1059
this._invalidId = dispatchOp.connect('invalidated',
1060
Lang.bind(this, function(domain, code, msg) {
1065
destroy: function() {
1066
if (this._invalidId != 0) {
1067
this._dispatchOp.disconnect(this._invalidId);
1068
this._invalidId = 0;
1074
createIcon: function(size) {
1075
return new St.Icon({ gicon: this._gicon,
1080
const RoomInviteNotification = new Lang.Class({
1081
Name: 'RoomInviteNotification',
1082
Extends: MessageTray.Notification,
1084
_init: function(source, dispatchOp, channel, inviter) {
1086
/* translators: argument is a room name like
1087
* room@jabber.org for example. */
1088
_("Invitation to %s").format(channel.get_identifier()),
1090
{ customContent: true });
1091
this.setResident(true);
1093
/* translators: first argument is the name of a contact and the second
1094
* one the name of a room. "Alice is inviting you to join room@jabber.org
1096
this.addBody(_("%s is inviting you to join %s").format(inviter.get_alias(), channel.get_identifier()));
1098
this.addButton('decline', _("Decline"));
1099
this.addButton('accept', _("Accept"));
1101
this.connect('action-invoked', Lang.bind(this, function(self, action) {
1104
dispatchOp.leave_channels_async(Tp.ChannelGroupChangeReason.NONE,
1105
'', function(src, result) {
1106
src.leave_channels_finish(result)});
1109
dispatchOp.handle_with_time_async('', global.get_current_time(),
1110
function(src, result) {
1111
src.handle_with_time_finish(result)});
1120
const AudioVideoNotification = new Lang.Class({
1121
Name: 'AudioVideoNotification',
1122
Extends: MessageTray.Notification,
1124
_init: function(source, dispatchOp, channel, contact, isVideo) {
1128
/* translators: argument is a contact name like Alice for example. */
1129
title = _("Video call from %s").format(contact.get_alias());
1131
/* translators: argument is a contact name like Alice for example. */
1132
title = _("Call from %s").format(contact.get_alias());
1134
this.parent(source, title, null, { customContent: true });
1135
this.setResident(true);
1137
this.addButton('reject', _("Reject"));
1138
/* translators: this is a button label (verb), not a noun */
1139
this.addButton('answer', _("Answer"));
1141
this.connect('action-invoked', Lang.bind(this, function(self, action) {
1144
dispatchOp.leave_channels_async(Tp.ChannelGroupChangeReason.NONE,
1145
'', function(src, result) {
1146
src.leave_channels_finish(result)});
1149
dispatchOp.handle_with_time_async('', global.get_current_time(),
1150
function(src, result) {
1151
src.handle_with_time_finish(result)});
1160
const FileTransferNotification = new Lang.Class({
1161
Name: 'FileTransferNotification',
1162
Extends: MessageTray.Notification,
1164
_init: function(source, dispatchOp, channel, contact) {
1166
/* To translators: The first parameter is
1167
* the contact's alias and the second one is the
1168
* file name. The string will be something
1169
* like: "Alice is sending you test.ogg"
1171
_("%s is sending you %s").format(contact.get_alias(),
1172
channel.get_filename()),
1174
{ customContent: true });
1175
this.setResident(true);
1177
this.addButton('decline', _("Decline"));
1178
this.addButton('accept', _("Accept"));
1180
this.connect('action-invoked', Lang.bind(this, function(self, action) {
1183
dispatchOp.leave_channels_async(Tp.ChannelGroupChangeReason.NONE,
1184
'', function(src, result) {
1185
src.leave_channels_finish(result)});
1188
dispatchOp.handle_with_time_async('', global.get_current_time(),
1189
function(src, result) {
1190
src.handle_with_time_finish(result)});
1198
// Subscription request
1199
const SubscriptionRequestNotification = new Lang.Class({
1200
Name: 'SubscriptionRequestNotification',
1201
Extends: MessageTray.Notification,
1203
_init: function(source, contact) {
1205
/* To translators: The parameter is the contact's alias */
1206
_("%s would like permission to see when you are online").format(contact.get_alias()),
1207
null, { customContent: true });
1209
this._contact = contact;
1210
this._connection = contact.get_connection();
1212
let layout = new St.BoxLayout({ vertical: false });
1215
let iconBox = new St.Bin({ style_class: 'avatar-box' });
1218
let textureCache = St.TextureCache.get_default();
1219
let file = contact.get_avatar_file();
1222
let uri = file.get_uri();
1223
iconBox.child = textureCache.load_uri_async(uri, iconBox._size, iconBox._size);
1226
iconBox.child = new St.Icon({ icon_name: 'avatar-default',
1227
icon_size: iconBox._size });
1230
layout.add(iconBox);
1232
// subscription request message
1233
let label = new St.Label({ style_class: 'subscription-message',
1234
text: contact.get_publish_request() });
1238
this.addActor(layout);
1240
this.addButton('decline', _("Decline"));
1241
this.addButton('accept', _("Accept"));
1243
this.connect('action-invoked', Lang.bind(this, function(self, action) {
1246
contact.remove_async(function(src, result) {
1247
src.remove_finish(result)});
1250
// Authorize the contact and request to see his status as well
1251
contact.authorize_publication_async(function(src, result) {
1252
src.authorize_publication_finish(result)});
1254
contact.request_subscription_async('', function(src, result) {
1255
src.request_subscription_finish(result)});
1259
// rely on _subscriptionStatesChangedCb to destroy the
1263
this._changedId = contact.connect('subscription-states-changed',
1264
Lang.bind(this, this._subscriptionStatesChangedCb));
1265
this._invalidatedId = this._connection.connect('invalidated',
1266
Lang.bind(this, this.destroy));
1269
destroy: function() {
1270
if (this._changedId != 0) {
1271
this._contact.disconnect(this._changedId);
1272
this._changedId = 0;
1275
if (this._invalidatedId != 0) {
1276
this._connection.disconnect(this._invalidatedId);
1277
this._invalidatedId = 0;
1283
_subscriptionStatesChangedCb: function(contact, subscribe, publish, msg) {
1284
// Destroy the notification if the subscription request has been
1286
if (publish != Tp.SubscriptionState.ASK)
1291
// Messages from empathy/libempathy/empathy-utils.c
1292
// create_errors_to_message_hash()
1294
/* Translator note: these should be the same messages that are
1295
* used in Empathy, so just copy and paste from there. */
1296
let _connectionErrorMessages = {};
1297
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.NETWORK_ERROR)]
1298
= _("Network error");
1299
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.AUTHENTICATION_FAILED)]
1300
= _("Authentication failed");
1301
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.ENCRYPTION_ERROR)]
1302
= _("Encryption error");
1303
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_NOT_PROVIDED)]
1304
= _("Certificate not provided");
1305
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_UNTRUSTED)]
1306
= _("Certificate untrusted");
1307
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_EXPIRED)]
1308
= _("Certificate expired");
1309
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_NOT_ACTIVATED)]
1310
= _("Certificate not activated");
1311
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_HOSTNAME_MISMATCH)]
1312
= _("Certificate hostname mismatch");
1313
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_FINGERPRINT_MISMATCH)]
1314
= _("Certificate fingerprint mismatch");
1315
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_SELF_SIGNED)]
1316
= _("Certificate self-signed");
1317
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CANCELLED)]
1318
= _("Status is set to offline");
1319
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.ENCRYPTION_NOT_AVAILABLE)]
1320
= _("Encryption is not available");
1321
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_INVALID)]
1322
= _("Certificate is invalid");
1323
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CONNECTION_REFUSED)]
1324
= _("Connection has been refused");
1325
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CONNECTION_FAILED)]
1326
= _("Connection can't be established");
1327
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CONNECTION_LOST)]
1328
= _("Connection has been lost");
1329
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.ALREADY_CONNECTED)]
1330
= _("This account is already connected to the server");
1331
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CONNECTION_REPLACED)]
1332
= _("Connection has been replaced by a new connection using the same resource");
1333
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.REGISTRATION_EXISTS)]
1334
= _("The account already exists on the server");
1335
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.SERVICE_BUSY)]
1336
= _("Server is currently too busy to handle the connection");
1337
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_REVOKED)]
1338
= _("Certificate has been revoked");
1339
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_INSECURE)]
1340
= _("Certificate uses an insecure cipher algorithm or is cryptographically weak");
1341
_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_LIMIT_EXCEEDED)]
1342
= _("The length of the server certificate, or the depth of the server certificate chain, exceed the limits imposed by the cryptography library");
1343
_connectionErrorMessages['org.freedesktop.DBus.Error.NoReply']
1344
= _("Internal error");
1346
const AccountNotification = new Lang.Class({
1347
Name: 'AccountNotification',
1348
Extends: MessageTray.Notification,
1350
_init: function(source, account, connectionError) {
1352
/* translators: argument is the account name, like
1353
* name@jabber.org for example. */
1354
_("Connection to %s failed").format(account.get_display_name()),
1355
null, { customContent: true });
1357
this._label = new St.Label();
1358
this.addActor(this._label);
1359
this._updateMessage(connectionError);
1361
this._account = account;
1363
this.addButton('reconnect', _("Reconnect"));
1364
this.addButton('edit', _("Edit account"));
1366
this.connect('action-invoked', Lang.bind(this, function(self, action) {
1369
// If it fails again, a new notification should pop up with the
1371
account.reconnect_async(null);
1374
let cmd = '/usr/bin/empathy-accounts'
1375
+ ' --select-account=%s'
1376
.format(account.get_path_suffix());
1377
let app_info = Gio.app_info_create_from_commandline(cmd, null, 0);
1378
app_info.launch([], global.create_app_launch_context());
1384
this._enabledId = account.connect('notify::enabled',
1385
Lang.bind(this, function() {
1386
if (!account.is_enabled())
1390
this._invalidatedId = account.connect('invalidated',
1391
Lang.bind(this, this.destroy));
1393
this._connectionStatusId = account.connect('notify::connection-status',
1394
Lang.bind(this, function() {
1395
let status = account.connection_status;
1396
if (status == Tp.ConnectionStatus.CONNECTED) {
1398
} else if (status == Tp.ConnectionStatus.DISCONNECTED) {
1399
this._updateMessage(account.connection_error);
1404
_updateMessage: function(connectionError) {
1406
if (connectionError in _connectionErrorMessages) {
1407
message = _connectionErrorMessages[connectionError];
1409
message = _("Unknown reason");
1411
this._label.set_text(message);
1414
destroy: function() {
1415
if (this._enabledId != 0) {
1416
this._account.disconnect(this._enabledId);
1417
this._enabledId = 0;
1420
if (this._invalidatedId != 0) {
1421
this._account.disconnect(this._invalidatedId);
1422
this._invalidatedId = 0;
1425
if (this._connectionStatusId != 0) {
1426
this._account.disconnect(this._connectionStatusId);
1427
this._connectionStatusId = 0;
1433
const Component = TelepathyClient;