discovery.py 77.9 KB
Newer Older
Philipp Hörist's avatar
Philipp Hörist committed
1 2 3 4 5
# Copyright (C) 2005-2006 Stéphan Kochen <stephan AT kochen.nl>
# Copyright (C) 2005-2007 Nikos Kouremenos <kourem AT gmail.com>
# Copyright (C) 2005-2014 Yann Leboulanger <asterix AT lagaule.org>
# Copyright (C) 2006 Dimitur Kirov <dkirov AT gmail.com>
# Copyright (C) 2006-2008 Jean-Marie Traissard <jim AT lapin.org>
6
# Copyright (C) 2006 Tomasz Melcer <liori AT exroot.org>
Philipp Hörist's avatar
Philipp Hörist committed
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
# Copyright (C) 2007 Stephan Erb <steve-e AT h3c.de>
#
# This file is part of Gajim.
#
# Gajim is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation; version 3 only.
#
# Gajim is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
22 23 24 25 26 27 28 29 30 31 32 33

# The appearance of the treeview, and parts of the dialog, are controlled by
# AgentBrowser (sub-)classes. Methods that probably should be overridden when
# subclassing are: (look at the docstrings and source for additional info)
# - def cleanup(self) *
# - def _create_treemodel(self) *
# - def _add_actions(self)
# - def _clean_actions(self)
# - def update_theme(self) *
# - def update_actions(self)
# - def default_action(self)
# - def _find_item(self, jid, node)
34
# - def _add_item(self, jid, node, parent_node, item, force)
35 36 37
# - def _update_item(self, iter_, jid, node, item)
# - def _update_info(self, iter_, jid, node, identities, features, data)
# - def _update_error(self, iter_, jid, node)
38 39
#
# * Should call the super class for this method.
40 41
# All others do not have to call back to the super class (but can if they want
# the functionality).
nkour's avatar
minor  
nkour committed
42
# There are more methods, of course, but this is a basic set.
43

44
import types
45
import weakref
46 47

import nbxmpp
48
from nbxmpp.structs import DiscoIdentity
Philipp Hörist's avatar
Philipp Hörist committed
49
from nbxmpp.namespaces import Namespace
50
from nbxmpp.errors import StanzaError
51

Yann Leboulanger's avatar
Yann Leboulanger committed
52
from gi.repository import GLib
53
from gi.repository import Gtk
Dicson's avatar
Dicson committed
54
from gi.repository import Gdk
55

56
from gajim.common import app
André's avatar
André committed
57
from gajim.common import helpers
58
from gajim.common.i18n import _
Philipp Hörist's avatar
Philipp Hörist committed
59
from gajim.common.const import StyleAttr
60

61
from gajim.gtk.adhoc import AdHocCommand
62
from gajim.gtk.dialogs import ErrorDialog
Philipp Hörist's avatar
Philipp Hörist committed
63
from gajim.gtk.search import Search
64
from gajim.gtk.service_registration import ServiceRegistration
65 66
from gajim.gtk.util import icon_exists
from gajim.gtk.util import get_builder
67

68 69 70
LABELS = {
    1: _('This service has not yet responded with detailed information'),
    2: _('This service could not respond with detailed information.\n'
71
         'It is most likely a legacy service or broken.'),
72 73
}

74 75 76 77
# Dictionary mapping category, type pairs to browser class, image pairs.
# This is a function, so we can call it after the classes are declared.
# For the browser class, None means that the service will only be browsable
# when it advertises disco as it's feature, False means it's never browsable.
78 79


