conversationtab.py 18.2 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

mathieui's avatar
mathieui committed
19
from poezio.tabs.basetabs import OneToOneTab, Tab
20

21 22 23 24 25 26 27 28 29 30
from poezio import common
from poezio import fixes
from poezio import windows
from poezio import xhtml
from poezio.common import safeJID
from poezio.config import config
from poezio.decorators import refresh_wrapper
from poezio.roster import roster
from poezio.theming import get_theme, dump_tuple
from poezio.decorators import command_args_parser
31

32
class ConversationTab(OneToOneTab):
33 34 35 36 37 38
    """
    The tab containg a normal conversation (not from a MUC)
    Must not be instantiated, use Static or Dynamic version only.
    """
    plugin_commands = {}
    plugin_keys = {}
39
    additional_information = {}
40
    message_type = 'chat'
41 42
    def __init__(self, core, jid):
        OneToOneTab.__init__(self, core, 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('version', self.command_version,
55 56
                desc='Get the software version of the current interlocutor (usually its XMPP client and Operating System).',
                shortdesc='Get the software version of the user.')
57
        self.register_command('info', self.command_info,
58
                shortdesc='Get the status of the contact.')
59
        self.register_command('last_activity', self.command_last_activity,
60 61 62
                usage='[jid]',
                desc='Get the last activity of the given or the current contact.',
                shortdesc='Get the activity.',
63
                completion=self.core.completion.last_activity)
64 65 66 67 68 69
        self.resize()
        self.update_commands()
        self.update_keys()

    @property
    def general_jid(self):
70
        return safeJID(self.name).bare
71 72 73 74 75 76

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

    @staticmethod
    def remove_information_element(plugin_name):
81
        del ConversationTab.additional_information[plugin_name]
82 83 84 85

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

86
    @command_args_parser.raw
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106
    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']
107
            if config.get_by_tabname('group_corrections', self.name):
108 109 110 111 112 113 114 115 116 117 118 119
                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'])
120 121
        if (config.get_by_tabname('send_chat_states', self.general_jid) and
                self.remote_wants_chatstates is not False):
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140
            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
141 142
        if self.remote_supports_receipts:
            msg._add_receipt = True
143 144 145 146 147
        msg.send()
        self.cancel_paused_delay()
        self.text_win.refresh()
        self.input.refresh()

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

        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
181
        self.core.xmpp.plugin['xep_0012'].get_last_activity(self.get_dest_jid(), callback=callback)
182 183

    @refresh_wrapper.conditional
184 185
    @command_args_parser.ignored
    def command_info(self):
186 187 188 189 190 191 192 193 194 195
        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:
196
            status = ('Status: %s' % resource.status) if resource.status else ''
197 198 199 200 201 202 203
            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

204 205
    @command_args_parser.quoted(0, 1)
    def command_version(self, args):
206
        """
207
        /version [jid]
208 209 210 211 212
        """
        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,
213 214 215
                                                             res.get('name') or 'an unknown software',
                                                             res.get('version') or 'unknown',
                                                             res.get('os') or 'an unknown platform')
216
            self.core.information(version, 'Info')
217
        if args:
218
            return self.core.command.version(args[0])
219 220 221 222 223
        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
224 225
        fixes.get_version(self.core.xmpp, jid,
                callback=callback)
226 227

    def resize(self):
228
        self.need_resize = False
229 230 231 232 233 234 235 236 237 238 239 240 241 242
        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)
243
        self.text_win.rebuild_everything(self._text_buffer)
244 245 246
        if display_bar:
            self.upper_bar.resize(1, self.width, 0, 0)
        self.info_header.resize(1, self.width,
247
                                self.height - 2 - info_win_height
248 249 250
                                    - tab_win_height,
                                0)
        self.input.resize(1, self.width, self.height - 1, 0)
251 252 253 254

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

258
        self.text_win.refresh()
259 260 261

        if display_bar:
            self.upper_bar.refresh(self.get_dest_jid(), roster[self.get_dest_jid()])
262
        self.info_header.refresh(self.get_dest_jid(), roster[self.get_dest_jid()], self.text_win, self.chatstate, ConversationTab.additional_information)
263 264 265

        if display_info_win:
            self.info_win.refresh()
266 267 268 269 270
        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()],
271
                self.text_win, self.chatstate, ConversationTab.additional_information)
272 273 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
        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
303 304 305 306
        if self.input.text:
            self.state = 'nonempty'
        else:
            self.state = 'normal'
