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