80
def _gen_agent_type_info():
81
    return {
82 83 84
        # Defaults
        (0, 0):                         (None, None),

Daniel Brötzmann's avatar
Daniel Brötzmann committed
85
        # XMPP server
86 87 88 89 90 91 92 93 94 95 96 97 98 99
        ('server', 'im'):               (ToplevelAgentBrowser, 'jabber'),
        ('services', 'jabber'):         (ToplevelAgentBrowser, 'jabber'),
        ('hierarchy', 'branch'):        (AgentBrowser, 'jabber'),

        # Services
        ('conference', 'text'):         (MucBrowser, 'conference'),
        ('headline', 'rss'):            (AgentBrowser, 'rss'),
        ('headline', 'weather'):        (False, 'weather'),
        ('gateway', 'weather'):         (False, 'weather'),
        ('_jid', 'weather'):            (False, 'weather'),
        ('gateway', 'sip'):             (False, 'sip'),
        ('directory', 'user'):          (None, 'jud'),
        ('pubsub', 'generic'):          (PubSubBrowser, 'pubsub'),
        ('pubsub', 'service'):          (PubSubBrowser, 'pubsub'),
100
        ('proxy', 'bytestreams'):       (None, 'bytestreams'),  # Socks5 FT proxy
101 102 103 104 105
        ('headline', 'newmail'):        (ToplevelAgentBrowser, 'mail'),

        # Transports
        ('conference', 'irc'):          (ToplevelAgentBrowser, 'irc'),
        ('_jid', 'irc'):                (False, 'irc'),
106
        ('gateway', 'irc'):             (False, 'irc'),
107 108 109 110 111 112 113 114 115 116 117 118
        ('gateway', 'gadu-gadu'):       (False, 'gadu-gadu'),
        ('_jid', 'gadugadu'):           (False, 'gadu-gadu'),
        ('gateway', 'http-ws'):         (False, 'http-ws'),
        ('gateway', 'icq'):             (False, 'icq'),
        ('_jid', 'icq'):                (False, 'icq'),
        ('gateway', 'sms'):             (False, 'sms'),
        ('_jid', 'sms'):                (False, 'sms'),
        ('gateway', 'smtp'):            (False, 'mail'),
        ('gateway', 'mrim'):            (False, 'mrim'),
        ('_jid', 'mrim'):               (False, 'mrim'),
        ('gateway', 'facebook'):        (False, 'facebook'),
        ('_jid', 'facebook'):           (False, 'facebook'),
Dicson's avatar
Dicson committed
119
        ('gateway', 'tv'):              (False, 'tv'),
120
        ('gateway', 'twitter'):         (False, 'twitter'),
121
    }
122

123

124
# Category type to "human-readable" description string
125
_cat_to_descr = {
126 127 128
    'other': _('Others'),
    'gateway': _('Transports'),
    '_jid': _('Transports'),
129
    'conference': _('Group Chat'),
130 131 132
}


shteef's avatar
shteef committed
133
class CacheDictionary:
134 135 136
    """
    A dictionary that keeps items around for only a specific time.  Lifetime is
    in minutes. Getrefresh specifies whether to refresh when an item is merely
Alexander Krotov's avatar
Alexander Krotov committed
137
    accessed instead of set as well
138 139
    """

140
    def __init__(self, lifetime, getrefresh=True):
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156
        self.lifetime = lifetime * 1000 * 60
        self.getrefresh = getrefresh
        self.cache = {}

    class CacheItem:
        """
        An object to store cache items and their timeouts
        """
        def __init__(self, value):
            self.value = value
            self.source = None

        def __call__(self):
            return self.value

    def cleanup(self):
157
        for key in list(self.cache.keys()):
158 159
            item = self.cache[key]
            if item.source:
Yann Leboulanger's avatar
Yann Leboulanger committed
160
                GLib.source_remove(item.source)
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176
            del self.cache[key]

    def _expire_timeout(self, key):
        """
        The timeout has expired, remove the object
        """
        if key in self.cache:
            del self.cache[key]
        return False

    def _refresh_timeout(self, key):
        """
        The object was accessed, refresh the timeout
        """
        item = self.cache[key]
        if item.source:
Yann Leboulanger's avatar
Yann Leboulanger committed
177
            GLib.source_remove(item.source)
178
        if self.lifetime:
179 180
            source = GLib.timeout_add_seconds(
                int(self.lifetime / 1000), self._expire_timeout, key)
181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196
            item.source = source

    def __getitem__(self, key):
        item = self.cache[key]
        if self.getrefresh:
            self._refresh_timeout(key)
        return item()

    def __setitem__(self, key, value):
        item = self.CacheItem(value)
        self.cache[key] = item
        self._refresh_timeout(key)

    def __delitem__(self, key):
        item = self.cache[key]
        if item.source:
Yann Leboulanger's avatar
Yann Leboulanger committed
197
            GLib.source_remove(item.source)
198 199 200 201
        del self.cache[key]

    def __contains__(self, key):
        return key in self.cache
shteef's avatar
shteef committed
202

203

