conversation_textview.py 50.6 KB
Newer Older
Philipp Hörist's avatar
Philipp Hörist committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
# Copyright (C) 2005 Norman Rasmussen <norman AT rasmussen.co.za>
# Copyright (C) 2005-2006 Alex Mauer <hawke AT hawkesnest.net>
#                         Travis Shirk <travis AT pobox.com>
# Copyright (C) 2005-2007 Nikos Kouremenos <kourem AT gmail.com>
# Copyright (C) 2005-2014 Yann Leboulanger <asterix AT lagaule.org>
# Copyright (C) 2006 Dimitur Kirov <dkirov AT gmail.com>
# Copyright (C) 2006-2008 Jean-Marie Traissard <jim AT lapin.org>
# Copyright (C) 2008 Jonathan Schleifer <js-gajim AT webkeks.org>
#                    Julien Pivotto <roidelapluie AT gmail.com>
#                    Stephan Erb <steve-e AT h3c.de>
#
# This file is part of Gajim.
#
# Gajim is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation; version 3 only.
#
# Gajim is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
25 26

import time
27
import queue
28
import urllib
Philipp Hörist's avatar
Philipp Hörist committed
29 30 31 32 33 34
import logging

from gi.repository import Gtk
from gi.repository import Pango
from gi.repository import GObject
from gi.repository import GLib
35 36
from gi.repository import Gdk

37
from gajim.common import app
André's avatar
André committed
38 39
from gajim.common import helpers
from gajim.common import i18n
40
from gajim.common.i18n import _
41
from gajim.common.i18n import Q_
42
from gajim.common.helpers import AdditionalDataDict
43 44
from gajim.common.const import StyleAttr
from gajim.common.const import Trust
45
from gajim.common.const import URI_SCHEMES
46
from gajim.common.helpers import to_user_string
47

48 49
from gajim.gtk import util
from gajim.gtk.util import get_cursor
50
from gajim.gtk.util import format_fingerprint
51
from gajim.gtk.util import text_to_color
52 53 54
from gajim.gtk.emoji_data import emoji_pixbufs
from gajim.gtk.emoji_data import is_emoji
from gajim.gtk.emoji_data import get_emoji_pixbuf
55
from gajim.gtk.htmltextview import HtmlTextView
56

57 58 59
NOT_SHOWN = 0
ALREADY_RECEIVED = 1
SHOWN = 2
60

61 62
log = logging.getLogger('gajim.conversation_textview')

63 64 65 66 67 68 69 70 71
TRUST_SYMBOL_DATA = {
    Trust.UNTRUSTED: ('dialog-error-symbolic',
                      _('Untrusted'),
                      'error-color'),
    Trust.UNDECIDED: ('security-low-symbolic',
                      _('Trust Not Decided'),
                      'warning-color'),
    Trust.BLIND: ('security-medium-symbolic',
                  _('Unverified'),
72
                  'encrypted-color'),
73 74 75 76 77 78
    Trust.VERIFIED: ('security-high-symbolic',
                     _('Verified'),
                     'encrypted-color')
}


79
class ConversationTextview(GObject.GObject):
80 81 82 83
    """
    Class for the conversation textview (where user reads already said messages)
    for chat/groupchat windows
    """
84 85 86 87 88 89 90
    __gsignals__ = dict(quote=(
        GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION,
        None, # return value
        (str, ) # arguments
        ))

    def __init__(self, account, used_in_history_window=False):
91 92 93 94
        """
        If used_in_history_window is True, then we do not show Clear menuitem in
        context menu
        """
95
        GObject.GObject.__init__(self)
96
        self.used_in_history_window = used_in_history_window
97
        self.line = 0
98
        self._message_list = []
99
        self.corrected_text_list = {}
100

Emmanuel Gil Peyrot's avatar
Emmanuel Gil Peyrot committed
101
        # no need to inherit TextView, use it as attribute is safer
Philipp Hörist's avatar
Philipp Hörist committed
102
        self.tv = HtmlTextView(account)
103
        self.tv.connect('query-tooltip', self._query_tooltip)
104

105
        self._buffer = self.tv.get_buffer()
106 107
        self.handlers = {}
        self.image_cache = {}
108
        # self.last_sent_message_id = message_id
109
        self.last_sent_message_id = None
110
        # last_received_message_id[name] = (message_id, line_start_mark)
111
        self.last_received_message_id = {}
Philipp Hörist's avatar
Philipp Hörist committed
112
        self.autoscroll = True
113 114 115 116 117 118 119 120
        # connect signals
        id_ = self.tv.connect('populate_popup', self.on_textview_populate_popup)
        self.handlers[id_] = self.tv
        id_ = self.tv.connect('button_press_event',
                self.on_textview_button_press_event)
        self.handlers[id_] = self.tv

        self.account = account
121
        self._cursor_changed = False
122
        self.last_time_printout = 0
123
        self.encryption_enabled = False
124

125
        style = self.tv.get_style_context()
