conversationtab.py 18.3 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13
"""
Module for the ConversationTabs

A ConversationTab is a direct chat between two JIDs, outside of a room.

There are two different instances of a ConversationTab:
- A DynamicConversationTab that implements XEP-0296 (best practices for
    resource locking), which means it will switch the resource it is
    focused on depending on the presences received. This is the default.
- A StaticConversationTab that will stay focused on one resource all
    the time.

"""
14 15 16 17 18
import logging
log = logging.getLogger(__name__)

import curses

19
from . basetabs import OneToOneTab, Tab
20 21 22 23 24 25 26 27 28 29

import common
import fixes
import windows
import xhtml
from common import safeJID
from config import config
from decorators import refresh_wrapper
from roster import roster
from theming import get_theme, dump_tuple
30
from decorators import command_args_parser
31

32
class ConversationTab(OneToOneTab):
33 34 35 36 37 38 39 40 41
    """
    The tab containg a normal conversation (not from a MUC)
    Must not be instantiated, use Static or Dynamic version only.
    """
    plugin_commands = {}
    plugin_keys = {}
    additional_informations = {}
    message_type = 'chat'
    def __init__(self, jid):
42
        OneToOneTab.__init__(self, jid)
43 44 45 46 47 48 49 50 51 52 53 54
        self.nick = None
        self.nick_sent = False
        self.state = 'normal'
        self.name = jid        # a conversation tab is linked to one specific full jid OR bare jid
        self.text_win = windows.TextWin()
        self._text_buffer.add_window(self.text_win)
        self.upper_bar = windows.ConversationStatusMessageWin()
        self.input = windows.MessageInput()
        # keys
        self.key_func['^I'] = self.completion
        # commands
        self.register_command('unquery', self.command_unquery,
55
                shortdesc='Close the tab.')
56
        self.register_command('close', self.command_unquery,
57
                shortdesc='Close the tab.')
58
        self.register_command('version', self.command_version,
59 60
                desc='Get the software version of the current interlocutor (usually its XMPP client and Operating System).',
                shortdesc='Get the software version of the user.')
61
        self.register_command('info', self.command_info,
62
                shortdesc='Get the status of the contact.')
63
        self.register_command('last_activity', self.command_last_activity,
64 65 66
                usage='[jid]',
                desc='Get the last activity of the given or the current contact.',
                shortdesc='Get the activity.',
67
                completion=self.core.completion.last_activity)
68 69 70 71 72 73
        self.resize()
        self.update_commands()
        self.update_keys()

    @property
    def general_jid(self):
74
        return safeJID(self.name).bare
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89

    @staticmethod
    def add_information_element(plugin_name, callback):
        """
        Lets a plugin add its own information to the ConversationInfoWin
        """
        ConversationTab.additional_informations[plugin_name] = callback

    @staticmethod
    def remove_information_element(plugin_name):
        del ConversationTab.additional_informations[plugin_name]

    def completion(self):
        self.complete_commands(self.input)

90
    @command_args_parser.raw
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110
    def command_say(self, line, attention=False, correct=False):
        msg = self.core.xmpp.make_message(self.get_dest_jid())
        msg['type'] = 'chat'
        msg['body'] = line
        if not self.nick_sent:
            msg['nick'] = self.core.own_nick
            self.nick_sent = True
        # trigger the event BEFORE looking for colors.
        # and before displaying the message in the window
        # This lets a plugin insert \x19xxx} colors, that will
        # be converted in xhtml.
        self.core.events.trigger('conversation_say', msg, self)
        if not msg['body']:
            self.cancel_paused_delay()
            self.text_win.refresh()
            self.input.refresh()
            return
        replaced = False
        if correct or msg['replace']['id']:
            msg['replace']['id'] = self.last_sent_message['id']
111
            if config.get_by_tabname('group_corrections', self.name):
112 113 114 115 116 117 118 119 120 121 122 123
                try:
                    self.modify_message(msg['body'], self.last_sent_message['id'], msg['id'], jid=self.core.xmpp.boundjid,
                            nickname=self.core.own_nick)
                    replaced = True
                except:
                    log.error('Unable to correct a message', exc_info=True)
        else:
            del msg['replace']
        if msg['body'].find('\x19') != -1:
            msg.enable('html')
            msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body'])
            msg['body'] = xhtml.clean_text(msg['body'])
