basetabs.py 37.7 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
import copy
17 18
import logging
import string
Madhur Garg's avatar
Madhur Garg committed
19
import asyncio
20
import time
Maxime Buquet's avatar
Maxime Buquet committed
21
from math import ceil, log10
Madhur Garg's avatar
Madhur Garg committed
22
from datetime import datetime
23
from xml.etree import ElementTree as ET
mathieui's avatar
mathieui committed
24 25 26 27 28 29 30
from typing import (
    Any,
    Callable,
    Dict,
    List,
    Optional,
    Union,
31
    Tuple,
mathieui's avatar
mathieui committed
32 33
    TYPE_CHECKING,
)
mathieui's avatar
mathieui committed
34

35 36 37 38 39 40
from poezio import (
    poopt,
    timed_events,
    xhtml,
    windows
)
41
from poezio.core.structs import Command, Completion, Status
42 43
from poezio.common import safeJID
from poezio.config import config
Maxime Buquet's avatar
Maxime Buquet committed
44
from poezio.decorators import command_args_parser, refresh_wrapper
45 46
from poezio.logger import logger
from poezio.text_buffer import TextBuffer
Maxime Buquet's avatar
Maxime Buquet committed
47
from poezio.theming import get_theme, dump_tuple
48 49
from poezio.ui.funcs import truncate_nick
from poezio.ui.consts import LONG_FORMAT_LENGTH
mathieui's avatar
mathieui committed
50
from poezio.ui.types import BaseMessage, InfoMessage, Message
Maxime Buquet's avatar
Maxime Buquet committed
51

52
from slixmpp import JID, InvalidJID, Message as SMessage
53

mathieui's avatar
mathieui committed
54
if TYPE_CHECKING:
55
    from _curses import _CursesWindow  # pylint: disable=E0611
56 57
    from poezio.size_manager import SizeManager
    from poezio.core.core import Core
mathieui's avatar
mathieui committed
58

mathieui's avatar
mathieui committed
59 60
log = logging.getLogger(__name__)

61 62
NS_MUC_USER = 'http://jabber.org/protocol/muc#user'

63
# getters for tab colors (lambdas, so that they are dynamic)
64
STATE_COLORS = {
mathieui's avatar
mathieui committed
65 66 67 68 69 70 71 72 73 74 75 76
    '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,
}
77
VERTICAL_STATE_COLORS = {
mathieui's avatar
mathieui committed
78 79 80 81 82 83 84 85 86 87 88 89
    '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,
}
90

91 92
# priority of the different tab states when using Alt+e
# higher means more priority, < 0 means not selectable
93
STATE_PRIORITY = {
mathieui's avatar
mathieui committed
94 95 96 97 98 99 100 101 102 103 104 105
    '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
}
106

107
SHOW_NAME = {
mathieui's avatar
mathieui committed
108 109 110 111 112 113 114
    'dnd': 'busy',
    'away': 'away',
    'xa': 'not available',
    'chat': 'chatty',
    '': 'available'
}

115

116
class Tab:
117
    plugin_commands = {}  # type: Dict[str, Command]
mathieui's avatar
mathieui committed
118
    plugin_keys = {}  # type: Dict[str, Callable]
119 120 121
    # Placeholder values, set on resize
    height = 1
    width = 1
mathieui's avatar
mathieui committed
122

123
    def __init__(self, core: 'Core'):
124
        self.core = core
125
        self.nb = 0
126 127
        if not hasattr(self, 'name'):
            self.name = self.__class__.__name__
128
        self.input = None
mathieui's avatar
mathieui committed
129
        self.closed = False
130
        self._state = 'normal'
131
        self._prev_state = None
132

133
        self.need_resize = False
mathieui's avatar
mathieui committed
134 135 136
        self.key_func = {}  # each tab should add their keys in there
        # and use them in on_input
        self.commands = {}  # and their own commands
137

138
    @property
139
    def size(self) -> 'SizeManager':
140
        return self.core.size
141

142
    @staticmethod