Philipp Hörist's avatar
Philipp Hörist committed
126
        style.add_class('gajim-conversation-font')
127 128 129 130 131
        buffer_ = self.tv.get_buffer()
        end_iter = buffer_.get_end_iter()
        buffer_.create_mark('end', end_iter, False)

        self.tagIn = buffer_.create_tag('incoming')
Philipp Hörist's avatar
Philipp Hörist committed
132 133
        color = app.css_config.get_value(
            '.gajim-incoming-nickname', StyleAttr.COLOR)
134
        self.tagIn.set_property('foreground', color)
Philipp Hörist's avatar
Philipp Hörist committed
135 136
        desc = app.css_config.get_font('.gajim-incoming-nickname')
        self.tagIn.set_property('font-desc', desc)
137 138

        self.tagOut = buffer_.create_tag('outgoing')
Philipp Hörist's avatar
Philipp Hörist committed
139 140
        color = app.css_config.get_value(
            '.gajim-outgoing-nickname', StyleAttr.COLOR)
141
        self.tagOut.set_property('foreground', color)
Philipp Hörist's avatar
Philipp Hörist committed
142 143
        desc = app.css_config.get_font('.gajim-outgoing-nickname')
        self.tagOut.set_property('font-desc', desc)
144 145

        self.tagStatus = buffer_.create_tag('status')
Philipp Hörist's avatar
Philipp Hörist committed
146 147
        color = app.css_config.get_value(
            '.gajim-status-message', StyleAttr.COLOR)
148
        self.tagStatus.set_property('foreground', color)
Philipp Hörist's avatar
Philipp Hörist committed
149 150
        desc = app.css_config.get_font('.gajim-status-message')
        self.tagStatus.set_property('font-desc', desc)
151 152

        self.tagInText = buffer_.create_tag('incomingtxt')
Philipp Hörist's avatar
Philipp Hörist committed
153 154
        color = app.css_config.get_value(
            '.gajim-incoming-message-text', StyleAttr.COLOR)
155 156
        if color:
            self.tagInText.set_property('foreground', color)
Philipp Hörist's avatar
Philipp Hörist committed
157 158
        desc = app.css_config.get_font('.gajim-incoming-message-text')
        self.tagInText.set_property('font-desc', desc)
159 160

        self.tagOutText = buffer_.create_tag('outgoingtxt')
Philipp Hörist's avatar
Philipp Hörist committed
161 162
        color = app.css_config.get_value(
            '.gajim-outgoing-message-text', StyleAttr.COLOR)
163
        if color:
Philipp Hörist's avatar
Philipp Hörist committed
164 165 166
            self.tagOutText.set_property('foreground', color)
        desc = app.css_config.get_font('.gajim-outgoing-message-text')
        self.tagOutText.set_property('font-desc', desc)
167

168
        self.tagMarked = buffer_.create_tag('marked')
Philipp Hörist's avatar
Philipp Hörist committed
169 170
        color = app.css_config.get_value(
            '.gajim-highlight-message', StyleAttr.COLOR)
171 172
        self.tagMarked.set_property('foreground', color)
        self.tagMarked.set_property('weight', Pango.Weight.BOLD)
173

174
        textview_icon = buffer_.create_tag('textview-icon')
175
        textview_icon.set_property('rise', Pango.units_from_double(-2.45))
176

177 178 179
        # To help plugins easily identify the nickname
        buffer_.create_tag('nickname')

180 181
        tag = buffer_.create_tag('time_sometimes')
        tag.set_property('foreground', 'darkgrey')
182 183 184
        #Pango.SCALE_SMALL
        tag.set_property('scale', 0.8333333333333)
        tag.set_property('justification', Gtk.Justification.CENTER)
185 186

        tag = buffer_.create_tag('small')
187 188
        #Pango.SCALE_SMALL
        tag.set_property('scale', 0.8333333333333)
189

190
        self.tv.create_tags()
191

192
        tag = buffer_.create_tag('bold')
193
        tag.set_property('weight', Pango.Weight.BOLD)
194 195

        tag = buffer_.create_tag('italic')
196
        tag.set_property('style', Pango.Style.ITALIC)
197

198 199
        tag = buffer_.create_tag('strikethrough')
        tag.set_property('strikethrough', True)
200

201
        buffer_.create_tag('focus-out-line', justification=Gtk.Justification.CENTER)
202
        self.displaymarking_tags = {}
203

Emmanuel Gil Peyrot's avatar
Emmanuel Gil Peyrot committed
204
        # One mark at the beginning then 2 marks between each lines
Philipp Hörist's avatar
Philipp Hörist committed
205
        size = app.settings.get('max_conversation_lines')
206
        size = 2 * size - 1
207
        self.marks_queue = queue.Queue(size)
208 209 210 211 212

        self.allow_focus_out_line = True
        # holds a mark at the end of --- line
        self.focus_out_end_mark = None

Yann Leboulanger's avatar
Yann Leboulanger committed
213
        self.just_cleared = False
