gajim-plural/gajim/chat_control_base.py

1452 lines
58 KiB
Python

# -*- coding:utf-8 -*-
## src/chat_control_base.py
##
## Copyright (C) 2006 Dimitur Kirov <dkirov AT gmail.com>
## Copyright (C) 2006-2014 Yann Leboulanger <asterix AT lagaule.org>
## Copyright (C) 2006-2008 Jean-Marie Traissard <jim AT lapin.org>
## Nikos Kouremenos <kourem AT gmail.com>
## Travis Shirk <travis AT pobox.com>
## Copyright (C) 2007 Lukas Petrovicky <lukas AT petrovicky.net>
## Julien Pivotto <roidelapluie AT gmail.com>
## Copyright (C) 2007-2008 Brendan Taylor <whateley AT gmail.com>
## Stephan Erb <steve-e AT h3c.de>
## Copyright (C) 2008 Jonathan Schleifer <js-gajim AT webkeks.org>
##
## This file is part of Gajim.
##
## Gajim is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published
## by the Free Software Foundation; version 3 only.
##
## Gajim is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with Gajim. If not, see <http://www.gnu.org/licenses/>.
##
import os
import time
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import Pango
from gi.repository import GLib
from gi.repository import Gio
from gajim import gtkgui_helpers
from gajim import message_control
from gajim import dialogs
from gajim import history_window
from gajim import notify
import re
from gajim import emoticons
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.message_control import MessageControl
from gajim.conversation_textview import ConversationTextview
from gajim.message_textview import MessageTextView
from gajim.common.contacts import GC_Contact
from gajim.common.connection_handlers_events import MessageOutgoingEvent
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.
from gajim.command_system.implementation import standard
from gajim.command_system.implementation import execute
if app.is_installed('GSPELL'):
from gi.repository import Gspell
################################################################################
class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools):
"""
A base class containing a banner, ConversationTextview, MessageTextView
"""
keymap = Gdk.Keymap.get_default()
try:
keycode_c = keymap.get_entries_for_keyval(Gdk.KEY_c)[1][0].keycode
except TypeError:
keycode_c = 54
try:
keycode_ins = keymap.get_entries_for_keyval(Gdk.KEY_Insert)[1][0].keycode
except TypeError:
keycode_ins = 118
except IndexError:
# There is no KEY_Insert (MacOS)
keycode_ins = None
def make_href(self, match):
url_color = app.config.get('urlmsgcolor')
url = match.group()
if not '://' in url:
url = 'http://' + url
return '<a href="%s"><span color="%s">%s</span></a>' % (url,
url_color, match.group())
def get_font_attrs(self):
"""
Get pango font attributes for banner from theme settings
"""
theme = app.config.get('roster_theme')
bannerfont = app.config.get_per('themes', theme, 'bannerfont')
bannerfontattrs = app.config.get_per('themes', theme, 'bannerfontattrs')
if bannerfont:
font = Pango.FontDescription(bannerfont)
else:
font = Pango.FontDescription('Normal')
if bannerfontattrs:
# B attribute is set by default
if 'B' in bannerfontattrs:
font.set_weight(Pango.Weight.HEAVY)
if 'I' in bannerfontattrs:
font.set_style(Pango.Style.ITALIC)
font_attrs = 'font_desc="%s"' % font.to_string()
# in case there is no font specified we use x-large font size
if font.get_size() == 0:
font_attrs = '%s size="x-large"' % font_attrs
font.set_weight(Pango.Weight.NORMAL)
font_attrs_small = 'font_desc="%s" size="small"' % font.to_string()
return (font_attrs, font_attrs_small)
def get_nb_unread(self):
jid = self.contact.jid
if self.resource:
jid += '/' + self.resource
type_ = self.type_id
return len(app.events.get_events(self.account, jid, ['printed_' + type_,
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()
self._update_banner_state_image()
app.plugin_manager.gui_extension_point('chat_control_base_draw_banner',
self)
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
"""
pass
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_banner_state_image(self):
"""
Derived types MAY implement this
"""
pass
def _update_toolbar(self):
"""
Derived types MAY implement this
"""
pass
def _nec_our_status(self, obj):
if self.account != obj.conn.name:
return
if obj.show == 'offline' or (obj.show == 'invisible' and \
obj.conn.is_zeroconf):
self.got_disconnected()
else:
# Other code rejoins all GCs, so we don't do it here
if not self.type_id == message_control.TYPE_GC:
self.got_connected()
if self.parent_win:
self.parent_win.redraw_tab(self)
def _nec_ping_sent(self, obj):
if self.contact != obj.contact:
return
self.print_conversation(_('Ping?'), 'status')
def _nec_ping_error(self, obj):
if self.contact != obj.contact:
return
self.print_conversation(_('Error.'), 'status')
def status_url_clicked(self, widget, url):
helpers.launch_browser_mailer('url', url)
def setup_seclabel(self, combo):
self.seclabel_combo = combo
self.seclabel_combo.hide()
self.seclabel_combo.set_no_show_all(True)
lb = Gtk.ListStore(str)
self.seclabel_combo.set_model(lb)
cell = Gtk.CellRendererText()
cell.set_property('xpad', 5) # padding for status text
self.seclabel_combo.pack_start(cell, True)
# text to show is in in first column of liststore
self.seclabel_combo.add_attribute(cell, 'text', 0)
if app.connections[self.account].seclabel_supported:
app.connections[self.account].seclabel_catalogue(self.contact.jid, self.on_seclabels_ready)
def on_seclabels_ready(self):
lb = self.seclabel_combo.get_model()
lb.clear()
i = 0
sel = 0
catalogue = app.connections[self.account].seclabel_catalogues[
self.contact.jid]
for label in catalogue[2]:
lb.append([label])
if label == catalogue[3]:
sel = i
i += 1
self.seclabel_combo.set_active(sel)
self.seclabel_combo.set_no_show_all(False)
self.seclabel_combo.show_all()
def __init__(self, type_id, parent_win, widget_name, contact, acct,
resource=None):
# 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.
c = app.contacts.get_contact_with_highest_priority(acct,
contact.jid)
if c and not isinstance(c, GC_Contact):
contact = c
MessageControl.__init__(self, type_id, parent_win, widget_name,
contact, acct, resource=resource)
if self.TYPE_ID != message_control.TYPE_GC:
# Create banner and connect signals
widget = self.xml.get_object('banner_eventbox')
id_ = widget.connect('button-press-event',
self._on_banner_eventbox_button_press_event)
self.handlers[id_] = widget
self.urlfinder = re.compile(
r"(www\.(?!\.)|[a-z][a-z0-9+.-]*://)[^\s<>'\"]+[^!,\.\s<>\)'\"\]]")
self.banner_status_label = self.xml.get_object('banner_label')
if self.banner_status_label is not None:
id_ = self.banner_status_label.connect('populate_popup',
self.on_banner_label_populate_popup)
self.handlers[id_] = self.banner_status_label
# Init DND
self.TARGET_TYPE_URI_LIST = 80
self.dnd_list = [Gtk.TargetEntry.new('text/uri-list', 0,
self.TARGET_TYPE_URI_LIST), Gtk.TargetEntry.new('MY_TREE_MODEL_ROW',
Gtk.TargetFlags.SAME_APP, 0)]
id_ = self.widget.connect('drag_data_received',
self._on_drag_data_received)
self.handlers[id_] = self.widget
self.widget.drag_dest_set(Gtk.DestDefaults.MOTION |
Gtk.DestDefaults.HIGHLIGHT | Gtk.DestDefaults.DROP,
self.dnd_list, Gdk.DragAction.COPY)
# 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.tv
id_ = self.conv_textview.tv.connect('key_press_event',
self._conv_textview_key_press_event)
self.handlers[id_] = self.conv_textview.tv
# FIXME: DND on non editable TextView, find a better way
self.drag_entered = False
id_ = self.conv_textview.tv.connect('drag_data_received',
self._on_drag_data_received)
self.handlers[id_] = self.conv_textview.tv
id_ = self.conv_textview.tv.connect('drag_motion', self._on_drag_motion)
self.handlers[id_] = self.conv_textview.tv
id_ = self.conv_textview.tv.connect('drag_leave', self._on_drag_leave)
self.handlers[id_] = self.conv_textview.tv
self.conv_textview.tv.drag_dest_set(Gtk.DestDefaults.MOTION |
Gtk.DestDefaults.HIGHLIGHT | Gtk.DestDefaults.DROP,
self.dnd_list, Gdk.DragAction.COPY)
self.conv_scrolledwindow = self.xml.get_object(
'conversation_scrolledwindow')
self.conv_scrolledwindow.add(self.conv_textview.tv)
widget = self.conv_scrolledwindow.get_vadjustment()
id_ = widget.connect('changed',
self.on_conversation_vadjustment_changed)
self.handlers[id_] = widget
vscrollbar = self.conv_scrolledwindow.get_vscrollbar()
id_ = vscrollbar.connect('button-release-event',
self._on_scrollbar_button_release)
self.handlers[id_] = vscrollbar
self.correcting = False
self.last_sent_msg = None
# add MessageTextView to UI and connect signals
self.msg_textview = MessageTextView()
self.msg_scrolledwindow = ScrolledWindow()
self.msg_scrolledwindow.add(self.msg_textview)
hbox = self.xml.get_object('hbox')
hbox.pack_start(self.msg_scrolledwindow, True, True, 0)
id_ = self.msg_textview.connect('key_press_event',
self._on_message_textview_key_press_event)
self.handlers[id_] = self.msg_textview
id_ = self.msg_textview.connect('populate_popup',
self.on_msg_textview_populate_popup)
self.handlers[id_] = self.msg_textview
# Setup DND
id_ = self.msg_textview.connect('drag_data_received',
self._on_drag_data_received)
self.handlers[id_] = self.msg_textview
self.msg_textview.drag_dest_set(Gtk.DestDefaults.MOTION |
Gtk.DestDefaults.HIGHLIGHT, self.dnd_list, Gdk.DragAction.COPY)
# 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
self.set_emoticon_popover()
# Attach speller
self.spell_checker = None
self.set_speller()
self.conv_textview.tv.show()
# For XEP-0172
self.user_nick = None
self.command_hits = []
self.last_key_tabs = False
# chatstate timers and state
self.reset_kbd_mouse_timeout_vars()
self.possible_paused_timeout_id = None
self.possible_inactive_timeout_id = None
message_tv_buffer = self.msg_textview.get_buffer()
id_ = message_tv_buffer.connect('changed',
self._on_message_tv_buffer_changed)
self.handlers[id_] = message_tv_buffer
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._schedule_activity_timers()
self.encryption = self.get_encryption_state()
# 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)
app.ged.register_event_handler('our-show', ged.GUI1,
self._nec_our_status)
app.ged.register_event_handler('ping-sent', ged.GUI1,
self._nec_ping_sent)
app.ged.register_event_handler('ping-reply', ged.GUI1,
self._nec_ping_reply)
app.ged.register_event_handler('ping-error', ged.GUI1,
self._nec_ping_error)
# 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 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)
action = Gio.SimpleAction.new(
'browse-history-%s' % self.control_id, GLib.VariantType.new('s'))
action.connect('activate', self._on_history)
self.parent_win.window.add_action(action)
action = Gio.SimpleAction.new(
'send-file-%s' % self.control_id, None)
action.connect('activate', self._on_send_file)
action.set_enabled(False)
self.parent_win.window.add_action(action)
action = Gio.SimpleAction.new(
'send-file-httpupload-%s' % self.control_id, None)
action.connect('activate', self._on_send_httpupload)
action.set_enabled(False)
self.parent_win.window.add_action(action)
action = Gio.SimpleAction.new(
'send-file-jingle-%s' % self.control_id, None)
action.connect('activate', self._on_send_jingle)
action.set_enabled(False)
self.parent_win.window.add_action(action)
# Actions
def _on_history(self, action, param):
"""
When history menuitem is pressed: call history window
"""
jid = param.get_string()
if jid == 'none':
jid = self.contact.jid
if 'logs' in app.interface.instances:
app.interface.instances['logs'].window.present()
app.interface.instances['logs'].open_history(jid, self.account)
else:
app.interface.instances['logs'] = \
history_window.HistoryWindow(jid, self.account)
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_encryption_state(self, encryption):
config_key = '%s-%s' % (self.account, self.contact.jid)
self.encryption = encryption
app.config.set_per('encryption', config_key,
'encryption', self.encryption or '')
def get_encryption_state(self):
config_key = '%s-%s' % (self.account, self.contact.jid)
state = app.config.get_per('encryption', config_key, '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.encryption_menu.get_image()
if image is None:
image = Gtk.Image()
self.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.config.get('use_speller'):
return
gspell_lang = self.get_speller_language()
if gspell_lang is None:
return
self.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(self.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)
self.spell_checker.connect('notify::language', self.on_language_changed)
def get_speller_language(self):
per_type = 'contacts'
if self.type_id == 'gc':
per_type = 'rooms'
lang = app.config.get_per(
per_type, self.contact.jid, 'speller_language')
if not lang:
# use the default one
lang = app.config.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()
per_type = 'contacts'
if self.type_id == message_control.TYPE_GC:
per_type = 'rooms'
if not app.config.get_per(per_type, self.contact.jid):
app.config.add_per(per_type, self.contact.jid)
app.config.set_per(per_type, self.contact.jid,
'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()
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):
super(ChatControlBase, self).shutdown()
# Disconnect timer callbacks
if self.possible_paused_timeout_id:
GLib.source_remove(self.possible_paused_timeout_id)
if self.possible_inactive_timeout_id:
GLib.source_remove(self.possible_inactive_timeout_id)
# PluginSystem: removing GUI extension points connected with ChatControlBase
# instance object
app.plugin_manager.remove_gui_extension_point('chat_control_base',
self)
app.plugin_manager.remove_gui_extension_point(
'chat_control_base_draw_banner', self)
app.ged.remove_event_handler('our-show', ged.GUI1,
self._nec_our_status)
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
menu.show_all()
def on_quote(self, widget, text):
self.msg_textview.remove_placeholder()
text = '>' + text.replace('\n', '\n>') + '\n'
message_buffer = self.msg_textview.get_buffer()
message_buffer.insert_at_cursor(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 _conv_textview_key_press_event(self, widget, event):
# translate any layout to latin_layout
valid, entries = self.keymap.get_entries_for_keyval(event.keyval)
keycode = entries[0].keycode
if (event.get_state() & Gdk.ModifierType.CONTROL_MASK and keycode in (
self.keycode_c, self.keycode_ins)):
return False
if event.get_state() & Gdk.ModifierType.SHIFT_MASK and \
event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Page_Up):
self._on_scroll(None, event.keyval)
return False
self.parent_win.notebook.event(event)
return True
def _on_message_textview_key_press_event(self, widget, 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 = widget.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.widget_name == 'groupchat_control':
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:
# CTRL + SHIFT + TAB
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
# SHIFT + PAGE_[UP|DOWN]: send to conv_textview
elif event.keyval == Gdk.KEY_Page_Down or \
event.keyval == Gdk.KEY_Page_Up:
self.conv_textview.tv.event(event)
return True
elif event.get_state() & Gdk.ModifierType.CONTROL_MASK:
if event.keyval == Gdk.KEY_Tab: # CTRL + 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)
splitted = text.split()
if (text.startswith(self.COMMAND_PREFIX) and not
text.startswith(self.COMMAND_PREFIX * 2) and len(splitted) == 1):
text = splitted[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 self.widget_name != 'groupchat_control':
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
message_textview = widget
message_buffer = message_textview.get_buffer()
emoticons.replace_with_codepoint(message_buffer)
start_iter, end_iter = message_buffer.get_bounds()
message = message_buffer.get_text(start_iter, end_iter, False)
xhtml = self.msg_textview.get_xhtml()
if event_state & Gdk.ModifierType.SHIFT_MASK:
send_message = False
else:
is_ctrl_enter = bool(event_state & Gdk.ModifierType.CONTROL_MASK)
send_message = is_ctrl_enter == app.config.get('send_on_ctrl_enter')
if send_message and app.connections[self.account].connected < 2:
# we are not connected
app.interface.raise_dialog('not-connected-while-sending')
elif send_message:
self.send_message(message, xhtml=xhtml)
else:
message_buffer.insert_at_cursor('\n')
mark = message_buffer.get_insert()
iter_ = message_buffer.get_iter_at_mark(mark)
if message_buffer.get_end_iter().equal(iter_):
GLib.idle_add(gtkgui_helpers.scroll_to_end, self.msg_scrolledwindow)
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
"""
pass
def _on_drag_leave(self, widget, context, time):
# FIXME: DND on non editable TextView, find a better way
self.drag_entered = False
self.conv_textview.tv.set_editable(False)
def _on_drag_motion(self, widget, context, x, y, time):
# FIXME: DND on non editable TextView, find a better way
if not self.drag_entered:
# We drag new data over the TextView, make it editable to catch dnd
self.drag_entered_conv = True
self.conv_textview.tv.set_editable(True)
def drag_data_file_transfer(self, contact, selection, widget):
# get file transfer preference
ft_pref = app.config.get_per('accounts', self.account,
'filetransfer_preference')
win = self.parent_win.window
con = app.connections[self.account]
httpupload = win.lookup_action(
'send-file-httpupload-%s' % self.control_id)
jingle = win.lookup_action('send-file-jingle-%s' % self.control_id)
# 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
if self.type_id == message_control.TYPE_GC:
# groupchat only supports httpupload on drag and drop
if httpupload.get_enabled():
# use httpupload
con.check_file_before_transfer(
path, self.encryption, contact,
self.session, groupchat=True)
else:
if httpupload.get_enabled() and jingle.get_enabled():
if ft_pref == 'httpupload':
con.check_file_before_transfer(
path, self.encryption, contact, self.session)
else:
ft = app.interface.instances['file_transfers']
ft.send_file(self.account, contact, path)
elif httpupload.get_enabled():
con.check_file_before_transfer(
path, self.encryption, contact, self.session)
elif jingle.get_enabled():
ft = app.interface.instances['file_transfers']
ft.send_file(self.account, contact, path)
def get_seclabel(self):
label = None
if self.seclabel_combo is not None:
idx = self.seclabel_combo.get_active()
if idx != -1:
cat = app.connections[self.account].seclabel_catalogues[self.contact.jid]
lname = cat[2][idx]
label = cat[1][lname]
return label
def send_message(self, message, keyID='', type_='chat', chatstate=None,
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
# refresh timers
self.reset_kbd_mouse_timeout_vars()
notifications = app.config.get('outgoing_chat_state_notifications')
if (self.contact.jid == app.get_jid_from_account(self.account) or
notifications == 'disabled'):
chatstate = None
label = self.get_seclabel()
if self.correcting and self.last_sent_msg:
correct_id = self.last_sent_msg
else:
correct_id = None
app.nec.push_outgoing_event(MessageOutgoingEvent(None,
account=self.account, jid=self.contact.jid, message=message,
keyID=keyID, type_=type_, chatstate=chatstate,
resource=resource, user_nick=self.user_nick, xhtml=xhtml,
label=label, control=self, attention=attention, correct_id=correct_id,
automatic_message=False, encryption=self.encryption))
# 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 check_for_possible_paused_chatstate(self, arg):
"""
Did we move mouse of that window or write something in message textview
in the last 5 seconds? If yes - we go active for mouse, composing for
kbd. If not - we go paused if we were previously composing
"""
contact = self.contact
jid = contact.jid
current_state = contact.our_chatstate
if current_state is False: # jid doesn't support chatstates
self.possible_paused_timeout_id = None
return False # stop looping
if current_state == 'composing':
if not self.kbd_activity_in_last_5_secs:
if self.msg_textview.has_text():
self.send_chatstate('paused', self.contact)
else:
self.send_chatstate('active', self.contact)
elif current_state == 'inactive':
if (self.mouse_over_in_last_5_secs and
jid == self.parent_win.get_active_jid()):
self.send_chatstate('active', self.contact)
# assume no activity and let the motion-notify or 'insert-text' make them
# True refresh 30 seconds vars too or else it's 30 - 5 = 25 seconds!
self.reset_kbd_mouse_timeout_vars()
return True # loop forever
def check_for_possible_inactive_chatstate(self, arg):
"""
Did we move mouse over that window or wrote something in message textview
in the last 30 seconds? if yes - we go active. If no - we go inactive
"""
contact = self.contact
current_state = contact.our_chatstate
if current_state is False: # jid doesn't support chatstates
self.possible_inactive_timeout_id = None
return False # stop looping
if self.mouse_over_in_last_5_secs or self.kbd_activity_in_last_5_secs:
return True # loop forever
if not self.mouse_over_in_last_30_secs or \
self.kbd_activity_in_last_30_secs:
self.send_chatstate('inactive', contact)
# assume no activity and let the motion-notify or 'insert-text' make them
# True refresh 30 seconds too or else it's 30 - 5 = 25 seconds!
self.reset_kbd_mouse_timeout_vars()
return True # loop forever
def _schedule_activity_timers(self):
if self.possible_paused_timeout_id:
GLib.source_remove(self.possible_paused_timeout_id)
if self.possible_inactive_timeout_id:
GLib.source_remove(self.possible_inactive_timeout_id)
self.possible_paused_timeout_id = GLib.timeout_add_seconds(5,
self.check_for_possible_paused_chatstate, None)
self.possible_inactive_timeout_id = GLib.timeout_add_seconds(30,
self.check_for_possible_inactive_chatstate, None)
def reset_kbd_mouse_timeout_vars(self):
self.kbd_activity_in_last_5_secs = False
self.mouse_over_in_last_5_secs = False
self.mouse_over_in_last_30_secs = False
self.kbd_activity_in_last_30_secs = False
def _on_window_motion_notify(self, widget, event):
"""
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, change vars assisting chatstate
self.mouse_over_in_last_5_secs = True
self.mouse_over_in_last_30_secs = True
def _on_message_tv_buffer_changed(self, textbuffer):
self.kbd_activity_in_last_5_secs = True
self.kbd_activity_in_last_30_secs = True
if not self.msg_textview.has_text():
return
self.send_chatstate('composing', self.contact)
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 = False if pos == size else True # are we scrolling?
# we don't want size of the buffer to grow indefinitely
max_size = app.config.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 print_conversation_line(self, text, kind, name, tim,
other_tags_for_name=None, other_tags_for_time=None, other_tags_for_text=None,
count_as_new=True, subject=None, old_kind=None, xhtml=None, simple=False,
xep0184_id=None, graphics=True, displaymarking=None, msg_log_id=None,
msg_stanza_id=None, correct_id=None, additional_data=None,
encrypted=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 = {}
textview.print_conversation_line(text, jid, kind, name, tim,
other_tags_for_name, other_tags_for_time, other_tags_for_text,
subject, old_kind, xhtml, simple=simple, graphics=graphics,
displaymarking=displaymarking, msg_stanza_id=msg_stanza_id,
correct_id=correct_id, additional_data=additional_data,
encrypted=encrypted)
if xep0184_id is not None:
textview.add_xep0184_mark(xep0184_id)
if not count_as_new:
return
if kind == 'incoming':
if not self.type_id == message_control.TYPE_GC or \
app.config.get('notify_on_all_muc_messages') or \
app.config.get_per('rooms', jid, 'notify_on_all_messages') 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')
if kind in ('incoming', 'incoming_queue', 'error'):
gc_message = False
if self.type_id == message_control.TYPE_GC:
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_id == message_control.TYPE_CHAT:
event_type = events.PrintedChatEvent
else:
event_type = events.PrintedPmEvent
event = 'message_received'
show_in_roster = notify.get_show_in_roster(event,
self.account, self.contact, self.session)
show_in_systray = notify.get_show_in_systray(event,
self.account, self.contact, event_type.type_)
event = event_type(text, subject, self, msg_log_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.config.get('emoticons_theme'):
self.emoticons_button.set_no_show_all(False)
self.emoticons_button.show()
else:
self.emoticons_button.set_no_show_all(True)
self.emoticons_button.hide()
def set_emoticon_popover(self):
if not app.config.get('emoticons_theme'):
return
if not self.parent_win:
return
popover = emoticons.get_popover()
popover.set_callbacks(self.msg_textview)
emoticons_button = self.xml.get_object('emoticons_button')
emoticons_button.set_popover(popover)
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 update_tags(self):
self.conv_textview.update_tags()
def clear(self, tv):
buffer_ = tv.get_buffer()
start, end = buffer_.get_bounds()
buffer_.delete(start, end)
def _on_history_menuitem_activate(self, widget=None, jid=None):
"""
When history menuitem is pressed: call history window
"""
if not jid:
jid = self.contact.jid
if 'logs' in app.interface.instances:
app.interface.instances['logs'].window.present()
app.interface.instances['logs'].open_history(jid, self.account)
else:
app.interface.instances['logs'] = \
history_window.HistoryWindow(jid, self.account)
def _on_send_file(self, action, param):
# get file transfer preference
ft_pref = app.config.get_per('accounts', self.account,
'filetransfer_preference')
win = self.parent_win.window
httpupload = win.lookup_action(
'send-file-httpupload-%s' % self.control_id)
jingle = win.lookup_action('send-file-jingle-%s' % self.control_id)
if httpupload.get_enabled() and jingle.get_enabled():
if ft_pref == 'httpupload':
httpupload.activate()
else:
jingle.activate()
elif httpupload.get_enabled():
httpupload.activate()
elif jingle.get_enabled():
jingle.activate()
def _on_send_httpupload(self, action, param):
app.interface.send_httpupload(self)
def _on_send_jingle(self, action, param):
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(c):
app.interface.instances['file_transfers'].show_file_send_request(
self.account, c)
if self.type_id == message_control.TYPE_PM:
gc_contact = self.gc_contact
if gc_contact:
# 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 not in ['admin',
'owner'] and self_contact.affiliation in ['admin', 'owner']:
contact = app.contacts.get_contact(self.account, gc_contact.jid)
if not contact or contact.sub not in ('both', 'to'):
prim_text = _('Really send file?')
sec_text = _('If you send a file to %s, your real JID will '
'be revealed.') % gc_contact.name
dialog = dialogs.NonModalConfirmationDialog(prim_text,
sec_text, on_response_ok=(_on_ok, gc_contact))
dialog.popup()
return
_on_ok(gc_contact)
return
_on_ok(self.contact)
def on_minimize_menuitem_toggled(self, widget):
"""
When a groupchat is minimized, unparent the tab, put it in roster etc
"""
old_value = True
non_minimized_gc = app.config.get_per('accounts', self.account,
'non_minimized_gc').split()
if self.contact.jid in non_minimized_gc:
old_value = False
minimize = widget.get_active()
if not minimize and not self.contact.jid in non_minimized_gc:
non_minimized_gc.append(self.contact.jid)
if minimize and self.contact.jid in non_minimized_gc:
non_minimized_gc.remove(self.contact.jid)
if old_value != minimize:
app.config.set_per('accounts', self.account, 'non_minimized_gc',
' '.join(non_minimized_gc))
def on_notify_menuitem_toggled(self, widget):
app.config.set_per('rooms', self.contact.jid, 'notify_on_all_messages',
widget.get_active())
def set_control_active(self, state):
if state:
self.set_emoticon_popover()
jid = self.contact.jid
if self.conv_textview.autoscroll:
# we are at the end
type_ = ['printed_' + self.type_id]
if self.type_id == message_control.TYPE_GC:
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)
# send chatstate inactive to the one we're leaving
# and active to the one we visit
if self.msg_textview.has_text():
self.send_chatstate('paused', self.contact)
else:
self.send_chatstate('active', self.contact)
self.reset_kbd_mouse_timeout_vars()
self._schedule_activity_timers()
else:
self.send_chatstate('inactive', self.contact)
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 = []
type_ = self.type_id
if type_ == message_control.TYPE_GC:
type_ = 'gc_msg'
types_list = ['printed_' + type_, type_, 'printed_marked_gc_msg']
else: # Not a GC
types_list = ['printed_' + type_, type_]
if not len(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 self.type_id == message_control.TYPE_GC:
if not app.events.remove_events(self.account, jid,
types=types_list):
self.redraw_after_event_removed(jid)
elif self.session and self.session.remove_events(types_list):
# There were events to remove
self.redraw_after_event_removed(jid)
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 gtkgui_helpers.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 cant be determined')
if direction != Gdk.ScrollDirection.UP:
return
# Check if we have a Scrollbar
adjustment = self.conv_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
"""
self.parent_win.redraw_tab(self)
self.parent_win.show_title()
# TODO : get the contact and check notify.get_show_in_roster()
if self.type_id == message_control.TYPE_PM:
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.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, 'msgcorrectingcolor')
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, 'msgcorrectingcolor')
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 widget_set_visible(self, widget, state):
"""
Show or hide a widget
"""
# make the last message visible, when changing to "full view"
if not state:
self.scroll_to_end()
widget.set_no_show_all(state)
if state:
widget.hide()
else:
widget.show_all()
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.no_autonegotiation = False
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.set_shadow_type(Gtk.ShadowType.IN)
def do_get_preferred_height(self):
min_height, natural_height = Gtk.ScrolledWindow.do_get_preferred_height(self)
# Gtk Bug: If policy is set to Automatic, the ScrolledWindow
# has a min size of around 46-82 depending on the System. Because
# we want it smaller, we set policy NEVER if the height is < 90
# so the ScrolledWindow will shrink to around 26 (1 line height).
# Once it gets over 90 its no problem to restore the policy.
if natural_height < 90:
GLib.idle_add(self.set_policy,
Gtk.PolicyType.AUTOMATIC,
Gtk.PolicyType.NEVER)
else:
GLib.idle_add(self.set_policy,
Gtk.PolicyType.AUTOMATIC,
Gtk.PolicyType.AUTOMATIC)
return min_height, natural_height