basetabs.py 37.5 KB
Newer Older
1
"""
2 3 4 5 6 7 8 9 10 11 12 13
Module for the base Tabs

The root class Tab defines the generic interface and attributes of a
tab. A tab organizes various Windows around the screen depending
of the tab specificity. If the tab shows messages, it will also
reference a buffer containing the messages.

Each subclass should redefine its own refresh() and resize() method
according to its windows.

This module also defines ChatTabs, the parent class for all tabs
revolving around chats.
14 15 16 17 18
"""

import logging
import string
import time
19
import asyncio
20
from datetime import datetime, timedelta
21
from xml.etree import cElementTree as ET
22
from typing import Any, Callable, Dict, List, Optional, Union
mathieui's avatar
mathieui committed
23

24
from slixmpp import JID, InvalidJID, Message
25

26
from poezio.core.structs import Command, Completion, Status
27 28 29
from poezio import timed_events
from poezio import windows
from poezio import xhtml
30 31 32
from poezio import poopt
from math import ceil, log10
from poezio.windows.funcs import truncate_nick, parse_attrs
33
from poezio import mam
34 35 36 37 38
from poezio.common import safeJID
from poezio.config import config
from poezio.decorators import refresh_wrapper
from poezio.logger import logger
from poezio.text_buffer import TextBuffer
39
from poezio.theming import to_curses_attr, get_theme, dump_tuple
40
from poezio.decorators import command_args_parser
41

mathieui's avatar
mathieui committed
42 43
log = logging.getLogger(__name__)

44
# getters for tab colors (lambdas, so that they are dynamic)
45
STATE_COLORS = {
mathieui's avatar
mathieui committed
46 47 48 49 50 51 52 53 54 55 56 57
    'disconnected': lambda: get_theme().COLOR_TAB_DISCONNECTED,
    'scrolled': lambda: get_theme().COLOR_TAB_SCROLLED,
    'nonempty': lambda: get_theme().COLOR_TAB_NONEMPTY,
    'joined': lambda: get_theme().COLOR_TAB_JOINED,
    'message': lambda: get_theme().COLOR_TAB_NEW_MESSAGE,
    'composing': lambda: get_theme().COLOR_TAB_COMPOSING,
    'highlight': lambda: get_theme().COLOR_TAB_HIGHLIGHT,
    'private': lambda: get_theme().COLOR_TAB_PRIVATE,
    'normal': lambda: get_theme().COLOR_TAB_NORMAL,
    'current': lambda: get_theme().COLOR_TAB_CURRENT,
    'attention': lambda: get_theme().COLOR_TAB_ATTENTION,
}
58
VERTICAL_STATE_COLORS = {
mathieui's avatar
mathieui committed
59 60 61 62 63 64 65 66 67 68 69 70
    'disconnected': lambda: get_theme().COLOR_VERTICAL_TAB_DISCONNECTED,
    'scrolled': lambda: get_theme().COLOR_VERTICAL_TAB_SCROLLED,
    'nonempty': lambda: get_theme().COLOR_VERTICAL_TAB_NONEMPTY,
    'joined': lambda: get_theme().COLOR_VERTICAL_TAB_JOINED,
    'message': lambda: get_theme().COLOR_VERTICAL_TAB_NEW_MESSAGE,
    'composing': lambda: get_theme().COLOR_VERTICAL_TAB_COMPOSING,
    'highlight': lambda: get_theme().COLOR_VERTICAL_TAB_HIGHLIGHT,
    'private': lambda: get_theme().COLOR_VERTICAL_TAB_PRIVATE,
    'normal': lambda: get_theme().COLOR_VERTICAL_TAB_NORMAL,
    'current': lambda: get_theme().COLOR_VERTICAL_TAB_CURRENT,
    'attention': lambda: get_theme().COLOR_VERTICAL_TAB_ATTENTION,
}
71

72 73
# priority of the different tab states when using Alt+e
# higher means more priority, < 0 means not selectable
74
STATE_PRIORITY = {
mathieui's avatar
mathieui committed
75 76 77 78 79 80 81 82 83 84 85 86
    'normal': -1,
    'current': -1,
    'disconnected': 0,
    'nonempty': 0.1,
    'scrolled': 0.5,
    'joined': 0.8,
    'composing': 0.9,
    'message': 1,
    'highlight': 2,
    'private': 2,
    'attention': 3
}
87

88
SHOW_NAME = {
mathieui's avatar
mathieui committed
89 90 91 92 93 94 95
    'dnd': 'busy',
    'away': 'away',
    'xa': 'not available',
    'chat': 'chatty',
    '': 'available'
}

96

97
class Tab:
98
    plugin_commands = {}  # type: Dict[str, Command]
mathieui's avatar
mathieui committed
99
    plugin_keys = {}  # type: Dict[str, Callable]
100 101 102
    # Placeholder values, set on resize
    height = 1
    width = 1
mathieui's avatar
mathieui committed
103

104 105
    def __init__(self, core):
        self.core = core
106
        self.nb = 0
107 108
        if not hasattr(self, 'name'):
            self.name = self.__class__.__name__
109
        self.input = None
mathieui's avatar
mathieui committed
110
        self.closed = False
111
        self._state = 'normal'