214

215
    def _query_tooltip(self, widget, x_pos, y_pos, keyboard_mode, tooltip):
216
        window = widget.get_window(Gtk.TextWindowType.TEXT)
217 218
        x_pos, y_pos = self.tv.window_to_buffer_coords(
            Gtk.TextWindowType.TEXT, x_pos, y_pos)
219 220

        iter_ = self.tv.get_iter_at_position(x_pos, y_pos)[1]
221 222 223 224 225 226 227 228
        for tag in iter_.get_tags():
            tag_name = tag.get_property('name')
            if tag_name == 'focus-out-line':
                tooltip.set_text(_(
                    'Text below this line is what has '
                    'been said since the\nlast time you paid attention to this '
                    'group chat'))
                return True
229 230 231 232 233 234
            if getattr(tag, 'is_anchor', False):
                text = getattr(tag, 'title', False)
                if text:
                    if len(text) > 50:
                        text = text[:47] + '…'
                    tooltip.set_text(text)
235
                    window.set_cursor(get_cursor('pointer'))
236
                    self._cursor_changed = True
237 238
                    return True
            if tag_name in ('url', 'mail', 'xmpp', 'sth_at_sth'):
239
                window.set_cursor(get_cursor('pointer'))
240
                self._cursor_changed = True
241
                return False
242

243
        if self._cursor_changed:
244
            window.set_cursor(get_cursor('text'))
245
            self._cursor_changed = False
246 247
        return False

248
    def del_handlers(self):
249
        for i in self.handlers:
250 251 252 253 254 255
            if self.handlers[i].handler_is_connected(i):
                self.handlers[i].disconnect(i)
        del self.handlers
        self.tv.destroy()

    def update_tags(self):
Philipp Hörist's avatar
Philipp Hörist committed
256 257
        self.tagIn.set_property('foreground', app.css_config.get_value('.gajim-incoming-nickname', StyleAttr.COLOR))
        self.tagOut.set_property('foreground', app.css_config.get_value('.gajim-outgoing-nickname', StyleAttr.COLOR))
258
        self.tagStatus.set_property('foreground',
Philipp Hörist's avatar
Philipp Hörist committed
259
            app.css_config.get_value('.gajim-status-message', StyleAttr.COLOR))
260
        self.tagMarked.set_property('foreground',
Philipp Hörist's avatar
Philipp Hörist committed
261
            app.css_config.get_value('.gajim-highlight-message', StyleAttr.COLOR))
262
        self.tv.update_tags()
263

Philipp Hörist's avatar
Philipp Hörist committed
264 265
    def scroll_to_end(self, force=False):
        if self.autoscroll or force:
266
            GLib.idle_add(util.scroll_to_end, self.tv.get_parent())
267

268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286
    def correct_message(self, correct_id, kind, name):
        allowed = True
        if kind == 'incoming':
            try:
                if correct_id in self.last_received_message_id[name]:
                    start_mark = self.last_received_message_id[name][1]
                else:
                    allowed = False
            except KeyError:
                allowed = False
        elif kind == 'outgoing':
            if self.last_sent_message_id[0] == correct_id:
                start_mark = self.last_sent_message_id[1]
            else:
                allowed = False
        else:
            allowed = False

        if not allowed:
Alexander Krotov's avatar
Alexander Krotov committed
287
            log.debug('Message correction not allowed')
288 289 290 291 292 293
            return None

        end_mark, index = self.get_end_mark(correct_id, start_mark)
        if not index:
            log.debug('Could not find line to correct')
            return None
294 295

        buffer_ = self.tv.get_buffer()
296 297 298 299 300 301 302 303 304 305 306 307
        if not end_mark:
            end_iter = self.tv.get_buffer().get_end_iter()
        else:
            end_iter = buffer_.get_iter_at_mark(end_mark)

        start_iter = buffer_.get_iter_at_mark(start_mark)

        old_txt = buffer_.get_text(start_iter, end_iter, True)
        buffer_.delete(start_iter, end_iter)
        buffer_.delete_mark(start_mark)

        return index, end_mark, old_txt
308

Philipp Hörist's avatar
Philipp Hörist committed
309
    def show_receipt(self, id_):
310 311
        line = self._get_message_line(id_)
        if line is None:
312
            return
313
        line.set_receipt()
314

315 316 317 318 319 320
    def show_displayed(self, id_):
        line = self._get_message_line(id_)
        if line is None:
            return
        line.set_displayed()

321 322 323 324
    def show_error(self, id_, error):
        line = self._get_message_line(id_)
        if line is None:
            return
325
        line.set_error(to_user_string(error))
326

Philipp Hörist's avatar
Philipp Hörist committed
327
    def show_focus_out_line(self):
328 329
        if not self.allow_focus_out_line:
            # if room did not receive focus-in from the last time we added
Emmanuel Gil Peyrot's avatar
Emmanuel Gil Peyrot committed
330
            # --- line then do not add again
