tabs.py 157 KB
Newer Older
louiz’'s avatar
louiz’ committed
1
# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org>
2
3
4
5
#
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
6
# it under the terms of the zlib license. See the COPYING file.
7
8

"""
9
a Tab object is a way to organize various Windows (see windows.py)
10
around the screen at once.
11
A tab is then composed of multiple Buffers.
12
Each Tab object has different refresh() and resize() methods, defining how its
13
Windows are displayed, resized, etc.
14
15
"""

16
17
MIN_WIDTH = 42
MIN_HEIGHT = 6
18

19
20
21
import logging
log = logging.getLogger(__name__)

louiz’'s avatar
Typo    
louiz’ committed
22
from gettext import gettext as _
louiz’'s avatar
louiz’ committed
23

24
import windows
25
import curses
mathieui's avatar
mathieui committed
26
import fixes
27
import difflib
louiz’'s avatar
louiz’ committed
28
import string
29
import common
louiz’'s avatar
louiz’ committed
30
31
import core
import singleton
32
import random
33
import xhtml
34
35
import weakref
import timed_events
mathieui's avatar
mathieui committed
36
import os
37
import time
38

louiz’'s avatar
louiz’ committed
39
40
import multiuserchat as muc

41
from theming import get_theme, dump_tuple
42

43
from common import safeJID
44
from decorators import refresh_wrapper
45
from sleekxmpp import JID, InvalidJID
mathieui's avatar
mathieui committed
46
47
from sleekxmpp.xmlstream import matcher
from sleekxmpp.xmlstream.handler import Callback
48
from config import config
49
from roster import RosterGroup, roster
50
from contact import Contact, Resource
mathieui's avatar
mathieui committed
51
from text_buffer import TextBuffer, CorrectionError
52
from user import User
mathieui's avatar
mathieui committed
53
from os import getenv, path
54
from logger import logger
55

56
from datetime import datetime, timedelta
mathieui's avatar
mathieui committed
57
from xml.etree import cElementTree as ET
58

59
60
61
62
63
64
65
66
SHOW_NAME = {
    'dnd': _('busy'),
    'away': _('away'),
    'xa': _('not available'),
    'chat': _('chatty'),
    '': _('available')
    }

67
68
NS_MUC_USER = 'http://jabber.org/protocol/muc#user'

69
STATE_COLORS = {
mathieui's avatar
mathieui committed
70
        'disconnected': lambda: get_theme().COLOR_TAB_DISCONNECTED,
mathieui's avatar
mathieui committed
71
        'scrolled': lambda: get_theme().COLOR_TAB_SCROLLED,
72
        'joined': lambda: get_theme().COLOR_TAB_JOINED,
mathieui's avatar
mathieui committed
73
74
75
76
77
        'message': lambda: get_theme().COLOR_TAB_NEW_MESSAGE,
        '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,
78
        'attention': lambda: get_theme().COLOR_TAB_ATTENTION,
79
80
    }

louiz’'s avatar
louiz’ committed
81
82
VERTICAL_STATE_COLORS = {
        'disconnected': lambda: get_theme().COLOR_VERTICAL_TAB_DISCONNECTED,
mathieui's avatar
mathieui committed
83
        'scrolled': lambda: get_theme().COLOR_VERTICAL_TAB_SCROLLED,
84
        'joined': lambda: get_theme().COLOR_VERTICAL_TAB_JOINED,
louiz’'s avatar
louiz’ committed
85
86
87
88
89
        'message': lambda: get_theme().COLOR_VERTICAL_TAB_NEW_MESSAGE,
        '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,
90
        'attention': lambda: get_theme().COLOR_VERTICAL_TAB_ATTENTION,
louiz’'s avatar
louiz’ committed
91
92
93
    }


94
95
96
STATE_PRIORITY = {
        'normal': -1,
        'current': -1,
mathieui's avatar
mathieui committed
97
        'disconnected': 0,
mathieui's avatar
mathieui committed
98
        'scrolled': 0.5,
99
        'message': 1,
100
        'joined': 1,
101
102
        'highlight': 2,
        'private': 2,
103
        'attention': 3
104
105
    }

106
class Tab(object):
louiz’'s avatar
louiz’ committed
107
    tab_core = None
mathieui's avatar
mathieui committed
108

louiz’'s avatar
louiz’ committed
109
    def __init__(self):
110
        self.input = None
mathieui's avatar
mathieui committed
111
112
113
114
115
        if isinstance(self, MucTab) and not self.joined:
            self._state = 'disconnected'
        else:
            self._state = 'normal'

116
        self.need_resize = False
117
        self.need_resize = False
118
119
        self.key_func = {}      # each tab should add their keys in there
                                # and use them in on_input
120
        self.commands = {}      # and their own commands
121

122