307 308
        self.text_win.remove_line_separator()
        self.text_win.add_line_separator(self._text_buffer)
309 310 311
        if (config.get_by_tabname('send_chat_states', self.general_jid)
                and (not self.input.get_text()
                    or not self.input.get_text().startswith('//'))):
312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328
            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)
329 330 331
        if (config.get_by_tabname('send_chat_states', self.general_jid)
                and (not self.input.get_text()
                    or not self.input.get_text().startswith('//'))):
332 333 334 335 336 337 338 339 340 341 342 343 344 345
            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)
346
        if config.get_by_tabname('send_chat_states', self.general_jid):
347 348 349 350
            self.send_chat_state('gone')

    def matching_names(self):
        res = []
351
        jid = safeJID(self.name)
352 353
        res.append((2, jid.bare))
        res.append((1, jid.user))
354
        contact = roster[self.name]
355 356 357 358 359 360 361 362 363 364
        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.
    """
mathieui's avatar
mathieui committed
365
    def __init__(self, core, jid, resource=None):
366 367 368 369 370
        self.locked_resource = None
        self.name = safeJID(jid).bare
        if resource:
            self.lock(resource)
        self.info_header = windows.DynamicConversationInfoWin()
mathieui's avatar
mathieui committed
371
        ConversationTab.__init__(self, core, jid)
372
        self.register_command('unlock', self.unlock_command,
373
                shortdesc='Unlock the conversation from a particular resource.')
374 375 376 377 378 379

    def lock(self, resource):
        """
        Lock the tab to the resource.
        """
        assert(resource)
380 381
        if resource != self.locked_resource:
            self.locked_resource = resource
382 383 384
            info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
            jid_c = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID)

385 386
            message = ('%(info)sConversation locked to '
                       '%(jid_c)s%(jid)s/%(resource)s%(info)s.') % {
387 388 389 390
                            'info': info,
                            'jid_c': jid_c,
                            'jid': self.name,
                            'resource': resource}
mathieui's avatar
mathieui committed
391
            self.add_message(message, typ=0)
392
            self.check_features()
393 394 395 396 397

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

mathieui's avatar
mathieui committed
398
    def unlock(self, from_=None):
399 400 401 402
        """
        Unlock the tab from a resource. It is now “associated” with the bare
        jid.
        """
403
        self.remote_wants_chatstates = None
404 405
        if self.locked_resource != None:
            self.locked_resource = None
406 407
            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
408

mathieui's avatar
mathieui committed
409
            if from_:
410 411
                message = ('%(info)sConversation unlocked (received activity'
                           ' from %(jid_c)s%(jid)s%(info)s).') % {
412 413 414
                                'info': info,
                                'jid_c': jid_c,
                                'jid': from_}
mathieui's avatar
mathieui committed
415
                self.add_message(message, typ=0)
mathieui's avatar
mathieui committed
416
            else:
417
                message = '%sConversation unlocked.' % info
mathieui's avatar
mathieui committed
418
                self.add_message(message, typ=0)
419 420 421 422 423 424 425

    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:
426 427
            return "%s/%s" % (self.name, self.locked_resource)
        return self.name
428 429 430 431 432 433 434

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

438
        self.text_win.refresh()
439
        if display_bar:
440
            self.upper_bar.refresh(self.name, roster[self.name])
441
        if self.locked_resource:
442
            displayed_jid = "%s/%s" % (self.name, self.locked_resource)
443
        else:
444 445
            displayed_jid = self.name
        self.info_header.refresh(displayed_jid, roster[self.name],
446
                                 self.text_win, self.chatstate,
447
                                 ConversationTab.additional_information)
448 449 450
        if display_info_win:
            self.info_win.refresh()

451 452 453 454 455 456 457 458
        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:
459
            displayed_jid = "%s/%s" % (self.name, self.locked_resource)
460
        else:
461 462
            displayed_jid = self.name
        self.info_header.refresh(displayed_jid, roster[self.name],
463
                self.text_win, self.chatstate, ConversationTab.additional_information)
464 465 466 467 468 469 470
        self.input.refresh()

class StaticConversationTab(ConversationTab):
    """
    A conversation tab associated with one Full JID. It cannot be locked to
    an different resource or unlocked.
    """
mathieui's avatar
mathieui committed
471
    def __init__(self, core, jid):
472 473
        assert(safeJID(jid).resource)
        self.info_header = windows.ConversationInfoWin()
mathieui's avatar
mathieui committed
474
        ConversationTab.__init__(self, core, jid)
475