331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358
            return

        print_focus_out_line = False
        buffer_ = self.tv.get_buffer()

        if self.focus_out_end_mark is None:
            # this happens only first time we focus out on this room
            print_focus_out_line = True

        else:
            focus_out_end_iter = buffer_.get_iter_at_mark(self.focus_out_end_mark)
            focus_out_end_iter_offset = focus_out_end_iter.get_offset()
            if focus_out_end_iter_offset != buffer_.get_end_iter().get_offset():
                # this means after last-focus something was printed
                # (else end_iter's offset is the same as before)
                # only then print ---- line (eg. we avoid printing many following
                # ---- lines)
                print_focus_out_line = True

        if print_focus_out_line and buffer_.get_char_count() > 0:
            buffer_.begin_user_action()

            # remove previous focus out line if such focus out line exists
            if self.focus_out_end_mark is not None:
                end_iter_for_previous_line = buffer_.get_iter_at_mark(
                        self.focus_out_end_mark)
                begin_iter_for_previous_line = end_iter_for_previous_line.copy()
                # img_char+1 (the '\n')
359
                begin_iter_for_previous_line.backward_chars(21)
360 361 362 363 364 365 366 367

                # remove focus out line
                buffer_.delete(begin_iter_for_previous_line,
                        end_iter_for_previous_line)
                buffer_.delete_mark(self.focus_out_end_mark)

            # add the new focus out line
            end_iter = buffer_.get_end_iter()
368
            buffer_.insert(end_iter, '\n' + '―' * 20)
369 370 371 372

            end_iter = buffer_.get_end_iter()
            before_img_iter = end_iter.copy()
            # one char back (an image also takes one char)
373
            before_img_iter.backward_chars(20)
374 375 376 377 378 379 380 381 382
            buffer_.apply_tag_by_name('focus-out-line', before_img_iter, end_iter)

            self.allow_focus_out_line = False

            # update the iter we hold to make comparison the next time
            self.focus_out_end_mark = buffer_.create_mark(None,
                    buffer_.get_end_iter(), left_gravity=True)

            buffer_.end_user_action()
Philipp Hörist's avatar
Philipp Hörist committed
383
            self.scroll_to_end()
384

385
    def clear(self, tv=None):
386 387 388 389 390 391
        """
        Clear text in the textview
        """
        buffer_ = self.tv.get_buffer()
        start, end = buffer_.get_bounds()
        buffer_.delete(start, end)
Philipp Hörist's avatar
Philipp Hörist committed
392
        size = app.settings.get('max_conversation_lines')
393
        size = 2 * size - 1
394
        self.marks_queue = queue.Queue(size)
395
        self.focus_out_end_mark = None
396
        self.just_cleared = True
397 398 399 400 401

    def visit_url_from_menuitem(self, widget, link):
        """
        Basically it filters out the widget instance
        """
402
        helpers.open_uri(link)
403 404 405 406 407 408 409 410 411 412

    def on_textview_populate_popup(self, textview, menu):
        """
        Override the default context menu and we prepend Clear (only if
        used_in_history_window is False) and if we have sth selected we show a
        submenu with actions on the phrase (see
        on_conversation_textview_button_press_event)
        """
        separator_menuitem_was_added = False
        if not self.used_in_history_window:
Dicson's avatar
Dicson committed
413
            item = Gtk.SeparatorMenuItem.new()
414 415 416
            menu.prepend(item)
            separator_menuitem_was_added = True

417
            item = Gtk.MenuItem.new_with_mnemonic(_('_Clear'))
418 419 420 421 422 423
            menu.prepend(item)
            id_ = item.connect('activate', self.clear)
            self.handlers[id_] = item

        if self.selected_phrase:
            if not separator_menuitem_was_added:
Dicson's avatar
Dicson committed
424
                item = Gtk.SeparatorMenuItem.new()
425 426 427
                menu.prepend(item)

            if not self.used_in_history_window:
Dicson's avatar
Dicson committed
428
                item = Gtk.MenuItem.new_with_mnemonic(_('_Quote'))
429 430 431 432 433 434
                id_ = item.connect('activate', self.on_quote)
                self.handlers[id_] = item
                menu.prepend(item)

            _selected_phrase = helpers.reduce_chars_newlines(
                    self.selected_phrase, 25, 2)
Dicson's avatar
Dicson committed
435 436
            item = Gtk.MenuItem.new_with_mnemonic(
                _('_Actions for "%s"') % _selected_phrase)
437
            menu.prepend(item)
438
            submenu = Gtk.Menu()
439
            item.set_submenu(submenu)
Dicson's avatar
Dicson committed
440 441
            phrase_for_url = urllib.parse.quote(self.selected_phrase.encode(
                'utf-8'))
442

Philipp Hörist's avatar
Philipp Hörist committed
443
            always_use_en = app.settings.get('always_english_wikipedia')
444
            if always_use_en:
Philipp Hörist's avatar
Philipp Hörist committed
445
                link = 'https://en.wikipedia.org/wiki/Special:Search?search=%s'\