112
        self._prev_state = None
113

114
        self.need_resize = False
mathieui's avatar
mathieui committed
115 116 117
        self.key_func = {}  # each tab should add their keys in there
        # and use them in on_input
        self.commands = {}  # and their own commands
118

119
    @property
mathieui's avatar
mathieui committed
120
    def size(self) -> int:
121
        return self.core.size
122

123
    @staticmethod
mathieui's avatar
mathieui committed
124
    def tab_win_height() -> int:
125 126 127 128
        """
        Returns 1 or 0, depending on if we are using the vertical tab list
        or not.
        """
129
        if config.get('enable_vertical_tab_list'):
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145
            return 0
        return 1

    @property
    def info_win(self):
        return self.core.information_win

    @property
    def color(self):
        return STATE_COLORS[self._state]()

    @property
    def vertical_color(self):
        return VERTICAL_STATE_COLORS[self._state]()

    @property
mathieui's avatar
mathieui committed
146
    def state(self) -> str:
147 148 149
        return self._state

    @state.setter
mathieui's avatar
mathieui committed
150
    def state(self, value: str):
151
        if value not in STATE_COLORS:
152 153 154 155
            log.debug("Invalid value for tab state: %s", value)
        elif STATE_PRIORITY[value] < STATE_PRIORITY[self._state] and \
                value not in ('current', 'disconnected') and \
                not (self._state == 'scrolled' and value == 'disconnected'):
mathieui's avatar
mathieui committed
156 157 158 159 160 161 162
            log.debug(
                "Did not set state because of lower priority, asked: %s, kept: %s",
                value, self._state)
        elif self._state == 'disconnected' and value not in ('joined',
                                                             'current'):
            log.debug(
                'Did not set state because disconnected tabs remain visible')
163 164
        else:
            self._state = value
165 166 167
            if self._state == 'current':
                self._prev_state = None

mathieui's avatar
mathieui committed
168
    def set_state(self, value: str):
169 170
        self._state = value

171 172 173 174 175 176 177 178 179 180
    def save_state(self):
        if self._state != 'composing':
            self._prev_state = self._state

    def restore_state(self):
        if self.state == 'composing' and self._prev_state:
            self._state = self._prev_state
            self._prev_state = None
        elif not self._prev_state:
            self._state = 'normal'
181 182 183

    @staticmethod
    def resize(scr):
184
        Tab.height, Tab.width = scr.getmaxyx()
185
        windows.base_wins.TAB_WIN = scr
186

187 188 189 190 191 192 193 194
    def missing_command_callback(self, command_name):
        """
        Callback executed when a command is not found.
        Returns True if the callback took care of displaying
        the error message, False otherwise.
        """
        return False

mathieui's avatar
mathieui committed
195
    def register_commands_batch(self, commands: List[Dict[str, Any]]):
196 197 198 199 200 201 202 203 204 205
        """
        Add several commands in a row, using a list of dictionaries
        """
        for command in commands:
            name = command['name']
            func = command['func']
            desc = command.get('desc', '')
            shortdesc = command.get('shortdesc', '')
            completion = command.get('completion')
            usage = command.get('usage', '')
mathieui's avatar
mathieui committed
206 207 208 209 210 211 212 213 214
            self.register_command(
                name,
                func,
                desc=desc,
                shortdesc=shortdesc,
                completion=completion,
                usage=usage)

    def register_command(self,
mathieui's avatar
mathieui committed
215 216
                         name: str,
                         func: Callable,
mathieui's avatar
mathieui committed
217 218 219
                         *,
                         desc='',
                         shortdesc='',
mathieui's avatar
mathieui committed
220
                         completion: Optional[Callable] = None,
mathieui's avatar
mathieui committed
221
                         usage=''):
222 223 224 225 226 227 228
        """
        Add a command
        """
        if name in self.commands:
            return
        if not desc and shortdesc:
            desc = shortdesc
229
        self.commands[name] = Command(func, desc, completion, shortdesc, usage)
230

mathieui's avatar
mathieui committed
231
    def complete_commands(self, the_input: windows.Input) -> bool:
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248
        """
        Does command completion on the specified input for both global and tab-specific
        commands.
        This should be called from the completion method (on tab, for example), passing
        the input where completion is to be made.
        It can completion the command name itself or an argument of the command.
        Returns True if a completion was made, False else.
        """
        txt = the_input.get_text()
        # check if this is a command
        if txt.startswith('/') and not txt.startswith('//'):
            position = the_input.get_argument_position(quoted=False)
            if position == 0:
                words = ['/%s'% (name) for name in sorted(self.core.commands)] +\
                    ['/%s' % (name) for name in sorted(self.commands)]
                the_input.new_completion(words, 0)
                # Do not try to cycle command completion if there was only
249
                # one possibility. The next tab will complete the argument.
250 251 252 253 254 255 256 257 258 259 260 261 262 263 264
                # Otherwise we would need to add a useless space before being
                # able to complete the arguments.
                hit_copy = set(the_input.hit_list)
                if len(hit_copy) == 1:
                    the_input.do_command(' ')
                    the_input.reset_completion()
                return True
            # check if we are in the middle of the command name
            elif len(txt.split()) > 1 or\
                    (txt.endswith(' ') and not the_input.last_completion):
                command_name = txt.split()[0][1:]
                if command_name in self.commands:
                    command = self.commands[command_name]
                elif command_name in self.core.commands:
                    command = self.core.commands[command_name]
