1# Copyright (C) 2006 Dimitur Kirov <dkirov AT gmail.com>
2# Copyright (C) 2006-2014 Yann Leboulanger <asterix AT lagaule.org>
3# Copyright (C) 2006-2008 Jean-Marie Traissard <jim AT lapin.org>
4#                         Nikos Kouremenos <kourem AT gmail.com>
5#                         Travis Shirk <travis AT pobox.com>
6# Copyright (C) 2007 Lukas Petrovicky <lukas AT petrovicky.net>
7#                    Julien Pivotto <roidelapluie AT gmail.com>
8# Copyright (C) 2007-2008 Brendan Taylor <whateley AT gmail.com>
9#                         Stephan Erb <steve-e AT h3c.de>
10# Copyright (C) 2008 Jonathan Schleifer <js-gajim AT webkeks.org>
11#
12# This file is part of Gajim.
13#
14# Gajim is free software; you can redistribute it and/or modify
15# it under the terms of the GNU General Public License as published
16# by the Free Software Foundation; version 3 only.
17#
18# Gajim is distributed in the hope that it will be useful,
19# but WITHOUT ANY WARRANTY; without even the implied warranty of
20# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21# GNU General Public License for more details.
22#
23# You should have received a copy of the GNU General Public License
24# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
25
26import os
27import sys
28import time
29import uuid
30import tempfile
31
32from gi.repository import Gtk
33from gi.repository import Gdk
34from gi.repository import GLib
35from gi.repository import Gio
36
37from gajim.common import events
38from gajim.common import app
39from gajim.common import helpers
40from gajim.common import ged
41from gajim.common import i18n
42from gajim.common.i18n import _
43from gajim.common.nec import EventHelper
44from gajim.common.helpers import AdditionalDataDict
45from gajim.common.helpers import event_filter
46from gajim.common.contacts import GC_Contact
47from gajim.common.const import Chatstate
48from gajim.common.structs import OutgoingMessage
49
50from gajim import gtkgui_helpers
51
52from gajim.conversation_textview import ConversationTextview
53
54from gajim.gui.dialogs import DialogButton
55from gajim.gui.dialogs import ConfirmationDialog
56from gajim.gui.dialogs import PastePreviewDialog
57from gajim.gui.message_input import MessageInputTextView
58from gajim.gui.util import at_the_end
59from gajim.gui.util import get_show_in_roster
60from gajim.gui.util import get_show_in_systray
61from gajim.gui.util import get_hardware_key_codes
62from gajim.gui.util import get_builder
63from gajim.gui.util import generate_account_badge
64from gajim.gui.const import ControlType  # pylint: disable=unused-import
65from gajim.gui.emoji_chooser import emoji_chooser
66
67from gajim.command_system.implementation.middleware import ChatCommandProcessor
68from gajim.command_system.implementation.middleware import CommandTools
69
70# The members of these modules are not referenced directly anywhere in this
71# module, but still they need to be kept around. Importing them automatically
72# registers the contained CommandContainers with the command system, thereby
73# populating the list of available commands.
74# pylint: disable=unused-import
75from gajim.command_system.implementation import standard
76from gajim.command_system.implementation import execute
77# pylint: enable=unused-import
78
79if app.is_installed('GSPELL'):
80    from gi.repository import Gspell  # pylint: disable=ungrouped-imports
81
82# This is needed so copying text from the conversation textview
83# works with different language layouts. Pressing the key c on a russian
84# layout yields another keyval than with the english layout.
85# So we match hardware keycodes instead of keyvals.
86# Multiple hardware keycodes can trigger a keyval like Gdk.KEY_c.
87KEYCODES_KEY_C = get_hardware_key_codes(Gdk.KEY_c)
88
89if sys.platform == 'darwin':
90    COPY_MODIFIER = Gdk.ModifierType.META_MASK
91    COPY_MODIFIER_KEYS = (Gdk.KEY_Meta_L, Gdk.KEY_Meta_R)
92else:
93    COPY_MODIFIER = Gdk.ModifierType.CONTROL_MASK
94    COPY_MODIFIER_KEYS = (Gdk.KEY_Control_L, Gdk.KEY_Control_R)
95
96
97################################################################################
98class ChatControlBase(ChatCommandProcessor, CommandTools, EventHelper):
99    """
100    A base class containing a banner, ConversationTextview, MessageInputTextView
101    """
102
103    _type = None  # type: ControlType
104
105    def __init__(self, parent_win, widget_name, contact, acct,
106                 resource=None):
107        EventHelper.__init__(self)
108        # Undo needs this variable to know if space has been pressed.
109        # Initialize it to True so empty textview is saved in undo list
110        self.space_pressed = True
111
112        if resource is None:
113            # We very likely got a contact with a random resource.
114            # This is bad, we need the highest for caps etc.
115            _contact = app.contacts.get_contact_with_highest_priority(
116                acct, contact.jid)
117            if _contact and not isinstance(_contact, GC_Contact):
118                contact = _contact
119
120        self.handlers = {}
121        self.parent_win = parent_win
122        self.contact = contact
123        self.account = acct
124        self.resource = resource
125
126        # control_id is a unique id for the control,
127        # its used as action name for actions that belong to a control
128        self.control_id = str(uuid.uuid4())
129        self.session = None
130
131        app.last_message_time[self.account][self.get_full_jid()] = 0
132
133        self.xml = get_builder('%s.ui' % widget_name)
134        self.xml.connect_signals(self)
135        self.widget = self.xml.get_object('%s_hbox' % widget_name)
136
137        self._accounts = app.get_enabled_accounts_with_labels()
138        if len(self._accounts) > 1:
139            account_badge = generate_account_badge(self.account)
140            account_badge.set_tooltip_text(
141                _('Account: %s') % app.get_account_label(self.account))
142            self.xml.account_badge.add(account_badge)
143            account_badge.show()
144
145        # Drag and drop
146        self.xml.overlay.add_overlay(self.xml.drop_area)
147        self.xml.drop_area.hide()
148        self.xml.overlay.connect(
149            'drag-data-received', self._on_drag_data_received)
150        self.xml.overlay.connect('drag-motion', self._on_drag_motion)
151        self.xml.overlay.connect('drag-leave', self._on_drag_leave)
152
153        self.TARGET_TYPE_URI_LIST = 80
154        uri_entry = Gtk.TargetEntry.new(
155            'text/uri-list',
156            Gtk.TargetFlags.OTHER_APP,
157            self.TARGET_TYPE_URI_LIST)
158        dst_targets = Gtk.TargetList.new([uri_entry])
159        dst_targets.add_text_targets(0)
160        self._dnd_list = [uri_entry,
161                          Gtk.TargetEntry.new(
162                              'MY_TREE_MODEL_ROW',
163                              Gtk.TargetFlags.SAME_APP,
164                              0)]
165
166        self.xml.overlay.drag_dest_set(
167            Gtk.DestDefaults.ALL,
168            self._dnd_list,
169            Gdk.DragAction.COPY | Gdk.DragAction.MOVE)
170        self.xml.overlay.drag_dest_set_target_list(dst_targets)
171
172        # Create textviews and connect signals
173        self.conv_textview = ConversationTextview(self.account)
174
175        id_ = self.conv_textview.connect('quote', self.on_quote)
176        self.handlers[id_] = self.conv_textview
177
178        self.conv_textview.tv.connect('key-press-event',
179                                      self._on_conv_textview_key_press_event)
180
181        # This is a workaround: as soon as a line break occurs in Gtk.TextView
182        # with word-char wrapping enabled, a hyphen character is automatically
183        # inserted before the line break. This triggers the hscrollbar to show,
184        # see: https://gitlab.gnome.org/GNOME/gtk/-/issues/2384
185        # Using set_hscroll_policy(Gtk.Scrollable.Policy.NEVER) would cause bad
186        # performance during resize, and prevent the window from being shrunk
187        # horizontally under certain conditions (applies to GroupchatControl)
188        hscrollbar = self.xml.conversation_scrolledwindow.get_hscrollbar()
189        hscrollbar.hide()
190
191        self.xml.conversation_scrolledwindow.add(self.conv_textview.tv)
192        widget = self.xml.conversation_scrolledwindow.get_vadjustment()
193        widget.connect('changed', self.on_conversation_vadjustment_changed)
194
195        vscrollbar = self.xml.conversation_scrolledwindow.get_vscrollbar()
196        vscrollbar.connect('button-release-event',
197                           self._on_scrollbar_button_release)
198
199        self.msg_textview = MessageInputTextView()
200        self.msg_textview.connect('paste-clipboard',
201                                  self._on_message_textview_paste_event)
202        self.msg_textview.connect('key-press-event',
203                                  self._on_message_textview_key_press_event)
204        self.msg_textview.connect('populate-popup',
205                                  self.on_msg_textview_populate_popup)
206        self.msg_textview.get_buffer().connect(
207            'changed', self._on_message_tv_buffer_changed)
208
209        # Send message button
210        self.xml.send_message_button.set_action_name(
211            'win.send-message-%s' % self.control_id)
212        self.xml.send_message_button.set_visible(
213            app.settings.get('show_send_message_button'))
214        app.settings.bind_signal(
215            'show_send_message_button',
216            self.xml.send_message_button,
217            'set_visible')
218
219        self.msg_scrolledwindow = ScrolledWindow()
220        self.msg_scrolledwindow.set_margin_start(3)
221        self.msg_scrolledwindow.set_margin_end(3)
222        self.msg_scrolledwindow.get_style_context().add_class(
223            'message-input-border')
224        self.msg_scrolledwindow.add(self.msg_textview)
225
226        self.xml.hbox.pack_start(self.msg_scrolledwindow, True, True, 0)
227
228        # the following vars are used to keep history of user's messages
229        self.sent_history = []
230        self.sent_history_pos = 0
231        self.received_history = []
232        self.received_history_pos = 0
233        self.orig_msg = None
234
235        # For XEP-0333
236        self.last_msg_id = None
237
238        self.correcting = False
239        self.last_sent_msg = None
240
241        self.set_emoticon_popover()
242
243        # Attach speller
244        self.set_speller()
245        self.conv_textview.tv.show()
246
247        # For XEP-0172
248        self.user_nick = None
249
250        self.command_hits = []
251        self.last_key_tabs = False
252
253        self.sendmessage = True
254
255        con = app.connections[self.account]
256        con.get_module('Chatstate').set_active(self.contact)
257
258        if parent_win is not None:
259            id_ = parent_win.window.connect('motion-notify-event',
260                                            self._on_window_motion_notify)
261            self.handlers[id_] = parent_win.window
262
263        self.encryption = self.get_encryption_state()
264        self.conv_textview.encryption_enabled = self.encryption is not None
265
266        # PluginSystem: adding GUI extension point for ChatControlBase
267        # instance object (also subclasses, eg. ChatControl or GroupchatControl)
268        app.plugin_manager.gui_extension_point('chat_control_base', self)
269
270        # pylint: disable=line-too-long
271        self.register_events([
272            ('our-show', ged.GUI1, self._nec_our_status),
273            ('ping-sent', ged.GUI1, self._nec_ping),
274            ('ping-reply', ged.GUI1, self._nec_ping),
275            ('ping-error', ged.GUI1, self._nec_ping),
276            ('sec-catalog-received', ged.GUI1, self._sec_labels_received),
277            ('style-changed', ged.GUI1, self._style_changed),
278        ])
279        # pylint: enable=line-too-long
280
281        # This is basically a very nasty hack to surpass the inability
282        # to properly use the super, because of the old code.
283        CommandTools.__init__(self)
284
285    def _on_conv_textview_key_press_event(self, textview, event):
286        if event.get_state() & Gdk.ModifierType.SHIFT_MASK:
287            if event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Page_Up):
288                return Gdk.EVENT_PROPAGATE
289
290        if event.keyval in COPY_MODIFIER_KEYS:
291            # Don’t route modifier keys for copy action to the Message Input
292            # otherwise pressing CTRL/META + c (the next event after that)
293            # will not reach the textview (because the Message Input would get
294            # focused).
295            return Gdk.EVENT_PROPAGATE
296
297        if event.get_state() & COPY_MODIFIER:
298            # Don’t reroute the event if it is META + c and the
299            # textview has a selection
300            if event.hardware_keycode in KEYCODES_KEY_C:
301                if textview.get_buffer().props.has_selection:
302                    return Gdk.EVENT_PROPAGATE
303
304        if not self.msg_textview.get_sensitive():
305            # If the input textview is not sensitive it can’t get the focus.
306            # In that case propagate_key_event() would send the event again
307            # to the conversation textview. This would mean a recursion.
308            return Gdk.EVENT_PROPAGATE
309
310        # Focus the Message Input and resend the event
311        self.msg_textview.grab_focus()
312        self.msg_textview.get_toplevel().propagate_key_event(event)
313        return Gdk.EVENT_STOP
314
315    @property
316    def type(self):
317        return self._type
318
319    @property
320    def is_chat(self):
321        return self._type.is_chat
322
323    @property
324    def is_privatechat(self):
325        return self._type.is_privatechat
326
327    @property
328    def is_groupchat(self):
329        return self._type.is_groupchat
330
331    def get_full_jid(self):
332        fjid = self.contact.jid
333        if self.resource:
334            fjid += '/' + self.resource
335        return fjid
336
337    def minimizable(self):
338        """
339        Called to check if control can be minimized
340
341        Derived classes MAY implement this.
342        """
343        return False
344
345    def safe_shutdown(self):
346        """
347        Called to check if control can be closed without losing data.
348        returns True if control can be closed safely else False
349
350        Derived classes MAY implement this.
351        """
352        return True
353
354    def allow_shutdown(self, method, on_response_yes, on_response_no,
355                    on_response_minimize):
356        """
357        Called to check is a control is allowed to shutdown.
358        If a control is not in a suitable shutdown state this method
359        should call on_response_no, else on_response_yes or
360        on_response_minimize
361
362        Derived classes MAY implement this.
363        """
364        on_response_yes(self)
365
366    def focus(self):
367        raise NotImplementedError
368
369    def get_nb_unread(self):
370        jid = self.contact.jid
371        if self.resource:
372            jid += '/' + self.resource
373        return len(app.events.get_events(
374            self.account,
375            jid,
376            ['printed_%s' % self._type, str(self._type)]))
377
378    def draw_banner(self):
379        """
380        Draw the fat line at the top of the window
381        that houses the icon, jid, etc
382
383        Derived types MAY implement this.
384        """
385        self.draw_banner_text()
386
387    def update_toolbar(self):
388        """
389        update state of buttons in toolbar
390        """
391        self._update_toolbar()
392        app.plugin_manager.gui_extension_point(
393            'chat_control_base_update_toolbar', self)
394
395    def draw_banner_text(self):
396        """
397        Derived types SHOULD implement this
398        """
399
400    def update_ui(self):
401        """
402        Derived types SHOULD implement this
403        """
404        self.draw_banner()
405
406    def repaint_themed_widgets(self):
407        """
408        Derived types MAY implement this
409        """
410        self.draw_banner()
411
412    def _update_toolbar(self):
413        """
414        Derived types MAY implement this
415        """
416
417    def get_tab_label(self, chatstate):
418        """
419        Return a suitable tab label string. Returns a tuple such as: (label_str,
420        color) either of which can be None if chatstate is given that means we
421        have HE SENT US a chatstate and we want it displayed
422
423        Derivded classes MUST implement this.
424        """
425        # Return a markup'd label and optional Gtk.Color in a tuple like:
426        # return (label_str, None)
427
428    def get_tab_image(self):
429        # Return a suitable tab image for display.
430        return None
431
432    def prepare_context_menu(self, hide_buttonbar_items=False):
433        """
434        Derived classes SHOULD implement this
435        """
436        return None
437
438    def set_session(self, session):
439        oldsession = None
440        if hasattr(self, 'session'):
441            oldsession = self.session
442
443        if oldsession and session == oldsession:
444            return
445
446        self.session = session
447
448        if session:
449            session.control = self
450
451        if session and oldsession:
452            oldsession.control = None
453
454    def remove_session(self, session):
455        if session != self.session:
456            return
457        self.session.control = None
458        self.session = None
459
460    @event_filter(['account'])
461    def _nec_our_status(self, event):
462        if event.show == 'connecting':
463            return
464
465        if event.show == 'offline':
466            self.got_disconnected()
467        else:
468            self.got_connected()
469        if self.parent_win:
470            self.parent_win.redraw_tab(self)
471
472    def _nec_ping(self, obj):
473        raise NotImplementedError
474
475    def setup_seclabel(self):
476        self.xml.label_selector.hide()
477        self.xml.label_selector.set_no_show_all(True)
478        lb = Gtk.ListStore(str)
479        self.xml.label_selector.set_model(lb)
480        cell = Gtk.CellRendererText()
481        cell.set_property('xpad', 5)  # padding for status text
482        self.xml.label_selector.pack_start(cell, True)
483        # text to show is in in first column of liststore
484        self.xml.label_selector.add_attribute(cell, 'text', 0)
485        con = app.connections[self.account]
486        jid = self.contact.jid
487        if self._type.is_privatechat:
488            jid = self.gc_contact.room_jid
489        if con.get_module('SecLabels').supported:
490            con.get_module('SecLabels').request_catalog(jid)
491
492    def _sec_labels_received(self, event):
493        if event.account != self.account:
494            return
495
496        jid = self.contact.jid
497        if self._type.is_privatechat:
498            jid = self.gc_contact.room_jid
499
500        if event.jid != jid:
501            return
502        model = self.xml.label_selector.get_model()
503        model.clear()
504
505        sel = 0
506        labellist = event.catalog.get_label_names()
507        default = event.catalog.default
508        for index, label in enumerate(labellist):
509            model.append([label])
510            if label == default:
511                sel = index
512
513        self.xml.label_selector.set_active(sel)
514        self.xml.label_selector.set_no_show_all(False)
515        self.xml.label_selector.show_all()
516
517    def delegate_action(self, action):
518        if action == 'browse-history':
519            dict_ = {'jid': GLib.Variant('s', self.contact.jid),
520                     'account': GLib.Variant('s', self.account)}
521            variant = GLib.Variant('a{sv}', dict_)
522            app.app.activate_action('browse-history', variant)
523            return Gdk.EVENT_STOP
524
525        if action == 'clear-chat':
526            self.conv_textview.clear()
527            return Gdk.EVENT_STOP
528
529        if action == 'delete-line':
530            self.clear(self.msg_textview)
531            return Gdk.EVENT_STOP
532
533        if action == 'show-emoji-chooser':
534            if sys.platform in ('win32', 'darwin'):
535                self.xml.emoticons_button.get_popover().show()
536                return Gdk.EVENT_STOP
537            self.msg_textview.emit('insert-emoji')
538            return Gdk.EVENT_STOP
539
540        return Gdk.EVENT_PROPAGATE
541
542    def add_actions(self):
543        action = Gio.SimpleAction.new_stateful(
544            'set-encryption-%s' % self.control_id,
545            GLib.VariantType.new('s'),
546            GLib.Variant('s', self.encryption or 'disabled'))
547        action.connect('change-state', self.change_encryption)
548        self.parent_win.window.add_action(action)
549
550        actions = {
551            'send-message-%s': self._on_send_message,
552            'send-file-%s': self._on_send_file,
553            'send-file-httpupload-%s': self._on_send_file,
554            'send-file-jingle-%s': self._on_send_file,
555        }
556
557        for name, func in actions.items():
558            action = Gio.SimpleAction.new(name % self.control_id, None)
559            action.connect('activate', func)
560            action.set_enabled(False)
561            self.parent_win.window.add_action(action)
562
563    def remove_actions(self):
564        actions = [
565            'send-message-',
566            'set-encryption-',
567            'send-file-',
568            'send-file-httpupload-',
569            'send-file-jingle-',
570        ]
571
572        for action in actions:
573            self.parent_win.window.remove_action(f'{action}{self.control_id}')
574
575    def change_encryption(self, action, param):
576        encryption = param.get_string()
577        if encryption == 'disabled':
578            encryption = None
579
580        if self.encryption == encryption:
581            return
582
583        if encryption:
584            plugin = app.plugin_manager.encryption_plugins[encryption]
585            if not plugin.activate_encryption(self):
586                return
587
588        action.set_state(param)
589        self.set_encryption_state(encryption)
590        self.set_encryption_menu_icon()
591        self.set_lock_image()
592
593    def set_lock_image(self):
594        encryption_state = {'visible': self.encryption is not None,
595                            'enc_type': self.encryption,
596                            'authenticated': False}
597
598        if self.encryption:
599            app.plugin_manager.extension_point(
600                'encryption_state' + self.encryption, self, encryption_state)
601
602        visible, enc_type, authenticated = encryption_state.values()
603
604        if authenticated:
605            authenticated_string = _('and authenticated')
606            self.xml.lock_image.set_from_icon_name(
607                'security-high-symbolic', Gtk.IconSize.MENU)
608        else:
609            authenticated_string = _('and NOT authenticated')
610            self.xml.lock_image.set_from_icon_name(
611                'security-low-symbolic', Gtk.IconSize.MENU)
612
613        tooltip = _('%(type)s encryption is active %(authenticated)s.') % {
614            'type': enc_type, 'authenticated': authenticated_string}
615
616        self.xml.authentication_button.set_tooltip_text(tooltip)
617        self.xml.authentication_button.set_visible(visible)
618        self.xml.lock_image.set_sensitive(visible)
619
620    def _on_authentication_button_clicked(self, _button):
621        app.plugin_manager.extension_point(
622            'encryption_dialog' + self.encryption, self)
623
624    def set_encryption_state(self, encryption):
625        self.encryption = encryption
626        self.conv_textview.encryption_enabled = encryption is not None
627        self.contact.settings.set('encryption', self.encryption or '')
628
629    def get_encryption_state(self):
630        state = self.contact.settings.get('encryption')
631        if not state:
632            return None
633        if state not in app.plugin_manager.encryption_plugins:
634            self.set_encryption_state(None)
635            return None
636        return state
637
638    def set_encryption_menu_icon(self):
639        image = self.xml.encryption_menu.get_image()
640        if image is None:
641            image = Gtk.Image()
642            self.xml.encryption_menu.set_image(image)
643        if not self.encryption:
644            image.set_from_icon_name('channel-insecure-symbolic',
645                                     Gtk.IconSize.MENU)
646        else:
647            image.set_from_icon_name('channel-secure-symbolic',
648                                     Gtk.IconSize.MENU)
649
650    def set_speller(self):
651        if not app.is_installed('GSPELL') or not app.settings.get('use_speller'):
652            return
653
654        gspell_lang = self.get_speller_language()
655        spell_checker = Gspell.Checker.new(gspell_lang)
656        spell_buffer = Gspell.TextBuffer.get_from_gtk_text_buffer(
657            self.msg_textview.get_buffer())
658        spell_buffer.set_spell_checker(spell_checker)
659        spell_view = Gspell.TextView.get_from_gtk_text_view(self.msg_textview)
660        spell_view.set_inline_spell_checking(False)
661        spell_view.set_enable_language_menu(True)
662
663        spell_checker.connect('notify::language', self.on_language_changed)
664
665    def get_speller_language(self):
666        lang = self.contact.settings.get('speller_language')
667        if not lang:
668            # use the default one
669            lang = app.settings.get('speller_language')
670            if not lang:
671                lang = i18n.LANG
672        gspell_lang = Gspell.language_lookup(lang)
673        if gspell_lang is None:
674            gspell_lang = Gspell.language_get_default()
675        return gspell_lang
676
677    def on_language_changed(self, checker, _param):
678        gspell_lang = checker.get_language()
679        self.contact.settings.set('speller_language', gspell_lang.get_code())
680
681    def on_banner_label_populate_popup(self, _label, menu):
682        """
683        Override the default context menu and add our own menuitems
684        """
685        item = Gtk.SeparatorMenuItem.new()
686        menu.prepend(item)
687
688        menu2 = self.prepare_context_menu()  # pylint: disable=assignment-from-none
689        i = 0
690        for item in menu2:
691            menu2.remove(item)
692            menu.prepend(item)
693            menu.reorder_child(item, i)
694            i += 1
695        menu.show_all()
696
697    def shutdown(self):
698        # remove_gui_extension_point() is called on shutdown, but also when
699        # a plugin is getting disabled. Plugins don’t know the difference.
700        # Plugins might want to remove their widgets on
701        # remove_gui_extension_point(), so delete the objects only afterwards.
702        app.plugin_manager.remove_gui_extension_point('chat_control_base', self)
703        app.plugin_manager.remove_gui_extension_point(
704            'chat_control_base_update_toolbar', self)
705
706        for i in list(self.handlers.keys()):
707            if self.handlers[i].handler_is_connected(i):
708                self.handlers[i].disconnect(i)
709        self.handlers.clear()
710
711        self.conv_textview.del_handlers()
712        del self.conv_textview
713        del self.msg_textview
714        del self.msg_scrolledwindow
715
716        self.widget.destroy()
717        del self.widget
718
719        del self.xml
720
721        self.unregister_events()
722
723    def on_msg_textview_populate_popup(self, _textview, menu):
724        """
725        Override the default context menu and we prepend an option to switch
726        languages
727        """
728        item = Gtk.MenuItem.new_with_mnemonic(_('_Undo'))
729        menu.prepend(item)
730        id_ = item.connect('activate', self.msg_textview.undo)
731        self.handlers[id_] = item
732
733        item = Gtk.SeparatorMenuItem.new()
734        menu.prepend(item)
735
736        item = Gtk.MenuItem.new_with_mnemonic(_('_Clear'))
737        menu.prepend(item)
738        id_ = item.connect('activate', self.msg_textview.clear)
739        self.handlers[id_] = item
740
741        paste_item = Gtk.MenuItem.new_with_label(_('Paste as quote'))
742        id_ = paste_item.connect('activate', self.paste_clipboard_as_quote)
743        self.handlers[id_] = paste_item
744        menu.append(paste_item)
745
746        menu.show_all()
747
748    def insert_as_quote(self, text: str) -> None:
749        text = '> ' + text.replace('\n', '\n> ') + '\n'
750        message_buffer = self.msg_textview.get_buffer()
751        message_buffer.insert_at_cursor(text)
752
753    def paste_clipboard_as_quote(self, _item: Gtk.MenuItem) -> None:
754        clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
755        text = clipboard.wait_for_text()
756        if text is None:
757            return
758        self.insert_as_quote(text)
759
760    def on_quote(self, _widget, text):
761        self.insert_as_quote(text)
762
763    # moved from ChatControl
764    def _on_banner_eventbox_button_press_event(self, _widget, event):
765        """
766        If right-clicked, show popup
767        """
768        if event.button == 3:  # right click
769            self.parent_win.popup_menu(event)
770
771    def _on_message_textview_paste_event(self, _texview):
772        clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
773        image = clipboard.wait_for_image()
774        if image is not None:
775            if not app.settings.get('confirm_paste_image'):
776                self._paste_event_confirmed(True, image)
777                return
778            PastePreviewDialog(
779                _('Paste Image'),
780                _('You are trying to paste an image'),
781                _('Are you sure you want to paste your '
782                  'clipboard\'s image into the chat window?'),
783                _('_Do not ask me again'),
784                image,
785                [DialogButton.make('Cancel'),
786                 DialogButton.make('Accept',
787                                   text=_('_Paste'),
788                                   callback=self._paste_event_confirmed,
789                                   args=[image])]).show()
790
791    def _paste_event_confirmed(self, is_checked, image):
792        if is_checked:
793            app.settings.set('confirm_paste_image', False)
794
795        dir_ = tempfile.gettempdir()
796        path = os.path.join(dir_, '%s.png' % str(uuid.uuid4()))
797        image.savev(path, 'png', [], [])
798
799        self._start_filetransfer(path)
800
801    def _get_pref_ft_method(self):
802        ft_pref = app.settings.get_account_setting(self.account,
803                                                   'filetransfer_preference')
804        httpupload = self.parent_win.window.lookup_action(
805            'send-file-httpupload-%s' % self.control_id)
806        jingle = self.parent_win.window.lookup_action(
807            'send-file-jingle-%s' % self.control_id)
808
809        if self._type.is_groupchat:
810            if httpupload.get_enabled():
811                return 'httpupload'
812            return None
813
814        if httpupload.get_enabled() and jingle.get_enabled():
815            return ft_pref
816
817        if httpupload.get_enabled():
818            return 'httpupload'
819
820        if jingle.get_enabled():
821            return 'jingle'
822        return None
823
824    def _start_filetransfer(self, path):
825        method = self._get_pref_ft_method()
826        if method is None:
827            return
828
829        if method == 'httpupload':
830            app.interface.send_httpupload(self, path)
831
832        else:
833            ft = app.interface.instances['file_transfers']
834            ft.send_file(self.account, self.contact, path)
835
836    def _on_message_textview_key_press_event(self, textview, event):
837        if event.keyval == Gdk.KEY_space:
838            self.space_pressed = True
839
840        elif (self.space_pressed or self.msg_textview.undo_pressed) and \
841        event.keyval not in (Gdk.KEY_Control_L, Gdk.KEY_Control_R) and \
842        not (event.keyval == Gdk.KEY_z and event.get_state() & Gdk.ModifierType.CONTROL_MASK):
843            # If the space key has been pressed and now it hasn't,
844            # we save the buffer into the undo list. But be careful we're not
845            # pressing Control again (as in ctrl+z)
846            _buffer = textview.get_buffer()
847            start_iter, end_iter = _buffer.get_bounds()
848            self.msg_textview.save_undo(_buffer.get_text(start_iter,
849                                                         end_iter,
850                                                         True))
851            self.space_pressed = False
852
853        # Ctrl [+ Shift] + Tab are not forwarded to notebook. We handle it here
854        if self._type.is_groupchat:
855            if event.keyval not in (Gdk.KEY_ISO_Left_Tab, Gdk.KEY_Tab):
856                self.last_key_tabs = False
857
858        if event.get_state() & Gdk.ModifierType.SHIFT_MASK:
859            if event.get_state() & Gdk.ModifierType.CONTROL_MASK and \
860                            event.keyval == Gdk.KEY_ISO_Left_Tab:
861                self.parent_win.move_to_next_unread_tab(False)
862                return True
863
864            if event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Page_Up):
865                self.conv_textview.tv.event(event)
866                self._on_scroll(None, event.keyval)
867                return True
868
869        if event.get_state() & Gdk.ModifierType.CONTROL_MASK:
870            if event.keyval == Gdk.KEY_Tab:
871                self.parent_win.move_to_next_unread_tab(True)
872                return True
873
874        message_buffer = self.msg_textview.get_buffer()
875        event_state = event.get_state()
876        if event.keyval == Gdk.KEY_Tab:
877            start, end = message_buffer.get_bounds()
878            position = message_buffer.get_insert()
879            end = message_buffer.get_iter_at_mark(position)
880            text = message_buffer.get_text(start, end, False)
881            split = text.split()
882            if (text.startswith(self.COMMAND_PREFIX) and
883                    not text.startswith(self.COMMAND_PREFIX * 2) and
884                    len(split) == 1):
885                text = split[0]
886                bare = text.lstrip(self.COMMAND_PREFIX)
887                if len(text) == 1:
888                    self.command_hits = []
889                    for command in self.list_commands():
890                        for name in command.names:
891                            self.command_hits.append(name)
892                else:
893                    if (self.last_key_tabs and self.command_hits and
894                            self.command_hits[0].startswith(bare)):
895                        self.command_hits.append(self.command_hits.pop(0))
896                    else:
897                        self.command_hits = []
898                        for command in self.list_commands():
899                            for name in command.names:
900                                if name.startswith(bare):
901                                    self.command_hits.append(name)
902
903                if self.command_hits:
904                    message_buffer.delete(start, end)
905                    message_buffer.insert_at_cursor(self.COMMAND_PREFIX + \
906                        self.command_hits[0] + ' ')
907                    self.last_key_tabs = True
908                return True
909            if not self._type.is_groupchat:
910                self.last_key_tabs = False
911        if event.keyval == Gdk.KEY_Up:
912            if event_state & Gdk.ModifierType.CONTROL_MASK:
913                if event_state & Gdk.ModifierType.SHIFT_MASK: # Ctrl+Shift+UP
914                    self.scroll_messages('up', message_buffer, 'received')
915                else:  # Ctrl+UP
916                    self.scroll_messages('up', message_buffer, 'sent')
917                return True
918        elif event.keyval == Gdk.KEY_Down:
919            if event_state & Gdk.ModifierType.CONTROL_MASK:
920                if event_state & Gdk.ModifierType.SHIFT_MASK: # Ctrl+Shift+Down
921                    self.scroll_messages('down', message_buffer, 'received')
922                else:  # Ctrl+Down
923                    self.scroll_messages('down', message_buffer, 'sent')
924                return True
925        elif (event.keyval == Gdk.KEY_Return or
926              event.keyval == Gdk.KEY_KP_Enter):  # ENTER
927
928            if event_state & Gdk.ModifierType.SHIFT_MASK:
929                textview.insert_newline()
930                return True
931
932            if event_state & Gdk.ModifierType.CONTROL_MASK:
933                if not app.settings.get('send_on_ctrl_enter'):
934                    textview.insert_newline()
935                    return True
936            else:
937                if app.settings.get('send_on_ctrl_enter'):
938                    textview.insert_newline()
939                    return True
940
941            if not app.account_is_available(self.account):
942                # we are not connected
943                app.interface.raise_dialog('not-connected-while-sending')
944                return True
945
946            self._on_send_message()
947            return True
948
949        elif event.keyval == Gdk.KEY_z: # CTRL+z
950            if event_state & Gdk.ModifierType.CONTROL_MASK:
951                self.msg_textview.undo()
952                return True
953
954        return False
955
956    def _on_drag_data_received(self, widget, context, x, y, selection,
957                               target_type, timestamp):
958        """
959        Derived types SHOULD implement this
960        """
961
962    def _on_drag_leave(self, *args):
963        self.xml.drop_area.set_no_show_all(True)
964        self.xml.drop_area.hide()
965
966    def _on_drag_motion(self, *args):
967        self.xml.drop_area.set_no_show_all(False)
968        self.xml.drop_area.show_all()
969
970    def drag_data_file_transfer(self, selection):
971        # we may have more than one file dropped
972        uri_splitted = selection.get_uris()
973        for uri in uri_splitted:
974            path = helpers.get_file_path_from_dnd_dropped_uri(uri)
975            if not os.path.isfile(path):  # is it a file?
976                continue
977
978            self._start_filetransfer(path)
979
980    def get_seclabel(self):
981        idx = self.xml.label_selector.get_active()
982        if idx == -1:
983            return None
984
985        con = app.connections[self.account]
986        jid = self.contact.jid
987        if self._type.is_privatechat:
988            jid = self.gc_contact.room_jid
989        catalog = con.get_module('SecLabels').get_catalog(jid)
990        labels, label_list = catalog.labels, catalog.get_label_names()
991        lname = label_list[idx]
992        label = labels[lname]
993        return label
994
995    def _on_send_message(self, *args):
996        self.msg_textview.replace_emojis()
997        message = self.msg_textview.get_text()
998        xhtml = self.msg_textview.get_xhtml()
999        self.send_message(message, xhtml=xhtml)
1000
1001    def send_message(self,
1002                     message,
1003                     type_='chat',
1004                     resource=None,
1005                     xhtml=None,
1006                     process_commands=True,
1007                     attention=False):
1008        """
1009        Send the given message to the active tab. Doesn't return None if error
1010        """
1011        if not message or message == '\n':
1012            return None
1013
1014        if process_commands and self.process_as_command(message):
1015            return
1016
1017        label = self.get_seclabel()
1018
1019        if self.correcting and self.last_sent_msg:
1020            correct_id = self.last_sent_msg
1021        else:
1022            correct_id = None
1023
1024        con = app.connections[self.account]
1025        chatstate = con.get_module('Chatstate').get_active_chatstate(
1026            self.contact)
1027
1028        message_ = OutgoingMessage(account=self.account,
1029                                   contact=self.contact,
1030                                   message=message,
1031                                   type_=type_,
1032                                   chatstate=chatstate,
1033                                   resource=resource,
1034                                   user_nick=self.user_nick,
1035                                   label=label,
1036                                   control=self,
1037                                   attention=attention,
1038                                   correct_id=correct_id,
1039                                   xhtml=xhtml)
1040
1041        con.send_message(message_)
1042
1043        # Record the history of sent messages
1044        self.save_message(message, 'sent')
1045
1046        # Be sure to send user nickname only once according to JEP-0172
1047        self.user_nick = None
1048
1049        # Clear msg input
1050        message_buffer = self.msg_textview.get_buffer()
1051        message_buffer.set_text('') # clear message buffer (and tv of course)
1052
1053    def _on_window_motion_notify(self, *args):
1054        """
1055        It gets called no matter if it is the active window or not
1056        """
1057        if not self.parent_win:
1058            # when a groupchat is minimized there is no parent window
1059            return
1060        if self.parent_win.get_active_jid() == self.contact.jid:
1061            # if window is the active one, set last interaction
1062            con = app.connections[self.account]
1063            con.get_module('Chatstate').set_mouse_activity(
1064                self.contact, self.msg_textview.has_text())
1065
1066    def _on_message_tv_buffer_changed(self, textbuffer):
1067        has_text = self.msg_textview.has_text()
1068        if self.parent_win is not None:
1069            self.parent_win.window.lookup_action(
1070                'send-message-' + self.control_id).set_enabled(has_text)
1071
1072        if textbuffer.get_char_count() and self.encryption:
1073            app.plugin_manager.extension_point(
1074                'typing' + self.encryption, self)
1075
1076        con = app.connections[self.account]
1077        con.get_module('Chatstate').set_keyboard_activity(self.contact)
1078        if not has_text:
1079            con.get_module('Chatstate').set_chatstate_delayed(self.contact,
1080                                                              Chatstate.ACTIVE)
1081            return
1082        con.get_module('Chatstate').set_chatstate(self.contact,
1083                                                  Chatstate.COMPOSING)
1084
1085    def save_message(self, message, msg_type):
1086        # save the message, so user can scroll though the list with key up/down
1087        if msg_type == 'sent':
1088            history = self.sent_history
1089            pos = self.sent_history_pos
1090        else:
1091            history = self.received_history
1092            pos = self.received_history_pos
1093        size = len(history)
1094        scroll = pos != size
1095        # we don't want size of the buffer to grow indefinitely
1096        max_size = app.settings.get('key_up_lines')
1097        for _i in range(size - max_size + 1):
1098            if pos == 0:
1099                break
1100            history.pop(0)
1101            pos -= 1
1102        history.append(message)
1103        if not scroll or msg_type == 'sent':
1104            pos = len(history)
1105        if msg_type == 'sent':
1106            self.sent_history_pos = pos
1107            self.orig_msg = None
1108        else:
1109            self.received_history_pos = pos
1110
1111    def add_info_message(self, text, message_id=None):
1112        self.conv_textview.print_conversation_line(
1113            text, 'info', '', None, message_id=message_id, graphics=False)
1114
1115    def add_status_message(self, text):
1116        self.conv_textview.print_conversation_line(
1117            text, 'status', '', None)
1118
1119    def add_message(self,
1120                    text,
1121                    kind,
1122                    name,
1123                    tim,
1124                    other_tags_for_name=None,
1125                    other_tags_for_time=None,
1126                    other_tags_for_text=None,
1127                    restored=False,
1128                    subject=None,
1129                    old_kind=None,
1130                    displaymarking=None,
1131                    msg_log_id=None,
1132                    message_id=None,
1133                    stanza_id=None,
1134                    correct_id=None,
1135                    additional_data=None,
1136                    marker=None,
1137                    error=None):
1138        """
1139        Print 'chat' type messages
1140        correct_id = (message_id, correct_id)
1141        """
1142        jid = self.contact.jid
1143        full_jid = self.get_full_jid()
1144        textview = self.conv_textview
1145        end = False
1146        if self.conv_textview.autoscroll or kind == 'outgoing':
1147            end = True
1148
1149        if other_tags_for_name is None:
1150            other_tags_for_name = []
1151        if other_tags_for_time is None:
1152            other_tags_for_time = []
1153        if other_tags_for_text is None:
1154            other_tags_for_text = []
1155        if additional_data is None:
1156            additional_data = AdditionalDataDict()
1157
1158        textview.print_conversation_line(text,
1159                                         kind,
1160                                         name,
1161                                         tim,
1162                                         other_tags_for_name,
1163                                         other_tags_for_time,
1164                                         other_tags_for_text,
1165                                         subject,
1166                                         old_kind,
1167                                         displaymarking=displaymarking,
1168                                         message_id=message_id,
1169                                         correct_id=correct_id,
1170                                         additional_data=additional_data,
1171                                         marker=marker,
1172                                         error=error)
1173
1174        if restored:
1175            return
1176
1177        if message_id:
1178            if self._type.is_groupchat:
1179                self.last_msg_id = stanza_id or message_id
1180            else:
1181                self.last_msg_id = message_id
1182
1183        if kind == 'incoming':
1184            if (not self._type.is_groupchat or
1185                    self.contact.can_notify() or
1186                    'marked' in other_tags_for_text):
1187                # it's a normal message, or a muc message with want to be
1188                # notified about if quitting just after
1189                # other_tags_for_text == ['marked'] --> highlighted gc message
1190                app.last_message_time[self.account][full_jid] = time.time()
1191
1192        if kind in ('incoming', 'incoming_queue'):
1193            # Record the history of received messages
1194            self.save_message(text, 'received')
1195
1196            # Send chat marker if we’re actively following the chat
1197            if self.parent_win and self.contact.settings.get('send_marker'):
1198                if (self.parent_win.get_active_control() == self and
1199                        self.parent_win.is_active() and
1200                        self.has_focus() and end):
1201                    con = app.connections[self.account]
1202                    con.get_module('ChatMarkers').send_displayed_marker(
1203                        self.contact,
1204                        self.last_msg_id,
1205                        self._type)
1206
1207        if kind in ('incoming', 'incoming_queue', 'error'):
1208            gc_message = False
1209            if self._type.is_groupchat:
1210                gc_message = True
1211
1212            if ((self.parent_win and (not self.parent_win.get_active_control() or \
1213            self != self.parent_win.get_active_control() or \
1214            not self.parent_win.is_active() or not end)) or \
1215            (gc_message and \
1216            jid in app.interface.minimized_controls[self.account])) and \
1217            kind in ('incoming', 'incoming_queue', 'error'):
1218                # we want to have save this message in events list
1219                # other_tags_for_text == ['marked'] --> highlighted gc message
1220                if gc_message:
1221                    if 'marked' in other_tags_for_text:
1222                        event_type = events.PrintedMarkedGcMsgEvent
1223                    else:
1224                        event_type = events.PrintedGcMsgEvent
1225                    event = 'gc_message_received'
1226                else:
1227                    if self._type.is_chat:
1228                        event_type = events.PrintedChatEvent
1229                    else:
1230                        event_type = events.PrintedPmEvent
1231                    event = 'message_received'
1232                show_in_roster = get_show_in_roster(event, self.session)
1233                show_in_systray = get_show_in_systray(
1234                    event_type.type_, self.account, self.contact.jid)
1235
1236                event = event_type(text,
1237                                   subject,
1238                                   self,
1239                                   msg_log_id,
1240                                   message_id=message_id,
1241                                   stanza_id=stanza_id,
1242                                   show_in_roster=show_in_roster,
1243                                   show_in_systray=show_in_systray)
1244                app.events.add_event(self.account, full_jid, event)
1245                # We need to redraw contact if we show in roster
1246                if show_in_roster:
1247                    app.interface.roster.draw_contact(self.contact.jid,
1248                                                      self.account)
1249
1250        if not self.parent_win:
1251            return
1252
1253        if (not self.parent_win.get_active_control() or \
1254        self != self.parent_win.get_active_control() or \
1255        not self.parent_win.is_active() or not end) and \
1256        kind in ('incoming', 'incoming_queue', 'error'):
1257            self.parent_win.redraw_tab(self)
1258            if not self.parent_win.is_active():
1259                self.parent_win.show_title(True, self) # Enabled Urgent hint
1260            else:
1261                self.parent_win.show_title(False, self) # Disabled Urgent hint
1262
1263    def toggle_emoticons(self):
1264        """
1265        Hide show emoticons_button
1266        """
1267        if app.settings.get('emoticons_theme'):
1268            self.xml.emoticons_button.set_no_show_all(False)
1269            self.xml.emoticons_button.show()
1270        else:
1271            self.xml.emoticons_button.set_no_show_all(True)
1272            self.xml.emoticons_button.hide()
1273
1274    def set_emoticon_popover(self):
1275        if not app.settings.get('emoticons_theme'):
1276            return
1277
1278        if not self.parent_win:
1279            return
1280
1281        if sys.platform in ('win32', 'darwin'):
1282            emoji_chooser.text_widget = self.msg_textview
1283            self.xml.emoticons_button.set_popover(emoji_chooser)
1284            return
1285
1286        self.xml.emoticons_button.set_sensitive(True)
1287        self.xml.emoticons_button.connect('clicked',
1288                                          self._on_emoticon_button_clicked)
1289
1290    def _on_emoticon_button_clicked(self, _widget):
1291        # Present GTK emoji chooser (not cross platform compatible)
1292        self.msg_textview.emit('insert-emoji')
1293        self.xml.emoticons_button.set_property('active', False)
1294
1295    def on_color_menuitem_activate(self, _widget):
1296        color_dialog = Gtk.ColorChooserDialog(None, self.parent_win.window)
1297        color_dialog.set_use_alpha(False)
1298        color_dialog.connect('response', self.msg_textview.color_set)
1299        color_dialog.show_all()
1300
1301    def on_font_menuitem_activate(self, _widget):
1302        font_dialog = Gtk.FontChooserDialog(None, self.parent_win.window)
1303        start, finish = self.msg_textview.get_active_iters()
1304        font_dialog.connect('response', self.msg_textview.font_set, start, finish)
1305        font_dialog.show_all()
1306
1307    def on_formatting_menuitem_activate(self, widget):
1308        tag = widget.get_name()
1309        self.msg_textview.set_tag(tag)
1310
1311    def on_clear_formatting_menuitem_activate(self, _widget):
1312        self.msg_textview.clear_tags()
1313
1314    def _style_changed(self, *args):
1315        self.update_tags()
1316
1317    def update_tags(self):
1318        self.conv_textview.update_tags()
1319
1320    @staticmethod
1321    def clear(tv):
1322        buffer_ = tv.get_buffer()
1323        start, end = buffer_.get_bounds()
1324        buffer_.delete(start, end)
1325
1326    def _on_send_file(self, action, _param):
1327        name = action.get_name()
1328        if 'httpupload' in name:
1329            app.interface.send_httpupload(self)
1330            return
1331
1332        if 'jingle' in name:
1333            self._on_send_file_jingle()
1334            return
1335
1336        method = self._get_pref_ft_method()
1337        if method is None:
1338            return
1339
1340        if method == 'httpupload':
1341            app.interface.send_httpupload(self)
1342        else:
1343            self._on_send_file_jingle()
1344
1345    def _on_send_file_jingle(self, gc_contact=None):
1346        """
1347        gc_contact can be set when we are in a groupchat control
1348        """
1349        def _on_ok(_contact):
1350            app.interface.instances['file_transfers'].show_file_send_request(
1351                self.account, _contact)
1352
1353        if self._type.is_privatechat:
1354            gc_contact = self.gc_contact
1355
1356        if not gc_contact:
1357            _on_ok(self.contact)
1358            return
1359
1360        # gc or pm
1361        gc_control = app.interface.msg_win_mgr.get_gc_control(
1362            gc_contact.room_jid, self.account)
1363        self_contact = app.contacts.get_gc_contact(self.account,
1364                                                   gc_control.room_jid,
1365                                                   gc_control.nick)
1366        if (gc_control.is_anonymous and
1367                gc_contact.affiliation.value not in ['admin', 'owner'] and
1368                self_contact.affiliation.value in ['admin', 'owner']):
1369            contact = app.contacts.get_contact(self.account, gc_contact.jid)
1370            if not contact or contact.sub not in ('both', 'to'):
1371
1372                ConfirmationDialog(
1373                    _('Privacy'),
1374                    _('Warning'),
1375                    _('If you send a file to <b>%s</b>, your real XMPP '
1376                      'address will be revealed.') % gc_contact.name,
1377                    [DialogButton.make('Cancel'),
1378                     DialogButton.make(
1379                         'OK',
1380                         text=_('_Continue'),
1381                         callback=lambda: _on_ok(gc_contact))]).show()
1382                return
1383        _on_ok(gc_contact)
1384
1385    def set_control_active(self, state):
1386        con = app.connections[self.account]
1387        if state:
1388            self.set_emoticon_popover()
1389            jid = self.contact.jid
1390            if self.conv_textview.autoscroll:
1391                # we are at the end
1392                type_ = [f'printed_{self._type}']
1393                if self._type.is_groupchat:
1394                    type_ = ['printed_gc_msg', 'printed_marked_gc_msg']
1395                if not app.events.remove_events(self.account,
1396                                                self.get_full_jid(),
1397                                                types=type_):
1398                    # There were events to remove
1399                    self.redraw_after_event_removed(jid)
1400                    # XEP-0333 Send <displayed> marker
1401                    con.get_module('ChatMarkers').send_displayed_marker(
1402                        self.contact,
1403                        self.last_msg_id,
1404                        self._type)
1405                    self.last_msg_id = None
1406            # send chatstate inactive to the one we're leaving
1407            # and active to the one we visit
1408            if self.msg_textview.has_text():
1409                con.get_module('Chatstate').set_chatstate(self.contact,
1410                                                          Chatstate.PAUSED)
1411            else:
1412                con.get_module('Chatstate').set_chatstate(self.contact,
1413                                                          Chatstate.ACTIVE)
1414        else:
1415            con.get_module('Chatstate').set_chatstate(self.contact,
1416                                                      Chatstate.INACTIVE)
1417
1418    def scroll_to_end(self, force=False):
1419        self.conv_textview.scroll_to_end(force)
1420
1421    def _on_edge_reached(self, _scrolledwindow, pos):
1422        if pos != Gtk.PositionType.BOTTOM:
1423            return
1424        # Remove all events and set autoscroll True
1425        app.log('autoscroll').info('Autoscroll enabled')
1426        self.conv_textview.autoscroll = True
1427        if self.resource:
1428            jid = self.contact.get_full_jid()
1429        else:
1430            jid = self.contact.jid
1431        types_list = []
1432        if self._type.is_groupchat:
1433            types_list = ['printed_gc_msg', 'gc_msg', 'printed_marked_gc_msg']
1434        else:
1435            types_list = [f'printed_{self._type}', str(self._type)]
1436
1437        if not app.events.get_events(self.account, jid, types_list):
1438            return
1439        if not self.parent_win:
1440            return
1441        if (self.parent_win.get_active_control() == self and
1442                self.parent_win.window.is_active()):
1443            # we are at the end
1444            if not app.events.remove_events(
1445                    self.account, jid, types=types_list):
1446                # There were events to remove
1447                self.redraw_after_event_removed(jid)
1448                # XEP-0333 Send <displayed> tag
1449                con = app.connections[self.account]
1450                con.get_module('ChatMarkers').send_displayed_marker(
1451                    self.contact,
1452                    self.last_msg_id,
1453                    self._type)
1454                self.last_msg_id = None
1455
1456    def _on_scrollbar_button_release(self, scrollbar, event):
1457        if event.get_button()[1] != 1:
1458            # We want only to catch the left mouse button
1459            return
1460        if not at_the_end(scrollbar.get_parent()):
1461            app.log('autoscroll').info('Autoscroll disabled')
1462            self.conv_textview.autoscroll = False
1463
1464    def has_focus(self):
1465        if self.parent_win:
1466            if self.parent_win.window.get_property('has-toplevel-focus'):
1467                if self == self.parent_win.get_active_control():
1468                    return True
1469        return False
1470
1471    def _on_scroll(self, widget, event):
1472        if not self.conv_textview.autoscroll:
1473            # autoscroll is already disabled
1474            return
1475
1476        if widget is None:
1477            # call from _conv_textview_key_press_event()
1478            # SHIFT + Gdk.KEY_Page_Up
1479            if event != Gdk.KEY_Page_Up:
1480                return
1481        else:
1482            # On scrolling UP disable autoscroll
1483            # get_scroll_direction() sets has_direction only TRUE
1484            # if smooth scrolling is deactivated. If we have smooth
1485            # smooth scrolling we have to use get_scroll_deltas()
1486            has_direction, direction = event.get_scroll_direction()
1487            if not has_direction:
1488                direction = None
1489                smooth, delta_x, delta_y = event.get_scroll_deltas()
1490                if smooth:
1491                    if delta_y < 0:
1492                        direction = Gdk.ScrollDirection.UP
1493                    elif delta_y > 0:
1494                        direction = Gdk.ScrollDirection.DOWN
1495                    elif delta_x < 0:
1496                        direction = Gdk.ScrollDirection.LEFT
1497                    elif delta_x > 0:
1498                        direction = Gdk.ScrollDirection.RIGHT
1499                else:
1500                    app.log('autoscroll').warning(
1501                        'Scroll directions can’t be determined')
1502
1503            if direction != Gdk.ScrollDirection.UP:
1504                return
1505        # Check if we have a Scrollbar
1506        adjustment = self.xml.conversation_scrolledwindow.get_vadjustment()
1507        if adjustment.get_upper() != adjustment.get_page_size():
1508            app.log('autoscroll').info('Autoscroll disabled')
1509            self.conv_textview.autoscroll = False
1510
1511    def on_conversation_vadjustment_changed(self, _adjustment):
1512        self.scroll_to_end()
1513
1514    def redraw_after_event_removed(self, jid):
1515        """
1516        We just removed a 'printed_*' event, redraw contact in roster or
1517        gc_roster and titles in roster and msg_win
1518        """
1519        if not self.parent_win:  # minimized groupchat
1520            return
1521        self.parent_win.redraw_tab(self)
1522        self.parent_win.show_title()
1523        # TODO : get the contact and check get_show_in_roster()
1524        if self._type.is_privatechat:
1525            room_jid, nick = app.get_room_and_nick_from_fjid(jid)
1526            groupchat_control = app.interface.msg_win_mgr.get_gc_control(
1527                room_jid, self.account)
1528            if room_jid in app.interface.minimized_controls[self.account]:
1529                groupchat_control = \
1530                        app.interface.minimized_controls[self.account][room_jid]
1531            contact = app.contacts.get_contact_with_highest_priority(
1532                self.account, room_jid)
1533            if contact:
1534                app.interface.roster.draw_contact(room_jid, self.account)
1535            if groupchat_control:
1536                groupchat_control.roster.draw_contact(nick)
1537                if groupchat_control.parent_win:
1538                    groupchat_control.parent_win.redraw_tab(groupchat_control)
1539        else:
1540            app.interface.roster.draw_contact(jid, self.account)
1541            app.interface.roster.show_title()
1542
1543    def scroll_messages(self, direction, msg_buf, msg_type):
1544        if msg_type == 'sent':
1545            history = self.sent_history
1546            pos = self.sent_history_pos
1547            self.received_history_pos = len(self.received_history)
1548        else:
1549            history = self.received_history
1550            pos = self.received_history_pos
1551            self.sent_history_pos = len(self.sent_history)
1552        size = len(history)
1553        if self.orig_msg is None:
1554            # user was typing something and then went into history, so save
1555            # whatever is already typed
1556            start_iter = msg_buf.get_start_iter()
1557            end_iter = msg_buf.get_end_iter()
1558            self.orig_msg = msg_buf.get_text(start_iter, end_iter, False)
1559        if pos == size and size > 0 and direction == 'up' and \
1560        msg_type == 'sent' and not self.correcting and (not \
1561        history[pos - 1].startswith('/') or history[pos - 1].startswith('/me')):
1562            self.correcting = True
1563            gtkgui_helpers.add_css_class(
1564                self.msg_textview, 'gajim-msg-correcting')
1565            message = history[pos - 1]
1566            msg_buf.set_text(message)
1567            return
1568        if self.correcting:
1569            # We were previously correcting
1570            gtkgui_helpers.remove_css_class(
1571                self.msg_textview, 'gajim-msg-correcting')
1572        self.correcting = False
1573        pos += -1 if direction == 'up' else +1
1574        if pos == -1:
1575            return
1576        if pos >= size:
1577            pos = size
1578            message = self.orig_msg
1579            self.orig_msg = None
1580        else:
1581            message = history[pos]
1582        if msg_type == 'sent':
1583            self.sent_history_pos = pos
1584        else:
1585            self.received_history_pos = pos
1586            if self.orig_msg is not None:
1587                message = '> %s\n' % message.replace('\n', '\n> ')
1588        msg_buf.set_text(message)
1589
1590    def got_connected(self):
1591        self.msg_textview.set_sensitive(True)
1592        self.msg_textview.set_editable(True)
1593        self.update_toolbar()
1594
1595    def got_disconnected(self):
1596        self.msg_textview.set_sensitive(False)
1597        self.msg_textview.set_editable(False)
1598        self.conv_textview.tv.grab_focus()
1599
1600        self.update_toolbar()
1601
1602
1603class ScrolledWindow(Gtk.ScrolledWindow):
1604    def __init__(self, *args, **kwargs):
1605        Gtk.ScrolledWindow.__init__(self, *args, **kwargs)
1606
1607        self.set_overlay_scrolling(False)
1608        self.set_max_content_height(100)
1609        self.set_propagate_natural_height(True)
1610        self.get_style_context().add_class('scrolled-no-border')
1611        self.get_style_context().add_class('no-scroll-indicator')
1612        self.get_style_context().add_class('scrollbar-style')
1613        self.get_style_context().add_class('one-line-scrollbar')
1614        self.set_shadow_type(Gtk.ShadowType.IN)
1615        self.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
1616