446
                        % phrase_for_url
447
            else:
Philipp Hörist's avatar
Philipp Hörist committed
448
                link = 'https://%s.wikipedia.org/wiki/Special:Search?search=%s'\
Philipp Hörist's avatar
Philipp Hörist committed
449
                        % (i18n.get_short_lang_code(), phrase_for_url)
Dicson's avatar
Dicson committed
450
            item = Gtk.MenuItem.new_with_mnemonic(_('Read _Wikipedia Article'))
451 452 453 454
            id_ = item.connect('activate', self.visit_url_from_menuitem, link)
            self.handlers[id_] = item
            submenu.append(item)

Dicson's avatar
Dicson committed
455
            item = Gtk.MenuItem.new_with_mnemonic(_('Look it up in _Dictionary'))
Philipp Hörist's avatar
Philipp Hörist committed
456
            dict_link = app.settings.get('dictionary_url')
457 458
            if dict_link == 'WIKTIONARY':
                # special link (yeah undocumented but default)
Philipp Hörist's avatar
Philipp Hörist committed
459
                always_use_en = app.settings.get('always_english_wiktionary')
460
                if always_use_en:
Philipp Hörist's avatar
Philipp Hörist committed
461
                    link = 'https://en.wiktionary.org/wiki/Special:Search?search=%s'\
462
                            % phrase_for_url
463
                else:
Philipp Hörist's avatar
Philipp Hörist committed
464
                    link = 'https://%s.wiktionary.org/wiki/Special:Search?search=%s'\
Philipp Hörist's avatar
Philipp Hörist committed
465
                            % (i18n.get_short_lang_code(), phrase_for_url)
466 467 468 469 470
                id_ = item.connect('activate', self.visit_url_from_menuitem, link)
                self.handlers[id_] = item
            else:
                if dict_link.find('%s') == -1:
                    # we must have %s in the url if not WIKTIONARY
471
                    item = Gtk.MenuItem.new_with_label(_(
472 473 474
                            'Dictionary URL is missing an "%s" and it is not WIKTIONARY'))
                    item.set_property('sensitive', False)
                else:
475
                    link = dict_link % phrase_for_url
476 477 478 479 480 481
                    id_ = item.connect('activate', self.visit_url_from_menuitem,
                            link)
                    self.handlers[id_] = item
            submenu.append(item)


Philipp Hörist's avatar
Philipp Hörist committed
482
            search_link = app.settings.get('search_engine')
483 484
            if search_link.find('%s') == -1:
                # we must have %s in the url
485 486
                item = Gtk.MenuItem.new_with_label(
                    _('Web Search URL is missing an "%s"'))
487 488
                item.set_property('sensitive', False)
            else:
Dicson's avatar
Dicson committed
489
                item = Gtk.MenuItem.new_with_mnemonic(_('Web _Search for it'))
490
                link = search_link % phrase_for_url
491 492 493 494
                id_ = item.connect('activate', self.visit_url_from_menuitem, link)
                self.handlers[id_] = item
            submenu.append(item)

Dicson's avatar
Dicson committed
495
            item = Gtk.MenuItem.new_with_mnemonic(_('Open as _Link'))
496 497 498 499 500 501 502 503 504 505
            id_ = item.connect('activate', self.visit_url_from_menuitem, link)
            self.handlers[id_] = item
            submenu.append(item)

        menu.show_all()

    def on_quote(self, widget):
        self.emit('quote', self.selected_phrase)

    def on_textview_button_press_event(self, widget, event):
Alexander Krotov's avatar
Alexander Krotov committed
506
        # If we clicked on a tagged text do NOT open the standard popup menu
507
        # if normal text check if we have sth selected
Alexander Krotov's avatar
Alexander Krotov committed
508
        self.selected_phrase = '' # do not move below event button check!
509 510 511 512

        if event.button != 3: # if not right click
            return False

513
        x, y = self.tv.window_to_buffer_coords(Gtk.TextWindowType.TEXT,
514 515
                int(event.x), int(event.y))
        iter_ = self.tv.get_iter_at_location(x, y)
516 517
        if isinstance(iter_, tuple):
            iter_ = iter_[1]
518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533
        tags = iter_.get_tags()

        if tags: # we clicked on sth special (it can be status message too)
            for tag in tags:
                tag_name = tag.get_property('name')
                if tag_name in ('url', 'mail', 'xmpp', 'sth_at_sth'):
                    return True # we block normal context menu

        # we check if sth was selected and if it was we assign
        # selected_phrase variable
        # so on_conversation_textview_populate_popup can use it
        buffer_ = self.tv.get_buffer()
        return_val = buffer_.get_selection_bounds()
        if return_val: # if sth was selected when we right-clicked
            # get the selected text
            start_sel, finish_sel = return_val[0], return_val[1]
Dicson's avatar
Dicson committed
534 535
            self.selected_phrase = buffer_.get_text(start_sel, finish_sel, True)
        elif iter_.get_char() and ord(iter_.get_char()) > 31:
536 537 538 539 540 541 542
            # we clicked on a word, do as if it's selected for context menu
            start_sel = iter_.copy()
            if not start_sel.starts_word():
                start_sel.backward_word_start()
            finish_sel = iter_.copy()
            if not finish_sel.ends_word():
                finish_sel.forward_word_end()
Dicson's avatar
Dicson committed
543
            self.selected_phrase = buffer_.get_text(start_sel, finish_sel, True)
544

545
    def detect_and_print_special_text(self, otext, other_tags, graphics=True,
546
    iter_=None, additional_data=None):
547 548 549 550 551 552 553
        """
        Detect special text (emots & links & formatting), print normal text
        before any special text it founds, then print special text (that happens
        many times until last special text is printed) and then return the index
        after *last* special text, so we can print it in
        print_conversation_line()
        """
Dicson's avatar
Dicson committed
554 555
        if not otext:
            return
556
        if additional_data is None:
557
            additional_data = AdditionalDataDict()
558
        buffer_ = self.tv.get_buffer()
559 560 561 562
        if other_tags:
            insert_tags_func = buffer_.insert_with_tags_by_name
        else:
            insert_tags_func = buffer_.insert
563
        # detect_and_print_special_text() is also used by
564
        # HtmlHandler.handle_specials() and there tags is Gtk.TextTag objects,
565
        # not strings
566
        if other_tags and isinstance(other_tags[0], Gtk.TextTag):
567 568 569 570 571 572 573 574 575
            insert_tags_func = buffer_.insert_with_tags

        index = 0

        # Too many special elements (emoticons, LaTeX formulas, etc)
        # may cause Gajim to freeze (see #5129).
        # We impose an arbitrary limit of 100 specials per message.
        specials_limit = 100

Philipp Hörist's avatar
Philipp Hörist committed
576
        # add oob text to the end
577 578 579 580

        oob_url = additional_data.get_value('gajim', 'oob_url')
        if oob_url is not None:
            oob_desc = additional_data.get_value('gajim', 'oob_desc', 'URL:')
581 582
            if oob_url != otext:
                otext += '\n{} {}'.format(oob_desc, oob_url)
Philipp Hörist's avatar
Philipp Hörist committed
583

584
        # basic: links + mail + formatting is always checked (we like that)
Philipp Hörist's avatar
Philipp Hörist committed
585
        if app.settings.get('emoticons_theme') and graphics:
586
            # search for emoticons & urls
587
            iterator = app.interface.emot_and_basic_re.finditer(otext)
588
        else: # search for just urls + mail + formatting
589
            iterator = app.interface.basic_pattern_re.finditer(otext)
590 591 592 593
        if iter_:
            end_iter = iter_
        else:
            end_iter = buffer_.get_end_iter()
594 595 596 597 598
        for match in iterator:
            start, end = match.span()
            special_text = otext[start:end]
            if start > index:
                text_before_special_text = otext[index:start]
599 600
                if not iter_:
                    end_iter = buffer_.get_end_iter()
601
                # we insert normal text
602 603 604 605
                if other_tags:
                    insert_tags_func(end_iter, text_before_special_text, *other_tags)
                else:
                    buffer_.insert(end_iter, text_before_special_text)
606 607 608
            index = end # update index

            # now print it
609
            self.print_special_text(special_text, other_tags, graphics=graphics,
610
                iter_=end_iter, additional_data=additional_data)
611 612 613 614 615 616 617
            specials_limit -= 1
            if specials_limit <= 0:
                break

        # add the rest of text located in the index and after
        insert_tags_func(end_iter, otext[index:], *other_tags)

618
        return end_iter
619

620
    def print_special_text(self, special_text, other_tags, graphics=True,
621
    iter_=None, additional_data=None):
622 623 624 625
        """
        Is called by detect_and_print_special_text and prints special text
        (emots, links, formatting)
        """
626
        if additional_data is None:
627
            additional_data = AdditionalDataDict()
628 629 630

        # PluginSystem: adding GUI extension point for ConversationTextview
        self.plugin_modified = False
631
        app.plugin_manager.extension_point('print_special_text', self,
632
            special_text, other_tags, graphics, additional_data, iter_)
633 634 635
        if self.plugin_modified:
            return

636 637 638
        tags = []
        use_other_tags = True
        text_is_valid_uri = False
Dicson's avatar
Dicson committed
639
        is_xhtml_link = None
640
        show_ascii_formatting_chars = \
Philipp Hörist's avatar
Philipp Hörist committed
641
            app.settings.get('show_ascii_formatting_chars')
642 643
        buffer_ = self.tv.get_buffer()

644 645 646 647 648 649 650 651
        # Detect XHTML-IM link
        ttt = buffer_.get_tag_table()
        tags_ = [(ttt.lookup(t) if isinstance(t, str) else t) for t in other_tags]
        for t in tags_:
            is_xhtml_link = getattr(t, 'href', None)
            if is_xhtml_link:
                break