mathieui's avatar
mathieui committed
265
                else:  # Unknown command, cannot complete
266
                    return False
267
                if command.comp is None:
mathieui's avatar
mathieui committed
268
                    return False  # There's no completion function
mathieui's avatar
mathieui committed
269 270 271 272
                comp = command.comp(the_input)
                if comp:
                    return comp.run()
                return comp
273 274
        return False

mathieui's avatar
mathieui committed
275
    def execute_command(self, provided_text: str) -> bool:
276 277 278 279 280 281 282 283
        """
        Execute the command in the input and return False if
        the input didn't contain a command
        """
        txt = provided_text or self.input.key_enter()
        if txt.startswith('/') and not txt.startswith('//') and\
                not txt.startswith('/me '):
            command = txt.strip().split()[0][1:]
mathieui's avatar
mathieui committed
284
            arg = txt[2 + len(command):]  # jump the '/' and the ' '
285
            func = None
mathieui's avatar
mathieui committed
286
            if command in self.commands:  # check tab-specific commands
287
                func = self.commands[command].func
mathieui's avatar
mathieui committed
288
            elif command in self.core.commands:  # check global commands
289
                func = self.core.commands[command].func
290 291 292
            else:
                low = command.lower()
                if low in self.commands:
293
                    func = self.commands[low].func
294
                elif low in self.core.commands:
295
                    func = self.core.commands[low].func
296
                else:
297 298 299
                    if self.missing_command_callback is not None:
                        error_handled = self.missing_command_callback(low)
                    if not error_handled:
mathieui's avatar
mathieui committed
300 301
                        self.core.information(
                            "Unknown command (%s)" % (command), 'Error')
mathieui's avatar
mathieui committed
302
            if command in ('correct', 'say'):  # hack
303 304 305 306
                arg = xhtml.convert_simple_to_full_colors(arg)
            else:
                arg = xhtml.clean_text_simple(arg)
            if func:
307 308
                if hasattr(self.input, "reset_completion"):
                    self.input.reset_completion()
309 310 311 312 313 314
                func(arg)
            return True
        else:
            return False

    def refresh_tab_win(self):
315
        if config.get('enable_vertical_tab_list'):
316 317 318
            left_tab_win = self.core.left_tab_win
            if left_tab_win and not self.size.core_degrade_x:
                left_tab_win.refresh()
319
        elif not self.size.core_degrade_y:
320
            self.core.tab_win.refresh()
321

322 323 324 325 326 327
    def refresh_input(self):
        """Refresh the current input if any"""
        if self.input is not None:
            self.input.refresh()
            self.core.doupdate()

328 329 330 331 332 333 334 335 336 337
    def refresh(self):
        """
        Called on each screen refresh (when something has changed)
        """
        pass

    def get_name(self):
        """
        get the name of the tab
        """
338
        return self.name
339

mathieui's avatar
mathieui committed
340
    def get_nick(self) -> str:
341 342 343
        """
        Get the nick of the tab (defaults to its name)
        """
344
        return self.name
345

mathieui's avatar
mathieui committed
346
    def get_text_window(self) -> Optional[windows.TextWin]:
347 348 349 350 351
        """
        Returns the principal TextWin window, if there's one
        """
        return None

mathieui's avatar
mathieui committed
352
    def on_input(self, key: str, raw: bool):
353 354 355 356 357 358 359
        """
        raw indicates if the key should activate the associated command or not.
        """
        pass

    def update_commands(self):
        for c in self.plugin_commands:
360
            if c not in self.commands:
361 362 363 364
                self.commands[c] = self.plugin_commands[c]

    def update_keys(self):
        for k in self.plugin_keys:
365
            if k not in self.key_func:
366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417
                self.key_func[k] = self.plugin_keys[k]

    def on_lose_focus(self):
        """
        called when this tab loses the focus.
        """
        self.state = 'normal'

    def on_gain_focus(self):
        """
        called when this tab gains the focus.
        """
        self.state = 'current'

    def on_scroll_down(self):
        """
        Defines what happens when we scroll down
        """
        pass

    def on_scroll_up(self):
        """
        Defines what happens when we scroll up
        """
        pass

    def on_line_up(self):
        """
        Defines what happens when we scroll one line up
        """
        pass

    def on_line_down(self):
        """
        Defines what happens when we scroll one line up
        """
        pass

    def on_half_scroll_down(self):
        """
        Defines what happens when we scroll half a screen down
        """
        pass

    def on_half_scroll_up(self):
        """
        Defines what happens when we scroll half a screen up
        """
        pass

    def on_info_win_size_changed(self):
        """
418
        Called when the window with the information is resized
419 420 421 422 423 424 425 426 427
        """
        pass

    def on_close(self):
        """
        Called when the tab is to be closed
        """
        if self.input:
            self.input.on_delete()
mathieui's avatar
mathieui committed
428
        self.closed = True
429

mathieui's avatar
mathieui committed
430
    def matching_names(self) -> List[str]:
431 432 433 434 435 436 437 438 439 440 441 442
        """
        Returns a list of strings that are used to name a tab with the /win
        command.  For example you could switch to a tab that returns
        ['hello', 'coucou'] using /win hel, or /win coucou
        If not implemented in the tab, it just doesn’t match with anything.
        """
        return []

    def __del__(self):
        log.debug('------ Closing tab %s', self.__class__.__name__)


mathieui's avatar
mathieui committed
443
class GapTab(Tab):
444 445 446 447 448 449
    def __bool__(self):
        return False

    def __len__(self):
        return 0

450 451
    @property
    def name(self):
452 453 454
        return ''

    def refresh(self):
mathieui's avatar
mathieui committed
455 456 457
        log.debug(
            'WARNING: refresh() called on a gap tab, this should not happen')

458 459 460 461 462 463 464 465

class ChatTab(Tab):
    """
    A tab containing a chat of any type.
    Just use this class instead of Tab if the tab needs a recent-words completion
    Also, ^M is already bound to on_enter
    And also, add the /say command
    """
466 467
    plugin_commands = {}  # type: Dict[str, Command]
    plugin_keys = {}  # type: Dict[str, Callable]
mathieui's avatar
mathieui committed
468
    message_type = 'chat'
mathieui's avatar
mathieui committed
469

470
    def __init__(self, core, jid: Union[JID, str]):
471
        Tab.__init__(self, core)
472 473 474 475 476

        if not isinstance(jid, JID):
            jid = JID(jid)
        assert jid.domain
        self._jid = jid
477
        self.query_id = 0
478

479
        self._name = jid.full  # type: Optional[str]
480
        self.text_win = None
mathieui's avatar
mathieui committed
481
        self.directed_presence = None
482
        self._text_buffer = TextBuffer()
mathieui's avatar
mathieui committed
483
        self.chatstate = None  # can be "active", "composing", "paused", "gone", "inactive"
louiz’'s avatar
louiz’ committed
484
        # We keep a reference of the event that will set our chatstate to "paused", so that
485 486
        # we can delete it or change it if we need to
        self.timed_event_paused = None
487
        self.timed_event_not_paused = None
488
        # Keeps the last sent message to complete it easily in completion_correct, and to replace it.
mathieui's avatar
mathieui committed
489
        self.last_sent_message = {}
490 491 492 493
        self.key_func['M-v'] = self.move_separator
        self.key_func['M-h'] = self.scroll_separator
        self.key_func['M-/'] = self.last_words_completion
        self.key_func['^M'] = self.on_enter
mathieui's avatar
mathieui committed
494 495 496 497 498
        self.register_command(
            'say',
            self.command_say,
            usage='<message>',
            shortdesc='Send the message.')
Madhur Garg's avatar
Madhur Garg committed
499
        self.register_command(
500
            'scrollback',
Maxime Buquet's avatar
Maxime Buquet committed
501
            self.command_scrollback,
502
            usage="end home clear status goto <+|-linecount>|<linenum>|<timestamp>",
503
            shortdesc='Scrollback to the given line number, message, or clear the buffer.')
504
        self.commands['sb'] = self.commands['scrollback']
mathieui's avatar
mathieui committed
505 506 507 508 509 510 511 512 513 514 515 516 517
        self.register_command(
            'xhtml',
            self.command_xhtml,
            usage='<custom xhtml>',
            shortdesc='Send custom XHTML.')
        self.register_command(
            'clear', self.command_clear, shortdesc='Clear the current buffer.')
        self.register_command(
            'correct',
            self.command_correct,
            desc='Fix the last message with whatever you want.',
            shortdesc='Correct the last message.',
            completion=self.completion_correct)
518 519 520 521 522
        self.chat_state = None
        self.update_commands()
        self.update_keys()

        # Get the logs
523
        log_nb = config.get('load_log')
524 525 526 527 528 529
        logs = self.load_logs(log_nb)

        if logs:
            for message in logs:
                self._text_buffer.add_message(**message)

530 531
    @property
    def name(self) -> str:
532 533 534
        if self._name is not None:
            return self._name
        return self._jid.full
535 536 537 538

    @name.setter
    def name(self, value: Union[JID, str]) -> None:
        if isinstance(value, JID):
539
            self.jid = value
540 541 542 543
        elif isinstance(value, str):
            try:
                value = JID(value)
                if value.domain:
544
                    self._jid = value
545
            except InvalidJID:
546
                self._name = value
547
        else:
548
            raise TypeError("Name %r must be of type JID or str." % value)
549 550

    @property
551
    def jid(self) -> JID:
552
        return self._jid
553 554

    @jid.setter
555
    def jid(self, value: JID) -> None:
556
        if not isinstance(value, JID):
557 558
            raise TypeError("Jid %r must be of type JID." % value)
        assert value.domain
559
        self._jid = value
560

mathieui's avatar
mathieui committed
561
    @property
mathieui's avatar
mathieui committed
562
    def general_jid(self) -> JID:
563
        raise NotImplementedError
mathieui's avatar
mathieui committed
564

mathieui's avatar
mathieui committed
565
    def load_logs(self, log_nb: int) -> Optional[List[Dict[str, Any]]]:
566
        return logger.get_logs(self.jid.bare, log_nb)
567

mathieui's avatar
mathieui committed
568 569 570 571 572
    def log_message(self,
                    txt: str,
                    nickname: str,
                    time: Optional[datetime] = None,
                    typ=1):
573 574 575
        """
        Log the messages in the archives.
        """
576 577 578
        name = self.jid.bare
        if not logger.log_message(name, nickname, txt, date=time, typ=typ):
            self.core.information('Unable to write in the log file', 'Error')
579

mathieui's avatar
mathieui committed
580 581 582 583 584 585 586 587 588 589 590
    def add_message(self,
                    txt,
                    time=None,
                    nickname=None,
                    forced_user=None,
                    nick_color=None,
                    identifier=None,
                    jid=None,
                    history=None,
                    typ=1,
                    highlight=False):
591
        self.log_message(txt, nickname, time=time, typ=typ)
mathieui's avatar
mathieui committed
592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609
        self._text_buffer.add_message(
            txt,
            time=time,
            nickname=nickname,
            highlight=highlight,
            nick_color=nick_color,
            history=history,
            user=forced_user,
            identifier=identifier,
            jid=jid)

    def modify_message(self,
                       txt,
                       old_id,
                       new_id,
                       user=None,
                       jid=None,
                       nickname=None):
610
        self.log_message(txt, nickname, typ=1)
mathieui's avatar
mathieui committed
611 612
        message = self._text_buffer.modify_message(
            txt, old_id, new_id, time=time, user=user, jid=jid)
613 614 615 616 617 618 619 620 621 622 623
        if message:
            self.text_win.modify_message(old_id, message)
            self.core.refresh_window()
            return True
        return False

    def last_words_completion(self):
        """
        Complete the input with words recently said
        """
        # build the list of the recent words
mathieui's avatar
mathieui committed
624
        char_we_dont_want = string.punctuation + ' ’„“”…«»'
625
        words = []
626 627 628 629 630 631 632 633 634
        for msg in self._text_buffer.messages[:-40:-1]:
            if not msg:
                continue
            txt = xhtml.clean_text(msg.txt)
            for char in char_we_dont_want:
                txt = txt.replace(char, ' ')
            for word in txt.split():
                if len(word) >= 4 and word not in words:
                    words.append(word)
635
        words.extend([word for word in config.get('words').split(':') if word])
636 637 638 639 640 641 642 643 644 645 646
        self.input.auto_completion(words, ' ', quotify=False)

    def on_enter(self):
        txt = self.input.key_enter()
        if txt:
            if not self.execute_command(txt):
                if txt.startswith('//'):
                    txt = txt[1:]
                self.command_say(xhtml.convert_simple_to_full_colors(txt))
        self.cancel_paused_delay()

647 648
    @command_args_parser.raw
    def command_xhtml(self, xhtml):
649 650 651
        """"
        /xhtml <custom xhtml>
        """
652
        message = self.generate_xhtml_message(xhtml)
653 654 655
        if message:
            message.send()

mathieui's avatar
mathieui committed
656
    def generate_xhtml_message(self, arg: str) -> Message:
657 658 659
        if not arg:
            return
        try:
mathieui's avatar
mathieui committed
660 661
            body = xhtml.clean_text(
                xhtml.xhtml_to_poezio_colors(arg, force=True))
662 663 664 665 666 667 668 669 670 671 672 673
            ET.fromstring(arg)
        except:
            self.core.information('Could not send custom xhtml', 'Error')
            log.error('/xhtml: Unable to send custom xhtml', exc_info=True)
            return

        msg = self.core.xmpp.make_message(self.get_dest_jid())
        msg['body'] = body
        msg.enable('html')
        msg['html']['body'] = arg
        return msg

mathieui's avatar
mathieui committed
674
    def get_dest_jid(self) -> JID:
Maxime Buquet's avatar
Maxime Buquet committed
675
        return self.jid
676 677

    @refresh_wrapper.always
678
    def command_clear(self, ignored):
679 680 681 682 683 684
        """
        /clear
        """
        self._text_buffer.messages = []
        self.text_win.rebuild_everything(self._text_buffer)

mathieui's avatar
mathieui committed
685 686 687 688
    def check_send_chat_state(self):
        "If we should send a chat state"
        return True

689 690 691 692
    def send_chat_state(self, state, always_send=False):
        """
        Send an empty chatstate message
        """
mathieui's avatar
mathieui committed
693
        if self.check_send_chat_state():
mathieui's avatar
mathieui committed
694 695
            if state in ('active', 'inactive',
                         'gone') and self.inactive and not always_send:
696
                return
697
            if config.get_by_tabname('send_chat_states', self.general_jid):
698 699 700 701 702
                msg = self.core.xmpp.make_message(self.get_dest_jid())
                msg['type'] = self.message_type
                msg['chat_state'] = state
                self.chat_state = state
                msg.send()
703
                return True
704 705 706 707 708 709 710

    def send_composing_chat_state(self, empty_after):
        """
        Send the "active" or "composing" chatstate, depending
        on the the current status of the input
        """
        name = self.general_jid
711
        if config.get_by_tabname('send_chat_states', name):
