basetabs.py 37.4 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 31 32
from typing import (
    Any,
    Callable,
    Dict,
    List,
    Optional,
    Union,
    TYPE_CHECKING,
)
mathieui's avatar
mathieui committed
33

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

from slixmpp import JID, InvalidJID, Message
52

mathieui's avatar
mathieui committed
53
if TYPE_CHECKING:
54
    from _curses import _CursesWindow  # pylint: disable=E0611
mathieui's avatar
mathieui committed
55

mathieui's avatar
mathieui committed
56 57
log = logging.getLogger(__name__)

58 59
NS_MUC_USER = 'http://jabber.org/protocol/muc#user'

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

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

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

112

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

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

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

135
    @property
mathieui's avatar
mathieui committed
136
    def size(self) -> int:
137
        return self.core.size
138

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

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

mathieui's avatar
mathieui committed
184
    def set_state(self, value: str):
185 186
        self._state = value

187 188 189 190 191 192 193 194 195 196
    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'
197 198

    @staticmethod
mathieui's avatar
mathieui committed
199
    def resize(scr: '_CursesWindow'):
200
        Tab.height, Tab.width = scr.getmaxyx()
201
        windows.base_wins.TAB_WIN = scr
202

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

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

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

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

    def refresh_tab_win(self):
331
        if config.get('enable_vertical_tab_list'):
332 333 334
            left_tab_win = self.core.left_tab_win
            if left_tab_win and not self.size.core_degrade_x:
                left_tab_win.refresh()
335
        elif not self.size.core_degrade_y:
336
            self.core.tab_win.refresh()
337

338 339 340 341 342 343
    def refresh_input(self):
        """Refresh the current input if any"""
        if self.input is not None:
            self.input.refresh()
            self.core.doupdate()

344 345 346 347 348 349 350 351 352 353
    def refresh(self):
        """
        Called on each screen refresh (when something has changed)
        """
        pass

    def get_name(self):
        """
        get the name of the tab
        """
354
        return self.name
355

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

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

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

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

    def update_keys(self):
        for k in self.plugin_keys:
381
            if k not in self.key_func:
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 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433
                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):
        """
434
        Called when the window with the information is resized
435 436 437 438 439 440 441 442 443
        """
        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
444
        self.closed = True
445

mathieui's avatar
mathieui committed
446
    def matching_names(self) -> List[str]:
447 448 449 450 451 452 453 454 455 456 457 458
        """
        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
459
class GapTab(Tab):
460 461 462 463 464 465
    def __bool__(self):
        return False

    def __len__(self):
        return 0

466 467
    @property
    def name(self):
468 469 470
        return ''

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

474 475 476 477 478 479 480 481

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

486
    def __init__(self, core, jid: Union[JID, str]):
487
        Tab.__init__(self, core)
488 489 490 491 492

        if not isinstance(jid, JID):
            jid = JID(jid)
        assert jid.domain
        self._jid = jid
493
        #: Is the tab currently requesting MAM data?
494
        self.query_status = False
495
        self.last_stanza_id = None
496

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

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

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

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

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

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

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

585 586 587
    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
588 589 590 591 592 593 594 595 596

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

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

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

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

mathieui's avatar
mathieui committed
671 672 673 674
    def check_send_chat_state(self):
        "If we should send a chat state"
        return True

675 676 677 678
    def send_chat_state(self, state, always_send=False):
        """
        Send an empty chatstate message
        """
679 680
        from poezio.tabs import PrivateTab

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

    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
703
        if config.get_by_tabname('send_chat_states', name):
704 705 706 707 708 709 710 711 712 713 714 715 716 717
            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
        """
718
        if not config.get_by_tabname('send_chat_states', self.general_jid):
719
            return
720 721 722
        # First, cancel the delay if it already exists, before rescheduling
        # it at a new date
        self.cancel_paused_delay()
mathieui's avatar
mathieui committed
723 724
        new_event = timed_events.DelayedEvent(4, self.send_chat_state,
                                              'paused')
725
        self.core.add_timed_event(new_event)
726
        self.timed_event_paused = new_event
mathieui's avatar
mathieui committed
727 728 729
        new_event = timed_events.DelayedEvent(
            30, self.send_chat_state, 'inactive'
            if self.inactive else 'active')
730
        self.core.add_timed_event(new_event)
731
        self.timed_event_not_paused = new_event
732 733 734 735 736 737 738

    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
        """
739 740 741
        if self.timed_event_paused is not None:
            self.core.remove_timed_event(self.timed_event_paused)
            self.timed_event_paused = None
742 743
            self.core.remove_timed_event(self.timed_event_not_paused)
            self.timed_event_not_paused = None
744

Maxime Buquet's avatar
Maxime Buquet committed
745 746 747 748 749 750 751 752 753
    def set_last_sent_message(self, msg, correct=False):
        """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

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

    @property
mathieui's avatar
mathieui committed
776
    def inactive(self) -> bool:
777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793
        """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'

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

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

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

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

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

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

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

945

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

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

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

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

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
            self._text_buffer.add_message(
1028 1029 1030 1031 1032 1033 1034 1035
                Message(
                    body,
                    nickname=self.core.own_nick,
                    nick_color=get_theme().COLOR_OWN_NICK,
                    identifier=message['id'],
                    jid=self.core.xmpp.boundjid,
                )
            )
1036 1037
            self.refresh()

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

1046 1047 1048
    @command_args_parser.raw
    def command_attention(self, message):
        """/attention [message]"""
1049
        if message != '':
1050 1051 1052 1053 1054 1055 1056
            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()

1057
    @command_args_parser.raw
1058 1059 1060
    def command_say(self, line, correct=False, attention=False):
        pass

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