louiz’'s avatar
louiz’ committed
123
124
125
126
127
128
    @property
    def core(self):
        if not Tab.tab_core:
            Tab.tab_core = singleton.Singleton(core.Core)
        return Tab.tab_core

mathieui's avatar
mathieui committed
129
130
131
132
133
134
135
    @property
    def nb(self):
        for index, tab in enumerate(self.core.tabs):
            if tab == self:
                return index
        return len(self.core.tabs)

mathieui's avatar
mathieui committed
136
137
138
139
140
141
    @property
    def tab_win(self):
        if not Tab.tab_core:
            Tab.tab_core = singleton.Singleton(core.Core)
        return Tab.tab_core.tab_win

louiz’'s avatar
louiz’ committed
142
143
144
145
146
147
    @property
    def left_tab_win(self):
        if not Tab.tab_core:
            Tab.tab_core = singleton.Singleton(core.Core)
        return Tab.tab_core.left_tab_win

148
149
150
151
152
153
154
155
156
    @staticmethod
    def tab_win_height():
        """
        Returns 1 or 0, depending on if we are using the vertical tab list
        or not.
        """
        if config.get('enable_vertical_tab_list', 'false') == 'true':
            return 0
        return 1
louiz’'s avatar
louiz’ committed
157

158
159
160
161
    @property
    def info_win(self):
        return self.core.information_win

162
163
    @property
    def color(self):
164
        return STATE_COLORS[self._state]()
165

louiz’'s avatar
louiz’ committed
166
167
168
169
    @property
    def vertical_color(self):
        return VERTICAL_STATE_COLORS[self._state]()

170
171
172
173
174
175
176
    @property
    def state(self):
        return self._state

    @state.setter
    def state(self, value):
        if not value in STATE_COLORS:
177
            log.debug("Invalid value for tab state: %s", value)
178
        elif STATE_PRIORITY[value] < STATE_PRIORITY[self._state] and \
mathieui's avatar
mathieui committed
179
180
                value not in ('current', 'disconnected') and \
                not (self._state == 'scrolled' and value == 'disconnected'):
181
182
183
            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')
184
185
        else:
            self._state = value
186

187
188
189
    @staticmethod
    def resize(scr):
        Tab.size = (Tab.height, Tab.width) = scr.getmaxyx()
190
191
192
193
        if Tab.height < MIN_HEIGHT or Tab.width < MIN_WIDTH:
            Tab.visible = False
        else:
            Tab.visible = True
louiz’'s avatar
louiz’ committed
194
        windows.Win._tab_win = scr
195

mathieui's avatar
mathieui committed
196
197
198
199
200
201
202
203
204
205
    def register_command(self, name, func, *, desc='', shortdesc='', completion=None, usage=''):
        """
        Add a command
        """
        if name in self.commands:
            return
        if not desc and shortdesc:
            desc = shortdesc
        self.commands[name] = core.Command(func, desc, completion, shortdesc, usage)

206
207
208
209
210
211
212
213
214
215
216
217
    def complete_commands(self, the_input):
        """
        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('//'):
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
            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
                # one possibily. The next tab will complete the argument.
                # Otherwise we would need to add a useless space before being
                # able to complete the arguments.
                hit_copy = set(the_input.hit_list)
                while not hit_copy:
                    whitespace = the_input.text.find(' ')
                    if whitespace == -1:
                        whitespace = len(the_input.text)
                    the_input.text = the_input.text[:whitespace-1] + the_input.text[whitespace:]
                    the_input.new_completion(words, 0)
                    hit_copy = set(the_input.hit_list)
                if len(hit_copy) == 1:
                    the_input.do_command(' ')
                    the_input.reset_completion()
                return True
239
            # check if we are in the middle of the command name
240
            elif len(txt.split()) > 1 or\
241
242
                    (txt.endswith(' ') and not the_input.last_completion):
                command_name = txt.split()[0][1:]
mathieui's avatar
mathieui committed
243
                if command_name in self.commands:
244
                    command = self.commands[command_name]
mathieui's avatar
mathieui committed
245
246
                elif command_name in self.core.commands:
                    command = self.core.commands[command_name]
247
248
249
                else:           # Unknown command, cannot complete
                    return False
                if command[2] is None:
250
                    return False # There's no completion function
251
252
253
254
                else:
                    return command[2](the_input)
                return True
        return False
255

256
    def execute_command(self, provided_text):
louiz’'s avatar
louiz’ committed
257
258
259
260
261
262
263
264
265
        """
        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:]
            arg = txt[2+len(command):] # jump the '/' and the ' '
266
            func = None
267
            if command in self.commands: # check tab-specific commands
268
                func = self.commands[command][0]
269
            elif command in self.core.commands: # check global commands
270
                func = self.core.commands[command][0]
louiz’'s avatar
louiz’ committed
271
            else:
272
273
                low = command.lower()
                if low in self.commands:
274
                    func = self.commands[low][0]
275
                elif low in self.core.commands:
276
                    func = self.core.commands[low][0]
277
278
                else:
                    self.core.information(_("Unknown command (%s)") % (command), _('Error'))
279
280
281
282
283
284
            if command in ('correct', 'say'): # hack
                arg = xhtml.convert_simple_to_full_colors(arg)
            else:
                arg = xhtml.clean_text_simple(arg)
            if func:
                func(arg)
louiz’'s avatar
louiz’ committed
285
286
287
288
            return True
        else:
            return False

louiz’'s avatar
louiz’ committed
289
290
291
    def refresh_tab_win(self):
        if self.left_tab_win:
            self.left_tab_win.refresh()
292
293
        else:
            self.tab_win.refresh()
louiz’'s avatar
louiz’ committed
294

louiz’'s avatar
louiz’ committed
295
    def refresh(self):
296
297
298
        """
        Called on each screen refresh (when something has changed)
        """
299
        pass
300

301
302
303
304
    def get_name(self):
        """
        get the name of the tab
        """
305
        return self.__class__.__name__
306

mathieui's avatar
mathieui committed
307
308
309
310
311
312
    def get_nick(self):
        """
        Get the nick of the tab (defaults to its name)
        """
        return self.get_name()

313
314
315
316
317
318
    def get_text_window(self):
        """
        Returns the principal TextWin window, if there's one
        """
        return None

319
320
321
322
    def on_input(self, key, raw):
        """
        raw indicates if the key should activate the associated command or not.
        """
323
        pass
324

325
326
327
    def update_commands(self):
        for c in self.plugin_commands:
            if not c in self.commands:
mathieui's avatar
mathieui committed
328
                self.commands[c] = self.plugin_commands[c]
329

330
331
332
333
334
    def update_keys(self):
        for k in self.plugin_keys:
            if not k in self.key_func:
                self.key_func[k] = self.plugin_keys[k]

335
336
337
338
    def on_lose_focus(self):
        """
        called when this tab loses the focus.
        """
339
        self.state = 'normal'
340
341
342
343
344

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

    def on_scroll_down(self):
        """
349
        Defines what happens when we scroll down
350
        """
351
        pass
352
353
354

    def on_scroll_up(self):
        """
355
356
357
358
359
360
361
362
363
364
365
366
367
        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
368
        """
369
        pass
370

mathieui's avatar
mathieui committed
371
372
373
374
375
376
377
378
379
380
381
382
    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

383
    def on_info_win_size_changed(self):
384
385
386
        """
        Called when the window with the informations is resized
        """
387
        pass
388

389
390
391
392
    def on_close(self):
        """
        Called when the tab is to be closed
        """
393
394
        if self.input:
            self.input.on_delete()
395

louiz’'s avatar
louiz’ committed
396
397
398
399
400
401
402
403
404
    def matching_names(self):
        """
        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 []

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

mathieui's avatar
mathieui committed
408
409
410
411
412
413
414
415
416
417
418
class GapTab(Tab):

    def __bool__(self):
        return False

    def __len__(self):
        return 0

    def get_name(self):
        return ''

419
420
421
    def refresh(self):
        log.debug('WARNING: refresh() called on a gap tab, this should not happen')

422
423
class ChatTab(Tab):
    """
424
425
    A tab containing a chat of any type.
    Just use this class instead of Tab if the tab needs a recent-words completion
426
    Also, ^M is already bound to on_enter
427
    And also, add the /say command
428
    """
429
    plugin_commands = {}
430
    plugin_keys = {}
431
    def __init__(self, jid=''):
louiz’'s avatar
louiz’ committed
432
        Tab.__init__(self)
433
        self.name = jid
434
        self._text_buffer = TextBuffer()
435
436
437
438
        self.remote_wants_chatstates = None # change this to True or False when
        # we know that the remote user wants chatstates, or not.
        # None means we don’t know yet, and we send only "active" chatstates
        self.chatstate = None   # can be "active", "composing", "paused", "gone", "inactive"
439
440
441
442
443
444
        # We keep a weakref of the event that will set our chatstate to "paused", so that
        # we can delete it or change it if we need to
        self.timed_event_paused = None
        # if that’s None, then no paused chatstate was sent recently
        # if that’s a weakref returning None, then a paused chatstate was sent
        # since the last input
445
        self.remote_supports_attention = False
446
447
        # Keeps the last sent message to complete it easily in completion_correct, and to replace it.
        self.last_sent_message = None
448
        self.key_func['M-v'] = self.move_separator
449
        self.key_func['M-h'] = self.scroll_separator
450
451
        self.key_func['M-/'] = self.last_words_completion
        self.key_func['^M'] = self.on_enter
mathieui's avatar
mathieui committed
452
453
454
455
456
457
458
459
460
461
462
463
        self.register_command('say', self.command_say,
                usage=_('<message>'),
                shortdesc=_('Send the message.'))
        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)
mathieui's avatar
mathieui committed
464
        self.chat_state = None
465
        self.update_commands()
466
        self.update_keys()
467

468
        # Get the logs
469
        log_nb = config.get('load_log', 10)
470
471
472
473
474
475

        if isinstance(self, PrivateTab):
            logs = logger.get_logs(safeJID(self.get_name()).full.replace('/', '\\'), log_nb)
        else:
            logs = logger.get_logs(safeJID(self.get_name()).bare, log_nb)
        if logs:
476
477
            for message in logs:
                self._text_buffer.add_message(**message)
478
479

    def log_message(self, txt, nickname, time=None, typ=1):
480
        """