712 713 714 715 716 717 718 719 720 721 722 723 724 725
            needed = 'inactive' if self.inactive else 'active'
            self.cancel_paused_delay()
            if not empty_after:
                if self.chat_state != "composing":
                    self.send_chat_state("composing")
                self.set_paused_delay(True)
            elif empty_after and self.chat_state != needed:
                self.send_chat_state(needed, True)

    def set_paused_delay(self, composing):
        """
        we create a timed event that will put us to paused
        in a few seconds
        """
726
        if not config.get_by_tabname('send_chat_states', self.general_jid):
727
            return
728 729 730
        # First, cancel the delay if it already exists, before rescheduling
        # it at a new date
        self.cancel_paused_delay()
mathieui's avatar
mathieui committed
731 732
        new_event = timed_events.DelayedEvent(4, self.send_chat_state,
                                              'paused')
733
        self.core.add_timed_event(new_event)
734
        self.timed_event_paused = new_event
mathieui's avatar
mathieui committed
735 736 737
        new_event = timed_events.DelayedEvent(
            30, self.send_chat_state, 'inactive'
            if self.inactive else 'active')
738
        self.core.add_timed_event(new_event)
739
        self.timed_event_not_paused = new_event
740 741 742 743 744 745 746

    def cancel_paused_delay(self):
        """
        Remove that event from the list and set it to None.
        Called for example when the input is emptied, or when the message
        is sent
        """
747 748 749
        if self.timed_event_paused is not None:
            self.core.remove_timed_event(self.timed_event_paused)
            self.timed_event_paused = None
750 751
            self.core.remove_timed_event(self.timed_event_not_paused)
            self.timed_event_not_paused = None
752

753
    @command_args_parser.raw
754 755 756 757 758
    def command_correct(self, line):
        """
        /correct <fixed message>
        """
        if not line:
759
            self.core.command.help('correct')
760 761
            return
        if not self.last_sent_message:
762
            self.core.information('There is no message to correct.', 'Error')
763 764 765 766 767
            return
        self.command_say(line, correct=True)

    def completion_correct(self, the_input):
        if self.last_sent_message and the_input.get_argument_position() == 1:
mathieui's avatar
mathieui committed
768 769 770 771
            return Completion(
                the_input.auto_completion, [self.last_sent_message['body']],
                '',
                quotify=False)
772
        return True
773 774

    @property
mathieui's avatar
mathieui committed
775
    def inactive(self) -> bool:
776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792
        """Whether we should send inactive or active as a chatstate"""
        return self.core.status.show in ('xa', 'away') or\
                (hasattr(self, 'directed_presence') and not self.directed_presence)

    def move_separator(self):
        self.text_win.remove_line_separator()
        self.text_win.add_line_separator(self._text_buffer)
        self.text_win.refresh()
        self.input.refresh()

    def get_conversation_messages(self):
        return self._text_buffer.messages

    def check_scrolled(self):
        if self.text_win.pos != 0:
            self.state = 'scrolled'

793
    @command_args_parser.raw
794 795 796
    def command_say(self, line, correct=False):
        pass

797 798 799 800
    def goto_build_lines(self, new_date):
        text_buffer = self._text_buffer
        built_lines = []
        message_count = 0
801 802
        timestamp = config.get('show_timestamps')
        nick_size = config.get('max_nick_length')
803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841
        for message in text_buffer.messages:
            # Build lines of a message
            txt = message.txt
            nick = truncate_nick(message.nickname, nick_size)
            offset = 0
            theme = get_theme()
            if message.ack:
                if message.ack > 0:
                    offset += poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1
                else:
                    offset += poopt.wcswidth(theme.CHAR_NACK) + 1
            if nick:
                offset += poopt.wcswidth(nick) + 2
            if message.revisions > 0:
                offset += ceil(log10(message.revisions + 1))
            if message.me:
                offset += 1
            if timestamp:
                if message.str_time:
                    offset += 1 + len(message.str_time)
                if theme.CHAR_TIME_LEFT and message.str_time:
                    offset += 1
                if theme.CHAR_TIME_RIGHT and message.str_time:
                    offset += 1
            lines = poopt.cut_text(txt, self.text_win.width - offset - 1)
            for line in lines:
                built_lines.append(line)
            # Find the message with timestamp less than or equal to the queried
            # timestamp and goto that location in the tab.
            if message.time <= new_date:
                message_count += 1
                if len(self.text_win.built_lines) - self.text_win.height >= len(built_lines):
                    self.text_win.pos = len(self.text_win.built_lines) - self.text_win.height - len(built_lines) + 1
                else:
                    self.text_win.pos = 0
        if message_count == 0:
            self.text_win.scroll_up(len(self.text_win.built_lines))
        self.core.refresh_window()

Madhur Garg's avatar
Madhur Garg committed
842
    @command_args_parser.quoted(0, 2)
Maxime Buquet's avatar
Maxime Buquet committed
843
    def command_scrollback(self, args):
Madhur Garg's avatar
Madhur Garg committed
844
        """
845 846 847 848 849
        /sb clear
        /sb home
        /sb end
        /sb goto <+|-linecount>|<linenum>|<timestamp>
        The format of timestamp must be ‘[dd[.mm]-<days ago>] hh:mi[:ss]’
Madhur Garg's avatar
Madhur Garg committed
850 851
        """
        if args is None or len(args) == 0:
Madhur Garg's avatar
Madhur Garg committed
852 853
            args = ['end']
        if len(args) == 1:
Madhur Garg's avatar
Madhur Garg committed
854 855 856
            if args[0] == 'end':
                self.text_win.scroll_down(len(self.text_win.built_lines))
                self.core.refresh_window()
857
                return
Madhur Garg's avatar
Madhur Garg committed
858 859 860
            elif args[0] == 'home':
                self.text_win.scroll_up(len(self.text_win.built_lines))
                self.core.refresh_window()
861
                return
Madhur Garg's avatar
Madhur Garg committed
862 863 864 865
            elif args[0] == 'clear':
                self._text_buffer.messages = []
                self.text_win.rebuild_everything(self._text_buffer)
                self.core.refresh_window()
866
                return
Madhur Garg's avatar
Madhur Garg committed
867
            elif args[0] == 'status':
868
                self.core.information('Total %s lines in this tab.' % len(self.text_win.built_lines), 'Info')
869
                return
Madhur Garg's avatar
Madhur Garg committed
870
        elif len(args) == 2 and args[0] == 'goto':
871 872 873
            for fmt in ('%d %H:%M', '%d %H:%M:%S', '%d:%m %H:%M', '%d:%m %H:%M:%S', '%H:%M', '%H:%M:%S'):
                try:
                    new_date = datetime.strptime(args[1], fmt)
874
                    if 'd' in fmt and 'm' in fmt:
875 876 877 878 879 880 881
                        new_date = new_date.replace(year=datetime.now().year)
                    elif 'd' in fmt:
                        new_date = new_date.replace(year=datetime.now().year, month=datetime.now().month)
                    else:
                        new_date = new_date.replace(year=datetime.now().year, month=datetime.now().month, day=datetime.now().day)
                except ValueError:
                    pass
882
            if args[1].startswith('-'):
883
                # Check if the user is giving argument of type goto <-linecount> or goto [-<days ago>] hh:mi[:ss]
884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901
                if ' ' in args[1]:
                    new_args = args[1].split(' ')
                    new_args[0] = new_args[0].strip('-')
                    new_date = datetime.now()
                    if new_args[0].isdigit():
                        new_date = new_date.replace(day=new_date.day - int(new_args[0]))
                    for fmt in ('%H:%M', '%H:%M:%S'):
                        try:
                            arg_date = datetime.strptime(new_args[1], fmt)
                            new_date = new_date.replace(hour=arg_date.hour, minute=arg_date.minute, second=arg_date.second)
                        except ValueError:
                            pass
                else:
                    scroll_len = args[1].strip('-')
                    if scroll_len.isdigit():
                        self.text_win.scroll_down(int(scroll_len))
                        self.core.refresh_window()
                        return
902
            elif args[1].startswith('+'):
903 904
                scroll_len = args[1].strip('+')
                if scroll_len.isdigit():
Madhur Garg's avatar
Madhur Garg committed
905 906 907
                    self.text_win.scroll_up(int(scroll_len))
                    self.core.refresh_window()
                    return
908
            # Check for the argument of type goto <linenum>
909 910 911
            elif args[1].isdigit():
                if len(self.text_win.built_lines) - self.text_win.height >= int(args[1]):
                    self.text_win.pos = len(self.text_win.built_lines) - self.text_win.height - int(args[1])
Madhur Garg's avatar
Madhur Garg committed
912 913
                    self.core.refresh_window()
                    return
Madhur Garg's avatar
Madhur Garg committed
914 915 916 917 918 919
                else:
                    self.text_win.pos = 0
                    self.core.refresh_window()
                    return
            elif args[1] == '0':
                args = ['home']
920 921
            # new_date is the timestamp for which the user has queried.
            self.goto_build_lines(new_date)
Madhur Garg's avatar
Madhur Garg committed
922

923 924 925 926 927 928 929
    def on_line_up(self):
        return self.text_win.scroll_up(1)

    def on_line_down(self):
        return self.text_win.scroll_down(1)

    def on_scroll_up(self):
930
        if self.query_id == 0:
931
            return mam.mam_scroll(tab=self)
932 933
        else:
            return self.text_win.scroll_up(self.text_win.height - 1)
934 935

    def on_scroll_down(self):
mathieui's avatar
mathieui committed
936
        return self.text_win.scroll_down(self.text_win.height - 1)
937 938

    def on_half_scroll_up(self):