652
        # Check if we accept this as an uri
653
        for scheme in URI_SCHEMES:
654
            if special_text.startswith(scheme):
655 656
                text_is_valid_uri = True

657 658 659 660
        if iter_:
            end_iter = iter_
        else:
            end_iter = buffer_.get_end_iter()
661

Philipp Hörist's avatar
Philipp Hörist committed
662
        theme = app.settings.get('emoticons_theme')
Philipp Hörist's avatar
Philipp Hörist committed
663 664
        show_emojis = theme and theme != 'font'
        if show_emojis and graphics and is_emoji(special_text):
665
            # it's an emoticon
Philipp Hörist's avatar
Philipp Hörist committed
666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687
            if emoji_pixbufs.complete:
                # only search for the pixbuf if we are sure
                # that loading is completed
                pixbuf = get_emoji_pixbuf(special_text)
                if pixbuf is None:
                    buffer_.insert(end_iter, special_text)
                else:
                    pixbuf = pixbuf.copy()
                    anchor = buffer_.create_child_anchor(end_iter)
                    anchor.plaintext = special_text
                    img = Gtk.Image.new_from_pixbuf(pixbuf)
                    img.show()
                    self.tv.add_child_at_anchor(img, anchor)
            else:
                # Set marks and save them so we can replace the emojis
                # once the loading is complete
                start_mark = buffer_.create_mark(None, end_iter, True)
                buffer_.insert(end_iter, special_text)
                end_mark = buffer_.create_mark(None, end_iter, True)
                emoji_pixbufs.append_marks(
                    self.tv, start_mark, end_mark, special_text)

688 689 690 691
        elif (special_text.startswith('www.') or
              special_text.startswith('ftp.') or
              text_is_valid_uri and not is_xhtml_link):
            tags.append('url')
Dicson's avatar
Dicson committed
692
        elif special_text.startswith('mailto:') and not is_xhtml_link:
693
            tags.append('mail')
Dicson's avatar
Dicson committed
694
        elif special_text.startswith('xmpp:') and not is_xhtml_link:
695
            tags.append('xmpp')
696
        elif app.interface.sth_at_sth_dot_sth_re.match(special_text) and\
Dicson's avatar
Dicson committed
697
        not is_xhtml_link:
698 699
            # it's a JID or mail
            tags.append('sth_at_sth')
Dicson's avatar
Dicson committed
700
        elif special_text.startswith('*'): # it's a bold text
701
            tags.append('bold')
702 703 704
            if special_text[1] == '~' and special_text[-2] == '~' and\
            len(special_text) > 4: # it's also strikethrough
                tags.append('strikethrough')
705
                if not show_ascii_formatting_chars:
706
                    special_text = special_text[2:-2] # remove *~ ~*
707
            elif special_text[1] == '_' and special_text[-2] == '_' and \
708 709
            len(special_text) > 4: # it's also italic
                tags.append('italic')
710 711 712 713 714
                if not show_ascii_formatting_chars:
                    special_text = special_text[2:-2] # remove *_ _*
            else:
                if not show_ascii_formatting_chars:
                    special_text = special_text[1:-1] # remove * *
715 716
        elif special_text.startswith('~'): # it's a strikethrough text
            tags.append('strikethrough')
717 718 719 720
            if special_text[1] == '*' and special_text[-2] == '*' and \
            len(special_text) > 4: # it's also bold
                tags.append('bold')
                if not show_ascii_formatting_chars:
721
                    special_text = special_text[2:-2] # remove ~* *~
722
            elif special_text[1] == '_' and special_text[-2] == '_' and \
723 724
            len(special_text) > 4: # it's also italic
                tags.append('italic')
725
                if not show_ascii_formatting_chars:
726
                    special_text = special_text[2:-2] # remove ~_ _~
727 728
            else:
                if not show_ascii_formatting_chars:
729 730 731
                    special_text = special_text[1:-1] # remove ~ ~
        elif special_text.startswith('_'): # it's an italic text
            tags.append('italic')
732 733 734 735 736
            if special_text[1] == '*' and special_text[-2] == '*' and \
            len(special_text) > 4: # it's also bold
                tags.append('bold')
                if not show_ascii_formatting_chars:
                    special_text = special_text[2:-2] # remove _* *_
737 738 739
            elif special_text[1] == '~' and special_text[-2] == '~' and \
            len(special_text) > 4: # it's also strikethrough
                tags.append('strikethrough')
740
                if not show_ascii_formatting_chars:
741
                    special_text = special_text[2:-2] # remove _~ ~_
742 743 744 745 746 747 748
            else:
                if not show_ascii_formatting_chars:
                    special_text = special_text[1:-1] # remove _ _
        else:
            # It's nothing special
            if use_other_tags:
                insert_tags_func = buffer_.insert_with_tags_by_name
749
                if other_tags and isinstance(other_tags[0], Gtk.TextTag):
750
                    insert_tags_func = buffer_.insert_with_tags