shteef's avatar
shteef committed
204 205
_icon_cache = CacheDictionary(15)

206

207
def get_agent_address(jid, node=None):
208 209 210 211 212
    """
    Get an agent's address for displaying in the GUI
    """
    if node:
        return '%s@%s' % (node, str(jid))
213
    return str(jid)
214

215

216
class Closure:
217 218 219
    """
    A weak reference to a callback with arguments as an object

220
    Weak references to methods immediately die, even if the object is still
221 222 223 224 225 226
    alive. Besides a handy way to store a callback, this provides a workaround
    that keeps a reference to the object instead.

    Userargs and removeargs must be tuples.
    """

227
    def __init__(self, cb, userargs=(), remove=None, removeargs=()):
228 229 230 231
        self.userargs = userargs
        self.remove = remove
        self.removeargs = removeargs
        if isinstance(cb, types.MethodType):
232
            self.meth_self = weakref.ref(cb.__self__, self._remove)
233
            self.meth_name = cb.__name__
234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251
        elif callable(cb):
            self.meth_self = None
            self.cb = weakref.ref(cb, self._remove)
        else:
            raise TypeError('Object is not callable')

    def _remove(self, ref):
        if self.remove:
            self.remove(self, *self.removeargs)

    def __call__(self, *args, **kwargs):
        if self.meth_self:
            obj = self.meth_self()
            cb = getattr(obj, self.meth_name)
        else:
            cb = self.cb()
        args = args + self.userargs
        return cb(*args, **kwargs)
252 253 254


class ServicesCache:
255 256 257 258 259 260 261
    """
    Class that caches our query results. Each connection will have it's own
    ServiceCache instance
    """

    def __init__(self, account):
        self.account = account
262 263
        self._items = CacheDictionary(0, getrefresh=False)
        self._info = CacheDictionary(0, getrefresh=False)
264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283
        self._subscriptions = CacheDictionary(5, getrefresh=False)
        self._cbs = {}

    def cleanup(self):
        self._items.cleanup()
        self._info.cleanup()

    def _clean_closure(self, cb, type_, addr):
        # A closure died, clean up
        cbkey = (type_, addr)
        try:
            self._cbs[cbkey].remove(cb)
        except KeyError:
            return
        except ValueError:
            return
        # Clean an empty list
        if not self._cbs[cbkey]:
            del self._cbs[cbkey]

284
    def get_icon(self, identities=None, addr=''):
285 286 287
        """
        Return the icon for an agent
        """
288 289
        if identities is None:
            identities = []
290 291 292
        # Grab the first identity with an icon
        for identity in identities:
            try:
293
                cat, type_ = identity.category, identity.type
294 295 296
                info = _agent_type_info[(cat, type_)]
            except KeyError:
                continue
297 298
            service_name = info[1]
            if service_name:
299 300 301
                break
        else:
            # Loop fell through, default to unknown
302 303
            service_name = addr.split('.')[0]

304
        # Or load it
305 306 307 308
        icon_name = 'gajim-agent-%s' % service_name
        if icon_exists(icon_name):
            return icon_name
        return 'gajim-agent-jabber'
309

310
    def get_browser(self, identities=None, features=None):
311 312 313
        """
        Return the browser class for an agent
        """
314 315 316 317
        if identities is None:
            identities = []
        if features is None:
            features = []
318 319 320
        # First pass, we try to find a ToplevelAgentBrowser
        for identity in identities:
            try:
321
                cat, type_ = identity.category, identity.type
322 323 324 325 326 327 328
                info = _agent_type_info[(cat, type_)]
            except KeyError:
                continue
            browser = info[0]
            if browser and browser == ToplevelAgentBrowser:
                return browser

329
        # Second pass, we haven't found a ToplevelAgentBrowser
330 331
        for identity in identities:
            try:
332
                cat, type_ = identity.category, identity.type
333 334 335 336 337 338
                info = _agent_type_info[(cat, type_)]
            except KeyError:
                continue
            browser = info[0]
            if browser:
                return browser
Philipp Hörist's avatar
Philipp Hörist committed
339
        # Namespace.BROWSE is deprecated, but we check for it anyways.
340
        # Some services list it in features and respond to
Philipp Hörist's avatar
Philipp Hörist committed
341
        # Namespace.DISCO_ITEMS anyways.