mathieui's avatar
mathieui committed
143
    def tab_win_height() -> int:
144 145 146 147
        """
        Returns 1 or 0, depending on if we are using the vertical tab list
        or not.
        """
148
        if config.get('enable_vertical_tab_list'):
149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164
            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
165
    def state(self) -> str:
166 167 168
        return self._state

    @state.setter
mathieui's avatar
mathieui committed
169
    def state(self, value: str):
170
        if value not in STATE_COLORS:
171 172 173 174
            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
175 176 177 178 179 180 181
            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')
182 183
        else:
            self._state = value
184 185 186
            if self._state == 'current':
                self._prev_state = None

mathieui's avatar
mathieui committed
187
    def set_state(self, value: str):
188 189
        self._state = value

190 191 192 193 194 195 196 197 198 199
    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'
200 201

    @staticmethod
202
    def initial_resize(scr: '_CursesWindow'):
203
        Tab.height, Tab.width = scr.getmaxyx()
204
        windows.base_wins.TAB_WIN = scr
205

206 207 208 209 210 211 212 213
    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
214
    def register_commands_batch(self, commands: List[Dict[str, Any]]):
215 216 217 218 219 220 221 222 223 224
        """
        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
225 226 227 228 229 230 231 232 233
            self.register_command(
                name,
                func,
                desc=desc,
                shortdesc=shortdesc,
                completion=completion,
                usage=usage)

    def register_command(self,
mathieui's avatar
mathieui committed
234 235
                         name: str,
                         func: Callable,
mathieui's avatar
mathieui committed
236 237 238
                         *,
                         desc='',
                         shortdesc='',
mathieui's avatar
mathieui committed
239
                         completion: Optional[Callable] = None,
mathieui's avatar
mathieui committed
240
                         usage=''):
241 242 243 244 245 246 247
        """
        Add a command
        """
        if name in self.commands:
            return
        if not desc and shortdesc:
            desc = shortdesc
248
        self.commands[name] = Command(func, desc, completion, shortdesc, usage)
249

mathieui's avatar
mathieui committed
250
    def complete_commands(self, the_input: windows.Input) -> bool:
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267
        """
        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
268
                # one possibility. The next tab will complete the argument.
269 270 271 272 273 274 275 276 277 278 279 280 281 282 283
                # 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
284
                else:  # Unknown command, cannot complete
285
                    return False
286
                if command.comp is None:
mathieui's avatar
mathieui committed
287
                    return False  # There's no completion function
mathieui's avatar
mathieui committed
288 289 290 291
                comp = command.comp(the_input)
                if comp:
                    return comp.run()
                return comp
292 293
        return False

mathieui's avatar
mathieui committed
294
    def execute_command(self, provided_text: str) -> bool:
295 296 297 298 299 300 301 302
        """
        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
303
            arg = txt[2 + len(command):]  # jump the '/' and the ' '
304
            func = None
mathieui's avatar
mathieui committed
305
            if command in self.commands:  # check tab-specific commands
306
                func = self.commands[command].func
mathieui's avatar
mathieui committed
307
            elif command in self.core.commands:  # check global commands
308
                func = self.core.commands[command].func
309 310 311
            else:
                low = command.lower()
                if low in self.commands:
312
                    func = self.commands[low].func
313
                elif low in self.core.commands:
314
                    func = self.core.commands[low].func
315
                else:
316 317 318
                    if self.missing_command_callback is not None:
                        error_handled = self.missing_command_callback(low)
                    if not error_handled:
mathieui's avatar
mathieui committed
319 320
                        self.core.information(
                            "Unknown command (%s)" % (command), 'Error')
mathieui's avatar
mathieui committed
321
            if command in ('correct', 'say'):  # hack
322 323 324 325
                arg = xhtml.convert_simple_to_full_colors(arg)
            else:
                arg = xhtml.clean_text_simple(arg)
            if func:
326 327
                if hasattr(self.input, "reset_completion"):
                    self.input.reset_completion()
328 329 330 331 332
                func(arg)
            return True
        else:
            return False

333
    def refresh_tab_win(self) -> None:
334
        if config.get('enable_vertical_tab_list'):
335 336 337
            left_tab_win = self.core.left_tab_win
            if left_tab_win and not self.size.core_degrade_x:
                left_tab_win.refresh()
338
        elif not self.size.core_degrade_y:
339
            self.core.tab_win.refresh()
340

341 342 343 344 345 346
    def refresh_input(self):
        """Refresh the current input if any"""
        if self.input is not None:
            self.input.refresh()
            self.core.doupdate()

347 348 349 350 351 352 353 354 355 356
    def refresh(self):
        """
        Called on each screen refresh (when something has changed)
        """
        pass

    def get_name(self):
        """
        get the name of the tab
        """
357
        return self.name
358

mathieui's avatar
mathieui committed
359
    def get_nick(self) -> str:
360 361 362
        """
        Get the nick of the tab (defaults to its name)
        """
363
        return self.name
364

mathieui's avatar
mathieui committed
365
    def get_text_window(self) -> Optional[windows.TextWin]:
366 367 368 369 370
        """
        Returns the principal TextWin window, if there's one
        """
        return None

mathieui's avatar
mathieui committed
371
    def on_input(self, key: str, raw: bool):
372 373 374 375 376
        """
        raw indicates if the key should activate the associated command or not.
        """
        pass

377
    def update_commands(self) -> None:
378
        for c in self.plugin_commands:
379
            if c not in self.commands:
380 381
                self.commands[c] = self.plugin_commands[c]

382
    def update_keys(self) -> None:
383
        for k in self.plugin_keys:
384
            if k not in self.key_func:
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 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436
                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):
        """
437
        Called when the window with the information is resized
438 439 440
        """
        pass

441
    def on_close(self) -> None:
442 443 444 445 446
        """
        Called when the tab is to be closed
        """
        if self.input:
            self.input.on_delete()
mathieui's avatar
mathieui committed
447
        self.closed = True
448

449
    def matching_names(self) -> List[Tuple[int, str]]:
450 451 452 453 454 455 456 457 458 459 460 461
        """
        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
462
class GapTab(Tab):
463 464 465 466 467 468
    def __bool__(self):
        return False

    def __len__(self):
        return 0

469 470
    @property
    def name(self):
471 472 473
        return ''

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