751 752 753 754
                if other_tags:
                    insert_tags_func(end_iter, special_text, *other_tags)
                else:
                    buffer_.insert(end_iter, special_text)
755 756 757 758 759 760 761 762

        if tags:
            all_tags = tags[:]
            if use_other_tags:
                all_tags += other_tags
            # convert all names to TextTag
            all_tags = [(ttt.lookup(t) if isinstance(t, str) else t) for t in all_tags]
            buffer_.insert_with_tags(end_iter, special_text, *all_tags)
763
            if 'url' in tags:
764 765
                puny_text = helpers.puny_encode_url(special_text)
                if puny_text != special_text:
766 767 768
                    puny_tags = []
                    if use_other_tags:
                        puny_tags += other_tags
769 770
                    if not puny_text:
                        puny_text = _('Invalid URL')
771 772
                    puny_tags = [(ttt.lookup(t) if isinstance(t, str) else t) for t in puny_tags]
                    buffer_.insert_with_tags(end_iter, " (%s)" % puny_text, *puny_tags)
773

774
    def print_empty_line(self, iter_=None):
775
        buffer_ = self.tv.get_buffer()
776 777 778
        if not iter_:
            iter_ = buffer_.get_end_iter()
        buffer_.insert_with_tags_by_name(iter_, '\n', 'eol')
779
        self.just_cleared = False
780

781
    def get_end_mark(self, message_id, start_mark):
782 783
        for index, line in enumerate(self._message_list):
            if line.id == message_id and line.start_mark == start_mark:
784
                try:
785
                    end_mark = self._message_list[index + 1].start_mark
786 787 788 789 790 791 792 793 794 795 796 797 798 799 800
                    end_mark_name = end_mark.get_name()
                except IndexError:
                    # We are at the last message
                    end_mark = None
                    end_mark_name = None

                log.debug('start mark: %s, end mark: %s, '
                          'replace message-list index: %s',
                          start_mark.get_name(), end_mark_name, index)

                return end_mark, index
        log.debug('stanza-id not in message list')
        return None, None

    def get_insert_mark(self, timestamp):
801
        # message_list = [(timestamp, line_start_mark, message_id)]
802 803
        # We check if this is a new Message
        try:
804
            if self._message_list[-1].timestamp <= timestamp:
805 806 807 808 809 810 811
                return None, None
        except IndexError:
            # We have no Messages in the TextView
            return None, None

        # Not a new Message
        # Search for insertion point
812 813 814
        for index, line in enumerate(self._message_list):
            if line.timestamp > timestamp:
                return line.start_mark, index
815 816 817 818

        # Should not happen, but who knows
        return None, None

819 820 821 822 823
    def _get_message_line(self, id_):
        for message_line in reversed(self._message_list):
            if message_line.id == id_:
                return message_line

Philipp Hörist's avatar
Philipp Hörist committed
824
    def print_conversation_line(self, text, kind, name, tim,
825
    other_tags_for_name=None, other_tags_for_time=None, other_tags_for_text=None,
Philipp Hörist's avatar
Philipp Hörist committed
826
    subject=None, old_kind=None, graphics=True,
827
    displaymarking=None, message_id=None, correct_id=None, additional_data=None,
828
    marker=None, error=None):
829 830 831
        """
        Print 'chat' type messages
        """
832
        if additional_data is None:
833
            additional_data = AdditionalDataDict()
834 835
        buffer_ = self.tv.get_buffer()
        buffer_.begin_user_action()
836 837
        insert_mark = None
        insert_mark_name = None
838 839 840 841 842

        if kind == 'incoming_queue':
            kind = 'incoming'
        if old_kind == 'incoming_queue':
            old_kind = 'incoming'
843

844
        if not tim:
845 846 847 848 849 850 851 852
            # For outgoing Messages and Status prints
            tim = time.time()

        corrected = False
        if correct_id:
            try:
                index, insert_mark, old_txt = \
                    self.correct_message(correct_id, kind, name)
853
                if correct_id in self.corrected_text_list:
854
                    self.corrected_text_list[message_id] = \
855 856 857 858
                        self.corrected_text_list[correct_id] + '\n{}' \
                        .format(GLib.markup_escape_text(old_txt))
                    del self.corrected_text_list[correct_id]
                else:
859
                    self.corrected_text_list[message_id] = \
860
                        _('<b>Message corrected. Original message:</b>\n{}') \
861
                        .format(GLib.markup_escape_text(old_txt))
862 863 864 865 866 867 868 869 870 871 872 873 874 875 876
                corrected = True
            except TypeError:
                log.debug('Message was not corrected !')

        if not corrected:
            # Get insertion point into TextView
            insert_mark, index = self.get_insert_mark(tim)

        if insert_mark:
            insert_mark_name = insert_mark.get_name()

        log.debug(
            'Printed Line: %s, %s, %s, inserted after: %s'
            ', stanza-id: %s, correct-id: %s',
            self.line, text, tim, insert_mark_name,
<