Alexander Krotov's avatar
Alexander Krotov committed
342
        # Allow browsing for unknown types as well.
343
        if ((not features and not identities) or
Philipp Hörist's avatar
Philipp Hörist committed
344 345
                Namespace.DISCO_ITEMS in features or
                Namespace.BROWSE in features):
346 347 348
            return ToplevelAgentBrowser
        return None

349
    def get_info(self, jid, node, cb, force=False, nofetch=False, args=()):
350 351 352 353 354
        """
        Get info for an agent
        """
        addr = get_agent_address(jid, node)
        # Check the cache
355
        if addr in self._info and not force:
356 357 358 359 360 361 362 363
            args = self._info[addr] + args
            cb(jid, node, *args)
            return
        if nofetch:
            return

        # Create a closure object
        cbkey = ('info', addr)
364
        cb = Closure(cb, userargs=args, remove=self._clean_closure,
365
                     removeargs=cbkey)
366 367 368 369 370
        # Are we already fetching this?
        if cbkey in self._cbs:
            self._cbs[cbkey].append(cb)
        else:
            self._cbs[cbkey] = [cb]
371 372
            con = app.connections[self.account]
            con.get_module('Discovery').disco_info(
373
                jid, node, callback=self._disco_info_received)
374

375
    def get_items(self, jid, node, cb, force=False, nofetch=False, args=()):
376 377 378 379 380
        """
        Get a list of items in an agent
        """
        addr = get_agent_address(jid, node)
        # Check the cache
381
        if addr in self._items and not force:
382 383 384 385 386 387 388 389
            args = (self._items[addr],) + args
            cb(jid, node, *args)
            return
        if nofetch:
            return

        # Create a closure object
        cbkey = ('items', addr)
390
        cb = Closure(cb, userargs=args, remove=self._clean_closure,
391
                     removeargs=cbkey)
392 393 394 395 396
        # Are we already fetching this?
        if cbkey in self._cbs:
            self._cbs[cbkey].append(cb)
        else:
            self._cbs[cbkey] = [cb]
397 398
            con = app.connections[self.account]
            con.get_module('Discovery').disco_items(
399
                jid, node, callback=self._disco_items_received)
400

401
    def _disco_info_received(self, task):
402 403 404 405
        """
        Callback for when we receive an agent's info
        array is (agent, node, identities, features, data)
        """
406

407 408 409 410
        try:
            result = task.finish()
        except StanzaError as error:
            self._disco_info_error(error)
411 412 413
            return

        identities = result.identities
414 415
        if not identities:
            # Ejabberd doesn't send identities when using admin nodes
416 417 418
            identities = [DiscoIdentity(category='server',
                                        type='im',
                                        name=result.node)]
419

420 421
        self._on_agent_info(str(result.jid), result.node, result.identities,
                            result.features, result.dataforms)
422

423
    def _disco_info_error(self, result):
424 425 426 427
        """
        Callback for when a query fails. Even after the browse and agents
        namespaces
        """
428
        addr = get_agent_address(result.jid)
429 430 431 432 433

        # Call callbacks
        cbkey = ('info', addr)
        if cbkey in self._cbs:
            for cb in self._cbs[cbkey]:
434
                cb(str(result.jid), '', 0, 0, 0)
435 436 437
            # clean_closure may have beaten us to it
            if cbkey in self._cbs:
                del self._cbs[cbkey]
438

439
    def _on_agent_info(self, fjid, node, identities, features, dataforms):
440
        addr = get_agent_address(fjid, node)
441 442

        # Store in cache
443
        self._info[addr] = (identities, features, dataforms)
444 445 446 447 448

        # Call callbacks
        cbkey = ('info', addr)
        if cbkey in self._cbs:
            for cb in self._cbs[cbkey]:
449
                cb(fjid, node, identities, features, dataforms)
450 451 452 453
            # clean_closure may have beaten us to it
            if cbkey in self._cbs:
                del self._cbs[cbkey]

454
    def _disco_items_received(self, task):
455 456 457 458
        """
        Callback for when we receive an agent's items
        array is (agent, node, items)
        """
459 460 461 462 463

        try:
            result = task.finish()
        except StanzaError as error:
            self._disco_items_error(error)
464 465 466 467 468 469 470
            return

        addr = get_agent_address(result.jid, result.node)

        items = []
        for item in result.items:
            items.append(item._asdict())
