root/trunk/src/notify.py

Revision 10730, 20.7 kB (checked in by asterix, 19 hours ago)

[urandom] fix position of notification when attach_to_systray is enabled. Fixes #4537

Line 
1# -*- coding:utf-8 -*-
2## src/notify.py
3##
4## Copyright (C) 2005 Sebastian Estienne
5## Copyright (C) 2005-2006 Andrew Sayman <lorien420 AT myrealbox.com>
6## Copyright (C) 2005-2007 Nikos Kouremenos <kourem AT gmail.com>
7## Copyright (C) 2005-2008 Yann Leboulanger <asterix AT lagaule.org>
8## Copyright (C) 2006 Travis Shirk <travis AT pobox.com>
9## Copyright (C) 2006-2008 Jean-Marie Traissard <jim AT lapin.org>
10## Copyright (C) 2007 Julien Pivotto <roidelapluie AT gmail.com>
11##                    Stephan Erb <steve-e AT h3c.de>
12## Copyright (C) 2008 Brendan Taylor <whateley AT gmail.com>
13##                    Jonathan Schleifer <js-gajim AT webkeks.org>
14##
15## This file is part of Gajim.
16##
17## Gajim is free software; you can redistribute it and/or modify
18## it under the terms of the GNU General Public License as published
19## by the Free Software Foundation; version 3 only.
20##
21## Gajim is distributed in the hope that it will be useful,
22## but WITHOUT ANY WARRANTY; without even the implied warranty of
23## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
24## GNU General Public License for more details.
25##
26## You should have received a copy of the GNU General Public License
27## along with Gajim. If not, see <http://www.gnu.org/licenses/>.
28##
29
30import os
31import time
32import dialogs
33import gobject
34import gtkgui_helpers
35
36from common import gajim
37from common import helpers
38
39from common import dbus_support
40if dbus_support.supported:
41        import dbus
42        import dbus.glib
43
44
45USER_HAS_PYNOTIFY = True # user has pynotify module
46try:
47        import pynotify
48        pynotify.init('Gajim Notification')
49except ImportError:
50        USER_HAS_PYNOTIFY = False
51
52USER_HAS_GROWL = True
53try:
54        import osx.growler
55        osx.growler.init()
56except Exception:
57        USER_HAS_GROWL = False
58
59def get_show_in_roster(event, account, contact, session=None):
60        '''Return True if this event must be shown in roster, else False'''
61        if event == 'gc_message_received':
62                return True
63        num = get_advanced_notification(event, account, contact)
64        if num is not None:
65                if gajim.config.get_per('notifications', str(num), 'roster') == 'yes':
66                        return True
67                if gajim.config.get_per('notifications', str(num), 'roster') == 'no':
68                        return False
69        if event == 'message_received':
70                if session and session.control:
71                        return False
72        return True
73
74def get_show_in_systray(event, account, contact, type_=None):
75        '''Return True if this event must be shown in systray, else False'''
76        num = get_advanced_notification(event, account, contact)
77        if num is not None:
78                if gajim.config.get_per('notifications', str(num), 'systray') == 'yes':
79                        return True
80                if gajim.config.get_per('notifications', str(num), 'systray') == 'no':
81                        return False
82        if type_ == 'printed_gc_msg' and not gajim.config.get(
83        'notify_on_all_muc_messages'):
84                # it's not an highlighted message, don't show in systray
85                return False
86        return gajim.config.get('trayicon_notification_on_events')
87
88def get_advanced_notification(event, account, contact):
89        '''Returns the number of the first (top most)
90        advanced notification else None'''
91        num = 0
92        notif = gajim.config.get_per('notifications', str(num))
93        while notif:
94                recipient_ok = False
95                status_ok = False
96                tab_opened_ok = False
97                # test event
98                if gajim.config.get_per('notifications', str(num), 'event') == event:
99                        # test recipient
100                        recipient_type = gajim.config.get_per('notifications', str(num),
101                                'recipient_type')
102                        recipients = gajim.config.get_per('notifications', str(num),
103                                'recipients').split()
104                        if recipient_type == 'all':
105                                recipient_ok = True
106                        elif recipient_type == 'contact' and contact.jid in recipients:
107                                recipient_ok = True
108                        elif recipient_type == 'group':
109                                for group in contact.groups:
110                                        if group in contact.groups:
111                                                recipient_ok = True
112                                                break
113                if recipient_ok:
114                        # test status
115                        our_status = gajim.SHOW_LIST[gajim.connections[account].connected]
116                        status = gajim.config.get_per('notifications', str(num), 'status')
117                        if status == 'all' or our_status in status.split():
118                                status_ok = True
119                if status_ok:
120                        # test window_opened
121                        tab_opened = gajim.config.get_per('notifications', str(num),
122                                'tab_opened')
123                        if tab_opened == 'both':
124                                tab_opened_ok = True
125                        else:
126                                chat_control = helpers.get_chat_control(account, contact)
127                                if (chat_control and tab_opened == 'yes') or (not chat_control and \
128                                tab_opened == 'no'):
129                                        tab_opened_ok = True
130                if tab_opened_ok:
131                        return num
132
133                num += 1
134                notif = gajim.config.get_per('notifications', str(num))
135
136def notify(event, jid, account, parameters, advanced_notif_num=None):
137        '''Check what type of notifications we want, depending on basic
138        and the advanced configuration of notifications and do these notifications;
139        advanced_notif_num holds the number of the first (top most) advanced
140        notification'''
141        # First, find what notifications we want
142        do_popup = False
143        do_sound = False
144        do_cmd = False
145        if event == 'status_change':
146                new_show = parameters[0]
147                status_message = parameters[1]
148                # Default: No popup for status change
149        elif event == 'contact_connected':
150                status_message = parameters
151                j = gajim.get_jid_without_resource(jid)
152                server = gajim.get_server_from_jid(j)
153                account_server = account + '/' + server
154                block_transport = False
155                if account_server in gajim.block_signed_in_notifications and \
156                gajim.block_signed_in_notifications[account_server]:
157                        block_transport = True
158                if helpers.allow_showing_notification(account, 'notify_on_signin') and \
159                not gajim.block_signed_in_notifications[account] and not block_transport:
160                        do_popup = True
161                if gajim.config.get_per('soundevents', 'contact_connected',
162                'enabled') and not gajim.block_signed_in_notifications[account] and \
163                not block_transport:
164                        do_sound = True
165        elif event == 'contact_disconnected':
166                status_message = parameters
167                if helpers.allow_showing_notification(account, 'notify_on_signout'):
168                        do_popup = True
169                if gajim.config.get_per('soundevents', 'contact_disconnected',
170                        'enabled'):
171                        do_sound = True
172        elif event == 'new_message':
173                message_type = parameters[0]
174                is_first_message = parameters[1]
175                nickname = parameters[2]
176                if gajim.config.get('notification_preview_message'):
177                        message = parameters[3]
178                        if message.startswith('/me ') or message.startswith('/me\n'):
179                                message = '* ' + nickname + message[3:]
180                else:
181                        # We don't want message preview, do_preview = False
182                        message = ''
183                focused = parameters[4]
184                if helpers.allow_showing_notification(account, 'notify_on_new_message',
185                advanced_notif_num, is_first_message):
186                        do_popup = True
187                if is_first_message and helpers.allow_sound_notification(
188                'first_message_received', advanced_notif_num):
189                        do_sound = True
190                elif not is_first_message and focused and \
191                helpers.allow_sound_notification('next_message_received_focused',
192                advanced_notif_num):
193                        do_sound = True
194                elif not is_first_message and not focused and \
195                helpers.allow_sound_notification('next_message_received_unfocused',
196                advanced_notif_num):
197                        do_sound = True
198        else:
199                print '*Event not implemeted yet*'
200
201        if advanced_notif_num is not None and gajim.config.get_per('notifications',
202        str(advanced_notif_num), 'run_command'):
203                do_cmd = True
204
205        # Do the wanted notifications
206        if do_popup:
207                if event in ('contact_connected', 'contact_disconnected',
208                'status_change'): # Common code for popup for these three events
209                        if event == 'contact_disconnected':
210                                show_image = 'offline.png'
211                                suffix = '_notif_size_bw'
212                        else: #Status Change or Connected
213                                # FIXME: for status change,
214                                # we don't always 'online.png', but we
215                                # first need 48x48 for all status
216                                show_image = 'online.png'
217                                suffix = '_notif_size_colored'
218                        transport_name = gajim.get_transport_name_from_jid(jid)
219                        img = None
220                        if transport_name:
221                                img = os.path.join(helpers.get_transport_path(transport_name),
222                                        '48x48', show_image)
223                        if not img or not os.path.isfile(img):
224                                iconset = gajim.config.get('iconset')
225                                img = os.path.join(helpers.get_iconset_path(iconset), '48x48',
226                                        show_image)
227                        path = gtkgui_helpers.get_path_to_generic_or_avatar(img,
228                                jid = jid, suffix = suffix)
229                        if event == 'status_change':
230                                title = _('%(nick)s Changed Status') % \
231                                        {'nick': gajim.get_name_from_jid(account, jid)}
232                                text = _('%(nick)s is now %(status)s') % \
233                                        {'nick': gajim.get_name_from_jid(account, jid),\
234                                        'status': helpers.get_uf_show(gajim.SHOW_LIST[new_show])}
235                                if status_message:
236                                        text = text + " : " + status_message
237                                popup(_('Contact Changed Status'), jid, account,
238                                        path_to_image=path, title=title, text=text)
239                        elif event == 'contact_connected':
240                                title = _('%(nickname)s Signed In') % \
241                                        {'nickname': gajim.get_name_from_jid(account, jid)}
242                                text = ''
243                                if status_message:
244                                        text = status_message
245                                popup(_('Contact Signed In'), jid, account,
246                                        path_to_image=path, title=title, text=text)
247                        elif event == 'contact_disconnected':
248                                title = _('%(nickname)s Signed Out') % \
249                                        {'nickname': gajim.get_name_from_jid(account, jid)}
250                                text = ''
251                                if status_message:
252                                        text = status_message
253                                popup(_('Contact Signed Out'), jid, account,
254                                        path_to_image=path, title=title, text=text)
255                elif event == 'new_message':
256                        if message_type == 'normal': # single message
257                                event_type = _('New Single Message')
258                                img = os.path.join(gajim.DATA_DIR, 'pixmaps', 'events',
259                                        'single_msg_recv.png')
260                                title = _('New Single Message from %(nickname)s') % \
261                                        {'nickname': nickname}
262                                text = message
263                        elif message_type == 'pm': # private message
264                                event_type = _('New Private Message')
265                                room_name = gajim.get_nick_from_jid(jid)
266                                img = os.path.join(gajim.DATA_DIR, 'pixmaps', 'events',
267                                        'priv_msg_recv.png')
268                                title = _('New Private Message from group chat %s') % room_name
269                                if message:
270                                        text = _('%(nickname)s: %(message)s') % {'nickname': nickname,
271                                                'message': message}
272                                else:
273                                        text = _('Messaged by %(nickname)s') % {'nickname': nickname}
274
275                        else: # chat message
276                                event_type = _('New Message')
277                                img = os.path.join(gajim.DATA_DIR, 'pixmaps', 'events',
278                                        'chat_msg_recv.png')
279                                title = _('New Message from %(nickname)s') % \
280                                        {'nickname': nickname}
281                                text = message
282                        path = gtkgui_helpers.get_path_to_generic_or_avatar(img)
283                        popup(event_type, jid, account, message_type,
284                                path_to_image=path, title=title, text=text)
285
286        if do_sound:
287                snd_file = None
288                snd_event = None # If not snd_file, play the event
289                if event == 'new_message':
290                        if advanced_notif_num is not None and gajim.config.get_per(
291                        'notifications', str(advanced_notif_num), 'sound') == 'yes':
292                                snd_file = gajim.config.get_per('notifications',
293                                        str(advanced_notif_num), 'sound_file')
294                        elif advanced_notif_num is not None and gajim.config.get_per(
295                        'notifications', str(advanced_notif_num), 'sound') == 'no':
296                                pass # do not set snd_event
297                        elif is_first_message:
298                                snd_event = 'first_message_received'
299                        elif focused:
300                                snd_event = 'next_message_received_focused'
301                        else:
302                                snd_event = 'next_message_received_unfocused'
303                elif event in ('contact_connected', 'contact_disconnected'):
304                        snd_event = event
305                if snd_file:
306                        helpers.play_sound_file(snd_file)
307                if snd_event:
308                        helpers.play_sound(snd_event)
309
310        if do_cmd:
311                command = gajim.config.get_per('notifications', str(advanced_notif_num),
312                        'command')
313                try:
314                        helpers.exec_command(command)
315                except Exception:
316                        pass
317
318def popup(event_type, jid, account, msg_type='', path_to_image=None,
319        title=None, text=None):
320        '''Notifies a user of an event. It first tries to a valid implementation of
321        the Desktop Notification Specification. If that fails, then we fall back to
322        the older style PopupNotificationWindow method.'''
323
324        # default image
325        if not path_to_image:
326                path_to_image = os.path.abspath(
327                        os.path.join(gajim.DATA_DIR, 'pixmaps', 'events',
328                                'chat_msg_recv.png')) # img to display
329
330        # Try Growl first, as we might have D-Bus and notification daemon running
331        # on OS X for some reason.
332        if USER_HAS_GROWL:
333                osx.growler.notify(event_type, jid, account, msg_type, path_to_image,
334                        title, text)
335                return
336
337        # Try to show our popup via D-Bus and notification daemon
338        if gajim.config.get('use_notif_daemon') and dbus_support.supported:
339                try:
340                        DesktopNotification(event_type, jid, account, msg_type,
341                                path_to_image, gobject.markup_escape_text(title),
342                                gobject.markup_escape_text(text))
343                        return  # sucessfully did D-Bus Notification procedure!
344                except dbus.DBusException, e:
345                        # Connection to D-Bus failed
346                        gajim.log.debug(str(e))
347                except TypeError, e:
348                        # This means that we sent the message incorrectly
349                        gajim.log.debug(str(e))
350
351        # Ok, that failed. Let's try pynotify, which also uses notification daemon
352        if gajim.config.get('use_notif_daemon') and USER_HAS_PYNOTIFY:
353                if not text and event_type == 'new_message':
354                        # empty text for new_message means do_preview = False
355                        # -> default value for text
356                        _text = gobject.markup_escape_text(
357                                gajim.get_name_from_jid(account, jid))
358                else:
359                        _text = gobject.markup_escape_text(text)
360
361                if not title:
362                        _title = ''
363                else:
364                        _title = gobject.markup_escape_text(title)
365
366                notification = pynotify.Notification(_title, _text)
367                timeout = gajim.config.get('notification_timeout') * 1000 # make it ms
368                notification.set_timeout(timeout)
369
370                notification.set_category(event_type)
371                notification.set_data('event_type', event_type)
372                notification.set_data('jid', jid)
373                notification.set_data('account', account)
374                notification.set_data('msg_type', msg_type)
375                notification.set_property('icon-name', path_to_image)
376                notification.add_action('default', 'Default Action',
377                        on_pynotify_notification_clicked)
378
379                try:
380                        notification.show()
381                        return
382                except gobject.GError, e:
383                        # Connection to notification-daemon failed, see #2893
384                        gajim.log.debug(str(e))
385
386        # Either nothing succeeded or the user wants old-style notifications
387        instance = dialogs.PopupNotificationWindow(event_type, jid, account,
388                msg_type, path_to_image, title, text)
389        gajim.interface.roster.popup_notification_windows.append(instance)
390
391def on_pynotify_notification_clicked(notification, action):
392        jid = notification.get_data('jid')
393        account = notification.get_data('account')
394        msg_type = notification.get_data('msg_type')
395
396        notification.close()
397        gajim.interface.handle_event(account, jid, msg_type)
398
399class NotificationResponseManager:
400        '''Collects references to pending DesktopNotifications and manages there
401        signalling. This is necessary due to a bug in DBus where you can't remove
402        a signal from an interface once it's connected.'''
403        def __init__(self):
404                self.pending = {}
405                self.received = []
406                self.interface = None
407
408        def attach_to_interface(self):
409                if self.interface is not None:
410                        return
411                self.interface = dbus_support.get_notifications_interface()
412                self.interface.connect_to_signal('ActionInvoked', self.on_action_invoked)
413                self.interface.connect_to_signal('NotificationClosed', self.on_closed)
414
415        def on_action_invoked(self, id_, reason):
416                self.received.append((id_, time.time(), reason))
417                if id_ in self.pending:
418                        notification = self.pending[id_]
419                        notification.on_action_invoked(id_, reason)
420                        del self.pending[id_]
421                if len(self.received) > 20:
422                        curt = time.time()
423                        for rec in self.received:
424                                diff = curt - rec[1]
425                                if diff > 10:
426                                        self.received.remove(rec)
427
428        def on_closed(self, id_, reason=None):
429                if id_ in self.pending:
430                        del self.pending[id_]
431
432        def add_pending(self, id_, object_):
433                # Check to make sure that we handle an event immediately if we're adding
434                # an id that's already been triggered
435                for rec in self.received:
436                        if rec[0] == id_:
437                                object_.on_action_invoked(id_, rec[2])
438                                self.received.remove(rec)
439                                return
440                if id_ not in self.pending:
441                        # Add it
442                        self.pending[id_] = object_
443                else:
444                        # We've triggered an event that has a duplicate ID!
445                        gajim.log.debug('Duplicate ID of notification. Can\'t handle this.')
446
447notification_response_manager = NotificationResponseManager()
448
449class DesktopNotification:
450        '''A DesktopNotification that interfaces with D-Bus via the Desktop
451        Notification specification'''
452        def __init__(self, event_type, jid, account, msg_type='',
453                path_to_image=None, title=None, text=None):
454                self.path_to_image = path_to_image
455                self.event_type = event_type
456                self.title = title
457                self.text = text
458                '''0.3.1 is the only version of notification daemon that has no way to determine which version it is. If no method exists, it means they're using that one.'''
459                self.default_version = [0, 3, 1]
460                self.account = account
461                self.jid = jid
462                self.msg_type = msg_type
463
464                # default value of text
465                if not text and event_type == 'new_message':
466                        # empty text for new_message means do_preview = False
467                        self.text = gajim.get_name_from_jid(account, jid)
468
469                if not title:
470                        self.title = event_type # default value
471
472                if event_type == _('Contact Signed In'):
473                        ntype = 'presence.online'
474                elif event_type == _('Contact Signed Out'):
475                        ntype = 'presence.offline'
476                elif event_type in (_('New Message'), _('New Single Message'),
477                        _('New Private Message')):
478                        ntype = 'im.received'
479                elif event_type == _('File Transfer Request'):
480                        ntype = 'transfer'
481                elif event_type == _('File Transfer Error'):
482                        ntype = 'transfer.error'
483                elif event_type in (_('File Transfer Completed'), _('File Transfer Stopped')):
484                        ntype = 'transfer.complete'
485                elif event_type == _('New E-mail'):
486                        ntype = 'email.arrived'
487                elif event_type == _('Groupchat Invitation'):
488                        ntype = 'im.invitation'
489                elif event_type == _('Contact Changed Status'):
490                        ntype = 'presence.status'
491                elif event_type == _('Connection Failed'):
492                        ntype = 'connection.failed'
493                else:
494                        # default failsafe values
495                        self.path_to_image = os.path.abspath(
496                                os.path.join(gajim.DATA_DIR, 'pixmaps', 'events',
497                                        'chat_msg_recv.png')) # img to display
498                        ntype = 'im' # Notification Type
499
500                self.notif = dbus_support.get_notifications_interface()
501                if self.notif is None:
502                        raise dbus.DBusException('unable to get notifications interface')
503                self.ntype = ntype
504
505                self.get_version()
506
507        def attempt_notify(self):
508                version = self.version
509                timeout = gajim.config.get('notification_timeout') # in seconds
510                ntype = self.ntype
511                if version[:2] == [0, 2]:
512                        try:
513                                self.notif.Notify(
514                                        dbus.String(_('Gajim')),
515                                        dbus.String(self.path_to_image),
516                                        dbus.UInt32(0),
517                                        ntype,
518                                        dbus.Byte(0),
519                                        dbus.String(self.title),
520                                        dbus.String(self.text),
521                                        [dbus.String(self.path_to_image)],
522                                        {'default': 0},
523                                        [''],
524                                        True,
525                                        dbus.UInt32(timeout),
526                                        reply_handler=self.attach_by_id,
527                                        error_handler=self.notify_another_way)
528                        except AttributeError:
529                                version = [0, 3, 1] # we're actually dealing with the newer version
530                if version > [0, 3]:
531                        if gajim.interface.systray_enabled and \
532                        gajim.config.get('attach_notifications_to_systray'):
533                                x, y = gajim.interface.systray.img_tray.window.get_origin()
534                                x_, y_, width, height, depth = \
535                                        gajim.interface.systray.img_tray.window.get_geometry()
536                                pos_x = x + (width / 2)
537                                pos_y = y + (height / 2)
538                                hints = {'x': pos_x, 'y': pos_y}
539                        else:
540                                hints = {}
541                        if version >= [0, 3, 2]:
542                                hints['urgency'] = dbus.Byte(0) # Low Urgency
543                                hints['category'] = dbus.String(ntype)
544                                self.notif.Notify(
545                                        dbus.String(_('Gajim')),
546                                        dbus.UInt32(0), # this notification does not replace other
547                                        dbus.String(self.path_to_image),
548                                        dbus.String(self.title),
549                                        dbus.String(self.text),
550                                        ( dbus.String('default'), dbus.String(self.event_type) ),
551                                        hints,
552                                        dbus.UInt32(timeout*1000),
553                                        reply_handler=self.attach_by_id,
554                                        error_handler=self.notify_another_way)
555                        else:
556                                self.notif.Notify(
557                                        dbus.String(_('Gajim')),
558                                        dbus.String(self.path_to_image),
559                                        dbus.UInt32(0),
560                                        dbus.String(self.title),
561                                        dbus.String(self.text),
562                                        dbus.String(''),
563                                        hints,
564                                        dbus.UInt32(timeout*1000),
565                                        reply_handler=self.attach_by_id,
566                                        error_handler=self.notify_another_way)
567
568        def attach_by_id(self, id_):
569                self.id = id_
570                notification_response_manager.attach_to_interface()
571                notification_response_manager.add_pending(self.id, self)
572
573        def notify_another_way(self,e):
574                gajim.log.debug(str(e))
575                gajim.log.debug('Need to implement a new way of falling back')
576
577        def on_action_invoked(self, id_, reason):
578                if self.notif is None:
579                        return
580                self.notif.CloseNotification(dbus.UInt32(id_))
581                self.notif = None
582
583                gajim.interface.handle_event(self.account, self.jid, self.msg_type)
584
585        def version_reply_handler(self, name, vendor, version, spec_version=None):
586                if spec_version:
587                        version = spec_version
588                version_list = version.split('.')
589                self.version = []
590                while len(version_list):
591                        self.version.append(int(version_list.pop(0)))
592                self.attempt_notify()
593
594        def get_version(self):
595                self.notif.GetServerInfo(
596                        reply_handler=self.version_reply_handler,
597                        error_handler=self.version_error_handler_2_x_try)
598<