# Copyright (C) 2006 Dimitur Kirov # Copyright (C) 2006-2014 Yann Leboulanger # Copyright (C) 2006-2008 Jean-Marie Traissard # Nikos Kouremenos # Travis Shirk # Copyright (C) 2007 Lukas Petrovicky # Julien Pivotto # Copyright (C) 2007-2008 Brendan Taylor # Stephan Erb # Copyright (C) 2008 Jonathan Schleifer # # This file is part of Gajim. # # Gajim is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published # by the Free Software Foundation; version 3 only. # # Gajim is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Gajim. If not, see . import os import sys import time import uuid import tempfile from gi.repository import Gtk from gi.repository import Gdk from gi.repository import GLib from gi.repository import Gio from gajim.common import events from gajim.common import app from gajim.common import helpers from gajim.common import ged from gajim.common import i18n from gajim.common.i18n import _ from gajim.common.nec import EventHelper from gajim.common.helpers import AdditionalDataDict from gajim.common.helpers import event_filter from gajim.common.contacts import GC_Contact from gajim.common.const import Chatstate from gajim.common.structs import OutgoingMessage from gajim import gtkgui_helpers from gajim.conversation_textview import ConversationTextview from gajim.gui.dialogs import DialogButton from gajim.gui.dialogs import ConfirmationDialog from gajim.gui.dialogs import PastePreviewDialog from gajim.gui.message_input import MessageInputTextView from gajim.gui.util import at_the_end from gajim.gui.util import get_show_in_roster from gajim.gui.util import get_show_in_systray from gajim.gui.util import get_hardware_key_codes from gajim.gui.util import get_builder from gajim.gui.util import generate_account_badge from gajim.gui.const import ControlType # pylint: disable=unused-import from gajim.gui.emoji_chooser import emoji_chooser from gajim.command_system.implementation.middleware import ChatCommandProcessor from gajim.command_system.implementation.middleware import CommandTools # The members of these modules are not referenced directly anywhere in this # module, but still they need to be kept around. Importing them automatically # registers the contained CommandContainers with the command system, thereby # populating the list of available commands. # pylint: disable=unused-import from gajim.command_system.implementation import standard from gajim.command_system.implementation import execute # pylint: enable=unused-import if app.is_installed('GSPELL'): from gi.repository import Gspell # pylint: disable=ungrouped-imports # This is needed so copying text from the conversation textview # works with different language layouts. Pressing the key c on a russian # layout yields another keyval than with the english layout. # So we match hardware keycodes instead of keyvals. # Multiple hardware keycodes can trigger a keyval like Gdk.KEY_c. KEYCODES_KEY_C = get_hardware_key_codes(Gdk.KEY_c) if sys.platform == 'darwin': COPY_MODIFIER = Gdk.ModifierType.META_MASK COPY_MODIFIER_KEYS = (Gdk.KEY_Meta_L, Gdk.KEY_Meta_R) else: COPY_MODIFIER = Gdk.ModifierType.CONTROL_MASK COPY_MODIFIER_KEYS = (Gdk.KEY_Control_L, Gdk.KEY_Control_R) ################################################################################ class ChatControlBase(ChatCommandProcessor, CommandTools, EventHelper): """ A base class containing a banner, ConversationTextview, MessageInputTextView """ _type = None # type: ControlType def __init__(self, parent_win, widget_name, contact, acct, resource=None): EventHelper.__init__(self) # Undo needs this variable to know if space has been pressed. # Initialize it to True so empty textview is saved in undo list self.space_pressed = True if resource is None: # We very likely got a contact with a random resource. # This is bad, we need the highest for caps etc. _contact = app.contacts.get_contact_with_highest_priority( acct, contact.jid) if _contact and not isinstance(_contact, GC_Contact): contact = _contact self.handlers = {} self.parent_win = parent_win self.contact = contact self.account = acct self.resource = resource # control_id is a unique id for the control, # its used as action name for actions that belong to a control self.control_id = str(uuid.uuid4()) self.session = None app.last_message_time[self.account][self.get_full_jid()] = 0 self.xml = get_builder('%s.ui' % widget_name) self.xml.connect_signals(self) self.widget = self.xml.get_object('%s_hbox' % widget_name) self._accounts = app.get_enabled_accounts_with_labels() if len(self._accounts) > 1: account_badge = generate_account_badge(self.account) account_badge.set_tooltip_text( _('Account: %s') % app.get_account_label(self.account)) self.xml.account_badge.add(account_badge) account_badge.show() # Drag and drop self.xml.overlay.add_overlay(self.xml.drop_area) self.xml.drop_area.hide() self.xml.overlay.connect( 'drag-data-received', self._on_drag_data_received) self.xml.overlay.connect('drag-motion', self._on_drag_motion) self.xml.overlay.connect('drag-leave', self._on_drag_leave) self.TARGET_TYPE_URI_LIST = 80 uri_entry = Gtk.TargetEntry.new( 'text/uri-list', Gtk.TargetFlags.OTHER_APP, self.TARGET_TYPE_URI_LIST) dst_targets = Gtk.TargetList.new([uri_entry]) dst_targets.add_text_targets(0) self._dnd_list = [uri_entry, Gtk.TargetEntry.new( 'MY_TREE_MODEL_ROW', Gtk.TargetFlags.SAME_APP, 0)] self.xml.overlay.drag_dest_set( Gtk.DestDefaults.ALL, self._dnd_list, Gdk.DragAction.COPY | Gdk.DragAction.MOVE) self.xml.overlay.drag_dest_set_target_list(dst_targets) # Create textviews and connect signals self.conv_textview = ConversationTextview(self.account) id_ = self.conv_textview.connect('quote', self.on_quote) self.handlers[id_] = self.conv_textview self.conv_textview.tv.connect('key-press-event', self._on_conv_textview_key_press_event) # This is a workaround: as soon as a line break occurs in Gtk.TextView # with word-char wrapping enabled, a hyphen character is automatically # inserted before the line break. This triggers the hscrollbar to show, # see: https://gitlab.gnome.org/GNOME/gtk/-/issues/2384 # Using set_hscroll_policy(Gtk.Scrollable.Policy.NEVER) would cause bad # performance during resize, and prevent the window from being shrunk # horizontally under certain conditions (applies to GroupchatControl) hscrollbar = self.xml.conversation_scrolledwindow.get_hscrollbar() hscrollbar.hide() self.xml.conversation_scrolledwindow.add(self.conv_textview.tv) widget = self.xml.conversation_scrolledwindow.get_vadjustment() widget.connect('changed', self.on_conversation_vadjustment_changed) vscrollbar = self.xml.conversation_scrolledwindow.get_vscrollbar() vscrollbar.connect('button-release-event', self._on_scrollbar_button_release) self.msg_textview = MessageInputTextView() self.msg_textview.connect('paste-clipboard', self._on_message_textview_paste_event) self.msg_textview.connect('key-press-event', self._on_message_textview_key_press_event) self.msg_textview.connect('populate-popup', self.on_msg_textview_populate_popup) self.msg_textview.get_buffer().connect( 'changed', self._on_message_tv_buffer_changed) # Send message button self.xml.send_message_button.set_action_name( 'win.send-message-%s' % self.control_id) self.xml.send_message_button.set_visible( app.settings.get('show_send_message_button')) app.settings.bind_signal( 'show_send_message_button', self.xml.send_message_button, 'set_visible') self.msg_scrolledwindow = ScrolledWindow() self.msg_scrolledwindow.set_margin_start(3) self.msg_scrolledwindow.set_margin_end(3) self.msg_scrolledwindow.get_style_context().add_class( 'message-input-border') self.msg_scrolledwindow.add(self.msg_textview) self.xml.hbox.pack_start(self.msg_scrolledwindow, True, True, 0) # the following vars are used to keep history of user's messages self.sent_history = [] self.sent_history_pos = 0 self.received_history = [] self.received_history_pos = 0 self.orig_msg = None # For XEP-0333 self.last_msg_id = None self.correcting = False self.last_sent_msg = None self.set_emoticon_popover() # Attach speller self.set_speller() self.conv_textview.tv.show() # For XEP-0172 self.user_nick = None self.command_hits = [] self.last_key_tabs = False self.sendmessage = True con = app.connections[self.account] con.get_module('Chatstate').set_active(self.contact) if parent_win is not None: id_ = parent_win.window.connect('motion-notify-event', self._on_window_motion_notify) self.handlers[id_] = parent_win.window self.encryption = self.get_encryption_state() self.conv_textview.encryption_enabled = self.encryption is not None # PluginSystem: adding GUI extension point for ChatControlBase # instance object (also subclasses, eg. ChatControl or GroupchatControl) app.plugin_manager.gui_extension_point('chat_control_base', self) # pylint: disable=line-too-long self.register_events([ ('our-show', ged.GUI1, self._nec_our_status), ('ping-sent', ged.GUI1, self._nec_ping), ('ping-reply', ged.GUI1, self._nec_ping), ('ping-error', ged.GUI1, self._nec_ping), ('sec-catalog-received', ged.GUI1, self._sec_labels_received), ('style-changed', ged.GUI1, self._style_changed), ]) # pylint: enable=line-too-long # This is basically a very nasty hack to surpass the inability # to properly use the super, because of the old code. CommandTools.__init__(self) def _on_conv_textview_key_press_event(self, textview, event): if event.get_state() & Gdk.ModifierType.SHIFT_MASK: if event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Page_Up): return Gdk.EVENT_PROPAGATE if event.keyval in COPY_MODIFIER_KEYS: # Don’t route modifier keys for copy action to the Message Input # otherwise pressing CTRL/META + c (the next event after that) # will not reach the textview (because the Message Input would get # focused). return Gdk.EVENT_PROPAGATE if event.get_state() & COPY_MODIFIER: # Don’t reroute the event if it is META + c and the # textview has a selection if event.hardware_keycode in KEYCODES_KEY_C: if textview.get_buffer().props.has_selection: return Gdk.EVENT_PROPAGATE if not self.msg_textview.get_sensitive(): # If the input textview is not sensitive it can’t get the focus. # In that case propagate_key_event() would send the event again # to the conversation textview. This would mean a recursion. return Gdk.EVENT_PROPAGATE # Focus the Message Input and resend the event self.msg_textview.grab_focus() self.msg_textview.get_toplevel().propagate_key_event(event) return Gdk.EVENT_STOP @property def type(self): return self._type @property def is_chat(self): return self._type.is_chat @property def is_privatechat(self): return self._type.is_privatechat @property def is_groupchat(self): return self._type.is_groupchat def get_full_jid(self): fjid = self.contact.jid if self.resource: fjid += '/' + self.resource return fjid def minimizable(self): """ Called to check if control can be minimized Derived classes MAY implement this. """ return False def safe_shutdown(self): """ Called to check if control can be closed without losing data. returns True if control can be closed safely else False Derived classes MAY implement this. """ return True def allow_shutdown(self, method, on_response_yes, on_response_no, on_response_minimize): """ Called to check is a control is allowed to shutdown. If a control is not in a suitable shutdown state this method should call on_response_no, else on_response_yes or on_response_minimize Derived classes MAY implement this. """ on_response_yes(self) def focus(self): raise NotImplementedError def get_nb_unread(self): jid = self.contact.jid if self.resource: jid += '/' + self.resource return len(app.events.get_events( self.account, jid, ['printed_%s' % self._type, str(self._type)])) def draw_banner(self): """ Draw the fat line at the top of the window that houses the icon, jid, etc Derived types MAY implement this. """ self.draw_banner_text() def update_toolbar(self): """ update state of buttons in toolbar """ self._update_toolbar() app.plugin_manager.gui_extension_point( 'chat_control_base_update_toolbar', self) def draw_banner_text(self): """ Derived types SHOULD implement this """ def update_ui(self): """ Derived types SHOULD implement this """ self.draw_banner() def repaint_themed_widgets(self): """ Derived types MAY implement this """ self.draw_banner() def _update_toolbar(self): """ Derived types MAY implement this """ def get_tab_label(self, chatstate): """ Return a suitable tab label string. Returns a tuple such as: (label_str, color) either of which can be None if chatstate is given that means we have HE SENT US a chatstate and we want it displayed Derivded classes MUST implement this. """ # Return a markup'd label and optional Gtk.Color in a tuple like: # return (label_str, None) def get_tab_image(self): # Return a suitable tab image for display. return None def prepare_context_menu(self, hide_buttonbar_items=False): """ Derived classes SHOULD implement this """ return None def set_session(self, session): oldsession = None if hasattr(self, 'session'): oldsession = self.session if oldsession and session == oldsession: return self.session = session if session: session.control = self if session and oldsession: oldsession.control = None def remove_session(self, session): if session != self.session: return self.session.control = None self.session = None @event_filter(['account']) def _nec_our_status(self, event): if event.show == 'connecting': return if event.show == 'offline': self.got_disconnected() else: self.got_connected() if self.parent_win: self.parent_win.redraw_tab(self) def _nec_ping(self, obj): raise NotImplementedError def setup_seclabel(self): self.xml.label_selector.hide() self.xml.label_selector.set_no_show_all(True) lb = Gtk.ListStore(str) self.xml.label_selector.set_model(lb) cell = Gtk.CellRendererText() cell.set_property('xpad', 5) # padding for status text self.xml.label_selector.pack_start(cell, True) # text to show is in in first column of liststore self.xml.label_selector.add_attribute(cell, 'text', 0) con = app.connections[self.account] jid = self.contact.jid if self._type.is_privatechat: jid = self.gc_contact.room_jid if con.get_module('SecLabels').supported: con.get_module('SecLabels').request_catalog(jid) def _sec_labels_received(self, event): if event.account != self.account: return jid = self.contact.jid if self._type.is_privatechat: jid = self.gc_contact.room_jid if event.jid != jid: return model = self.xml.label_selector.get_model() model.clear() sel = 0 labellist = event.catalog.get_label_names() default = event.catalog.default for index, label in enumerate(labellist): model.append([label]) if label == default: sel = index self.xml.label_selector.set_active(sel) self.xml.label_selector.set_no_show_all(False) self.xml.label_selector.show_all() def delegate_action(self, action): if action == 'browse-history': dict_ = {'jid': GLib.Variant('s', self.contact.jid), 'account': GLib.Variant('s', self.account)} variant = GLib.Variant('a{sv}', dict_) app.app.activate_action('browse-history', variant) return Gdk.EVENT_STOP if action == 'clear-chat': self.conv_textview.clear() return Gdk.EVENT_STOP if action == 'delete-line': self.clear(self.msg_textview) return Gdk.EVENT_STOP if action == 'show-emoji-chooser': if sys.platform in ('win32', 'darwin'): self.xml.emoticons_button.get_popover().show() return Gdk.EVENT_STOP self.msg_textview.emit('insert-emoji') return Gdk.EVENT_STOP return Gdk.EVENT_PROPAGATE def add_actions(self): action = Gio.SimpleAction.new_stateful( 'set-encryption-%s' % self.control_id, GLib.VariantType.new('s'), GLib.Variant('s', self.encryption or 'disabled')) action.connect('change-state', self.change_encryption) self.parent_win.window.add_action(action) actions = { 'send-message-%s': self._on_send_message, 'send-file-%s': self._on_send_file, 'send-file-httpupload-%s': self._on_send_file, 'send-file-jingle-%s': self._on_send_file, } for name, func in actions.items(): action = Gio.SimpleAction.new(name % self.control_id, None) action.connect('activate', func) action.set_enabled(False) self.parent_win.window.add_action(action) def remove_actions(self): actions = [ 'send-message-', 'set-encryption-', 'send-file-', 'send-file-httpupload-', 'send-file-jingle-', ] for action in actions: self.parent_win.window.remove_action(f'{action}{self.control_id}') def change_encryption(self, action, param): encryption = param.get_string() if encryption == 'disabled': encryption = None if self.encryption == encryption: return if encryption: plugin = app.plugin_manager.encryption_plugins[encryption] if not plugin.activate_encryption(self): return action.set_state(param) self.set_encryption_state(encryption) self.set_encryption_menu_icon() self.set_lock_image() def set_lock_image(self): encryption_state = {'visible': self.encryption is not None, 'enc_type': self.encryption, 'authenticated': False} if self.encryption: app.plugin_manager.extension_point( 'encryption_state' + self.encryption, self, encryption_state) visible, enc_type, authenticated = encryption_state.values() if authenticated: authenticated_string = _('and authenticated') self.xml.lock_image.set_from_icon_name( 'security-high-symbolic', Gtk.IconSize.MENU) else: authenticated_string = _('and NOT authenticated') self.xml.lock_image.set_from_icon_name( 'security-low-symbolic', Gtk.IconSize.MENU) tooltip = _('%(type)s encryption is active %(authenticated)s.') % { 'type': enc_type, 'authenticated': authenticated_string} self.xml.authentication_button.set_tooltip_text(tooltip) self.xml.authentication_button.set_visible(visible) self.xml.lock_image.set_sensitive(visible) def _on_authentication_button_clicked(self, _button): app.plugin_manager.extension_point( 'encryption_dialog' + self.encryption, self) def set_encryption_state(self, encryption): self.encryption = encryption self.conv_textview.encryption_enabled = encryption is not None self.contact.settings.set('encryption', self.encryption or '') def get_encryption_state(self): state = self.contact.settings.get('encryption') if not state: return None if state not in app.plugin_manager.encryption_plugins: self.set_encryption_state(None) return None return state def set_encryption_menu_icon(self): image = self.xml.encryption_menu.get_image() if image is None: image = Gtk.Image() self.xml.encryption_menu.set_image(image) if not self.encryption: image.set_from_icon_name('channel-insecure-symbolic', Gtk.IconSize.MENU) else: image.set_from_icon_name('channel-secure-symbolic', Gtk.IconSize.MENU) def set_speller(self): if not app.is_installed('GSPELL') or not app.settings.get('use_speller'): return gspell_lang = self.get_speller_language() spell_checker = Gspell.Checker.new(gspell_lang) spell_buffer = Gspell.TextBuffer.get_from_gtk_text_buffer( self.msg_textview.get_buffer()) spell_buffer.set_spell_checker(spell_checker) spell_view = Gspell.TextView.get_from_gtk_text_view(self.msg_textview) spell_view.set_inline_spell_checking(False) spell_view.set_enable_language_menu(True) spell_checker.connect('notify::language', self.on_language_changed) def get_speller_language(self): lang = self.contact.settings.get('speller_language') if not lang: # use the default one lang = app.settings.get('speller_language') if not lang: lang = i18n.LANG gspell_lang = Gspell.language_lookup(lang) if gspell_lang is None: gspell_lang = Gspell.language_get_default() return gspell_lang def on_language_changed(self, checker, _param): gspell_lang = checker.get_language() self.contact.settings.set('speller_language', gspell_lang.get_code()) def on_banner_label_populate_popup(self, _label, menu): """ Override the default context menu and add our own menuitems """ item = Gtk.SeparatorMenuItem.new() menu.prepend(item) menu2 = self.prepare_context_menu() # pylint: disable=assignment-from-none i = 0 for item in menu2: menu2.remove(item) menu.prepend(item) menu.reorder_child(item, i) i += 1 menu.show_all() def shutdown(self): # remove_gui_extension_point() is called on shutdown, but also when # a plugin is getting disabled. Plugins don’t know the difference. # Plugins might want to remove their widgets on # remove_gui_extension_point(), so delete the objects only afterwards. app.plugin_manager.remove_gui_extension_point('chat_control_base', self) app.plugin_manager.remove_gui_extension_point( 'chat_control_base_update_toolbar', self) for i in list(self.handlers.keys()): if self.handlers[i].handler_is_connected(i): self.handlers[i].disconnect(i) self.handlers.clear() self.conv_textview.del_handlers() del self.conv_textview del self.msg_textview del self.msg_scrolledwindow self.widget.destroy() del self.widget del self.xml self.unregister_events() def on_msg_textview_populate_popup(self, _textview, menu): """ Override the default context menu and we prepend an option to switch languages """ item = Gtk.MenuItem.new_with_mnemonic(_('_Undo')) menu.prepend(item) id_ = item.connect('activate', self.msg_textview.undo) self.handlers[id_] = item item = Gtk.SeparatorMenuItem.new() menu.prepend(item) item = Gtk.MenuItem.new_with_mnemonic(_('_Clear')) menu.prepend(item) id_ = item.connect('activate', self.msg_textview.clear) self.handlers[id_] = item paste_item = Gtk.MenuItem.new_with_label(_('Paste as quote')) id_ = paste_item.connect('activate', self.paste_clipboard_as_quote) self.handlers[id_] = paste_item menu.append(paste_item) menu.show_all() def insert_as_quote(self, text: str) -> None: text = '> ' + text.replace('\n', '\n> ') + '\n' message_buffer = self.msg_textview.get_buffer() message_buffer.insert_at_cursor(text) def paste_clipboard_as_quote(self, _item: Gtk.MenuItem) -> None: clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) text = clipboard.wait_for_text() if text is None: return self.insert_as_quote(text) def on_quote(self, _widget, text): self.insert_as_quote(text) # moved from ChatControl def _on_banner_eventbox_button_press_event(self, _widget, event): """ If right-clicked, show popup """ if event.button == 3: # right click self.parent_win.popup_menu(event) def _on_message_textview_paste_event(self, _texview): clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) image = clipboard.wait_for_image() if image is not None: if not app.settings.get('confirm_paste_image'): self._paste_event_confirmed(True, image) return PastePreviewDialog( _('Paste Image'), _('You are trying to paste an image'), _('Are you sure you want to paste your ' 'clipboard\'s image into the chat window?'), _('_Do not ask me again'), image, [DialogButton.make('Cancel'), DialogButton.make('Accept', text=_('_Paste'), callback=self._paste_event_confirmed, args=[image])]).show() def _paste_event_confirmed(self, is_checked, image): if is_checked: app.settings.set('confirm_paste_image', False) dir_ = tempfile.gettempdir() path = os.path.join(dir_, '%s.png' % str(uuid.uuid4())) image.savev(path, 'png', [], []) self._start_filetransfer(path) def _get_pref_ft_method(self): ft_pref = app.settings.get_account_setting(self.account, 'filetransfer_preference') httpupload = self.parent_win.window.lookup_action( 'send-file-httpupload-%s' % self.control_id) jingle = self.parent_win.window.lookup_action( 'send-file-jingle-%s' % self.control_id) if self._type.is_groupchat: if httpupload.get_enabled(): return 'httpupload' return None if httpupload.get_enabled() and jingle.get_enabled(): return ft_pref if httpupload.get_enabled(): return 'httpupload' if jingle.get_enabled(): return 'jingle' return None def _start_filetransfer(self, path): method = self._get_pref_ft_method() if method is None: return if method == 'httpupload': app.interface.send_httpupload(self, path) else: ft = app.interface.instances['file_transfers'] ft.send_file(self.account, self.contact, path) def _on_message_textview_key_press_event(self, textview, event): if event.keyval == Gdk.KEY_space: self.space_pressed = True elif (self.space_pressed or self.msg_textview.undo_pressed) and \ event.keyval not in (Gdk.KEY_Control_L, Gdk.KEY_Control_R) and \ not (event.keyval == Gdk.KEY_z and event.get_state() & Gdk.ModifierType.CONTROL_MASK): # If the space key has been pressed and now it hasn't, # we save the buffer into the undo list. But be careful we're not # pressing Control again (as in ctrl+z) _buffer = textview.get_buffer() start_iter, end_iter = _buffer.get_bounds() self.msg_textview.save_undo(_buffer.get_text(start_iter, end_iter, True)) self.space_pressed = False # Ctrl [+ Shift] + Tab are not forwarded to notebook. We handle it here if self._type.is_groupchat: if event.keyval not in (Gdk.KEY_ISO_Left_Tab, Gdk.KEY_Tab): self.last_key_tabs = False if event.get_state() & Gdk.ModifierType.SHIFT_MASK: if event.get_state() & Gdk.ModifierType.CONTROL_MASK and \ event.keyval == Gdk.KEY_ISO_Left_Tab: self.parent_win.move_to_next_unread_tab(False) return True if event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Page_Up): self.conv_textview.tv.event(event) self._on_scroll(None, event.keyval) return True if event.get_state() & Gdk.ModifierType.CONTROL_MASK: if event.keyval == Gdk.KEY_Tab: self.parent_win.move_to_next_unread_tab(True) return True message_buffer = self.msg_textview.get_buffer() event_state = event.get_state() if event.keyval == Gdk.KEY_Tab: start, end = message_buffer.get_bounds() position = message_buffer.get_insert() end = message_buffer.get_iter_at_mark(position) text = message_buffer.get_text(start, end, False) split = text.split() if (text.startswith(self.COMMAND_PREFIX) and not text.startswith(self.COMMAND_PREFIX * 2) and len(split) == 1): text = split[0] bare = text.lstrip(self.COMMAND_PREFIX) if len(text) == 1: self.command_hits = [] for command in self.list_commands(): for name in command.names: self.command_hits.append(name) else: if (self.last_key_tabs and self.command_hits and self.command_hits[0].startswith(bare)): self.command_hits.append(self.command_hits.pop(0)) else: self.command_hits = [] for command in self.list_commands(): for name in command.names: if name.startswith(bare): self.command_hits.append(name) if self.command_hits: message_buffer.delete(start, end) message_buffer.insert_at_cursor(self.COMMAND_PREFIX + \ self.command_hits[0] + ' ') self.last_key_tabs = True return True if not self._type.is_groupchat: self.last_key_tabs = False if event.keyval == Gdk.KEY_Up: if event_state & Gdk.ModifierType.CONTROL_MASK: if event_state & Gdk.ModifierType.SHIFT_MASK: # Ctrl+Shift+UP self.scroll_messages('up', message_buffer, 'received') else: # Ctrl+UP self.scroll_messages('up', message_buffer, 'sent') return True elif event.keyval == Gdk.KEY_Down: if event_state & Gdk.ModifierType.CONTROL_MASK: if event_state & Gdk.ModifierType.SHIFT_MASK: # Ctrl+Shift+Down self.scroll_messages('down', message_buffer, 'received') else: # Ctrl+Down self.scroll_messages('down', message_buffer, 'sent') return True elif (event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter): # ENTER if event_state & Gdk.ModifierType.SHIFT_MASK: textview.insert_newline() return True if event_state & Gdk.ModifierType.CONTROL_MASK: if not app.settings.get('send_on_ctrl_enter'): textview.insert_newline() return True else: if app.settings.get('send_on_ctrl_enter'): textview.insert_newline() return True if not app.account_is_available(self.account): # we are not connected app.interface.raise_dialog('not-connected-while-sending') return True self._on_send_message() return True elif event.keyval == Gdk.KEY_z: # CTRL+z if event_state & Gdk.ModifierType.CONTROL_MASK: self.msg_textview.undo() return True return False def _on_drag_data_received(self, widget, context, x, y, selection, target_type, timestamp): """ Derived types SHOULD implement this """ def _on_drag_leave(self, *args): self.xml.drop_area.set_no_show_all(True) self.xml.drop_area.hide() def _on_drag_motion(self, *args): self.xml.drop_area.set_no_show_all(False) self.xml.drop_area.show_all() def drag_data_file_transfer(self, selection): # we may have more than one file dropped uri_splitted = selection.get_uris() for uri in uri_splitted: path = helpers.get_file_path_from_dnd_dropped_uri(uri) if not os.path.isfile(path): # is it a file? continue self._start_filetransfer(path) def get_seclabel(self): idx = self.xml.label_selector.get_active() if idx == -1: return None con = app.connections[self.account] jid = self.contact.jid if self._type.is_privatechat: jid = self.gc_contact.room_jid catalog = con.get_module('SecLabels').get_catalog(jid) labels, label_list = catalog.labels, catalog.get_label_names() lname = label_list[idx] label = labels[lname] return label def _on_send_message(self, *args): self.msg_textview.replace_emojis() message = self.msg_textview.get_text() xhtml = self.msg_textview.get_xhtml() self.send_message(message, xhtml=xhtml) def send_message(self, message, type_='chat', resource=None, xhtml=None, process_commands=True, attention=False): """ Send the given message to the active tab. Doesn't return None if error """ if not message or message == '\n': return None if process_commands and self.process_as_command(message): return label = self.get_seclabel() if self.correcting and self.last_sent_msg: correct_id = self.last_sent_msg else: correct_id = None con = app.connections[self.account] chatstate = con.get_module('Chatstate').get_active_chatstate( self.contact) message_ = OutgoingMessage(account=self.account, contact=self.contact, message=message, type_=type_, chatstate=chatstate, resource=resource, user_nick=self.user_nick, label=label, control=self, attention=attention, correct_id=correct_id, xhtml=xhtml) con.send_message(message_) # Record the history of sent messages self.save_message(message, 'sent') # Be sure to send user nickname only once according to JEP-0172 self.user_nick = None # Clear msg input message_buffer = self.msg_textview.get_buffer() message_buffer.set_text('') # clear message buffer (and tv of course) def _on_window_motion_notify(self, *args): """ It gets called no matter if it is the active window or not """ if not self.parent_win: # when a groupchat is minimized there is no parent window return if self.parent_win.get_active_jid() == self.contact.jid: # if window is the active one, set last interaction con = app.connections[self.account] con.get_module('Chatstate').set_mouse_activity( self.contact, self.msg_textview.has_text()) def _on_message_tv_buffer_changed(self, textbuffer): has_text = self.msg_textview.has_text() if self.parent_win is not None: self.parent_win.window.lookup_action( 'send-message-' + self.control_id).set_enabled(has_text) if textbuffer.get_char_count() and self.encryption: app.plugin_manager.extension_point( 'typing' + self.encryption, self) con = app.connections[self.account] con.get_module('Chatstate').set_keyboard_activity(self.contact) if not has_text: con.get_module('Chatstate').set_chatstate_delayed(self.contact, Chatstate.ACTIVE) return con.get_module('Chatstate').set_chatstate(self.contact, Chatstate.COMPOSING) def save_message(self, message, msg_type): # save the message, so user can scroll though the list with key up/down if msg_type == 'sent': history = self.sent_history pos = self.sent_history_pos else: history = self.received_history pos = self.received_history_pos size = len(history) scroll = pos != size # we don't want size of the buffer to grow indefinitely max_size = app.settings.get('key_up_lines') for _i in range(size - max_size + 1): if pos == 0: break history.pop(0) pos -= 1 history.append(message) if not scroll or msg_type == 'sent': pos = len(history) if msg_type == 'sent': self.sent_history_pos = pos self.orig_msg = None else: self.received_history_pos = pos def add_info_message(self, text, message_id=None): self.conv_textview.print_conversation_line( text, 'info', '', None, message_id=message_id, graphics=False) def add_status_message(self, text): self.conv_textview.print_conversation_line( text, 'status', '', None) def add_message(self, text, kind, name, tim, other_tags_for_name=None, other_tags_for_time=None, other_tags_for_text=None, restored=False, subject=None, old_kind=None, displaymarking=None, msg_log_id=None, message_id=None, stanza_id=None, correct_id=None, additional_data=None, marker=None, error=None): """ Print 'chat' type messages correct_id = (message_id, correct_id) """ jid = self.contact.jid full_jid = self.get_full_jid() textview = self.conv_textview end = False if self.conv_textview.autoscroll or kind == 'outgoing': end = True if other_tags_for_name is None: other_tags_for_name = [] if other_tags_for_time is None: other_tags_for_time = [] if other_tags_for_text is None: other_tags_for_text = [] if additional_data is None: additional_data = AdditionalDataDict() textview.print_conversation_line(text, kind, name, tim, other_tags_for_name, other_tags_for_time, other_tags_for_text, subject, old_kind, displaymarking=displaymarking, message_id=message_id, correct_id=correct_id, additional_data=additional_data, marker=marker, error=error) if restored: return if message_id: if self._type.is_groupchat: self.last_msg_id = stanza_id or message_id else: self.last_msg_id = message_id if kind == 'incoming': if (not self._type.is_groupchat or self.contact.can_notify() or 'marked' in other_tags_for_text): # it's a normal message, or a muc message with want to be # notified about if quitting just after # other_tags_for_text == ['marked'] --> highlighted gc message app.last_message_time[self.account][full_jid] = time.time() if kind in ('incoming', 'incoming_queue'): # Record the history of received messages self.save_message(text, 'received') # Send chat marker if we’re actively following the chat if self.parent_win and self.contact.settings.get('send_marker'): if (self.parent_win.get_active_control() == self and self.parent_win.is_active() and self.has_focus() and end): con = app.connections[self.account] con.get_module('ChatMarkers').send_displayed_marker( self.contact, self.last_msg_id, self._type) if kind in ('incoming', 'incoming_queue', 'error'): gc_message = False if self._type.is_groupchat: gc_message = True if ((self.parent_win and (not self.parent_win.get_active_control() or \ self != self.parent_win.get_active_control() or \ not self.parent_win.is_active() or not end)) or \ (gc_message and \ jid in app.interface.minimized_controls[self.account])) and \ kind in ('incoming', 'incoming_queue', 'error'): # we want to have save this message in events list # other_tags_for_text == ['marked'] --> highlighted gc message if gc_message: if 'marked' in other_tags_for_text: event_type = events.PrintedMarkedGcMsgEvent else: event_type = events.PrintedGcMsgEvent event = 'gc_message_received' else: if self._type.is_chat: event_type = events.PrintedChatEvent else: event_type = events.PrintedPmEvent event = 'message_received' show_in_roster = get_show_in_roster(event, self.session) show_in_systray = get_show_in_systray( event_type.type_, self.account, self.contact.jid) event = event_type(text, subject, self, msg_log_id, message_id=message_id, stanza_id=stanza_id, show_in_roster=show_in_roster, show_in_systray=show_in_systray) app.events.add_event(self.account, full_jid, event) # We need to redraw contact if we show in roster if show_in_roster: app.interface.roster.draw_contact(self.contact.jid, self.account) if not self.parent_win: return if (not self.parent_win.get_active_control() or \ self != self.parent_win.get_active_control() or \ not self.parent_win.is_active() or not end) and \ kind in ('incoming', 'incoming_queue', 'error'): self.parent_win.redraw_tab(self) if not self.parent_win.is_active(): self.parent_win.show_title(True, self) # Enabled Urgent hint else: self.parent_win.show_title(False, self) # Disabled Urgent hint def toggle_emoticons(self): """ Hide show emoticons_button """ if app.settings.get('emoticons_theme'): self.xml.emoticons_button.set_no_show_all(False) self.xml.emoticons_button.show() else: self.xml.emoticons_button.set_no_show_all(True) self.xml.emoticons_button.hide() def set_emoticon_popover(self): if not app.settings.get('emoticons_theme'): return if not self.parent_win: return if sys.platform in ('win32', 'darwin'): emoji_chooser.text_widget = self.msg_textview self.xml.emoticons_button.set_popover(emoji_chooser) return self.xml.emoticons_button.set_sensitive(True) self.xml.emoticons_button.connect('clicked', self._on_emoticon_button_clicked) def _on_emoticon_button_clicked(self, _widget): # Present GTK emoji chooser (not cross platform compatible) self.msg_textview.emit('insert-emoji') self.xml.emoticons_button.set_property('active', False) def on_color_menuitem_activate(self, _widget): color_dialog = Gtk.ColorChooserDialog(None, self.parent_win.window) color_dialog.set_use_alpha(False) color_dialog.connect('response', self.msg_textview.color_set) color_dialog.show_all() def on_font_menuitem_activate(self, _widget): font_dialog = Gtk.FontChooserDialog(None, self.parent_win.window) start, finish = self.msg_textview.get_active_iters() font_dialog.connect('response', self.msg_textview.font_set, start, finish) font_dialog.show_all() def on_formatting_menuitem_activate(self, widget): tag = widget.get_name() self.msg_textview.set_tag(tag) def on_clear_formatting_menuitem_activate(self, _widget): self.msg_textview.clear_tags() def _style_changed(self, *args): self.update_tags() def update_tags(self): self.conv_textview.update_tags() @staticmethod def clear(tv): buffer_ = tv.get_buffer() start, end = buffer_.get_bounds() buffer_.delete(start, end) def _on_send_file(self, action, _param): name = action.get_name() if 'httpupload' in name: app.interface.send_httpupload(self) return if 'jingle' in name: self._on_send_file_jingle() return method = self._get_pref_ft_method() if method is None: return if method == 'httpupload': app.interface.send_httpupload(self) else: self._on_send_file_jingle() def _on_send_file_jingle(self, gc_contact=None): """ gc_contact can be set when we are in a groupchat control """ def _on_ok(_contact): app.interface.instances['file_transfers'].show_file_send_request( self.account, _contact) if self._type.is_privatechat: gc_contact = self.gc_contact if not gc_contact: _on_ok(self.contact) return # gc or pm gc_control = app.interface.msg_win_mgr.get_gc_control( gc_contact.room_jid, self.account) self_contact = app.contacts.get_gc_contact(self.account, gc_control.room_jid, gc_control.nick) if (gc_control.is_anonymous and gc_contact.affiliation.value not in ['admin', 'owner'] and self_contact.affiliation.value in ['admin', 'owner']): contact = app.contacts.get_contact(self.account, gc_contact.jid) if not contact or contact.sub not in ('both', 'to'): ConfirmationDialog( _('Privacy'), _('Warning'), _('If you send a file to %s, your real XMPP ' 'address will be revealed.') % gc_contact.name, [DialogButton.make('Cancel'), DialogButton.make( 'OK', text=_('_Continue'), callback=lambda: _on_ok(gc_contact))]).show() return _on_ok(gc_contact) def set_control_active(self, state): con = app.connections[self.account] if state: self.set_emoticon_popover() jid = self.contact.jid if self.conv_textview.autoscroll: # we are at the end type_ = [f'printed_{self._type}'] if self._type.is_groupchat: type_ = ['printed_gc_msg', 'printed_marked_gc_msg'] if not app.events.remove_events(self.account, self.get_full_jid(), types=type_): # There were events to remove self.redraw_after_event_removed(jid) # XEP-0333 Send marker con.get_module('ChatMarkers').send_displayed_marker( self.contact, self.last_msg_id, self._type) self.last_msg_id = None # send chatstate inactive to the one we're leaving # and active to the one we visit if self.msg_textview.has_text(): con.get_module('Chatstate').set_chatstate(self.contact, Chatstate.PAUSED) else: con.get_module('Chatstate').set_chatstate(self.contact, Chatstate.ACTIVE) else: con.get_module('Chatstate').set_chatstate(self.contact, Chatstate.INACTIVE) def scroll_to_end(self, force=False): self.conv_textview.scroll_to_end(force) def _on_edge_reached(self, _scrolledwindow, pos): if pos != Gtk.PositionType.BOTTOM: return # Remove all events and set autoscroll True app.log('autoscroll').info('Autoscroll enabled') self.conv_textview.autoscroll = True if self.resource: jid = self.contact.get_full_jid() else: jid = self.contact.jid types_list = [] if self._type.is_groupchat: types_list = ['printed_gc_msg', 'gc_msg', 'printed_marked_gc_msg'] else: types_list = [f'printed_{self._type}', str(self._type)] if not app.events.get_events(self.account, jid, types_list): return if not self.parent_win: return if (self.parent_win.get_active_control() == self and self.parent_win.window.is_active()): # we are at the end if not app.events.remove_events( self.account, jid, types=types_list): # There were events to remove self.redraw_after_event_removed(jid) # XEP-0333 Send tag con = app.connections[self.account] con.get_module('ChatMarkers').send_displayed_marker( self.contact, self.last_msg_id, self._type) self.last_msg_id = None def _on_scrollbar_button_release(self, scrollbar, event): if event.get_button()[1] != 1: # We want only to catch the left mouse button return if not at_the_end(scrollbar.get_parent()): app.log('autoscroll').info('Autoscroll disabled') self.conv_textview.autoscroll = False def has_focus(self): if self.parent_win: if self.parent_win.window.get_property('has-toplevel-focus'): if self == self.parent_win.get_active_control(): return True return False def _on_scroll(self, widget, event): if not self.conv_textview.autoscroll: # autoscroll is already disabled return if widget is None: # call from _conv_textview_key_press_event() # SHIFT + Gdk.KEY_Page_Up if event != Gdk.KEY_Page_Up: return else: # On scrolling UP disable autoscroll # get_scroll_direction() sets has_direction only TRUE # if smooth scrolling is deactivated. If we have smooth # smooth scrolling we have to use get_scroll_deltas() has_direction, direction = event.get_scroll_direction() if not has_direction: direction = None smooth, delta_x, delta_y = event.get_scroll_deltas() if smooth: if delta_y < 0: direction = Gdk.ScrollDirection.UP elif delta_y > 0: direction = Gdk.ScrollDirection.DOWN elif delta_x < 0: direction = Gdk.ScrollDirection.LEFT elif delta_x > 0: direction = Gdk.ScrollDirection.RIGHT else: app.log('autoscroll').warning( 'Scroll directions can’t be determined') if direction != Gdk.ScrollDirection.UP: return # Check if we have a Scrollbar adjustment = self.xml.conversation_scrolledwindow.get_vadjustment() if adjustment.get_upper() != adjustment.get_page_size(): app.log('autoscroll').info('Autoscroll disabled') self.conv_textview.autoscroll = False def on_conversation_vadjustment_changed(self, _adjustment): self.scroll_to_end() def redraw_after_event_removed(self, jid): """ We just removed a 'printed_*' event, redraw contact in roster or gc_roster and titles in roster and msg_win """ if not self.parent_win: # minimized groupchat return self.parent_win.redraw_tab(self) self.parent_win.show_title() # TODO : get the contact and check get_show_in_roster() if self._type.is_privatechat: room_jid, nick = app.get_room_and_nick_from_fjid(jid) groupchat_control = app.interface.msg_win_mgr.get_gc_control( room_jid, self.account) if room_jid in app.interface.minimized_controls[self.account]: groupchat_control = \ app.interface.minimized_controls[self.account][room_jid] contact = app.contacts.get_contact_with_highest_priority( self.account, room_jid) if contact: app.interface.roster.draw_contact(room_jid, self.account) if groupchat_control: groupchat_control.roster.draw_contact(nick) if groupchat_control.parent_win: groupchat_control.parent_win.redraw_tab(groupchat_control) else: app.interface.roster.draw_contact(jid, self.account) app.interface.roster.show_title() def scroll_messages(self, direction, msg_buf, msg_type): if msg_type == 'sent': history = self.sent_history pos = self.sent_history_pos self.received_history_pos = len(self.received_history) else: history = self.received_history pos = self.received_history_pos self.sent_history_pos = len(self.sent_history) size = len(history) if self.orig_msg is None: # user was typing something and then went into history, so save # whatever is already typed start_iter = msg_buf.get_start_iter() end_iter = msg_buf.get_end_iter() self.orig_msg = msg_buf.get_text(start_iter, end_iter, False) if pos == size and size > 0 and direction == 'up' and \ msg_type == 'sent' and not self.correcting and (not \ history[pos - 1].startswith('/') or history[pos - 1].startswith('/me')): self.correcting = True gtkgui_helpers.add_css_class( self.msg_textview, 'gajim-msg-correcting') message = history[pos - 1] msg_buf.set_text(message) return if self.correcting: # We were previously correcting gtkgui_helpers.remove_css_class( self.msg_textview, 'gajim-msg-correcting') self.correcting = False pos += -1 if direction == 'up' else +1 if pos == -1: return if pos >= size: pos = size message = self.orig_msg self.orig_msg = None else: message = history[pos] if msg_type == 'sent': self.sent_history_pos = pos else: self.received_history_pos = pos if self.orig_msg is not None: message = '> %s\n' % message.replace('\n', '\n> ') msg_buf.set_text(message) def got_connected(self): self.msg_textview.set_sensitive(True) self.msg_textview.set_editable(True) self.update_toolbar() def got_disconnected(self): self.msg_textview.set_sensitive(False) self.msg_textview.set_editable(False) self.conv_textview.tv.grab_focus() self.update_toolbar() class ScrolledWindow(Gtk.ScrolledWindow): def __init__(self, *args, **kwargs): Gtk.ScrolledWindow.__init__(self, *args, **kwargs) self.set_overlay_scrolling(False) self.set_max_content_height(100) self.set_propagate_natural_height(True) self.get_style_context().add_class('scrolled-no-border') self.get_style_context().add_class('no-scroll-indicator') self.get_style_context().add_class('scrollbar-style') self.get_style_context().add_class('one-line-scrollbar') self.set_shadow_type(Gtk.ShadowType.IN) self.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)