471 472

        # Store in cache
473
        self._items[addr] = items
474 475 476 477 478

        # Call callbacks
        cbkey = ('items', addr)
        if cbkey in self._cbs:
            for cb in self._cbs[cbkey]:
479
                cb(str(result.jid), result.node, items)
480 481 482 483
            # clean_closure may have beaten us to it
            if cbkey in self._cbs:
                del self._cbs[cbkey]

484
    def _disco_items_error(self, result):
485 486 487 488
        """
        Callback for when a query fails. Even after the browse and agents
        namespaces
        """
489
        addr = get_agent_address(result.jid)
490 491 492 493 494

        # Call callbacks
        cbkey = ('items', addr)
        if cbkey in self._cbs:
            for cb in self._cbs[cbkey]:
495
                cb(str(result.jid), '', 0)
496 497 498
            # clean_closure may have beaten us to it
            if cbkey in self._cbs:
                del self._cbs[cbkey]
499

500

501
class ServiceDiscoveryWindow:
502 503 504
    """
    Class that represents the Services Discovery window
    """
505
    def __init__(self, account, jid='', node=None, address_entry=False,
506
                 parent=None, initial_identities=None):
507
        self._account = account
508 509
        self.parent = parent
        if not jid:
510
            jid = app.settings.get_account_setting(account, 'hostname')
511
            node = None
512 513 514 515 516 517

        self.jid = None
        self.browser = None
        self.children = []
        self.dying = False
        self.node = None
518
        self.reloading = False
519 520

        # Check connection
521
        if not app.account_is_available(account):
522
            ErrorDialog(_('You are not connected to the server'),
523 524
                        _('Without a connection, you can not browse '
                          'available services'))
525
            raise RuntimeError('You must be connected to browse services')
526 527 528

        # Get a ServicesCache object.
        try:
529
            self.cache = app.connections[account].services_cache
530 531
        except AttributeError:
            self.cache = ServicesCache(account)
532
            app.connections[account].services_cache = self.cache
533

534
        if initial_identities:
535
            self.cache._on_agent_info(jid, node, initial_identities, [], None)
536 537 538
        self._ui = get_builder('service_discovery_window.ui')
        self.window = self._ui.service_discovery_window
        self.services_treeview = self._ui.services_treeview
539 540 541
        self.model = None
        # This is more reliable than the cursor-changed signal.
        selection = self.services_treeview.get_selection()
542 543 544 545 546 547 548 549 550
        selection.connect_after(
            'changed', self._on_services_treeview_selection_changed)
        self.services_treeview.connect(
            'row-activated', self._on_services_treeview_row_activated)
        self.services_scrollwin = self._ui.services_scrollwin
        self.progressbar = self._ui.services_progressbar
        self.banner_header = self._ui.banner_agent_header
        self.banner_subheader = self._ui.banner_agent_subheader
        self.banner_icon = self._ui.banner_agent_icon
551
        self.style_event_id = 0
552
        self.action_buttonbox = self._ui.action_buttonbox
553 554

        # Address combobox
555
        self.address_comboboxtext = None
556
        if address_entry:
557
            self.address_comboboxtext = self._ui.address_comboboxtext
558

Philipp Hörist's avatar
Philipp Hörist committed
559
            self.latest_addresses = app.settings.get(
560
                'latest_disco_addresses').split()
561 562 563 564 565 566
            if jid in self.latest_addresses:
                self.latest_addresses.remove(jid)
            self.latest_addresses.insert(0, jid)
            if len(self.latest_addresses) > 10:
                self.latest_addresses = self.latest_addresses[0:10]
            for j in self.latest_addresses:
567 568
                self.address_comboboxtext.append_text(j)
            self.address_comboboxtext.get_child().set_text(jid)
569 570
        else:
            # Don't show it at all if we didn't ask for it
571 572
            self._ui.address_box.set_no_show_all(True)
            self._ui.address_box.hide()
573

574 575 576
        accel_group = Gtk.AccelGroup()
        keyval, mod = Gtk.accelerator_parse('<Control>r')
        accel_group.connect(keyval, mod, Gtk.AccelFlags.VISIBLE,
577
                            self.accel_group_func)
578 579
        self.window.add_accel_group(accel_group)