477 478 479 480 481 482 483 484

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
    """
485 486
    plugin_commands = {}  # type: Dict[str, Command]
    plugin_keys = {}  # type: Dict[str, Callable]
mathieui's avatar
mathieui committed
487
    message_type = 'chat'
mathieui's avatar
mathieui committed
488

489
    def __init__(self, core, jid: Union[JID, str]):
490
        Tab.__init__(self, core)
491 492 493 494 495

        if not isinstance(jid, JID):
            jid = JID(jid)
        assert jid.domain
        self._jid = jid
496
        #: Is the tab currently requesting MAM data?
497
        self.query_status = False
498
        self._name = jid.full  # type: Optional[str]
499
        self.text_win = windows.TextWin()
mathieui's avatar
mathieui committed
500
        self.directed_presence = None
501
        self._text_buffer = TextBuffer()
502
        self._text_buffer.add_window(self.text_win)
mathieui's avatar
mathieui committed
503
        self.chatstate = None  # can be "active", "composing", "paused", "gone", "inactive"
louiz’'s avatar
louiz’ committed
504
        # We keep a reference of the event that will set our chatstate to "paused", so that
505 506
        # we can delete it or change it if we need to
        self.timed_event_paused = None
507
        self.timed_event_not_paused = None
508
        # Keeps the last sent message to complete it easily in completion_correct, and to replace it.
mathieui's avatar
mathieui committed
509
        self.last_sent_message = {}
510 511 512 513
        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
514 515 516 517 518
        self.register_command(
            'say',
            self.command_say,
            usage='<message>',
            shortdesc='Send the message.')
Madhur Garg's avatar
Madhur Garg committed
519
        self.register_command(
520
            'scrollback',
Maxime Buquet's avatar
Maxime Buquet committed
521
            self.command_scrollback,
522
            usage="end home clear status goto <+|-linecount>|<linenum>|<timestamp>",
523
            shortdesc='Scrollback to the given line number, message, or clear the buffer.')
524
        self.commands['sb'] = self.commands['scrollback']
mathieui's avatar
mathieui committed
525 526 527 528 529 530 531 532 533 534 535 536 537
        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)
538
        self.chat_state = None  # type: Optional[str]
539 540 541
        self.update_commands()
        self.update_keys()

542 543
    @property
    def name(self) -> str:
544 545 546
        if self._name is not None:
            return self._name
        return self._jid.full
547 548 549 550

    @name.setter
    def name(self, value: Union[JID, str]) -> None:
        if isinstance(value, JID):
551
            self.jid = value
552 553 554 555
        elif isinstance(value, str):
            try:
                value = JID(value)
                if value.domain:
556
                    self._jid = value
557
            except InvalidJID:
558
                self._name = value
559
        else:
560
            raise TypeError("Name %r must be of type JID or str." % value)
561 562

    @property
563
    def jid(self) -> JID:
564
        return copy.copy(self._jid)
565 566

    @jid.setter
567
    def jid(self, value: JID) -> None:
568
        if not isinstance(value, JID):
569 570
            raise TypeError("Jid %r must be of type JID." % value)
        assert value.domain
571
        self._jid = value
572

mathieui's avatar
mathieui committed
573
    @property
mathieui's avatar
mathieui committed
574
    def general_jid(self) -> JID:
575
        raise NotImplementedError
mathieui's avatar
mathieui committed
576

577
    def log_message(self, message: BaseMessage, typ=1):
578 579 580
        """
        Log the messages in the archives.
        """
581
        name = self.jid.bare
582 583 584
        if not isinstance(message, Message):
            return
        if not logger.log_message(name, message.nickname, message.txt, date=message.time, typ=typ):
585
            self.core.information('Unable to write in the log file', 'Error')
586

587 588 589
    def add_message(self, message: BaseMessage, typ=1):
        self.log_message(message, typ=typ)
        self._text_buffer.add_message(message)
mathieui's avatar
mathieui committed
590 591 592 593 594 595 596 597 598

    def modify_message(self,
                       txt,
                       old_id,
                       new_id,
                       user=None,
                       jid=None,
                       nickname=None):
        message = self._text_buffer.modify_message(
599
            txt, old_id, new_id, user=user, jid=jid)
600
        if message:
601
            self.log_message(message, typ=1)
Maxime Buquet's avatar
Maxime Buquet committed
602
            self.text_win.modify_message(message.identifier, message)
603 604 605 606 607 608 609 610 611
            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
612
        char_we_dont_want = string.punctuation + ' ’„“”…«»'
613
        words = []
614 615 616 617 618 619 620 621 622
        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)
623
        words.extend([word for word in config.get('words').split(':') if word])
624 625 626 627 628 629 630 631 632 633 634
        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()

635 636
    @command_args_parser.raw
    def command_xhtml(self, xhtml):
637 638 639
        """"
        /xhtml <custom xhtml>
        """
640
        message = self.generate_xhtml_message(xhtml)
641 642 643
        if message:
            message.send()

644
    def generate_xhtml_message(self, arg: str) -> SMessage:
645 646 647
        if not arg:
            return
        try:
mathieui's avatar
mathieui committed
648 649
            body = xhtml.clean_text(
                xhtml.xhtml_to_poezio_colors(arg, force=True))
650 651 652 653 654 655 656 657 658 659 660 661
            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
662
    def get_dest_jid(self) -> JID:
Maxime Buquet's avatar
Maxime Buquet committed
663
        return self.jid
664 665

    @refresh_wrapper.always
666
    def command_clear(self, ignored):
667 668 669 670 671 672
        """
        /clear
        """
        self._text_buffer.messages = []
        self.text_win.rebuild_everything(self._text_buffer)

673
    def check_send_chat_state(self) -> bool:
mathieui's avatar
mathieui committed
674 675 676
        "If we should send a chat state"
        return True

677
    def send_chat_state(self, state: str, always_send: bool = False) -> None:
678 679 680
        """
        Send an empty chatstate message
        """
681 682
        from poezio.tabs import PrivateTab

mathieui's avatar
mathieui committed
683
        if self.check_send_chat_state():
mathieui's avatar
mathieui committed
684 685
            if state in ('active', 'inactive',
                         'gone') and self.inactive and not always_send:
686
                return
687
            if config.get_by_tabname('send_chat_states', self.general_jid):
688 689 690 691
                msg = self.core.xmpp.make_message(self.get_dest_jid())
                msg['type'] = self.message_type
                msg['chat_state'] = state
                self.chat_state = state
692
                msg['no-store'] = True
693 694 695
                if isinstance(self, PrivateTab):
                    x = ET.Element('{%s}x' % NS_MUC_USER)
                    msg.append(x)
696 697
                msg.send()

698
    def send_composing_chat_state(self, empty_after: bool) -> None:
699 700 701 702 703
        """
        Send the "active" or "composing" chatstate, depending
        on the the current status of the input
        """
        name = self.general_jid
704
        if config.get_by_tabname('send_chat_states', name):
705 706 707 708 709 710 711 712 713 714 715 716 717 718
            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
        """