mathieui's avatar
mathieui committed
939
        return self.text_win.scroll_up((self.text_win.height - 1) // 2)
940 941

    def on_half_scroll_down(self):
mathieui's avatar
mathieui committed
942
        return self.text_win.scroll_down((self.text_win.height - 1) // 2)
943 944 945 946 947

    @refresh_wrapper.always
    def scroll_separator(self):
        self.text_win.scroll_to_separator()

948

mathieui's avatar
mathieui committed
949
class OneToOneTab(ChatTab):
950
    def __init__(self, core, jid):
951
        ChatTab.__init__(self, core, jid)
952

953 954 955
        self.__status = Status("", "")
        self.last_remote_message = datetime.now()

956 957
        # Set to true once the first disco is done
        self.__initial_disco = False
958
        self.check_features()
mathieui's avatar
mathieui committed
959 960 961 962
        self.register_command(
            'unquery', self.command_unquery, shortdesc='Close the tab.')
        self.register_command(
            'close', self.command_unquery, shortdesc='Close the tab.')
963
        self.register_command(
mathieui's avatar
mathieui committed
964 965
            'attention',
            self.command_attention,
966 967 968
            usage='[message]',
            shortdesc='Request the attention.',
            desc='Attention: Request the attention of the contact.  Can also '
mathieui's avatar
mathieui committed
969
            'send a message along with the attention.')
970

971 972 973 974 975
    def remote_user_color(self):
        return dump_tuple(get_theme().COLOR_REMOTE_USER)

    def update_status(self, status):
        old_status = self.__status
mathieui's avatar
mathieui committed
976 977
        if not (old_status.show != status.show
                or old_status.message != status.message):
978 979 980
            return
        self.__status = status
        hide_status_change = config.get_by_tabname('hide_status_change',
Maxime Buquet's avatar
Maxime Buquet committed
981
                                                   self.jid.bare)
982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997
        now = datetime.now()
        dff = now - self.last_remote_message
        if hide_status_change > -1 and dff.total_seconds() > hide_status_change:
            return

        info_c = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
        nick = self.get_nick()
        remote = self.remote_user_color()
        msg = '\x19%(color)s}%(nick)s\x19%(info)s} changed: '
        msg %= {'color': remote, 'nick': nick, 'info': info_c}
        if status.message != old_status.message and status.message:
            msg += 'status: %s, ' % status.message
        if status.show in SHOW_NAME:
            msg += 'show: %s, ' % SHOW_NAME[status.show]
        self.add_message(msg[:-2], typ=2)

998
    def ack_message(self, msg_id: str, msg_jid: JID):
999 1000 1001
        """
        Ack a message
        """
1002
        new_msg = self._text_buffer.ack_message(msg_id, msg_jid)
1003 1004 1005 1006
        if new_msg:
            self.text_win.modify_message(msg_id, new_msg)
            self.core.refresh_window()

1007
    def nack_message(self, error: str, msg_id: str, msg_jid: JID):
1008
        """
1009
        Non-ack a message (e.g. timeout)
1010 1011 1012 1013 1014 1015 1016 1017
        """
        new_msg = self._text_buffer.nack_message(error, msg_id, msg_jid)
        if new_msg:
            self.text_win.modify_message(msg_id, new_msg)
            self.core.refresh_window()
            return True
        return False

1018 1019 1020 1021
    @command_args_parser.raw
    def command_xhtml(self, xhtml_data):
        message = self.generate_xhtml_message(xhtml_data)
        if message:
1022
            message['type'] = 'chat'
1023
            message._add_receipt = True
1024
            message['chat_sate'] = 'active'
1025 1026
            message.send()
            body = xhtml.xhtml_to_poezio_colors(xhtml_data, force=True)
mathieui's avatar
mathieui committed
1027 1028 1029
            self._text_buffer.add_message(
                body,
                nickname=self.core.own_nick,
1030
                nick_color=get_theme().COLOR_OWN_NICK,
mathieui's avatar
mathieui committed
1031
                identifier=message['id'],
mathieui's avatar
mathieui committed
1032
                jid=self.core.xmpp.boundjid)
1033 1034
            self.refresh()

1035 1036
    def check_features(self):
        "check the features supported by the other party"
1037 1038
        if safeJID(self.get_dest_jid()).resource:
            self.core.xmpp.plugin['xep_0030'].get_info(
mathieui's avatar
mathieui committed
1039 1040 1041
                jid=self.get_dest_jid(),
                timeout=5,
                callback=self.features_checked)
1042

1043 1044 1045
    @command_args_parser.raw
    def command_attention(self, message):
        """/attention [message]"""
1046 1047 1048 1049 1050 1051 1052 1053
        if message is not '':
            self.command_say(message, attention=True)
        else:
            msg = self.core.xmpp.make_message(self.get_dest_jid())
            msg['type'] = 'chat'
            msg['attention'] = True
            msg.send()

1054
    @command_args_parser.raw
1055 1056 1057
    def command_say(self, line, correct=False, attention=False):
        pass

1058 1059 1060 1061 1062
    @command_args_parser.ignored
    def command_unquery(self):
        """
        /unquery
        """
mathieui's avatar
mathieui committed
1063
        self.send_chat_state('gone', always_send=True)
1064 1065
        self.core.close_tab(self)

1066 1067 1068 1069 1070
    def missing_command_callback(self, command_name):
        if command_name not in ('correct', 'attention'):
            return False

        if command_name == 'correct':
1071
            feature = 'message correction'
1072
        elif command_name == 'attention':
1073 1074 1075
            feature = 'attention requests'
        msg = ('%s does not support %s, therefore the /%s '
               'command is currently disabled in this tab.')
1076 1077 1078 1079
        msg = msg % (self.name, feature, command_name)
        self.core.information(msg, 'Info')
        return True

1080 1081 1082
    def features_checked(self, iq):
        "Features check callback"
        features = iq['disco_info'].get_features() or []