580
        self._initial_state()
581
        self._ui.connect_signals(self)
582 583 584 585
        self.travel(jid, node)
        self.window.show_all()

    @property
586 587
    def account(self):
        return self._account
588

589 590 591
    @account.setter
    def account(self, value):
        self._account = value
592 593 594 595
        self.cache.account = value
        if self.browser:
            self.browser.account = value

596
    def _on_key_press_event(self, widget, event):
597 598 599
        if event.keyval == Gdk.KEY_Escape:
            self.window.destroy()

600
    def accel_group_func(self, accel_group, acceleratable, keyval, modifier):
601
        if (modifier & Gdk.ModifierType.CONTROL_MASK) and (keyval == Gdk.KEY_r):
602 603
            self.reload()

604 605 606 607 608 609 610 611 612 613
    def _initial_state(self):
        """
        Set some initial state on the window. Separated in a method because it's
        handy to use within browser's cleanup method
        """
        self.progressbar.hide()
        title_text = _('Service Discovery using account %s') % self.account
        self.window.set_title(title_text)
        self._set_window_banner_text(_('Service Discovery'))
        self.banner_icon.clear()
614
        self.banner_icon.hide()  # Just clearing it doesn't work
615

Philipp Hörist's avatar
Philipp Hörist committed
616
    def _set_window_banner_text(self, text, text_after=None):
617
        self.banner_header.set_text(text)
Philipp Hörist's avatar
Philipp Hörist committed
618
        if text_after is not None:
619 620 621 622
            self.banner_subheader.show()
            self.banner_subheader.set_text(text_after)
        else:
            self.banner_subheader.hide()
623

624
    def _destroy(self, chain=False):
625 626 627 628 629 630 631 632 633 634 635
        """
        Close the browser. This can optionally close its children and propagate
        to the parent. This should happen on actions like register, or join to
        kill off the entire browser chain
        """
        if self.dying:
            return
        self.dying = True

        # self.browser._get_agent_address() would break when no browser.
        addr = get_agent_address(self.jid, self.node)
636 637
        if addr in app.interface.instances[self.account]['disco']:
            del app.interface.instances[self.account]['disco'][addr]
638 639 640 641 642 643 644 645 646 647

        if self.browser:
            self.window.hide()
            self.browser.cleanup()
            self.browser = None
        self.window.destroy()

        for child in self.children[:]:
            child.parent = None
            if chain:
648
                child.destroy(chain=chain)
649 650 651 652 653
                self.children.remove(child)
        if self.parent:
            if self in self.parent.children:
                self.parent.children.remove(self)
            if chain and not self.parent.children:
654
                self.parent.destroy(chain=chain)
655 656 657 658
                self.parent = None
        else:
            self.cache.cleanup()

659 660 661 662 663 664
    def reload(self):
        if not self.jid:
            return
        self.reloading = True
        self.travel(self.jid, self.node)

665 666 667 668 669 670 671 672 673 674
    def travel(self, jid, node):
        """
        Travel to an agent within the current services window
        """
        if self.browser:
            self.browser.cleanup()
            self.browser = None
        # Update the window list
        if self.jid:
            old_addr = get_agent_address(self.jid, self.node)
675 676
            if old_addr in app.interface.instances[self.account]['disco']:
                del app.interface.instances[self.account]['disco'][old_addr]
677
        addr = get_agent_address(jid, node)
678
        app.interface.instances[self.account]['disco'][addr] = self
679 680 681
        # We need to store these, self.browser is not always available.
        self.jid = jid
        self.node = node
682
        self.cache.get_info(jid, node, self._travel, force=self.reloading)
683 684 685 686 687 688 689 690

    def _travel(self, jid, node, identities, features, data):
        """
        Continuation of travel
        """
        if self.dying or jid != self.jid or node != self.node:
            return
        if not identities:
691
            if not self.address_comboboxtext:
692
                # We can't travel anywhere else.
693
                self._destroy()
694 695
            ErrorDialog(
                _('The service could not be found'),
696
                _('There is no service at the address you entered, or it is '
697
                  'not responding. Check the address and try again.'),
698
                transient_for=self.window)
699 700 701
            return
        klass = self.cache.get_browser(identities, features)
        if not klass:
702 703
            ErrorDialog(
                _('The service is not browsable'),
704 705
                _('This type of service does not contain any items to browse.'),
                transient_for=self.window)