124 125
        if (config.get_by_tabname('send_chat_states', self.general_jid) and
                self.remote_wants_chatstates is not False):
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
            needed = 'inactive' if self.inactive else 'active'
            msg['chat_state'] = needed
        if attention and self.remote_supports_attention:
            msg['attention'] = True
        self.core.events.trigger('conversation_say_after', msg, self)
        if not msg['body']:
            self.cancel_paused_delay()
            self.text_win.refresh()
            self.input.refresh()
            return
        if not replaced:
            self.add_message(msg['body'],
                    nickname=self.core.own_nick,
                    nick_color=get_theme().COLOR_OWN_NICK,
                    identifier=msg['id'],
                    jid=self.core.xmpp.boundjid,
                    typ=1)

        self.last_sent_message = msg
145 146
        if self.remote_supports_receipts:
            msg._add_receipt = True
147 148 149 150 151
        msg.send()
        self.cancel_paused_delay()
        self.text_win.refresh()
        self.input.refresh()

152 153
    @command_args_parser.quoted(0, 1)
    def command_last_activity(self, args):
154
        """
mathieui's avatar
mathieui committed
155
        /last_activity [jid]
156
        """
mathieui's avatar
mathieui committed
157 158
        if args and args[0]:
            return self.core.command_last_activity(args[0])
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184

        def callback(iq):
            if iq['type'] != 'result':
                if iq['error']['type'] == 'auth':
                    self.core.information('You are not allowed to see the activity of this contact.', 'Error')
                else:
                    self.core.information('Error retrieving the activity', 'Error')
                return
            seconds = iq['last_activity']['seconds']
            status = iq['last_activity']['status']
            from_ = iq['from']
            msg = '\x19%s}The last activity of %s was %s ago%s'
            if not safeJID(from_).user:
                msg = '\x19%s}The uptime of %s is %s.' % (
                        dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
                        from_,
                        common.parse_secs_to_str(seconds))
            else:
                msg = '\x19%s}The last activity of %s was %s ago%s' % (
                    dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
                    from_,
                    common.parse_secs_to_str(seconds),
                    (' and his/her last status was %s' % status) if status else '',)
            self.add_message(msg)
            self.core.refresh_window()

mathieui's avatar
mathieui committed
185
        self.core.xmpp.plugin['xep_0012'].get_last_activity(self.get_dest_jid(), callback=callback)
186 187

    @refresh_wrapper.conditional
188 189
    @command_args_parser.ignored
    def command_info(self):
190 191 192 193 194 195 196 197 198 199
        contact = roster[self.get_dest_jid()]
        jid = safeJID(self.get_dest_jid())
        if contact:
            if jid.resource:
                resource = contact[jid.full]
            else:
                resource = contact.get_highest_priority_resource()
        else:
            resource = None
        if resource:
200
            status = ('Status: %s' % resource.status) if resource.status else ''
201 202 203 204 205 206 207
            self._text_buffer.add_message("\x19%(info_col)s}Show: %(show)s, %(status)s\x19o" % {
                'show': resource.show or 'available', 'status': status, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)})
            return True
        else:
            self._text_buffer.add_message("\x19%(info_col)s}No information available\x19o" % {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)})
            return True

208 209
    @command_args_parser.ignored
    def command_unquery(self):
210 211
        self.core.close_tab()

212 213
    @command_args_parser.quoted(0, 1)
    def command_version(self, args):
214
        """
215
        /version [jid]
216 217 218 219 220
        """
        def callback(res):
            if not res:
                return self.core.information('Could not get the software version from %s' % (jid,), 'Warning')
            version = '%s is running %s version %s on %s' % (jid,
221 222 223
                                                             res.get('name') or 'an unknown software',
                                                             res.get('version') or 'unknown',
                                                             res.get('os') or 'an unknown platform')
224
            self.core.information(version, 'Info')
225 226
        if args:
            return self.core.command_version(args[0])
227 228 229 230 231
        jid = safeJID(self.name)
        if not jid.resource:
            if jid in roster:
                resource = roster[jid].get_highest_priority_resource()
                jid = resource.jid if resource else jid
232 233
        fixes.get_version(self.core.xmpp, jid,
                callback=callback)
234 235

    def resize(self):
236
        self.need_resize = False