719
        if not config.get_by_tabname('send_chat_states', self.general_jid):
720
            return
721 722 723
        # First, cancel the delay if it already exists, before rescheduling
        # it at a new date
        self.cancel_paused_delay()
mathieui's avatar
mathieui committed
724 725
        new_event = timed_events.DelayedEvent(4, self.send_chat_state,
                                              'paused')
726
        self.core.add_timed_event(new_event)
727
        self.timed_event_paused = new_event
mathieui's avatar
mathieui committed
728 729 730
        new_event = timed_events.DelayedEvent(
            30, self.send_chat_state, 'inactive'
            if self.inactive else 'active')
731
        self.core.add_timed_event(new_event)
732
        self.timed_event_not_paused = new_event
733

734
    def cancel_paused_delay(self) -> None:
735 736 737 738 739
        """
        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
        """
740 741 742
        if self.timed_event_paused is not None:
            self.core.remove_timed_event(self.timed_event_paused)
            self.timed_event_paused = None
743 744
            self.core.remove_timed_event(self.timed_event_not_paused)
            self.timed_event_not_paused = None
745

746
    def set_last_sent_message(self, msg: SMessage, correct: bool = False) -> None:
Maxime Buquet's avatar
Maxime Buquet committed
747 748 749 750 751 752 753 754
        """Ensure last_sent_message is set with the correct attributes"""
        if correct:
            # XXX: Is the copy needed. Is the object passed here reused
            # afterwards? Who knows.
            msg = copy.copy(msg)
            msg['id'] = self.last_sent_message['id']
        self.last_sent_message = msg

755
    @command_args_parser.raw
756
    def command_correct(self, line: str) -> None:
757 758 759 760
        """
        /correct <fixed message>
        """
        if not line:
761
            self.core.command.help('correct')
762 763
            return
        if not self.last_sent_message:
764
            self.core.information('There is no message to correct.', 'Error')
765 766 767 768 769
            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
770 771 772 773
            return Completion(
                the_input.auto_completion, [self.last_sent_message['body']],
                '',
                quotify=False)
774
        return True
775 776

    @property
mathieui's avatar
mathieui committed
777
    def inactive(self) -> bool:
778 779 780 781
        """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)

782
    def move_separator(self) -> None:
783 784 785 786 787 788 789 790
        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

791
    def check_scrolled(self) -> None:
792 793 794
        if self.text_win.pos != 0:
            self.state = 'scrolled'

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

799 800 801 802
    def goto_build_lines(self, new_date):
        text_buffer = self._text_buffer
        built_lines = []
        message_count = 0
803 804
        timestamp = config.get('show_timestamps')
        nick_size = config.get('max_nick_length')
805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822
        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:
823 824
                if message.history:
                    offset += 1 + LONG_FORMAT_LENGTH
825 826 827 828 829 830 831 832 833 834 835 836 837 838 839
            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
840
    @command_args_parser.quoted(0, 2)
Maxime Buquet's avatar
Maxime Buquet committed
841
    def command_scrollback(self, args):
Madhur Garg's avatar
Madhur Garg committed
842
        """
843 844 845 846 847
        /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
848 849
        """
        if args is None or len(args) == 0:
Madhur Garg's avatar
Madhur Garg committed
850 851
            args = ['end']
        if len(args) == 1:
Madhur Garg's avatar
Madhur Garg committed
852 853 854
            if args[0] == 'end':
                self.text_win.scroll_down(len(self.text_win.built_lines))
                self.core.refresh_window()
855
                return
Madhur Garg's avatar
Madhur Garg committed
856 857 858
            elif args[0] == 'home':
                self.text_win.scroll_up(len(self.text_win.built_lines))
                self.core.refresh_window()
859
                return
Madhur Garg's avatar
Madhur Garg committed
860 861 862 863
            elif args[0] == 'clear':
                self._text_buffer.messages = []
                self.text_win.rebuild_everything(self._text_buffer)
                self.core.refresh_window()
864
                return
Madhur Garg's avatar
Madhur Garg committed
865
            elif args[0] == 'status':
866
                self.core.information('Total %s lines in this tab.' % len(self.text_win.built_lines), 'Info')
867
                return
Madhur Garg's avatar
Madhur Garg committed
868
        elif len(args) == 2 and args[0] == 'goto':
869 870 871
            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)
872
                    if 'd' in fmt and 'm' in fmt:
873 874 875 876 877 878 879
                        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
880
            if args[1].startswith('-'):
881
                # Check if the user is giving argument of type goto <-linecount> or goto [-<days ago>] hh:mi[:ss]
882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899
                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
900
            elif args[1].startswith('+'):
901 902
                scroll_len = args[1].strip('+')
                if scroll_len.isdigit():
Madhur Garg's avatar
Madhur Garg committed
903 904 905
                    self.text_win.scroll_up(int(scroll_len))
                    self.core.refresh_window()
                    return
906
            # Check for the argument of type goto <linenum>
907 908 909
            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
910 911
                    self.core.refresh_window()
                    return
Madhur Garg's avatar
Madhur Garg committed
912 913 914 915 916 917
                else:
                    self.text_win.pos = 0
                    self.core.refresh_window()
                    return
            elif args[1] == '0':
                args = ['home']
918 919
            # new_date is the timestamp for which the user has queried.
            self.goto_build_lines(new_date)
Madhur Garg's avatar
Madhur Garg committed
920

921 922 923 924 925 926 927
    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):
928
        if not self.query_status:
mathieui's avatar
mathieui committed
929 930
            from poezio import mam
            mam.schedule_scroll_up(tab=self)
931
        return self.text_win.scroll_up(self.text_win.height - 1)
932 933

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

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

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

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

946

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

951 952 953
        self.__status = Status("", "")
        self.last_remote_message = datetime.now()

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

969 970 971 972 973
    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
974 975
        if not (old_status.show != status.show
                or old_status.message != status.message):
976 977 978
            return
        self.__status = status
        hide_status_change = config.get_by_tabname('hide_status_change',
Maxime Buquet's avatar
Maxime Buquet committed
979
                                                   self.jid.bare)
980 981 982 983 984 985 986 987 988 989 990 991 992 993
        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]
994 995 996 997
        self.add_message(
            InfoMessage(txt=msg[:-2]),
            typ=2,
        )
998

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

1008
    def nack_message(self, error: str, msg_id: str, msg_jid: JID):
1009
        """
1010
        Non-ack a message (e.g. timeout)
1011 1012 1013 1014 1015 1016 1017 1018
        """
        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

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