706
            return
707
        if klass is None:
708 709 710
            klass = AgentBrowser
        self.browser = klass(self.account, jid, node)
        self.browser.prepare_window(self)
711 712
        self.browser.browse(force=self.reloading)
        self.reloading = False
713 714 715 716 717 718

    def open(self, jid, node):
        """
        Open an agent. By default, this happens in a new window
        """
        try:
719 720
            win = app.interface.instances[
                self.account]['disco'][get_agent_address(jid, node)]
721 722 723 724 725 726 727 728 729 730 731
            win.window.present()
            return
        except KeyError:
            pass
        try:
            win = ServiceDiscoveryWindow(self.account, jid, node, parent=self)
        except RuntimeError:
            # Disconnected, perhaps
            return
        self.children.append(win)

732 733
    def _on_service_discovery_window_destroy(self, widget):
        self._destroy()
734

735
    def _on_address_comboboxtext_changed(self, widget):
736
        if self.address_comboboxtext.get_active() != -1:
737 738
            # User selected one of the entries so do auto-visit
            jid = self._ui.address_comboboxtext_entry.get_text()
739 740
            try:
                jid = helpers.parse_jid(jid)
Yann Leboulanger's avatar
Yann Leboulanger committed
741
            except helpers.InvalidFormat as s:
742
                ErrorDialog(_('Invalid Server Name'), str(s))
743
                return
744
            self.travel(jid, None)
745

746 747
    def _on_go_button_clicked(self, widget):
        jid = self._ui.address_comboboxtext_entry.get_text()
748 749
        try:
            jid = helpers.parse_jid(jid)
Yann Leboulanger's avatar
Yann Leboulanger committed
750
        except helpers.InvalidFormat as s:
751 752 753
            ErrorDialog(_('Invalid Server Name'),
                        str(s),
                        transient_for=self.window)
754
            return
755
        if jid == self.jid:  # jid has not changed
756 757 758 759 760 761
            return
        if jid in self.latest_addresses:
            self.latest_addresses.remove(jid)
        self.latest_addresses.insert(0, jid)
        if len(self.latest_addresses) > 10:
            self.latest_addresses = self.latest_addresses[0:10]
762
        self.address_comboboxtext.get_model().clear()
763
        for j in self.latest_addresses:
764
            self.address_comboboxtext.append_text(j)
Philipp Hörist's avatar
Philipp Hörist committed
765
        app.settings.set('latest_disco_addresses',
766
                       ' '.join(self.latest_addresses))
767
        self.travel(jid, None)
768

769
    def _on_services_treeview_row_activated(self, widget, path, col=0):
770 771 772
        if self.browser:
            self.browser.default_action()

773
    def _on_services_treeview_selection_changed(self, widget):
774 775
        if self.browser:
            self.browser.update_actions()
776

777 778
    def _on_entry_key_press_event(self, widget, event):
        if event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter:
779
            self._on_go_button_clicked(widget)
780

781 782

class AgentBrowser:
783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808
    """
    Class that deals with browsing agents and appearance of the browser window.
    This class and subclasses should basically be treated as "part" of the
    ServiceDiscoveryWindow class, but had to be separated because this part is
    dynamic
    """

    def __init__(self, account, jid, node):
        self.account = account
        self.jid = jid
        self.node = node
        self._total_items = 0
        self.browse_button = None
        # This is for some timeout callbacks
        self.active = False

    def _get_agent_address(self):
        """
        Get the agent's address for displaying in the GUI
        """
        return get_agent_address(self.jid, self.node)

    def _set_initial_title(self):
        """
        Set the initial window title based on agent address
        """
809 810 811
        self.window.window.set_title(
            _('Browsing %(address)s using account %(account)s') % {
                'address': self._get_agent_address(),
812 813 814 815 816 817 818 819 820 821
                'account': self.account})
        self.window._set_window_banner_text(self._get_agent_address())

    def _create_treemodel(self):
        """
        Create the treemodel for the services treeview. When subclassing, note
        that the first two columns should ALWAYS be of type string and contain
        the JID and node of the item respectively
        """
        # JID, node, name, address
822 823
        self.model = Gtk.ListStore(str, str, str, str)
        self.model.set_sort_column_id(3, Gtk.SortType.ASCENDING)