237 238 239 240 241 242 243 244 245 246 247 248 249 250
        if self.size.tab_degrade_y:
            display_bar = False
            info_win_height = 0
            tab_win_height = 0
            bar_height = 0
        else:
            display_bar = True
            info_win_height = self.core.information_win_size
            tab_win_height = Tab.tab_win_height()
            bar_height = 1

        self.text_win.resize(self.height - 2 - bar_height - info_win_height
                                - tab_win_height,
                             self.width, bar_height, 0)
251
        self.text_win.rebuild_everything(self._text_buffer)
252 253 254
        if display_bar:
            self.upper_bar.resize(1, self.width, 0, 0)
        self.info_header.resize(1, self.width,
255
                                self.height - 2 - info_win_height
256 257 258
                                    - tab_win_height,
                                0)
        self.input.resize(1, self.width, self.height - 1, 0)
259 260 261 262

    def refresh(self):
        if self.need_resize:
            self.resize()
263
        log.debug('  TAB   Refresh: %s', self.__class__.__name__)
264 265
        display_bar = display_info_win = not self.size.tab_degrade_y

266
        self.text_win.refresh()
267 268 269

        if display_bar:
            self.upper_bar.refresh(self.get_dest_jid(), roster[self.get_dest_jid()])
270
        self.info_header.refresh(self.get_dest_jid(), roster[self.get_dest_jid()], self.text_win, self.chatstate, ConversationTab.additional_informations)
271 272 273

        if display_info_win:
            self.info_win.refresh()
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310
        self.refresh_tab_win()
        self.input.refresh()

    def refresh_info_header(self):
        self.info_header.refresh(self.get_dest_jid(), roster[self.get_dest_jid()],
                self.text_win, self.chatstate, ConversationTab.additional_informations)
        self.input.refresh()

    def get_nick(self):
        jid = safeJID(self.name)
        contact = roster[jid.bare]
        if contact:
            return contact.name or jid.user
        else:
            if self.nick:
                return self.nick
            return jid.user

    def on_input(self, key, raw):
        if not raw and key in self.key_func:
            self.key_func[key]()
            return False
        self.input.do_command(key, raw=raw)
        empty_after = self.input.get_text() == '' or (self.input.get_text().startswith('/') and not self.input.get_text().startswith('//'))
        self.send_composing_chat_state(empty_after)
        return False

    def on_lose_focus(self):
        contact = roster[self.get_dest_jid()]
        jid = safeJID(self.get_dest_jid())
        if contact:
            if jid.resource:
                resource = contact[jid.full]
            else:
                resource = contact.get_highest_priority_resource()
        else:
            resource = None
311 312 313 314
        if self.input.text:
            self.state = 'nonempty'
        else:
            self.state = 'normal'
315 316
        self.text_win.remove_line_separator()
        self.text_win.add_line_separator(self._text_buffer)
317 318 319
        if (config.get_by_tabname('send_chat_states', self.general_jid)
                and (not self.input.get_text()
                    or not self.input.get_text().startswith('//'))):
320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336
            if resource:
                self.send_chat_state('inactive')
        self.check_scrolled()

    def on_gain_focus(self):
        contact = roster[self.get_dest_jid()]
        jid = safeJID(self.get_dest_jid())
        if contact:
            if jid.resource:
                resource = contact[jid.full]
            else:
                resource = contact.get_highest_priority_resource()
        else:
            resource = None

        self.state = 'current'
        curses.curs_set(1)
337 338 339
        if (config.get_by_tabname('send_chat_states', self.general_jid)
                and (not self.input.get_text()
                    or not self.input.get_text().startswith('//'))):
340 341 342 343 344 345 346 347 348 349 350 351 352 353
            if resource:
                self.send_chat_state('active')

    def on_info_win_size_changed(self):
        if self.core.information_win_size >= self.height-3:
            return
        self.text_win.resize(self.height-3-self.core.information_win_size - Tab.tab_win_height(), self.width, 1, 0)
        self.info_header.resize(1, self.width, self.height-2-self.core.information_win_size - Tab.tab_win_height(), 0)

    def get_text_window(self):
        return self.text_win

    def on_close(self):
        Tab.on_close(self)
354
        if config.get_by_tabname('send_chat_states', self.general_jid):
355 356 357 358
            self.send_chat_state('gone')

    def matching_names(self):
        res = []
359
        jid = safeJID(self.name)
360 361
        res.append((2, jid.bare))
        res.append((1, jid.user))
362
        contact = roster[self.name]
363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380
        if contact and contact.name:
            res.append((0, contact.name))
        return res