mathieui's avatar
mathieui committed
481
        Log the messages in the archives.
482
        """
483
484
        name = safeJID(self.name).bare
        if not logger.log_message(name, nickname, txt, date=time, typ=typ):
mathieui's avatar
mathieui committed
485
            self.core.information(_('Unable to write in the log file'), 'Error')
486

487
488
    def add_message(self, txt, time=None, nickname=None, forced_user=None, nick_color=None, identifier=None, jid=None, history=None, typ=1):
        self.log_message(txt, nickname, time=time, typ=typ)
mathieui's avatar
mathieui committed
489
490
491
        self._text_buffer.add_message(txt, time=time,
                nickname=nickname,
                nick_color=nick_color,
492
                history=history,
mathieui's avatar
mathieui committed
493
                user=forced_user,
494
495
                identifier=identifier,
                jid=jid)
mathieui's avatar
mathieui committed
496

497
498
    def modify_message(self, txt, old_id, new_id, user=None,jid=None, nickname=None):
        self.log_message(txt, nickname, typ=1)
499
        message = self._text_buffer.modify_message(txt, old_id, new_id, time=time, user=user, jid=jid)
mathieui's avatar
mathieui committed
500
501
502
503
        if message:
            self.text_win.modify_message(old_id, message)
            self.core.refresh_window()
            return True
mathieui's avatar
mathieui committed
504
505
        return False

506
507
508
509
510
    def last_words_completion(self):
        """
        Complete the input with words recently said
        """
        # build the list of the recent words
511
        char_we_dont_want = string.punctuation+' ’„“”…«»'
512
        words = list()
513
        for msg in self._text_buffer.messages[:-40:-1]:
514
515
            if not msg:
                continue
516
            txt = xhtml.clean_text(msg.txt)
517
            for char in char_we_dont_want:
518
519
                txt = txt.replace(char, ' ')
            for word in txt.split():
520
521
                if len(word) >= 4 and word not in words:
                    words.append(word)
522
        words.extend([word for word in config.get('words', '').split(':') if word])
523
        self.input.auto_completion(words, ' ', quotify=False)
524
525

    def on_enter(self):
526
        txt = self.input.key_enter()
mathieui's avatar
mathieui committed
527
        if txt:
528
            if not self.execute_command(txt):
mathieui's avatar
mathieui committed
529
530
                if txt.startswith('//'):
                    txt = txt[1:]
531
                self.command_say(xhtml.convert_simple_to_full_colors(txt))
532
        self.cancel_paused_delay()
533

mathieui's avatar
mathieui committed
534
535
536
537
538
539
540
541
    def command_xhtml(self, arg):
        """"
        /xhtml <custom xhtml>
        """
        if not arg:
            return
        try:
            body = xhtml.clean_text(xhtml.xhtml_to_poezio_colors(arg))
542
543
            # The <body /> element is the only allowable child of the <xhtm-im>
            arg = "<body xmlns='http://www.w3.org/1999/xhtml'>%s</body>" % (arg,)
mathieui's avatar
mathieui committed
544
545
546
            ET.fromstring(arg)
        except:
            self.core.information('Could not send custom xhtml', 'Error')
547
            log.error('/xhtml: Unable to send custom xhtml', exc_info=True)
mathieui's avatar
mathieui committed
548
549
            return

550
        msg = self.core.xmpp.make_message(self.get_dest_jid())
mathieui's avatar
mathieui committed
551
        msg['body'] = body
mathieui's avatar
mathieui committed
552
553
        msg.enable('html')
        msg['html']['body'] = arg
mathieui's avatar
mathieui committed
554
555
556
557
558
559
560
        if isinstance(self, MucTab):
            msg['type'] = 'groupchat'
        if isinstance(self, ConversationTab):
            self.core.add_message_to_text_buffer(self._text_buffer, body, None, self.core.own_nick)
            self.refresh()
        msg.send()

561
562
563
    def get_dest_jid(self):
        return self.get_name()

564
    @refresh_wrapper.always
565
566
567
568
569
570
571
    def command_clear(self, args):
        """
        /clear
        """
        self._text_buffer.messages = []
        self.text_win.rebuild_everything(self._text_buffer)

572
    def send_chat_state(self, state, always_send=False):
573
574
575
        """
        Send an empty chatstate message
        """
576
        if not isinstance(self, MucTab) or self.joined:
mathieui's avatar
mathieui committed
577
            if state in ('active', 'inactive', 'gone') and self.inactive and not always_send:
578
                return
579
            msg = self.core.xmpp.make_message(self.get_dest_jid())
580
581
            msg['type'] = self.message_type
            msg['chat_state'] = state
mathieui's avatar
mathieui committed
582
            self.chat_state = state
583
            msg.send()
584

585
    def send_composing_chat_state(self, empty_after):
586
587
588
589
        """
        Send the "active" or "composing" chatstate, depending
        on the the current status of the input
        """
590
591
        name = self.general_jid
        if config.get_by_tabname('send_chat_states', 'true', name, True) == 'true' and self.remote_wants_chatstates:
mathieui's avatar
mathieui committed
592
            needed = 'inactive' if self.inactive else 'active'
593
594
595
596
            self.cancel_paused_delay()
            if not empty_after:
                if self.chat_state != "composing":
                    self.send_chat_state("composing")
597
                self.set_paused_delay(True)
598
599
            elif empty_after and self.chat_state != needed:
                self.send_chat_state(needed, True)
600

601
602
603
604
605
    def set_paused_delay(self, composing):
        """
        we create a timed event that will put us to paused
        in a few seconds
        """
606
        if config.get_by_tabname('send_chat_states', 'true', self.general_jid, True) != 'true':
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
            return
        if self.timed_event_paused:
            # check the weakref
            event = self.timed_event_paused()
            if event:
                # the event already exists: we just update
                # its date
                event.change_date(datetime.now() + timedelta(seconds=4))
                return
        new_event = timed_events.DelayedEvent(4, self.send_chat_state, 'paused')
        self.core.add_timed_event(new_event)
        self.timed_event_paused = weakref.ref(new_event)

    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
        """
        if self.timed_event_paused:
            event = self.timed_event_paused()
            if event:
629
                self.core.remove_timed_event(event)
630
631
632
                del event
        self.timed_event_paused = None

633
634
635
636
637
638
639
640
641
642
643
644
645
    def command_correct(self, line):
        """
        /correct <fixed message>
        """
        if not line:
            self.core.command_help('correct')
            return
        if not self.last_sent_message:
            self.core.information(_('There is no message to correct.'))
            return
        self.command_say(line, correct=True)

    def completion_correct(self, the_input):
646
        if self.last_sent_message and the_input.get_argument_position() == 1:
647
            return the_input.auto_completion([self.last_sent_message['body']], '', quotify=False)
648

mathieui's avatar
mathieui committed
649
650
651
652
653
654
    @property
    def inactive(self):
        """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)

655
656
    def move_separator(self):
        self.text_win.remove_line_separator()
657
        self.text_win.add_line_separator(self._text_buffer)
658
        self.text_win.refresh()
659
660
        self.input.refresh()

661
    def get_conversation_messages(self):
662
        return self._text_buffer.messages
663

mathieui's avatar
mathieui committed
664
665
666
667
    def check_scrolled(self):
        if self.text_win.pos != 0:
            self.state = 'scrolled'

668
    def command_say(self, line, correct=False):
669
        pass
670

671
    def on_line_up(self):
mathieui's avatar
mathieui committed
672
        return self.text_win.scroll_up(1)
673
674

    def on_line_down(self):
mathieui's avatar
mathieui committed
675
        return self.text_win.scroll_down(1)
676
677

    def on_scroll_up(self):
mathieui's avatar
mathieui committed
678
        return self.text_win.scroll_up(self.text_win.height-1)
679
680

    def on_scroll_down(self):
mathieui's avatar
mathieui committed
681
        return self.text_win.scroll_down(self.text_win.height-1)
682

mathieui's avatar
mathieui committed
683
    def on_half_scroll_up(self):
mathieui's avatar
mathieui committed
684
        return self.text_win.scroll_up((self.text_win.height-1) // 2)
mathieui's avatar
mathieui committed
685
686

    def on_half_scroll_down(self):
mathieui's avatar
mathieui committed
687
        return self.text_win.scroll_down((self.text_win.height-1) // 2)
mathieui's avatar
mathieui committed
688

689
    @refresh_wrapper.always
690
691
692
    def scroll_separator(self):
        self.text_win.scroll_to_separator()

693
class MucTab(ChatTab):
694
695
696
697
    """
    The tab containing a multi-user-chat room.
    It contains an userlist, an input, a topic, an information and a chat zone
    """
698
    message_type = 'groupchat'
699
    plugin_commands = {}
700
    plugin_keys = {}
louiz’'s avatar
louiz’ committed
701
    def __init__(self, jid, nick):
mathieui's avatar
mathieui committed
702
        self.joined = False
703
        ChatTab.__init__(self, jid)
louiz’'s avatar
louiz’ committed
704
705
706
        self.own_nick = nick
        self.name = jid
        self.users = []
mathieui's avatar
mathieui committed
707
        self.privates = [] # private conversations
louiz’'s avatar
louiz’ committed
708
        self.topic = ''
709
710
711
712
        self.remote_wants_chatstates = True
        # We send active, composing and paused states to the MUC because
        # the chatstate may or may not be filtered by the MUC,
        # that’s not our problem.
713
714
        self.topic_win = windows.Topic()
        self.text_win = windows.TextWin()
louiz’'s avatar
louiz’ committed
715
        self._text_buffer.add_window(self.text_win)
716
717
718
719
        self.v_separator = windows.VerticalSeparator()
        self.user_win = windows.UserList()
        self.info_header = windows.MucInfoWin()
        self.input = windows.MessageInput()
720
721
        self.ignores = []       # set of Users
        # keys
722
        self.key_func['^I'] = self.completion
723
724
        self.key_func['M-u'] = self.scroll_user_list_down
        self.key_func['M-y'] = self.scroll_user_list_up
725
726
        self.key_func['M-n'] = self.go_to_next_hl
        self.key_func['M-p'] = self.go_to_prev_hl
727
        # commands
mathieui's avatar
mathieui committed
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
        self.register_command('ignore', self.command_ignore,
                usage=_('<nickname>'),
                desc=_('Ignore a specified nickname.'),
                shortdesc=_('Ignore someone'),
                completion=self.completion_ignore)
        self.register_command('unignore', self.command_unignore,
                usage=_('<nickname>'),
                desc=_('Remove the specified nickname from the ignore list.'),
                shortdesc=_('Unignore someone.'),
                completion=self.completion_unignore)
        self.register_command('kick', self.command_kick,
                usage=_('<nick> [reason]'),
                desc=_('Kick the user with the specified nickname. You also can give an optional reason.'),
                shortdesc=_('Kick someone.'),
                completion=self.completion_quoted)
        self.register_command('ban', self.command_ban,
                usage=_('<nick> [reason]'),
                desc=_('Ban the user with the specified nickname. You also can give an optional reason.'),
                shortdesc='Ban someone',
                completion=self.completion_quoted)
        self.register_command('role', self.command_role,
                usage=_('<nick> <role> [reason]'),
                desc=_('Set the role of an user. Roles can be: none, visitor, participant, moderator. You also can give an optional reason.'),
                shortdesc=_('Set the role of an user.'),
                completion=self.completion_role)
        self.register_command('affiliation', self.command_affiliation,
                usage=_('<nick or jid> <affiliation>'),
                desc=_('Set the affiliation of an user. Affiliations can be: outcast, none, member, admin, owner.'),
                shortdesc=_('Set the affiliation of an user.'),
                completion=self.completion_affiliation)
        self.register_command('topic', self.command_topic,
                usage=_('<subject>'),
                desc=_('Change the subject of the room.'),
                shortdesc=_('Change the subject.'),
                completion=self.completion_topic)
        self.register_command('query', self.command_query,
                usage=_('<nick> [message]'),
                desc=_('Query: Open a private conversation with <nick>. This nick has to be present in the room you\'re currently in. If you specified a message after the nickname, it will immediately be sent to this user.'),
                shortdesc=_('Query an user.'),
                completion=self.completion_quoted)
        self.register_command('part', self.command_part,
                usage=_('[message]'),
                desc=_('Disconnect from a room. You can specify an optional message.'),
                shortdesc=_('Leave the room.'))
        self.register_command('close', self.command_close,
                usage=_('[message]'),
                desc=_('Disconnect from a room and close the tab. You can specify an optional message if you are still connected.'),
                shortdesc=_('Close the tab.'))
        self.register_command('nick', self.command_nick,
                usage=_('<nickname>'),
                desc=_('Change your nickname in the current room.'),
                shortdesc=_('Change your nickname.'),
                completion=self.completion_nick)
        self.register_command('recolor', self.command_recolor,
                desc=_('Re-assign a color to all participants of the current room, based on the last time they talked. Use this if the participants currently talking have too many identical colors.'),
                shortdesc=_('Change the nicks colors.'),
                completion=self.completion_recolor)
        self.register_command('cycle', self.command_cycle,
                usage=_('[message]'),
                desc=_('Leave the current room and rejoin it immediately.'),
                shortdesc=_('Leave and re-join the room.'))
        self.register_command('info', self.command_info,
                usage=_('<nickname>'),
                desc=_('Display some information about the user in the MUC: its/his/her role, affiliation, status and status message.'),
                shortdesc=_('Show an user\'s infos.'),
                completion=self.completion_info)
        self.register_command('configure', self.command_configure,
                desc=_('Configure the current room, through a form.'),
                shortdesc=_('Configure the room.'))
        self.register_command('version', self.command_version,
                usage=_('<jid or nick>'),
                desc=_('Get the software version of the given JID or nick in room (usually its XMPP client and Operating System).'),
                shortdesc=_('Get the software version of a jid.'),
                completion=self.completion_version)
        self.register_command('names', self.command_names,
                desc=_('Get the list of the users in the room, and the list of the people assuming the different roles.'),
                shortdesc=_('List the users.'))
805
806
807
808
809
        self.register_command('invite', self.command_invite,
                desc=_('Invite a contact to this room'),
                usage=_('<jid> [reason]'),
                shortdesc=_('Invite a contact to this room'),
                completion=self.completion_invite)
810
811
812
813

        if self.core.xmpp.boundjid.server == "gmail.com": #gmail sucks
            del self.commands["nick"]

814
        self.resize()
815
        self.update_commands()
816
        self.update_keys()
817

818
819
820
821
    @property
    def general_jid(self):
        return self.get_name()

822
823
824
825
826
    @property
    def last_connection(self):
        last_message = self._text_buffer.last_message
        if last_message:
            return last_message.time
mathieui's avatar
mathieui committed
827
        return None
828

829
    @refresh_wrapper.always
830
831
832
833
834
835
    def go_to_next_hl(self):
        """
        Go to the next HL in the room, or the last
        """
        self.text_win.next_highlight()

836
    @refresh_wrapper.always
837
838
839
840
841
842
    def go_to_prev_hl(self):
        """
        Go to the previous HL in the room, or the first
        """
        self.text_win.previous_highlight()

mathieui's avatar
mathieui committed
843
844
    def completion_version(self, the_input):
        """Completion for /version"""
mathieui's avatar
mathieui committed
845
846
847
        compare_users = lambda x: x.last_talked
        userlist = [user.nick for user in sorted(self.users, key=compare_users, reverse=True)\
                         if user.nick != self.own_nick]
848
        return the_input.auto_completion(userlist, quotify=False)
mathieui's avatar
mathieui committed
849

mathieui's avatar
mathieui committed
850
851
852
853
    def completion_info(self, the_input):
        """Completion for /info"""
        compare_users = lambda x: x.last_talked
        userlist = [user.nick for user in sorted(self.users, key=compare_users, reverse=True)]
854
        return the_input.auto_completion(userlist, quotify=False)
mathieui's avatar
mathieui committed
855

mathieui's avatar
mathieui committed
856
857
858
    def completion_nick(self, the_input):
        """Completion for /nick"""
        nicks = [os.environ.get('USER'), config.get('default_nick', ''), self.core.get_bookmark_nickname(self.get_name())]
mathieui's avatar
mathieui committed
859
        nicks = [i for i in nicks if i]
860
        return the_input.auto_completion(nicks, '', quotify=False)
mathieui's avatar
mathieui committed
861

862
    def completion_recolor(self, the_input):
863
864
865
        if the_input.get_argument_position() == 1:
            return the_input.new_completion(['random'], 1, '', quotify=False)
        return True
866

mathieui's avatar
mathieui committed
867
    def completion_ignore(self, the_input):
mathieui's avatar
mathieui committed
868
        """Completion for /ignore"""
mathieui's avatar
mathieui committed
869
        userlist = [user.nick for user in self.users]
870
871
872
        if self.own_nick in userlist:
            userlist.remove(self.own_nick)
        userlist.sort()
873
        return the_input.auto_completion(userlist, quotify=False)
mathieui's avatar
mathieui committed
874

mathieui's avatar
mathieui committed
875
876
    def completion_role(self, the_input):
        """Completion for /role"""
877
878
        n = the_input.get_argument_position(quoted=True)
        if n == 1:
mathieui's avatar
mathieui committed
879
            userlist = [user.nick for user in self.users]
880
881
            if self.own_nick in userlist:
                userlist.remove(self.own_nick)
882
883
            return the_input.new_completion(userlist, 1, '', quotify=True)
        elif n == 2:
mathieui's avatar
mathieui committed
884
            possible_roles = ['none', 'visitor', 'participant', 'moderator']
885
            return the_input.new_completion(possible_roles, 2, '', quotify=True)
mathieui's avatar
mathieui committed
886

mathieui's avatar
mathieui committed
887
888
    def completion_affiliation(self, the_input):
        """Completion for /affiliation"""
889
890
        n = the_input.get_argument_position(quoted=True)
        if n == 1:
mathieui's avatar
mathieui committed
891
            userlist = [user.nick for user in self.users]
892
893
            if self.own_nick in userlist:
                userlist.remove(self.own_nick)
894
            jidlist = [user.jid.bare for user in self.users]
895
896
            if self.core.xmpp.boundjid.bare in jidlist:
                jidlist.remove(self.core.xmpp.boundjid.bare)
897
            userlist.extend(jidlist)
898
899
            return the_input.new_completion(userlist, 1, '', quotify=True)
        elif n == 2:
900
            possible_affiliations = ['none', 'member', 'admin', 'owner', 'outcast']
901
            return the_input.new_completion(possible_affiliations, 2,  '', quotify=True)
mathieui's avatar
mathieui committed
902

903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
    def command_invite(self, args):
        """/invite <jid> [reason]"""
        args = common.shell_split(args)
        if len(args) == 1:
            jid, reason = args[0], ''
        elif len(args) == 2:
            jid, reason = args
        else:
            return self.core.command_help('invite')
        self.core.command_invite('%s %s "%s"' % (jid, self.name, reason))

    def completion_invite(self, the_input):
        """Completion for /invite"""
        n = the_input.get_argument_position(quoted=True)
        if n == 1:
            return the_input.new_completion(roster.jids(), 1, quotify=True)

920
921
    def scroll_user_list_up(self):
        self.user_win.scroll_up()
922
        self.user_win.refresh(self.users)
923
        self.input.refresh()
924
925
926

    def scroll_user_list_down(self):
        self.user_win.scroll_down()
927
        self.user_win.refresh(self.users)
928
        self.input.refresh()
929

930
    def command_info(self, arg):
931
932
933
934
        """
        /info <nick>
        """
        if not arg:
mathieui's avatar
mathieui committed
935
            return self.core.command_help('info')
936
        user = self.get_user_by_name(arg)
937
        if not user:
938
            return self.core.information("Unknown user: %s" % arg)
939
940
941
942
943
944
        theme = get_theme()
        info = '\x19%s}%s\x19o%s: show: \x19%s}%s\x19o, affiliation: \x19%s}%s\x19o, role: \x19%s}%s\x19o%s' % (
                        dump_tuple(user.color),
                        arg,
                        (' (\x19%s}%s\x19o)' % (dump_tuple(theme.COLOR_MUC_JID), user.jid)) if user.jid != '' else '',
                        dump_tuple(theme.color_show(user.show)),
945
                        user.show or 'Available',
946
                        dump_tuple(theme.color_role(user.role)),
947
                        user.affiliation or 'None',
948
                        dump_tuple(theme.color_role(user.role)),
949
                        user.role or 'None',
950
                        '\n%s' % user.status if user.status else '')
951
        self.core.information(info, 'Info')
952

louiz’'s avatar
louiz’ committed
953
    def command_configure(self, arg):
mathieui's avatar
mathieui committed
954
        form = fixes.get_room_form(self.core.xmpp, self.get_name())
955
        if not form:
mathieui's avatar
mathieui committed
956
            self.core.information('Could not retrieve the configuration form', 'Error')
957
            return
louiz’'s avatar
louiz’ committed
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
        self.core.open_new_form(form, self.cancel_config, self.send_config)

    def cancel_config(self, form):
        """
        The user do not want to send his/her config, send an iq cancel
        """
        self.core.xmpp.plugin['xep_0045'].cancelConfig(self.get_name())
        self.core.close_tab()

    def send_config(self, form):
        """
        The user sends his/her config to the server
        """
        self.core.xmpp.plugin['xep_0045'].configureRoom(self.get_name(), form)
        self.core.close_tab()

974
    def command_cycle(self, arg):
mathieui's avatar
mathieui committed
975
        """/cycle [reason]"""
976
977
978
979
        if self.joined:
            muc.leave_groupchat(self.core.xmpp, self.get_name(), self.own_nick, arg)
        self.disconnect()
        self.core.disable_private_tabs(self.name)
980
        self.core.command_join('"/%s"' % self.own_nick)
mathieui's avatar
mathieui committed
981
        self.user_win.pos = 0
982

983
984
    def command_recolor(self, arg):
        """
985
        /recolor [random]
986
987
        Re-assign color to the participants of the room
        """
988
        arg = arg.strip()
989
        compare_users = lambda x: x.last_talked
990
        users = list(self.users)
991
992
993
        sorted_users = sorted(users, key=compare_users, reverse=True)
        # search our own user, to remove it from the list
        for user in sorted_users:
994
            if user.nick == self.own_nick:
995
996
                sorted_users.remove(user)
                user.color = get_theme().COLOR_OWN_NICK
louiz’'s avatar
louiz’ committed
997
        colors = list(get_theme().LIST_COLOR_NICKNAMES)
998
999
        if arg and arg == 'random':
            random.shuffle(colors)
1000
        for i, user in enumerate(sorted_users):
For faster browsing, not all history is shown. View entire blame