824 825
        self.window.services_treeview.set_model(self.model)
        # Name column
826 827
        col = Gtk.TreeViewColumn(_('Name'))
        renderer = Gtk.CellRendererText()
828 829
        col.pack_start(renderer, True)
        col.add_attribute(renderer, 'text', 2)
830 831 832
        self.window.services_treeview.insert_column(col, -1)
        col.set_resizable(True)
        # Address column
833
        col = Gtk.TreeViewColumn(_('XMPP Address'))
834
        renderer = Gtk.CellRendererText()
835 836
        col.pack_start(renderer, True)
        col.add_attribute(renderer, 'text', 3)
837 838 839 840 841 842 843 844 845 846 847 848 849 850 851
        self.window.services_treeview.insert_column(col, -1)
        col.set_resizable(True)
        self.window.services_treeview.set_headers_visible(True)

    def _clean_treemodel(self):
        self.model.clear()
        for col in self.window.services_treeview.get_columns():
            self.window.services_treeview.remove_column(col)
        self.window.services_treeview.set_headers_visible(False)

    def _add_actions(self):
        """
        Add the action buttons to the buttonbox for actions the browser can
        perform
        """
852
        self.browse_button = Gtk.Button()
853 854
        self.browse_button.connect('clicked', self.on_browse_button_clicked)
        self.window.action_buttonbox.add(self.browse_button)
855
        self.browse_button.set_label(_('Browse'))
856 857 858 859 860 861 862 863 864 865 866 867 868 869 870
        self.browse_button.show_all()

    def _clean_actions(self):
        """
        Remove the action buttons specific to this browser
        """
        if self.browse_button:
            self.browse_button.destroy()
            self.browse_button = None

    def _set_title(self, jid, node, identities, features, data):
        """
        Set the window title based on agent info
        """
        # Set the banner and window title
871
        name = ''
Dicson's avatar
Dicson committed
872
        if len(identities) > 1:
873
            # Check if an identity with server category is present
874
            for _index, identity in enumerate(identities):
875 876
                if identity.category == 'server' and identity.name is not None:
                    name = identity.name
877
                    break
Philipp Hörist's avatar
Philipp Hörist committed
878
                if identities[0].name is not None:
879
                    name = identities[0].name
Dicson's avatar
Dicson committed
880 881

        if name:
Yann Leboulanger's avatar
Yann Leboulanger committed
882
            self.window._set_window_banner_text(self._get_agent_address(), name)
883 884

        # Add an icon to the banner.
885 886
        icon_name = self.cache.get_icon(identities,
                                        addr=self._get_agent_address())
887 888
        self.window.banner_icon.set_from_icon_name(icon_name,
                                                   Gtk.IconSize.DIALOG)
889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929
        self.window.banner_icon.show()

    def _clean_title(self):
        # Everything done here is done in window._initial_state
        # This is for subclasses.
        pass

    def prepare_window(self, window):
        """
        Prepare the service discovery window. Called when a browser is hooked up
        with a ServiceDiscoveryWindow instance
        """
        self.window = window
        self.cache = window.cache

        self._set_initial_title()
        self._create_treemodel()
        self._add_actions()

        self.update_actions()

        self.active = True
        self.cache.get_info(self.jid, self.node, self._set_title)

    def cleanup(self):
        """
        Cleanup when the window intends to switch browsers
        """
        self.active = False

        self._clean_actions()
        self._clean_treemodel()
        self._clean_title()

        self.window._initial_state()

    def update_theme(self):
        """
        Called when the default theme is changed
        """

930
    def on_browse_button_clicked(self, widget=None):
931 932 933 934
        """
        When we want to browse an agent: open a new services window with a
        browser for the agent type
        """
935 936
        model, iter_ = \
            self.window.services_treeview.get_selection().get_selected()
937 938
        if not iter_:
            return
939
        jid = model[iter_][0]
940
        if jid:
941
            node = model[iter_][1]
942 943 944 945 946 947 948 949
            self.window.open(jid, node)

    def update_actions(self):
        """
        When we select a row: activate action buttons based on the agent's info
        """
        if self.browse_button:
            self.browse_button.set_sensitive(False)
950 951
        model, iter_ = \
            self.window.services_treeview.get_selection().get_selected()
952 953
        if not iter_:
            return