class DynamicConversationTab(ConversationTab):
    """
    A conversation tab associated with one bare JID that can be “locked” to
    a full jid, and unlocked, as described in the XEP-0296.
    Only one DynamicConversationTab can be opened for a given jid.
    """
    def __init__(self, jid, resource=None):
        self.locked_resource = None
        self.name = safeJID(jid).bare
        if resource:
            self.lock(resource)
        self.info_header = windows.DynamicConversationInfoWin()
        ConversationTab.__init__(self, jid)
        self.register_command('unlock', self.unlock_command,
381
                shortdesc='Unlock the conversation from a particular resource.')
382 383 384 385 386 387

    def lock(self, resource):
        """
        Lock the tab to the resource.
        """
        assert(resource)
388 389
        if resource != self.locked_resource:
            self.locked_resource = resource
390 391 392
            info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
            jid_c = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID)

393 394
            message = ('%(info)sConversation locked to '
                       '%(jid_c)s%(jid)s/%(resource)s%(info)s.') % {
395 396 397 398
                            'info': info,
                            'jid_c': jid_c,
                            'jid': self.name,
                            'resource': resource}
mathieui's avatar
mathieui committed
399
            self.add_message(message, typ=0)
400
            self.check_features()
401 402 403 404 405

    def unlock_command(self, arg=None):
        self.unlock()
        self.refresh_info_header()

mathieui's avatar
mathieui committed
406
    def unlock(self, from_=None):
407 408 409 410
        """
        Unlock the tab from a resource. It is now “associated” with the bare
        jid.
        """
411
        self.remote_wants_chatstates = None
412 413
        if self.locked_resource != None:
            self.locked_resource = None
414 415
            info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
            jid_c = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID)
mathieui's avatar
mathieui committed
416

mathieui's avatar
mathieui committed
417
            if from_:
418 419
                message = ('%(info)sConversation unlocked (received activity'
                           ' from %(jid_c)s%(jid)s%(info)s).') % {
420 421 422
                                'info': info,
                                'jid_c': jid_c,
                                'jid': from_}
mathieui's avatar
mathieui committed
423
                self.add_message(message, typ=0)
mathieui's avatar
mathieui committed
424
            else:
425
                message = '%sConversation unlocked.' % info
mathieui's avatar
mathieui committed
426
                self.add_message(message, typ=0)
427 428 429 430 431 432 433

    def get_dest_jid(self):
        """
        Returns the full jid (using the locked resource), or the bare jid if
        the conversation is not locked.
        """
        if self.locked_resource:
434 435
            return "%s/%s" % (self.name, self.locked_resource)
        return self.name
436 437 438 439 440 441 442

    def refresh(self):
        """
        Different from the parent class only for the info_header object.
        """
        if self.need_resize:
            self.resize()
443
        log.debug('  TAB   Refresh: %s', self.__class__.__name__)
444 445
        display_bar = display_info_win = not self.size.tab_degrade_y

446
        self.text_win.refresh()
447
        if display_bar:
448
            self.upper_bar.refresh(self.name, roster[self.name])
449
        if self.locked_resource:
450
            displayed_jid = "%s/%s" % (self.name, self.locked_resource)
451
        else:
452 453
            displayed_jid = self.name
        self.info_header.refresh(displayed_jid, roster[self.name],
454 455 456 457 458
                                 self.text_win, self.chatstate,
                                 ConversationTab.additional_informations)
        if display_info_win:
            self.info_win.refresh()

459 460 461 462 463 464 465 466
        self.refresh_tab_win()
        self.input.refresh()

    def refresh_info_header(self):
        """
        Different from the parent class only for the info_header object.
        """
        if self.locked_resource:
467
            displayed_jid = "%s/%s" % (self.name, self.locked_resource)
468
        else:
469 470
            displayed_jid = self.name
        self.info_header.refresh(displayed_jid, roster[self.name],
471 472 473 474 475 476 477 478 479 480 481 482 483 484
                self.text_win, self.chatstate, ConversationTab.additional_informations)
        self.input.refresh()

class StaticConversationTab(ConversationTab):
    """
    A conversation tab associated with one Full JID. It cannot be locked to
    an different resource or unlocked.
    """
    def __init__(self, jid):
        assert(safeJID(jid).resource)
        self.info_header = windows.ConversationInfoWin()
        ConversationTab.__init__(self, jid)