9578053 Jan 22 2022 distfiles.gentoo.org/distfiles/gajim-1.3.3-2.tar.gz

This commit is contained in:
emdee 2022-10-19 18:09:31 +00:00
parent a5b3822651
commit 4c1b226bff
1045 changed files with 753037 additions and 18 deletions

33
gajim/gtk/__init__.py Normal file
View file

@ -0,0 +1,33 @@
# Copyright (C) 2003-2005 Vincent Hanquez <tab AT snarc.org>
# Copyright (C) 2005 Alex Podaras <bigpod AT gmail.com>
# Stéphan Kochen <stephan AT kochen.nl>
# Alex Mauer <hawke AT hawkesnest.net>
# Copyright (C) 2003-2014 Yann Leboulanger <asterix AT lagaule.org>
# Copyright (C) 2005-2006 Dimitur Kirov <dkirov AT gmail.com>
# Travis Shirk <travis AT pobox.com>
# Copyright (C) 2005-2008 Nikos Kouremenos <kourem AT gmail.com>
# Copyright (C) 2006 Junglecow J <junglecow AT gmail.com>
# Copyright (C) 2006-2007 Travis Shirk <travis AT pobox.com>
# Stefan Bethge <stefan AT lanpartei.de>
# Copyright (C) 2006-2008 Jean-Marie Traissard <jim AT lapin.org>
# Copyright (C) 2007 James Newton <redshodan AT gmail.com>
# Lukas Petrovicky <lukas AT petrovicky.net>
# Copyright (C) 2007-2008 Brendan Taylor <whateley AT gmail.com>
# Julien Pivotto <roidelapluie AT gmail.com>
# Stephan Erb <steve-e AT h3c.de>
# Copyright (C) 2008 Jonathan Schleifer <js-gajim AT webkeks.org>
# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com>
#
# 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/>.

97
gajim/gtk/about.py Normal file
View file

@ -0,0 +1,97 @@
# 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 nbxmpp
from gi.repository import Gdk
from gi.repository import Gtk
from gi.repository import GLib
from gi.repository import GObject
from gajim.common import app
from gajim.common.helpers import open_uri
from gajim.common.i18n import _
from gajim.common.const import DEVS_CURRENT
from gajim.common.const import DEVS_PAST
from gajim.common.const import ARTISTS
from gajim.common.const import THANKS
class AboutDialog(Gtk.AboutDialog):
def __init__(self):
Gtk.AboutDialog.__init__(self)
self.set_transient_for(app.interface.roster.window)
self.set_name('Gajim')
self.set_version(app.version)
self.set_copyright('Copyright © 2003-2021 Gajim Team')
self.set_license_type(Gtk.License.GPL_3_0_ONLY)
self.set_website('https://gajim.org/')
gtk_ver = '%i.%i.%i' % (
Gtk.get_major_version(),
Gtk.get_minor_version(),
Gtk.get_micro_version())
gobject_ver = '.'.join(map(str, GObject.pygobject_version))
glib_ver = '.'.join(map(str, [GLib.MAJOR_VERSION,
GLib.MINOR_VERSION,
GLib.MICRO_VERSION]))
comments = []
comments.append(_('A GTK XMPP client'))
comments.append(_('GTK Version: %s') % gtk_ver)
comments.append(_('GLib Version: %s') % glib_ver)
comments.append(_('PyGObject Version: %s') % gobject_ver)
comments.append(_('python-nbxmpp Version: %s') % nbxmpp.__version__)
self.set_comments("\n".join(comments))
self.add_credit_section(_('Current Developers'), DEVS_CURRENT)
self.add_credit_section(_('Past Developers'), DEVS_PAST)
self.add_credit_section(_('Artists'), ARTISTS)
thanks = list(THANKS)
thanks.append('')
thanks.append(_('Last but not least'))
thanks.append(_('we would like to thank all the package maintainers.'))
self.add_credit_section(_('Thanks'), thanks)
self.set_translator_credits(_('translator-credits'))
self.set_logo_icon_name('org.gajim.Gajim')
self.connect(
'response', lambda dialog, *args: Gtk.AboutDialog.do_close(dialog))
self.show()
self.connect('activate-link', self._on_activate_link)
# See https://gitlab.gnome.org/GNOME/gtk/issues/1561
self._connect_link_handler(self)
@staticmethod
def _on_activate_link(_label, uri):
# We have to use this, because the default GTK handler
# is not cross-platform compatible
open_uri(uri)
return Gdk.EVENT_STOP
def _connect_link_handler(self, parent):
def _find_child(parent_):
if not hasattr(parent_, 'get_children'):
return
for child in parent_.get_children():
if isinstance(child, Gtk.Label):
if 'href' in child.get_label():
child.connect('activate-link', self._on_activate_link)
_find_child(child)
_find_child(parent)

982
gajim/gtk/account_wizard.py Normal file
View file

@ -0,0 +1,982 @@
# 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 logging
from gi.repository import Gdk
from gi.repository import GLib
from gi.repository import Gtk
from gi.repository import Gio
from gi.repository import GObject
from nbxmpp.client import Client
from nbxmpp.protocol import JID
from nbxmpp.protocol import validate_domainpart
from nbxmpp.const import Mode
from nbxmpp.const import StreamError
from nbxmpp.const import ConnectionProtocol
from nbxmpp.const import ConnectionType
from nbxmpp.errors import StanzaError
from nbxmpp.errors import MalformedStanzaError
from nbxmpp.errors import RegisterStanzaError
from gajim.common import app
from gajim.common import configpaths
from gajim.common import helpers
from gajim.common.nec import NetworkEvent
from gajim.common.helpers import open_uri
from gajim.common.helpers import validate_jid
from gajim.common.helpers import get_proxy
from gajim.common.i18n import _
from gajim.common.const import SASL_ERRORS
from gajim.common.const import GIO_TLS_ERRORS
from .assistant import Assistant
from .assistant import Page
from .assistant import SuccessPage
from .assistant import ErrorPage
from .dataform import DataFormWidget
from .util import get_builder
from .util import open_window
log = logging.getLogger('gajim.gui.account_wizard')
class AccountWizard(Assistant):
def __init__(self):
Assistant.__init__(self, height=500)
self._destroyed = False
self.add_button('signup', _('Sign Up'), complete=True,
css_class='suggested-action')
self.add_button('connect', _('Connect'), css_class='suggested-action')
self.add_button('next', _('Next'), css_class='suggested-action')
self.add_button('login', _('Log In'), complete=True,
css_class='suggested-action')
self.add_button('back', _('Back'))
self.add_pages({'login': Login(),
'signup': Signup(),
'advanced': AdvancedSettings(),
'security-warning': SecurityWarning(),
'form': Form(),
'redirect': Redirect(),
'success': Success(),
'error': Error(),
})
self._progress = self.add_default_page('progress')
self.get_page('login').connect('clicked', self._on_button_clicked)
self.connect('button-clicked', self._on_assistant_button_clicked)
self.connect('page-changed', self._on_page_changed)
self.connect('destroy', self._on_destroy)
self.show_all()
self.update_proxy_list()
self._client = None
self._method = 'login'
def get_currenct_method(self):
return self._method
def _on_button_clicked(self, _page, button_name):
if button_name == 'login':
if self.get_page('login').is_advanced():
self.show_page('advanced',
Gtk.StackTransitionType.SLIDE_LEFT)
else:
self._test_credentials()
elif button_name == 'signup':
self.show_page('signup', Gtk.StackTransitionType.SLIDE_LEFT)
def _on_assistant_button_clicked(self, _assistant, button_name):
page = self.get_current_page()
if button_name == 'login':
if page == 'advanced':
self._test_credentials()
elif page == 'security-warning':
if self.get_page('security-warning').trust_certificate:
app.cert_store.add_certificate(
self.get_page('security-warning').cert)
self._test_credentials(ignore_all_errors=True)
elif button_name == 'signup':
if page == 'signup':
if self.get_page('signup').is_advanced():
self.show_page('advanced',
Gtk.StackTransitionType.SLIDE_LEFT)
elif self.get_page('signup').is_anonymous():
self._test_anonymous_server()
else:
self._register_with_server()
elif page == 'advanced':
if self.get_page('signup').is_anonymous():
self._test_anonymous_server()
else:
self._register_with_server()
elif page == 'security-warning':
if self.get_page('security-warning').trust_certificate:
app.cert_store.add_certificate(
self.get_page('security-warning').cert)
if self.get_page('signup').is_anonymous():
self._test_anonymous_server(ignore_all_errors=True)
else:
self._register_with_server(ignore_all_errors=True)
elif page == 'form':
self._show_progress_page(_('Creating Account...'),
_('Trying to create account...'))
self._submit_form()
elif button_name == 'connect':
if page == 'success':
app.interface.enable_account(self.get_page('success').account)
self.destroy()
elif button_name == 'back':
if page == 'signup':
self.show_page('login', Gtk.StackTransitionType.SLIDE_RIGHT)
elif page in ('advanced', 'error', 'security-warning'):
if (page == 'error' and
self._method == 'signup' and
self.get_page('form').has_form):
self.show_page('form', Gtk.StackTransitionType.SLIDE_RIGHT)
else:
self.show_page(self._method,
Gtk.StackTransitionType.SLIDE_RIGHT)
elif page == 'form':
self.show_page('signup', Gtk.StackTransitionType.SLIDE_RIGHT)
self.get_page('form').remove_form()
self._disconnect()
elif page == 'redirect':
self.show_page('login', Gtk.StackTransitionType.SLIDE_RIGHT)
def _on_page_changed(self, _assistant, page_name):
if page_name == 'signup':
self._method = page_name
self.get_page('signup').focus()
elif page_name == 'login':
self._method = page_name
self.get_page('login').focus()
elif page_name == 'form':
self.get_page('form').focus()
def update_proxy_list(self):
self.get_page('advanced').update_proxy_list()
def _get_base_client(self,
domain,
username,
mode,
advanced,
ignore_all_errors):
client = Client(log_context='Account Wizard')
client.set_domain(domain)
client.set_username(username)
client.set_mode(mode)
client.set_ignore_tls_errors(ignore_all_errors)
client.set_accepted_certificates(
app.cert_store.get_certificates())
if advanced:
custom_host = self.get_page('advanced').get_custom_host()
if custom_host is not None:
client.set_custom_host(*custom_host)
proxy_name = self.get_page('advanced').get_proxy()
proxy_data = get_proxy(proxy_name)
if proxy_data is not None:
client.set_proxy(proxy_data)
client.subscribe('disconnected', self._on_disconnected)
client.subscribe('connection-failed', self._on_connection_failed)
client.subscribe('stanza-sent', self._on_stanza_sent)
client.subscribe('stanza-received', self._on_stanza_received)
return client
def _disconnect(self):
if self._client is None:
return
self._client.remove_subscriptions()
self._client.disconnect()
self._client = None
@staticmethod
def _on_stanza_sent(_client, _signal_name, stanza):
app.nec.push_incoming_event(NetworkEvent('stanza-sent',
account='AccountWizard',
stanza=stanza))
@staticmethod
def _on_stanza_received(_client, _signal_name, stanza):
app.nec.push_incoming_event(NetworkEvent('stanza-received',
account='AccountWizard',
stanza=stanza))
def _test_credentials(self, ignore_all_errors=False):
self._show_progress_page(_('Connecting...'),
_('Connecting to server...'))
address, password = self.get_page('login').get_credentials()
jid = JID.from_string(address)
advanced = self.get_page('login').is_advanced()
self._client = self._get_base_client(
jid.domain,
jid.localpart,
Mode.LOGIN_TEST,
advanced,
ignore_all_errors)
self._client.set_password(password)
self._client.subscribe('login-successful', self._on_login_successful)
self._client.connect()
def _test_anonymous_server(self, ignore_all_errors=False):
self._show_progress_page(_('Connecting...'),
_('Connecting to server...'))
domain = self.get_page('signup').get_server()
advanced = self.get_page('signup').is_advanced()
self._client = self._get_base_client(
domain,
None,
Mode.ANONYMOUS_TEST,
advanced,
ignore_all_errors)
self._client.subscribe('anonymous-supported',
self._on_anonymous_supported)
self._client.connect()
def _register_with_server(self, ignore_all_errors=False):
self._show_progress_page(_('Connecting...'),
_('Connecting to server...'))
domain = self.get_page('signup').get_server()
advanced = self.get_page('signup').is_advanced()
self._client = self._get_base_client(
domain,
None,
Mode.REGISTER,
advanced,
ignore_all_errors)
self._client.subscribe('connected', self._on_connected)
self._client.connect()
def _on_login_successful(self, client, _signal_name):
account = self._generate_account_name(client.domain)
proxy_name = None
if client.proxy is not None:
proxy_name = self.get_page('advanced').get_proxy()
app.interface.create_account(account,
client.username,
client.domain,
client.password,
proxy_name,
client.custom_host)
self.get_page('success').set_account(account)
self.show_page('success', Gtk.StackTransitionType.SLIDE_LEFT)
def _on_connected(self, client, _signal_name):
client.get_module('Register').request_register_form(
callback=self._on_register_form)
def _on_anonymous_supported(self, client, _signal_name):
account = self._generate_account_name(client.domain)
proxy_name = None
if client.proxy is not None:
proxy_name = self.get_page('advanced').get_proxy()
app.interface.create_account(account,
None,
client.domain,
client.password,
proxy_name,
client.custom_host,
anonymous=True)
self.get_page('success').set_account(account)
self.show_page('success', Gtk.StackTransitionType.SLIDE_LEFT)
def _on_disconnected(self, client, _signal_name):
domain, error, text = client.get_error()
if domain == StreamError.SASL:
if error == 'anonymous-not-supported':
self._show_error_page(_('Anonymous login not supported'),
_('Anonymous login not supported'),
_('This server does not support '
'anonymous login.'))
else:
self._show_error_page(_('Authentication failed'),
SASL_ERRORS.get(error),
text or '')
elif domain == StreamError.BAD_CERTIFICATE:
self.get_page('security-warning').set_warning(
self._client.domain, *self._client.peer_certificate)
self.show_page('security-warning',
Gtk.StackTransitionType.SLIDE_LEFT)
elif domain == StreamError.REGISTER:
if error == 'register-not-supported':
self._show_error_page(_('Signup not allowed'),
_('Signup not allowed'),
_('This server does not allow signup.'))
elif domain == StreamError.STREAM:
# The credential test often ends with a stream error, because
# after auth there should be a stream restart but nbxmpp ends
# the stream with </stream> which is considered not-well-formed
# by the server. This ignores all stream errors if we already
# know that we succeeded.
if self.get_current_page() != 'success':
self._show_error_page(_('Error'), _('Error'), error)
self.get_page('form').remove_form()
self._client.destroy()
self._client = None
def _on_connection_failed(self, _client, _signal_name):
self._show_error_page(_('Connection failed'),
_('Connection failed'),
_('Gajim was not able to reach the server. '
'Make sure your XMPP address is correct.'))
self._client.destroy()
self._client = None
def _show_error_page(self, title, heading, text):
self.get_page('error').set_title(title)
self.get_page('error').set_heading(heading)
self.get_page('error').set_text(text or '')
self.show_page('error', Gtk.StackTransitionType.SLIDE_LEFT)
def _show_progress_page(self, title, text):
self._progress.set_title(title)
self._progress.set_text(text)
self.show_page('progress', Gtk.StackTransitionType.SLIDE_LEFT)
@staticmethod
def _generate_account_name(domain):
i = 1
while domain in app.settings.get_accounts():
domain = domain + str(i)
i += 1
return domain
def _on_register_form(self, task):
try:
result = task.finish()
except (StanzaError, MalformedStanzaError) as error:
self._show_error_page(_('Error'),
_('Error'),
error.get_text())
self._disconnect()
return
if result.bob_data is not None:
algo_hash = result.bob_data.cid.split('@')[0]
app.bob_cache[algo_hash] = result.bob_data.data
form = result.form
if result.form is None:
form = result.fields_form
if form is not None:
self.get_page('form').add_form(form)
elif result.oob_url is not None:
self.get_page('redirect').set_redirect(result.oob_url,
result.instructions)
self.show_page('redirect', Gtk.StackTransitionType.SLIDE_LEFT)
self._disconnect()
return
self.show_page('form', Gtk.StackTransitionType.SLIDE_LEFT)
def _submit_form(self):
self.get_page('progress').set_text(_('Account is being created'))
self.show_page('progress', Gtk.StackTransitionType.SLIDE_LEFT)
form = self.get_page('form').get_submit_form()
self._client.get_module('Register').submit_register_form(
form,
callback=self._on_register_result)
def _on_register_result(self, task):
try:
task.finish()
except RegisterStanzaError as error:
self._set_error_text(error)
if error.type != 'modify':
self.get_page('form').remove_form()
self._disconnect()
return
register_data = error.get_data()
form = register_data.form
if register_data.form is None:
form = register_data.fields_form
if form is not None:
self.get_page('form').add_form(form)
else:
self.get_page('form').remove_form()
self._disconnect()
return
except (StanzaError, MalformedStanzaError) as error:
self._set_error_text(error)
self.get_page('form').remove_form()
self._disconnect()
return
username, password = self.get_page('form').get_credentials()
account = self._generate_account_name(self._client.domain)
proxy_name = None
if self._client.proxy is not None:
proxy_name = self.get_page('advanced').get_proxy()
app.interface.create_account(account,
username,
self._client.domain,
password,
proxy_name,
self._client.custom_host)
self.get_page('success').set_account(account)
self.show_page('success', Gtk.StackTransitionType.SLIDE_LEFT)
self.get_page('form').remove_form()
self._disconnect()
def _set_error_text(self, error):
error_text = error.get_text()
if not error_text:
error_text = _('The server rejected the registration '
'without an error message')
self._show_error_page(_('Error'), _('Error'), error_text)
def _on_destroy(self, *args):
self._disconnect()
self._destroyed = True
class Login(Page):
__gsignals__ = {
'clicked': (GObject.SignalFlags.RUN_LAST, None, (str,)),
}
def __init__(self):
Page.__init__(self)
self.title = _('Add Account')
self._ui = get_builder('account_wizard.ui')
self._ui.log_in_address_entry.connect(
'activate', self._on_address_entry_activate)
self._ui.log_in_address_entry.connect(
'changed', self._on_address_changed)
self._ui.log_in_password_entry.connect(
'changed', self._set_complete)
self._ui.log_in_password_entry.connect(
'activate', self._on_password_entry_activate)
self._create_server_completion()
self._ui.log_in_button.connect('clicked', self._on_login)
self._ui.sign_up_button.connect('clicked', self._on_signup)
self.pack_start(self._ui.login_box, True, True, 0)
self.show_all()
def focus(self):
self._ui.log_in_address_entry.grab_focus()
def _on_login(self, *args):
self.emit('clicked', 'login')
def _on_signup(self, *args):
self.emit('clicked', 'signup')
def _create_server_completion(self):
# Parse servers.json
file_path = configpaths.get('DATA') / 'other' / 'servers.json'
self._servers = helpers.load_json(file_path, default=[])
# Create a separate model for the address entry, because it will
# be updated with our localpart@
address_model = Gtk.ListStore(str)
for server in self._servers:
address_model.append((server,))
self._ui.log_in_address_entry.get_completion().set_model(address_model)
def _on_address_changed(self, entry):
self._update_completion(entry)
self._set_complete()
def _update_completion(self, entry):
text = entry.get_text()
if '@' not in text:
self._show_icon(False)
return
text = text.split('@', 1)[0]
model = entry.get_completion().get_model()
model.clear()
for server in self._servers:
model.append(['%s@%s' % (text, server)])
def _show_icon(self, show):
icon = 'dialog-warning-symbolic' if show else None
self._ui.log_in_address_entry.set_icon_from_icon_name(
Gtk.EntryIconPosition.SECONDARY, icon)
def _on_address_entry_activate(self, _widget):
GLib.idle_add(self._ui.log_in_password_entry.grab_focus)
def _on_password_entry_activate(self, _widget):
if self._ui.log_in_button.get_sensitive():
self._ui.log_in_button.activate()
def _validate_jid(self, address):
if not address:
self._show_icon(False)
return False
try:
jid = validate_jid(address, type_='bare')
if jid.resource:
raise ValueError
except ValueError:
self._show_icon(True)
self._ui.log_in_address_entry.set_icon_tooltip_text(
Gtk.EntryIconPosition.SECONDARY, _('Invalid Address'))
return False
self._show_icon(False)
return True
def _set_complete(self, *args):
address = self._validate_jid(self._ui.log_in_address_entry.get_text())
password = self._ui.log_in_password_entry.get_text()
self._ui.log_in_button.set_sensitive(address and password)
def is_advanced(self):
return self._ui.login_advanced_checkbutton.get_active()
def get_credentials(self):
data = (self._ui.log_in_address_entry.get_text(),
self._ui.log_in_password_entry.get_text())
return data
class Signup(Page):
def __init__(self):
Page.__init__(self)
self.complete = False
self.title = _('Create New Account')
self._ui = get_builder('account_wizard.ui')
self._ui.server_comboboxtext_sign_up_entry.set_activates_default(True)
self._create_server_completion()
self._ui.recommendation_link1.connect(
'activate-link', self._on_activate_link)
self._ui.recommendation_link2.connect(
'activate-link', self._on_activate_link)
self._ui.visit_server_button.connect('clicked',
self._on_visit_server)
self._ui.server_comboboxtext_sign_up_entry.connect(
'changed', self._set_complete)
self.pack_start(self._ui.signup_grid, True, True, 0)
self.show_all()
def focus(self):
self._ui.server_comboboxtext_sign_up_entry.grab_focus()
def _create_server_completion(self):
# Parse servers.json
file_path = configpaths.get('DATA') / 'other' / 'servers.json'
servers = helpers.load_json(file_path, default=[])
# Create servers_model for comboboxes and entries
servers_model = Gtk.ListStore(str)
for server in servers:
servers_model.append((server,))
# Sign up combobox and entry
self._ui.server_comboboxtext_sign_up.set_model(servers_model)
self._ui.server_comboboxtext_sign_up_entry.get_completion().set_model(
servers_model)
def _on_visit_server(self, _widget):
server = self._ui.server_comboboxtext_sign_up_entry.get_text().strip()
server = 'https://' + server
open_uri(server)
return Gdk.EVENT_STOP
def _set_complete(self, *args):
try:
self.get_server()
except Exception:
self.complete = False
self._ui.visit_server_button.set_visible(False)
else:
self.complete = True
self._ui.visit_server_button.set_visible(True)
self.update_page_complete()
def is_anonymous(self):
return self._ui.sign_up_anonymously.get_active()
def is_advanced(self):
return self._ui.sign_up_advanced_checkbutton.get_active()
def get_server(self):
return validate_domainpart(
self._ui.server_comboboxtext_sign_up_entry.get_text())
@staticmethod
def _on_activate_link(_label, uri):
# We have to use this, because the default GTK handler
# is not cross-platform compatible
open_uri(uri)
return Gdk.EVENT_STOP
def get_visible_buttons(self):
return ['back', 'signup']
def get_default_button(self):
return 'signup'
class AdvancedSettings(Page):
def __init__(self):
Page.__init__(self)
self.title = _('Advanced settings')
self.complete = False
self._ui = get_builder('account_wizard.ui')
self._ui.manage_proxies_button.connect('clicked',
self._on_proxy_manager)
self._ui.proxies_combobox.connect('changed', self._set_complete)
self._ui.custom_host_entry.connect('changed', self._set_complete)
self._ui.custom_port_entry.connect('changed', self._set_complete)
self.pack_start(self._ui.advanced_grid, True, True, 0)
self.show_all()
@staticmethod
def _on_proxy_manager(_widget):
app.app.activate_action('manage-proxies')
def update_proxy_list(self):
model = Gtk.ListStore(str)
self._ui.proxies_combobox.set_model(model)
proxies = app.settings.get_proxies()
proxies.insert(0, _('No Proxy'))
for proxy in proxies:
model.append([proxy])
self._ui.proxies_combobox.set_active(0)
def get_proxy(self):
active = self._ui.proxies_combobox.get_active()
return self._ui.proxies_combobox.get_model()[active][0]
def get_custom_host(self):
host = self._ui.custom_host_entry.get_text()
port = self._ui.custom_port_entry.get_text()
if not host or not port:
return None
con_type = self._ui.con_type_combo.get_active_text()
protocol = ConnectionProtocol.TCP
if host.startswith('ws://') or host.startswith('wss://'):
protocol = ConnectionProtocol.WEBSOCKET
return ('%s:%s' % (host, port),
protocol,
ConnectionType(con_type))
def _show_host_icon(self, show):
icon = 'dialog-warning-symbolic' if show else None
self._ui.custom_host_entry.set_icon_from_icon_name(
Gtk.EntryIconPosition.SECONDARY, icon)
def _show_port_icon(self, show):
icon = 'dialog-warning-symbolic' if show else None
self._ui.custom_port_entry.set_icon_from_icon_name(
Gtk.EntryIconPosition.SECONDARY, icon)
def _validate_host(self):
host = self._ui.custom_host_entry.get_text()
try:
validate_domainpart(host)
except Exception:
self._show_host_icon(True)
self._ui.custom_host_entry.set_icon_tooltip_text(
Gtk.EntryIconPosition.SECONDARY, _('Invalid domain name'))
return False
self._show_host_icon(False)
return True
def _validate_port(self):
port = self._ui.custom_port_entry.get_text()
if not port:
self._show_port_icon(False)
return False
try:
port = int(port)
except Exception:
self._show_port_icon(True)
self._ui.custom_port_entry.set_icon_tooltip_text(
Gtk.EntryIconPosition.SECONDARY, _('Must be a port number'))
return False
if port not in range(0, 65535):
self._show_port_icon(True)
self._ui.custom_port_entry.set_icon_tooltip_text(
Gtk.EntryIconPosition.SECONDARY,
_('Port must be a number between 0 and 65535'))
return False
self._show_port_icon(False)
return True
def _is_custom_host_set(self):
host = bool(self._ui.custom_host_entry.get_text())
port = bool(self._ui.custom_port_entry.get_text())
return host or port
def _is_proxy_set(self):
return self._ui.proxies_combobox.get_active() != 0
def _set_complete(self, *args):
self.complete = False
if self._is_proxy_set():
self.complete = True
if self._is_custom_host_set():
port_valid = self._validate_port()
host_valid = self._validate_host()
self.complete = port_valid and host_valid
self.update_page_complete()
def get_visible_buttons(self):
return ['back', self.get_toplevel().get_currenct_method()]
def get_default_button(self):
return self.get_toplevel().get_currenct_method()
class SecurityWarning(Page):
def __init__(self):
Page.__init__(self)
self.title = _('Security Warning')
self._cert = None
self._domain = None
self._ui = get_builder('account_wizard.ui')
self.pack_start(self._ui.security_warning_box, True, True, 0)
self._ui.view_cert_button.connect('clicked', self._on_view_cert)
self.show_all()
@property
def cert(self):
return self._cert
def set_warning(self, domain, cert, errors):
# Clear list
self._cert = cert
self._domain = domain
self._ui.error_list.foreach(self._ui.error_list.remove)
unknown_error = _('Unknown TLS error \'%s\'')
for error in errors:
error_text = GIO_TLS_ERRORS.get(error, unknown_error % error)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
image = Gtk.Image.new_from_icon_name('dialog-warning-symbolic',
Gtk.IconSize.LARGE_TOOLBAR)
image.get_style_context().add_class('warning-color')
label = Gtk.Label(label=error_text)
label.set_line_wrap(True)
label.set_xalign(0)
label.set_selectable(True)
box.add(image)
box.add(label)
box.show_all()
self._ui.error_list.add(box)
self._ui.trust_cert_checkbutton.set_visible(
Gio.TlsCertificateFlags.UNKNOWN_CA in errors)
def _on_view_cert(self, _button):
open_window('CertificateDialog',
account=self._domain,
transient_for=self.get_toplevel(),
cert=self._cert)
@property
def trust_certificate(self):
return self._ui.trust_cert_checkbutton.get_active()
def get_visible_buttons(self):
return ['back', self.get_toplevel().get_currenct_method()]
def get_default_button(self):
return 'back'
class Form(Page):
def __init__(self):
Page.__init__(self)
self.set_valign(Gtk.Align.FILL)
self.complete = False
self.title = _('Create Account')
self._current_form = None
heading = Gtk.Label(label=_('Create Account'))
heading.get_style_context().add_class('large-header')
heading.set_max_width_chars(30)
heading.set_line_wrap(True)
heading.set_halign(Gtk.Align.CENTER)
heading.set_justify(Gtk.Justification.CENTER)
self.pack_start(heading, False, True, 0)
self.show_all()
@property
def has_form(self):
return self._current_form is not None
def _on_is_valid(self, _widget, is_valid):
self.complete = is_valid
self.update_page_complete()
def add_form(self, form):
self.remove_form()
options = {'hide-fallback-fields': True,
'entry-activates-default': True}
self._current_form = DataFormWidget(form, options)
self._current_form.connect('is-valid', self._on_is_valid)
self._current_form.validate()
self.pack_start(self._current_form, True, True, 0)
self._current_form.show_all()
def get_credentials(self):
return (self._current_form.get_form()['username'].value,
self._current_form.get_form()['password'].value)
def get_submit_form(self):
return self._current_form.get_submit_form()
def remove_form(self):
if self._current_form is None:
return
self.remove(self._current_form)
self._current_form.destroy()
self._current_form = None
def focus(self):
self._current_form.focus_first_entry()
def get_visible_buttons(self):
return ['back', 'signup']
def get_default_button(self):
return 'signup'
class Redirect(Page):
def __init__(self):
Page.__init__(self)
self.title = _('Redirect')
self._link = None
self._ui = get_builder('account_wizard.ui')
self.pack_start(self._ui.redirect_box, True, True, 0)
self._ui.link_button.connect('clicked', self._on_link_button)
self.show_all()
def set_redirect(self, link, instructions):
if instructions is None:
instructions = _('Register on the Website')
self._ui.instructions.set_text(instructions)
self._link = link
def _on_link_button(self, _button):
open_uri(self._link)
def get_visible_buttons(self):
return ['back']
class Success(SuccessPage):
def __init__(self):
SuccessPage.__init__(self)
self.set_title(_('Account Added'))
self.set_heading(_('Account has been added successfully'))
self._account = None
def set_account(self, account):
self._account = account
@property
def account(self):
return self._account
def get_visible_buttons(self):
return ['connect']
class Error(ErrorPage):
def __init__(self):
ErrorPage.__init__(self)
self.set_heading(_('An error occurred during account creation'))
def get_visible_buttons(self):
return ['back']

1029
gajim/gtk/accounts.py Normal file

File diff suppressed because it is too large Load diff

509
gajim/gtk/add_contact.py Normal file
View file

@ -0,0 +1,509 @@
# 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/>.
from gi.repository import Gdk
from gi.repository import Gtk
from gajim import vcard
from gajim.common import app
from gajim.common import ged
from gajim.common import helpers
from gajim.common.i18n import _
from .dialogs import ErrorDialog
from .util import get_builder
from .util import EventHelper
class AddNewContactWindow(Gtk.ApplicationWindow, EventHelper):
uid_labels = {'jabber': _('XMPP Address'),
'gadu-gadu': _('GG Number'),
'icq': _('ICQ Number')}
def __init__(self, account=None, contact_jid=None, user_nick=None, group=None):
Gtk.ApplicationWindow.__init__(self)
EventHelper.__init__(self)
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_resizable(False)
self.set_title(_('Add Contact'))
self.connect_after('key-press-event', self._on_key_press)
self.account = account
self.adding_jid = False
if contact_jid is not None:
contact_jid = app.get_jid_without_resource(contact_jid)
# fill accounts with active accounts
accounts = app.get_enabled_accounts_with_labels()
if not accounts:
return
if not account:
self.account = accounts[0][0]
self.xml = get_builder('add_new_contact_window.ui')
self.add(self.xml.get_object('add_contact_box'))
self.xml.connect_signals(self)
for w in ('account_combobox', 'account_label', 'prompt_label',
'uid_label', 'uid_entry', 'show_contact_info_button',
'protocol_combobox', 'protocol_jid_combobox',
'protocol_label', 'nickname_entry',
'message_scrolledwindow', 'save_message_checkbutton',
'register_hbox', 'add_button', 'message_textview',
'connected_label', 'group_comboboxentry',
'auto_authorize_checkbutton', 'save_message_revealer',
'nickname_label', 'group_label'):
self.__dict__[w] = self.xml.get_object(w)
self.subscription_table = [self.uid_label, self.uid_entry,
self.show_contact_info_button,
self.nickname_label, self.nickname_entry,
self.group_label, self.group_comboboxentry]
self.add_button.grab_default()
self.agents = {'jabber': []}
self.gateway_prompt = {}
# types to which we are not subscribed but account has an agent for it
self.available_types = []
for acct in accounts:
for j in app.contacts.get_jid_list(acct[0]):
if app.jid_is_transport(j):
type_ = app.get_transport_name_from_jid(j, False)
if not type_:
continue
if type_ in self.agents:
self.agents[type_].append(j)
else:
self.agents[type_] = [j]
self.gateway_prompt[j] = {'desc': None, 'prompt': None}
# Now add the one to which we can register
for acct in accounts:
for type_ in app.connections[acct[0]].available_transports:
if type_ in self.agents:
continue
self.agents[type_] = []
for jid_ in app.connections[acct[0]].available_transports[type_]:
if jid_ not in self.agents[type_]:
self.agents[type_].append(jid_)
self.gateway_prompt[jid_] = {'desc': None,
'prompt': None}
self.available_types.append(type_)
uf_type = {'jabber': 'XMPP', 'gadu-gadu': 'Gadu Gadu', 'icq': 'ICQ'}
# Jabber as first
liststore = self.protocol_combobox.get_model()
liststore.append(['XMPP', 'xmpp', 'jabber'])
for type_ in self.agents:
if type_ == 'jabber':
continue
if type_ in uf_type:
liststore.append([uf_type[type_], type_ + '-online', type_])
else:
liststore.append([type_, type_ + '-online', type_])
if account:
for service in self.agents[type_]:
con = app.connections[account]
con.get_module('Gateway').request_gateway_prompt(service)
self.protocol_combobox.set_active(0)
self.auto_authorize_checkbutton.show()
if contact_jid:
self.jid_escaped = True
type_ = app.get_transport_name_from_jid(contact_jid)
if not type_:
type_ = 'jabber'
if type_ == 'jabber':
self.uid_entry.set_text(contact_jid)
transport = None
else:
uid, transport = app.get_name_and_server_from_jid(contact_jid)
self.uid_entry.set_text(uid.replace('%', '@', 1))
self.show_contact_info_button.set_sensitive(True)
# set protocol_combobox
model = self.protocol_combobox.get_model()
iter_ = model.get_iter_first()
i = 0
while iter_:
if model[iter_][2] == type_:
self.protocol_combobox.set_active(i)
break
iter_ = model.iter_next(iter_)
i += 1
# set protocol_jid_combobox
self.protocol_jid_combobox.set_active(0)
model = self.protocol_jid_combobox.get_model()
iter_ = model.get_iter_first()
i = 0
while iter_:
if model[iter_][0] == transport:
self.protocol_jid_combobox.set_active(i)
break
iter_ = model.iter_next(iter_)
i += 1
if user_nick:
self.nickname_entry.set_text(user_nick)
self.nickname_entry.grab_focus()
else:
self.jid_escaped = False
self.uid_entry.grab_focus()
group_names = []
for acct in accounts:
for g in app.groups[acct[0]].keys():
if g not in helpers.special_groups and g not in group_names:
group_names.append(g)
group_names.sort()
i = 0
for g in group_names:
self.group_comboboxentry.append_text(g)
if group == g:
self.group_comboboxentry.set_active(i)
i += 1
if len(accounts) > 1:
liststore = self.account_combobox.get_model()
for acc in accounts:
liststore.append(acc)
self.account_combobox.set_active_id(self.account)
self.account_label.show()
self.account_combobox.show()
if len(self.agents) > 1:
self.protocol_label.show()
self.protocol_combobox.show()
if self.account:
message_buffer = self.message_textview.get_buffer()
msg = helpers.from_one_line(helpers.get_subscription_request_msg(
self.account))
message_buffer.set_text(msg)
self.uid_entry.connect('changed', self.on_uid_entry_changed)
self.show_all()
self.register_events([
('gateway-prompt-received', ged.GUI1, self._nec_gateway_prompt_received),
('presence-received', ged.GUI1, self._nec_presence_received),
])
def on_uid_entry_changed(self, widget):
is_empty = bool(not self.uid_entry.get_text() == '')
self.show_contact_info_button.set_sensitive(is_empty)
def on_show_contact_info_button_clicked(self, widget):
"""
Ask for vCard
"""
jid = self.uid_entry.get_text().strip()
if jid in app.interface.instances[self.account]['infos']:
app.interface.instances[self.account]['infos'][jid].window.present()
else:
contact = app.contacts.create_contact(jid=jid, account=self.account)
app.interface.instances[self.account]['infos'][jid] = \
vcard.VcardWindow(contact, self.account)
# Remove xmpp page
app.interface.instances[self.account]['infos'][jid].xml.\
get_object('information_notebook').remove_page(0)
def on_register_button_clicked(self, widget):
model = self.protocol_jid_combobox.get_model()
row = self.protocol_jid_combobox.get_active()
jid = model[row][0]
from .service_registration import ServiceRegistration
ServiceRegistration(self.account, jid)
def _on_key_press(self, widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
def on_cancel_button_clicked(self, widget):
"""
When Cancel button is clicked
"""
self.destroy()
def on_message_textbuffer_changed(self, widget):
self.save_message_revealer.show()
self.save_message_revealer.set_reveal_child(True)
def on_add_button_clicked(self, widget):
"""
When Subscribe button is clicked
"""
jid = self.uid_entry.get_text().strip()
if not jid:
ErrorDialog(
_('%s Missing') % self.uid_label.get_text(),
(_('You must supply the %s of the new contact.') %
self.uid_label.get_text())
)
return
model = self.protocol_combobox.get_model()
row = self.protocol_combobox.get_active_iter()
type_ = model[row][2]
if type_ != 'jabber':
model = self.protocol_jid_combobox.get_model()
row = self.protocol_jid_combobox.get_active()
transport = model[row][0]
if self.account and not self.jid_escaped:
self.adding_jid = (jid, transport, type_)
con = app.connections[self.account]
con.get_module('Gateway').request_gateway_prompt(transport, jid)
else:
jid = jid.replace('@', '%') + '@' + transport
self._add_jid(jid, type_)
else:
self._add_jid(jid, type_)
def _add_jid(self, jid, type_):
# check if jid is conform to RFC and stringprep it
try:
jid = helpers.parse_jid(jid)
except helpers.InvalidFormat as s:
pritext = _('Invalid User ID')
ErrorDialog(pritext, str(s))
return
# No resource in jid
if jid.find('/') >= 0:
pritext = _('Invalid User ID')
ErrorDialog(pritext, _('The user ID must not contain a resource.'))
return
if jid == app.get_jid_from_account(self.account):
pritext = _('Invalid User ID')
ErrorDialog(pritext, _('You cannot add yourself to your contact list.'))
return
if not app.account_is_available(self.account):
ErrorDialog(
_('Account Offline'),
_('Your account must be online to add new contacts.')
)
return
nickname = self.nickname_entry.get_text() or ''
# get value of account combobox, if account was not specified
if not self.account:
model = self.account_combobox.get_model()
index = self.account_combobox.get_active()
self.account = model[index][1]
# Check if jid is already in roster
if jid in app.contacts.get_jid_list(self.account):
c = app.contacts.get_first_contact_from_jid(self.account, jid)
if _('Not in contact list') not in c.groups and c.sub in ('both', 'to'):
ErrorDialog(
_('Contact Already in Contact List'),
_('This contact is already in your contact list.'))
return
if type_ == 'jabber':
message_buffer = self.message_textview.get_buffer()
start_iter = message_buffer.get_start_iter()
end_iter = message_buffer.get_end_iter()
message = message_buffer.get_text(start_iter, end_iter, True)
if self.save_message_checkbutton.get_active():
msg = helpers.to_one_line(message)
app.settings.set_account_setting(self.account,
'subscription_request_msg',
msg)
else:
message = ''
group = self.group_comboboxentry.get_child().get_text()
groups = []
if group:
groups = [group]
auto_auth = self.auto_authorize_checkbutton.get_active()
app.interface.roster.req_sub(
self, jid, message, self.account,
groups=groups, nickname=nickname, auto_auth=auto_auth)
self.destroy()
def on_account_combobox_changed(self, widget):
account = widget.get_active_id()
message_buffer = self.message_textview.get_buffer()
message_buffer.set_text(helpers.get_subscription_request_msg(account))
self.account = account
def on_protocol_jid_combobox_changed(self, widget):
model = widget.get_model()
iter_ = widget.get_active_iter()
if not iter_:
return
jid_ = model[iter_][0]
model = self.protocol_combobox.get_model()
iter_ = self.protocol_combobox.get_active_iter()
type_ = model[iter_][2]
desc = None
if self.agents[type_] and jid_ in self.gateway_prompt:
desc = self.gateway_prompt[jid_]['desc']
if desc:
self.prompt_label.set_markup(desc)
self.prompt_label.show()
else:
self.prompt_label.hide()
prompt = None
if self.agents[type_] and jid_ in self.gateway_prompt:
prompt = self.gateway_prompt[jid_]['prompt']
if not prompt:
if type_ in self.uid_labels:
prompt = self.uid_labels[type_]
else:
prompt = _('User ID:')
self.uid_label.set_text(prompt)
def on_protocol_combobox_changed(self, widget):
model = widget.get_model()
iter_ = widget.get_active_iter()
type_ = model[iter_][2]
model = self.protocol_jid_combobox.get_model()
model.clear()
if self.agents[type_]:
for jid_ in self.agents[type_]:
model.append([jid_])
self.protocol_jid_combobox.set_active(0)
desc = None
if self.agents[type_]:
jid_ = self.agents[type_][0]
if jid_ in self.gateway_prompt:
desc = self.gateway_prompt[jid_]['desc']
if desc:
self.prompt_label.set_markup(desc)
self.prompt_label.show()
else:
self.prompt_label.hide()
if len(self.agents[type_]) > 1:
self.protocol_jid_combobox.show()
else:
self.protocol_jid_combobox.hide()
prompt = None
if self.agents[type_]:
jid_ = self.agents[type_][0]
if jid_ in self.gateway_prompt:
prompt = self.gateway_prompt[jid_]['prompt']
if not prompt:
if type_ in self.uid_labels:
prompt = self.uid_labels[type_]
else:
prompt = _('User ID:')
self.uid_label.set_text(prompt)
if type_ == 'jabber':
self.message_scrolledwindow.show()
self.save_message_checkbutton.show()
else:
self.message_scrolledwindow.hide()
self.save_message_checkbutton.hide()
if type_ in self.available_types:
self.register_hbox.show()
self.auto_authorize_checkbutton.hide()
self.connected_label.hide()
self._subscription_table_hide()
self.add_button.set_sensitive(False)
else:
self.register_hbox.hide()
if type_ != 'jabber':
model = self.protocol_jid_combobox.get_model()
row = self.protocol_jid_combobox.get_active()
jid = model[row][0]
contact = app.contacts.get_first_contact_from_jid(
self.account, jid)
if contact is None or contact.show in ('offline', 'error'):
self._subscription_table_hide()
self.connected_label.show()
self.add_button.set_sensitive(False)
self.auto_authorize_checkbutton.hide()
return
self._subscription_table_show()
self.auto_authorize_checkbutton.show()
self.connected_label.hide()
self.add_button.set_sensitive(True)
def transport_signed_in(self, jid):
model = self.protocol_jid_combobox.get_model()
row = self.protocol_jid_combobox.get_active()
_jid = model[row][0]
if _jid == jid:
self.register_hbox.hide()
self.connected_label.hide()
self._subscription_table_show()
self.auto_authorize_checkbutton.show()
self.add_button.set_sensitive(True)
def transport_signed_out(self, jid):
model = self.protocol_jid_combobox.get_model()
row = self.protocol_jid_combobox.get_active()
_jid = model[row][0]
if _jid == jid:
self._subscription_table_hide()
self.auto_authorize_checkbutton.hide()
self.connected_label.show()
self.add_button.set_sensitive(False)
def _nec_presence_received(self, obj):
if app.jid_is_transport(obj.jid):
if obj.old_show == 0 and obj.new_show > 1:
self.transport_signed_in(obj.jid)
elif obj.old_show > 1 and obj.new_show == 0:
self.transport_signed_out(obj.jid)
def _nec_gateway_prompt_received(self, obj):
if self.adding_jid:
jid, transport, type_ = self.adding_jid
if obj.stanza.getError():
ErrorDialog(
_('Error while adding transport contact'),
_('This error occurred while adding a contact for transport '
'%(transport)s:\n\n%(error)s') % {
'transport': transport,
'error': obj.stanza.getErrorMsg()})
return
if obj.prompt_jid:
self._add_jid(obj.prompt_jid, type_)
else:
jid = jid.replace('@', '%') + '@' + transport
self._add_jid(jid, type_)
elif obj.jid in self.gateway_prompt:
if obj.desc:
self.gateway_prompt[obj.jid]['desc'] = obj.desc
if obj.prompt:
self.gateway_prompt[obj.jid]['prompt'] = obj.prompt
def _subscription_table_hide(self):
for widget in self.subscription_table:
widget.hide()
def _subscription_table_show(self):
for widget in self.subscription_table:
widget.show()

404
gajim/gtk/adhoc.py Normal file
View file

@ -0,0 +1,404 @@
# 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 logging
from gi.repository import Gtk
from gi.repository import GObject
from nbxmpp.const import AdHocAction
from nbxmpp.modules import dataforms
from nbxmpp.errors import StanzaError
from nbxmpp.errors import MalformedStanzaError
from gajim.common import app
from gajim.common.i18n import _
from gajim.common.helpers import to_user_string
from .assistant import Assistant
from .assistant import Page
from .assistant import ErrorPage
from .assistant import ProgressPage
from .dataform import DataFormWidget
from .util import MultiLineLabel
from .util import ensure_not_destroyed
log = logging.getLogger('gajim.gui.adhoc')
class AdHocCommand(Assistant):
def __init__(self, account, jid=None):
Assistant.__init__(self, width=600, height=500)
self._destroyed = False
self._client = app.get_client(account)
self._account = account
self._jid = jid
self.add_button('complete', _('Complete'), complete=True,
css_class='suggested-action')
self.add_button('next', _('Next'), complete=True,
css_class='suggested-action')
self.add_button('prev', _('Previous'))
self.add_button('cancel', _('Cancel'),
css_class='destructive-action')
self.add_button('commands', _('Commands'),
css_class='suggested-action')
self.add_button('execute', _('Execute'), css_class='suggested-action')
self.add_pages({
'request': RequestCommandList(),
'commands': Commands(),
'stage': Stage(),
'completed': Completed(),
'error': Error(),
'executing': Executing(),
})
self.get_page('commands').connect('execute', self._on_execute)
self.connect('button-clicked', self._on_button_clicked)
self.connect('destroy', self._on_destroy)
self._client.get_module('AdHocCommands').request_command_list(
jid, callback=self._received_command_list)
self.show_all()
@ensure_not_destroyed
def _received_command_list(self, task):
try:
commands = task.finish()
except (StanzaError, MalformedStanzaError) as error:
self._set_error(to_user_string(error), False)
return
if not commands:
self._set_error(_('No commands available'), False)
return
self.get_page('commands').add_commands(commands)
self.show_page('commands')
@ensure_not_destroyed
def _received_stage(self, task):
try:
stage = task.finish()
except (StanzaError, MalformedStanzaError) as error:
self._set_error(to_user_string(error), True)
return
page_name = 'stage'
if stage.is_completed:
page_name = 'completed'
page = self.get_page(page_name)
page.process_stage(stage)
self.show_page(page_name)
def _set_error(self, text, show_command_button):
self.get_page('error').set_show_commands_button(show_command_button)
self.get_page('error').set_text(text)
self.show_page('error')
def _on_destroy(self, *args):
self._destroyed = True
def _on_button_clicked(self, _assistant, button_name):
if button_name == 'commands':
self.show_page('commands')
elif button_name == 'execute':
self._on_execute()
elif button_name in ('prev', 'next', 'complete'):
self._on_stage_action(AdHocAction(button_name))
elif button_name == 'cancel':
self._on_cancel()
else:
raise ValueError('Invalid button name: %s' % button_name)
def _on_stage_action(self, action):
command, dataform = self.get_page('stage').stage_data
if action == AdHocAction.PREV:
dataform = None
self._client.get_module('AdHocCommands').execute_command(
command,
action=action,
dataform=dataform,
callback=self._received_stage)
self.show_page('executing')
self.get_page('stage').clear()
def _on_execute(self, *args):
command = self.get_page('commands').get_selected_command()
if command is None:
return
self._client.get_module('AdHocCommands').execute_command(
command,
action=AdHocAction.EXECUTE,
callback=self._received_stage)
self.show_page('executing')
def _on_cancel(self):
command, _ = self.get_page('stage').stage_data
self._client.get_module('AdHocCommands').execute_command(
command, AdHocAction.CANCEL)
self.show_page('commands')
class Commands(Page):
__gsignals__ = {
'execute': (GObject.SignalFlags.RUN_LAST, None, ()),
}
def __init__(self):
Page.__init__(self)
self.set_valign(Gtk.Align.FILL)
self.complete = True
self.title = _('Command List')
self._commands = {}
self._scrolled = Gtk.ScrolledWindow()
self._scrolled.get_style_context().add_class('adhoc-scrolled')
self._scrolled.set_max_content_height(400)
self._scrolled.set_max_content_width(400)
self._scrolled.set_policy(Gtk.PolicyType.NEVER,
Gtk.PolicyType.AUTOMATIC)
self._treeview = Gtk.TreeView()
self._treeview.get_style_context().add_class('adhoc-treeview')
self._store = Gtk.ListStore(str, str)
self._treeview.set_model(self._store)
column = Gtk.TreeViewColumn(_('Commands'))
column.set_expand(True)
self._treeview.append_column(column)
renderer = Gtk.CellRendererText()
column.pack_start(renderer, True)
column.add_attribute(renderer, 'text', 0)
self._treeview.connect('row-activated', self._on_row_activate)
self._treeview.set_search_equal_func(self._search_func)
self._scrolled.add(self._treeview)
self.pack_start(self._scrolled, True, True, 0)
self.show_all()
@staticmethod
def _search_func(model, _column, search_text, iter_):
return search_text.lower() not in model[iter_][0].lower()
def _on_row_activate(self, _tree_view, _path, _column):
self.emit('execute')
def add_commands(self, commands):
self._store.clear()
self._commands = {}
for command in commands:
key = '%s:%s' % (command.jid, command.node)
self._commands[key] = command
self._store.append((command.name, key))
def get_selected_command(self):
model, treeiter = self._treeview.get_selection().get_selected()
if treeiter is None:
return None
key = model[treeiter][1]
return self._commands[key]
def get_visible_buttons(self):
return ['execute']
class Stage(Page):
def __init__(self):
Page.__init__(self)
self.set_valign(Gtk.Align.FILL)
self.complete = False
self.title = _('Stage')
self._dataform_widget = None
self._notes = []
self._last_stage_data = None
self.default = None
self.show_all()
@property
def stage_data(self):
return self._last_stage_data, self._dataform_widget.get_submit_form()
@property
def actions(self):
return self._last_stage_data.actions
def clear(self):
self._show_form(None)
self._show_notes(None)
self._last_stage_data = None
def process_stage(self, stage_data):
self._last_stage_data = stage_data
self._show_notes(stage_data.notes)
self._show_form(stage_data.data)
self.default = stage_data.default
def _show_form(self, form):
if self._dataform_widget is not None:
self.remove(self._dataform_widget)
self._dataform_widget.destroy()
if form is None:
return
form = dataforms.extend_form(node=form)
options = {'entry-activates-default': True}
self._dataform_widget = DataFormWidget(form, options)
self._dataform_widget.connect('is-valid', self._on_is_valid)
self._dataform_widget.validate()
self._dataform_widget.show_all()
self.add(self._dataform_widget)
def _show_notes(self, notes):
for note in self._notes:
self.remove(note)
self._notes = []
if notes is None:
return
for note in notes:
label = Gtk.Label(label=note.text)
label.show()
self._notes.append(label)
self.add(label)
def _on_is_valid(self, _widget, is_valid):
self.complete = is_valid
self.update_page_complete()
def get_visible_buttons(self):
actions = list(map(lambda action: action.value,
self._last_stage_data.actions))
return actions
def get_default_button(self):
return self._last_stage_data.default.value
class Completed(Page):
def __init__(self):
Page.__init__(self)
self.set_valign(Gtk.Align.FILL)
self.complete = True
self.title = _('Completed')
self._notes = []
self._dataform_widget = None
icon = Gtk.Image.new_from_icon_name('object-select-symbolic',
Gtk.IconSize.DIALOG)
icon.get_style_context().add_class('success-color')
icon.show()
label = Gtk.Label(label='Completed')
label.show()
self._icon_text = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self._icon_text.set_spacing(12)
self._icon_text.set_halign(Gtk.Align.CENTER)
self._icon_text.add(icon)
self._icon_text.add(label)
self.add(self._icon_text)
self.show_all()
def process_stage(self, stage_data):
self._show_notes(stage_data.notes)
self._show_form(stage_data.data)
self._show_icon_text(stage_data.data is None)
def _show_icon_text(self, show):
if show:
self.set_valign(Gtk.Align.CENTER)
self._icon_text.show_all()
else:
self.set_valign(Gtk.Align.FILL)
self._icon_text.hide()
def _show_form(self, form):
if self._dataform_widget is not None:
self.remove(self._dataform_widget)
self._dataform_widget.destroy()
if form is None:
return
form = dataforms.extend_form(node=form)
self._dataform_widget = DataFormWidget(
form, options={'read-only': True})
self._dataform_widget.show_all()
self.add(self._dataform_widget)
def _show_notes(self, notes):
for note in self._notes:
self.remove(note)
self._notes = []
for note in notes:
label = MultiLineLabel(label=note.text)
label.set_justify(Gtk.Justification.CENTER)
label.show()
self._notes.append(label)
self.add(label)
def get_visible_buttons(self):
return ['commands']
class Error(ErrorPage):
def __init__(self):
ErrorPage.__init__(self)
self._show_commands_button = False
self.set_heading(_('An error occurred'))
def set_show_commands_button(self, value):
self._show_commands_button = value
def get_visible_buttons(self):
if self._show_commands_button:
return ['commands']
return None
class Executing(ProgressPage):
def __init__(self):
ProgressPage.__init__(self)
self.set_title(_('Executing…'))
self.set_text(_('Executing…'))
class RequestCommandList(ProgressPage):
def __init__(self):
ProgressPage.__init__(self)
self.set_title(_('Requesting Command List'))
self.set_text(_('Requesting Command List'))

View file

@ -0,0 +1,215 @@
# Copyright (C) 2005 Travis Shirk <travis AT pobox.com>
# Vincent Hanquez <tab AT snarc.org>
# Copyright (C) 2005-2014 Yann Leboulanger <asterix AT lagaule.org>
# Copyright (C) 2005-2007 Nikos Kouremenos <kourem AT gmail.com>
# Copyright (C) 2006 Dimitur Kirov <dkirov AT gmail.com>
# Copyright (C) 2006-2007 Jean-Marie Traissard <jim AT lapin.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/>.
from enum import IntEnum
from enum import unique
from gi.repository import Gdk
from gi.repository import Gtk
from gi.repository import Pango
from gajim.common import app
from gajim.common.i18n import _
from gajim.common.i18n import Q_
from gajim.common.setting_values import ADVANCED_SETTINGS
from gajim.common.setting_values import APP_SETTINGS
from .util import get_builder
@unique
class Column(IntEnum):
NAME = 0
VALUE = 1
TYPE = 2
BOOL_DICT = {
True: _('Activated'),
False: _('Deactivated')
}
SETTING_TYPES = {
bool: Q_('?config type:Boolean'),
int: Q_('?config type:Integer'),
str: Q_('?config type:Text'),
}
class AdvancedConfig(Gtk.ApplicationWindow):
def __init__(self):
Gtk.ApplicationWindow.__init__(self)
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_name('AdvancedConfig')
self.set_title(_('Advanced Configuration Editor (ACE)'))
self._ui = get_builder('advanced_configuration.ui')
self.add(self._ui.box)
treeview = self._ui.advanced_treeview
self.treeview = treeview
self.model = Gtk.TreeStore(str, str, str)
self._fill_model()
self.model.set_sort_column_id(0, Gtk.SortType.ASCENDING)
self.modelfilter = self.model.filter_new()
self.modelfilter.set_visible_func(self._visible_func)
renderer_text = Gtk.CellRendererText()
renderer_text.set_property('ellipsize', Pango.EllipsizeMode.END)
col = Gtk.TreeViewColumn(Q_('?config:Preference Name'),
renderer_text, text=0)
treeview.insert_column(col, -1)
col.props.expand = True
col.props.sizing = Gtk.TreeViewColumnSizing.FIXED
col.set_resizable(True)
self.renderer_text = Gtk.CellRendererText()
self.renderer_text.connect('edited', self._on_config_edited)
self.renderer_text.set_property('ellipsize', Pango.EllipsizeMode.END)
col = Gtk.TreeViewColumn(
Q_('?config:Value'), self.renderer_text, text=1)
treeview.insert_column(col, -1)
col.set_cell_data_func(self.renderer_text,
self._value_column_data_callback)
col.props.expand = True
col.props.sizing = Gtk.TreeViewColumnSizing.FIXED
col.set_resizable(True)
renderer_text = Gtk.CellRendererText()
col = Gtk.TreeViewColumn(Q_('?config:Type'), renderer_text, text=2)
treeview.insert_column(col, -1)
col.props.sizing = Gtk.TreeViewColumnSizing.FIXED
treeview.set_model(self.modelfilter)
self.connect_after('key-press-event', self._on_key_press)
self._ui.connect_signals(self)
self.show_all()
def _on_key_press(self, _widget, event):
if event.keyval != Gdk.KEY_Escape:
return
if self._ui.search_entry.get_text():
self._ui.search_entry.set_text('')
return
self.destroy()
def _value_column_data_callback(self, _col, cell, model, iter_, _data):
opttype = model[iter_][Column.TYPE]
cell.set_property('editable', opttype != SETTING_TYPES[bool])
def _on_treeview_selection_changed(self, treeselection):
model, iter_ = treeselection.get_selected()
if not iter_:
self._ui.reset_button.set_sensitive(False)
return
setting = model[iter_][Column.NAME]
desc = ADVANCED_SETTINGS['app'][setting]
self._ui.description.set_text(desc or Q_('?config description:None'))
self._ui.reset_button.set_sensitive(True)
def _on_treeview_row_activated(self, _treeview, path, _column):
modelpath = self.modelfilter.convert_path_to_child_path(path)
modelrow = self.model[modelpath]
setting = modelrow[Column.NAME]
if modelrow[Column.TYPE] != SETTING_TYPES[bool]:
return
setting_value = modelrow[Column.VALUE] != _('Activated')
column_value = BOOL_DICT[setting_value]
app.settings.set(setting, setting_value)
modelrow[Column.VALUE] = column_value
def _on_config_edited(self, _cell, path, text):
path = Gtk.TreePath.new_from_string(path)
modelpath = self.modelfilter.convert_path_to_child_path(path)
modelrow = self.model[modelpath]
setting = modelrow[Column.NAME]
value = text
if modelrow[Column.TYPE] == SETTING_TYPES[int]:
value = int(text)
app.settings.set(setting, value)
modelrow[Column.VALUE] = text
def _on_reset_button_clicked(self, button):
model, iter_ = self.treeview.get_selection().get_selected()
if not iter_:
return
setting = model[iter_][Column.NAME]
default = APP_SETTINGS[setting]
if isinstance(default, bool):
model[iter_][Column.VALUE] = BOOL_DICT[default]
else:
model[iter_][Column.VALUE] = str(default)
app.settings.set(setting, default)
button.set_sensitive(False)
def _fill_model(self, node=None, parent=None):
for category, settings in ADVANCED_SETTINGS.items():
if category != 'app':
continue
for setting, description in settings.items():
value = app.settings.get(setting)
if isinstance(value, bool):
value = BOOL_DICT[value]
type_ = SETTING_TYPES[bool]
elif isinstance(value, int):
value = str(value)
type_ = SETTING_TYPES[int]
elif isinstance(value, str):
type_ = SETTING_TYPES[str]
else:
raise ValueError
self.model.append(parent, [setting, value, type_])
def _visible_func(self, model, treeiter, _data):
search_string = self._ui.search_entry.get_text().lower()
if not search_string:
return True
setting = model[treeiter][Column.NAME]
desc = ADVANCED_SETTINGS['app'][setting]
if search_string in setting or search_string in desc.lower():
return True
return False
def _on_search_entry_changed(self, _widget):
self.modelfilter.refilter()

276
gajim/gtk/assistant.py Normal file
View file

@ -0,0 +1,276 @@
# 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/>.
from gi.repository import Gdk
from gi.repository import Gtk
from gi.repository import Gio
from gi.repository import GObject
from .util import get_builder
from .util import EventHelper
class Assistant(Gtk.ApplicationWindow, EventHelper):
__gsignals__ = dict(
button_clicked=(
GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION,
None,
(str, )
),
page_changed=(
GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION,
None,
(str, )
))
def __init__(self,
transient_for=None,
width=550,
height=400,
transition_duration=200):
Gtk.ApplicationWindow.__init__(self)
EventHelper.__init__(self)
self.set_application(Gio.Application.get_default())
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_name('Assistant')
self.set_default_size(width, height)
self.set_resizable(True)
self.set_transient_for(transient_for)
self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
self._pages = {}
self._buttons = {}
self._button_visible_func = None
self._ui = get_builder('assistant.ui')
self.add(self._ui.main_grid)
self._ui.stack.set_transition_duration(transition_duration)
self.connect('key-press-event', self._on_key_press_event)
self.connect('destroy', self.__on_destroy)
self._ui.connect_signals(self)
def show_all(self):
page_name = self._ui.stack.get_visible_child_name()
self.emit('page-changed', page_name)
Gtk.ApplicationWindow.show_all(self)
def _on_key_press_event(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
def _update_page_complete(self, *args):
page_widget = self._ui.stack.get_visible_child()
for button, complete in self._buttons.values():
if complete:
button.set_sensitive(page_widget.complete)
def update_title(self):
self.set_title(self._ui.stack.get_visible_child().title)
def _hide_buttons(self):
for button, _ in self._buttons.values():
button.hide()
def _set_buttons_visible(self):
page_name = self._ui.stack.get_visible_child_name()
if self._button_visible_func is None:
buttons = self.get_page(page_name).get_visible_buttons()
if buttons is not None:
if len(buttons) == 1:
default = buttons[0]
else:
default = self.get_page(page_name).get_default_button()
self.set_default_button(default)
else:
buttons = self._button_visible_func(self, page_name)
self._update_page_complete()
if buttons is None:
return
for button_name in buttons:
button, _ = self._buttons[button_name]
button.show()
def set_button_visible_func(self, func):
self._button_visible_func = func
def set_default_button(self, button_name):
button, _ = self._buttons[button_name]
button.grab_default()
def add_button(self, name, label, css_class=None, complete=False):
button = Gtk.Button(label=label,
can_default=True,
no_show_all=True)
button.connect('clicked', self.__on_button_clicked)
if css_class is not None:
button.get_style_context().add_class(css_class)
self._buttons[name] = (button, complete)
self._ui.action_area.pack_end(button, False, False, 0)
def add_pages(self, pages):
self._pages = pages
for name, widget in pages.items():
widget.connect('update-page-complete', self._update_page_complete)
self._ui.stack.add_named(widget, name)
def add_default_page(self, name):
if name == 'success':
page = SuccessPage()
elif name == 'error':
page = ErrorPage()
elif name == 'progress':
page = ProgressPage()
else:
raise ValueError('Unknown page: %s' % name)
self._pages[name] = page
self._ui.stack.add_named(page, name)
return page
def get_current_page(self):
return self._ui.stack.get_visible_child_name()
def show_page(self, name, transition=Gtk.StackTransitionType.NONE):
if self._ui.stack.get_visible_child_name() == name:
return
self._hide_buttons()
self._ui.stack.set_visible_child_full(name, transition)
def get_page(self, name):
return self._pages[name]
def _on_visible_child_name(self, stack, _param):
if stack.get_visible_child_name() is None:
# Happens for some reason when adding the first page
return
self.update_title()
self._set_buttons_visible()
self.emit('page-changed', stack.get_visible_child_name())
def __on_button_clicked(self, button):
for button_name, button_data in self._buttons.items():
button_ = button_data[0]
if button_ == button:
self.emit('button-clicked', button_name)
return
def __on_destroy(self, *args):
self._pages.clear()
self._buttons.clear()
class Page(Gtk.Box):
__gsignals__: dict = {
'update-page-complete': (GObject.SignalFlags.RUN_LAST, None, ()),
}
def __init__(self):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
self.set_spacing(18)
self.set_valign(Gtk.Align.CENTER)
self.title = ''
self.complete = True
def get_visible_buttons(self):
return None
def get_default_button(self):
return None
def update_page_complete(self):
self.emit('update-page-complete')
class DefaultPage(Page):
def __init__(self, icon_name, icon_css_class):
Page.__init__(self)
self._heading = Gtk.Label()
self._heading.get_style_context().add_class('large-header')
self._heading.set_max_width_chars(30)
self._heading.set_line_wrap(True)
self._heading.set_halign(Gtk.Align.CENTER)
self._heading.set_justify(Gtk.Justification.CENTER)
icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG)
icon.get_style_context().add_class(icon_css_class)
self._label = Gtk.Label()
self._label.set_max_width_chars(50)
self._label.set_line_wrap(True)
self._label.set_halign(Gtk.Align.CENTER)
self._label.set_justify(Gtk.Justification.CENTER)
self.pack_start(self._heading, False, True, 0)
self.pack_start(icon, False, True, 0)
self.pack_start(self._label, False, True, 0)
self.show_all()
def set_heading(self, heading):
self._heading.set_text(heading)
def set_text(self, text):
self._label.set_text(text)
def set_title(self, title):
self.title = title
class ErrorPage(DefaultPage):
def __init__(self):
DefaultPage.__init__(self,
icon_name='dialog-error-symbolic',
icon_css_class='error-color')
class SuccessPage(DefaultPage):
def __init__(self):
DefaultPage.__init__(self,
icon_name='object-select-symbolic',
icon_css_class='success-color')
class ProgressPage(Page):
def __init__(self):
Page.__init__(self)
self._label = Gtk.Label()
self._label.set_max_width_chars(50)
self._label.set_line_wrap(True)
self._label.set_halign(Gtk.Align.CENTER)
self._label.set_justify(Gtk.Justification.CENTER)
spinner = Gtk.Spinner()
spinner.start()
self.pack_start(spinner, True, True, 0)
self.pack_start(self._label, False, True, 0)
self.show_all()
def set_text(self, text):
self._label.set_text(text)
def set_title(self, title):
self.title = title

364
gajim/gtk/avatar.py Normal file
View file

@ -0,0 +1,364 @@
# 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 logging
import hashlib
from math import pi
from functools import lru_cache
from collections import defaultdict
from gi.repository import Gdk
from gi.repository import GdkPixbuf
import cairo
from gajim.common import app
from gajim.common import configpaths
from gajim.common.helpers import Singleton
from gajim.common.helpers import get_groupchat_name
from gajim.common.const import AvatarSize
from gajim.common.const import StyleAttr
from .util import load_pixbuf
from .util import text_to_color
from .util import scale_with_ratio
from .util import get_css_show_class
from .util import convert_rgb_string_to_float
log = logging.getLogger('gajim.gui.avatar')
def generate_avatar(letters, color, size, scale):
# Get color for nickname with XEP-0392
color_r, color_g, color_b = color
# Set up colors and size
if scale is not None:
size = size * scale
width = size
height = size
font_size = size * 0.5
# Set up surface
surface = cairo.ImageSurface(cairo.Format.ARGB32, width, height)
context = cairo.Context(surface)
context.set_source_rgb(color_r, color_g, color_b)
context.rectangle(0, 0, width, height)
context.fill()
# Draw letters
context.select_font_face('sans-serif',
cairo.FontSlant.NORMAL,
cairo.FontWeight.NORMAL)
context.set_font_size(font_size)
extends = context.text_extents(letters)
x_bearing = extends.x_bearing
y_bearing = extends.y_bearing
ex_width = extends.width
ex_height = extends.height
x_pos = width / 2 - (ex_width / 2 + x_bearing)
y_pos = height / 2 - (ex_height / 2 + y_bearing)
context.move_to(x_pos, y_pos)
context.set_source_rgb(0.95, 0.95, 0.95)
context.set_operator(cairo.Operator.OVER)
context.show_text(letters)
return context.get_target()
def add_status_to_avatar(surface, show):
width = surface.get_width()
height = surface.get_height()
new_surface = cairo.ImageSurface(cairo.Format.ARGB32, width, height)
new_surface.set_device_scale(*surface.get_device_scale())
scale = surface.get_device_scale()[0]
context = cairo.Context(new_surface)
context.set_source_surface(surface, 0, 0)
context.paint()
# Correct height and width for scale
width = width / scale
height = height / scale
clip_radius = width / 5.5
center_x = width - clip_radius
center_y = height - clip_radius
context.set_source_rgb(255, 255, 255)
context.set_operator(cairo.Operator.CLEAR)
context.arc(center_x, center_y, clip_radius, 0, 2 * pi)
context.fill()
css_color = get_css_show_class(show)
color = convert_rgb_string_to_float(
app.css_config.get_value(css_color, StyleAttr.COLOR))
show_radius = clip_radius * 0.75
context.set_source_rgb(*color)
context.set_operator(cairo.Operator.OVER)
context.arc(center_x, center_y, show_radius, 0, 2 * pi)
context.fill()
if show == 'dnd':
line_length = clip_radius / 2
context.move_to(center_x - line_length, center_y)
context.line_to(center_x + line_length, center_y)
context.set_source_rgb(255, 255, 255)
context.set_line_width(clip_radius / 4)
context.stroke()
return context.get_target()
def square(surface, size):
width = surface.get_width()
height = surface.get_height()
if width == height:
return surface
new_surface = cairo.ImageSurface(cairo.Format.ARGB32, size, size)
new_surface.set_device_scale(*surface.get_device_scale())
context = cairo.Context(new_surface)
scale = surface.get_device_scale()[0]
if width == size:
x_pos = 0
y_pos = (size - height) / 2 / scale
else:
y_pos = 0
x_pos = (size - width) / 2 / scale
context.set_source_surface(surface, x_pos, y_pos)
context.paint()
return context.get_target()
def clip_circle(surface):
new_surface = cairo.ImageSurface(cairo.Format.ARGB32,
surface.get_width(),
surface.get_height())
new_surface.set_device_scale(*surface.get_device_scale())
context = cairo.Context(new_surface)
context.set_source_surface(surface, 0, 0)
width = surface.get_width()
height = surface.get_height()
scale = surface.get_device_scale()[0]
radius = width / 2 / scale
context.arc(width / 2 / scale, height / 2 / scale, radius, 0, 2 * pi)
context.clip()
context.paint()
return context.get_target()
def get_avatar_from_pixbuf(pixbuf, scale, show=None):
size = max(pixbuf.get_width(), pixbuf.get_height())
size *= scale
surface = Gdk.cairo_surface_create_from_pixbuf(pixbuf, scale)
if surface is None:
return None
surface = square(surface, size)
if surface is None:
return None
surface = clip_circle(surface)
if surface is None:
return None
if show is not None:
return add_status_to_avatar(surface, show)
return surface
class AvatarStorage(metaclass=Singleton):
def __init__(self):
self._cache = defaultdict(dict)
def invalidate_cache(self, jid):
self._cache.pop(jid, None)
def get_pixbuf(self, contact, size, scale, show=None, default=False):
surface = self.get_surface(contact, size, scale, show, default)
return Gdk.pixbuf_get_from_surface(surface, 0, 0, size, size)
def get_surface(self, contact, size, scale, show=None, default=False):
jid = contact.jid
if contact.is_gc_contact:
jid = contact.get_full_jid()
if not default:
surface = self._cache[jid].get((size, scale, show))
if surface is not None:
return surface
surface = self._get_avatar_from_storage(contact, size, scale)
if surface is not None:
if show is not None:
surface = add_status_to_avatar(surface, show)
self._cache[jid][(size, scale, show)] = surface
return surface
name = contact.get_shown_name()
# Use nickname for group chats and bare JID for single contacts
if contact.is_gc_contact:
color_string = contact.name
else:
color_string = contact.jid
letter = self._generate_letter(name)
surface = self._generate_default_avatar(
letter, color_string, size, scale)
if show is not None:
surface = add_status_to_avatar(surface, show)
self._cache[jid][(size, scale, show)] = surface
return surface
def get_muc_surface(self, account, jid, size, scale, default=False):
if not default:
surface = self._cache[jid].get((size, scale))
if surface is not None:
return surface
avatar_sha = app.storage.cache.get_muc_avatar_sha(jid)
if avatar_sha is not None:
surface = self.surface_from_filename(avatar_sha, size, scale)
if surface is None:
return None
surface = clip_circle(surface)
self._cache[jid][(size, scale)] = surface
return surface
con = app.connections[account]
name = get_groupchat_name(con, jid)
letter = self._generate_letter(name)
surface = self._generate_default_avatar(letter, jid, size, scale)
self._cache[jid][(size, scale)] = surface
return surface
def prepare_for_publish(self, path):
success, data = self._load_for_publish(path)
if not success:
return None, None
sha = self.save_avatar(data)
if sha is None:
return None, None
return data, sha
@staticmethod
def _load_for_publish(path):
pixbuf = load_pixbuf(path)
if pixbuf is None:
return None
width = pixbuf.get_width()
height = pixbuf.get_height()
if width > AvatarSize.PUBLISH or height > AvatarSize.PUBLISH:
# Scale only down, never up
width, height = scale_with_ratio(AvatarSize.PUBLISH, width, height)
pixbuf = pixbuf.scale_simple(width,
height,
GdkPixbuf.InterpType.BILINEAR)
return pixbuf.save_to_bufferv('png', [], [])
@staticmethod
def save_avatar(data):
"""
Save an avatar to the harddisk
:param data: bytes
returns SHA1 value of the avatar or None on error
"""
if data is None:
return None
sha = hashlib.sha1(data).hexdigest()
path = configpaths.get('AVATAR') / sha
try:
with open(path, 'wb') as output_file:
output_file.write(data)
except Exception:
log.error('Storing avatar failed', exc_info=True)
return None
return sha
@staticmethod
def get_avatar_path(filename):
path = configpaths.get('AVATAR') / filename
if not path.is_file():
return None
return path
def surface_from_filename(self, filename, size, scale):
size = size * scale
path = self.get_avatar_path(filename)
if path is None:
return None
pixbuf = load_pixbuf(path, size)
if pixbuf is None:
return None
surface = Gdk.cairo_surface_create_from_pixbuf(pixbuf, scale)
return square(surface, size)
def _load_surface_from_storage(self, contact, size, scale):
filename = contact.avatar_sha
size = size * scale
path = self.get_avatar_path(filename)
if path is None:
return None
pixbuf = load_pixbuf(path, size)
if pixbuf is None:
return None
surface = Gdk.cairo_surface_create_from_pixbuf(pixbuf, scale)
return square(surface, size)
def _get_avatar_from_storage(self, contact, size, scale):
if contact.avatar_sha is None:
return None
surface = self._load_surface_from_storage(contact, size, scale)
if surface is None:
return None
return clip_circle(surface)
@staticmethod
def _generate_letter(name):
for letter in name:
if letter.isalpha():
return letter.capitalize()
return name[0].capitalize()
@staticmethod
@lru_cache(maxsize=2048)
def _generate_default_avatar(letter, color_string, size, scale):
color = text_to_color(color_string)
surface = generate_avatar(letter, color, size, scale)
surface = clip_circle(surface)
surface.set_device_scale(scale, scale)
return surface

715
gajim/gtk/avatar_selector.py Executable file
View file

@ -0,0 +1,715 @@
# This is a port of um-crop-area.c from GNOMEs 'Cheese' application, see
# https://gitlab.gnome.org/GNOME/cheese/-/blob/3.34.0/libcheese/um-crop-area.c
#
# 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 logging
from enum import IntEnum
from enum import unique
from gi.repository import Gdk
from gi.repository import GdkPixbuf
from gi.repository import GLib
from gi.repository import Gtk
import cairo
from gajim.common.const import AvatarSize
from gajim.common.i18n import _
from gajim.common.helpers import get_file_path_from_dnd_dropped_uri
from .util import scale_with_ratio
log = logging.getLogger('gajim.gui.avatar_selector')
@unique
class Loc(IntEnum):
OUTSIDE = 0
INSIDE = 1
TOP = 2
TOP_LEFT = 3
TOP_RIGHT = 4
BOTTOM = 5
BOTTOM_LEFT = 6
BOTTOM_RIGHT = 7
LEFT = 8
RIGHT = 9
@unique
class Range(IntEnum):
BELOW = 0
LOWER = 1
BETWEEN = 2
UPPER = 3
ABOVE = 4
class AvatarSelector(Gtk.Box):
def __init__(self):
Gtk.Box.__init__(self)
self.set_orientation(Gtk.Orientation.VERTICAL)
self.get_style_context().add_class('padding-18')
uri_entry = Gtk.TargetEntry.new(
'text/uri-list', Gtk.TargetFlags.OTHER_APP, 80)
dst_targets = Gtk.TargetList.new([uri_entry])
self.drag_dest_set(
Gtk.DestDefaults.ALL,
[uri_entry],
Gdk.DragAction.COPY | Gdk.DragAction.MOVE)
self.drag_dest_set_target_list(dst_targets)
self.connect('drag-data-received', self._on_drag_data_received)
self._crop_area = CropArea()
self._crop_area.set_vexpand(True)
self.add(self._crop_area)
self._helper_label = Gtk.Label(
label=_('Select a picture or drop it here'))
self._helper_label.get_style_context().add_class('bold')
self._helper_label.get_style_context().add_class('dim-label')
self._helper_label.set_vexpand(True)
self._helper_label.set_no_show_all(True)
self._helper_label.show()
self.add(self._helper_label)
self.show_all()
def prepare_crop_area(self, path):
pixbuf = self._get_pixbuf_from_path(path)
self._crop_area.set_pixbuf(pixbuf)
self._helper_label.hide()
self._crop_area.show()
def _on_drag_data_received(self, _widget, _context, _x_coord, _y_coord,
selection, target_type, _timestamp):
if not selection.get_data():
return
if target_type == 80:
uri_split = selection.get_uris() # Might be more than one
path = get_file_path_from_dnd_dropped_uri(uri_split[0])
if not os.path.isfile(path):
return
self.prepare_crop_area(path)
@staticmethod
def _get_pixbuf_from_path(path):
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file(path)
return pixbuf
except GLib.Error as err:
log.error('Unable to load file %s: %s', path, str(err))
return None
def get_prepared(self):
return bool(self._crop_area.get_pixbuf())
@staticmethod
def _scale_for_publish(pixbuf):
width = pixbuf.get_width()
height = pixbuf.get_height()
if width > AvatarSize.PUBLISH or height > AvatarSize.PUBLISH:
# Scale only down, never up
width, height = scale_with_ratio(AvatarSize.PUBLISH, width, height)
pixbuf = pixbuf.scale_simple(width,
height,
GdkPixbuf.InterpType.BILINEAR)
return pixbuf, width, height
def get_avatar_surface(self):
pixbuf = self._crop_area.get_pixbuf()
if pixbuf is None:
return None
scaled, width, height = self._scale_for_publish(pixbuf)
return Gdk.cairo_surface_create_from_pixbuf(
scaled, self.get_scale_factor()), width, height
def get_avatar_bytes(self):
pixbuf = self._crop_area.get_pixbuf()
if pixbuf is None:
return False, None, 0, 0
scaled, width, height = self._scale_for_publish(pixbuf)
success, data = scaled.save_to_bufferv('png', [], [])
return success, data, width, height
class CropArea(Gtk.DrawingArea):
def __init__(self):
Gtk.DrawingArea.__init__(self)
self.set_no_show_all(True)
self.add_events(
Gdk.EventMask.BUTTON_PRESS_MASK |
Gdk.EventMask.BUTTON_RELEASE_MASK |
Gdk.EventMask.POINTER_MOTION_MASK)
self._image = Gdk.Rectangle()
self._crop = Gdk.Rectangle()
self._pixbuf = None
self._browse_pixbuf = None
self._color_shifted_pixbuf = None
self._current_cursor = None
self._scale = float(0.0)
self._image.x = 0
self._image.y = 0
self._image.width = 0
self._image.height = 0
self._active_region = Loc.OUTSIDE
self._last_press_x = -1
self._last_press_y = -1
self._base_width = 10
self._base_height = 10
self._aspect = float(1.0)
self.set_size_request(self._base_width, self._base_height)
self.connect('draw', self._on_draw)
self.connect('button-press-event', self._on_button_press)
self.connect('button-release-event', self._on_button_release)
self.connect('motion-notify-event', self._on_motion_notify)
def set_min_size(self, width, height):
self._base_width = width
self._base_height = height
self.set_size_request(self._base_width, self._base_height)
if self._aspect > 0:
self._aspect = self._base_width / self._base_height
def set_contstrain_aspect(self, constrain):
if constrain:
self._aspect = self._base_width / self._base_height
else:
self._aspect = -1
def set_pixbuf(self, pixbuf):
if pixbuf:
self._browse_pixbuf = pixbuf
width = pixbuf.get_width()
height = pixbuf.get_height()
else:
width = 0
height = 0
self._crop.width = 2 * self._base_width
self._crop.height = 2 * self._base_height
self._crop.x = abs((width - self._crop.width) / 2)
self._crop.y = abs((height - self._crop.height) / 2)
self._scale = 0.0
self._image.x = 0
self._image.y = 0
self._image.width = 0
self._image.height = 0
self.queue_draw()
def get_pixbuf(self):
if self._browse_pixbuf is None:
return None
width = self._browse_pixbuf.get_width()
height = self._browse_pixbuf.get_height()
width = min(self._crop.width, width - self._crop.x)
height = min(self._crop.height, height - self._crop.y)
if width <= 0 or height <= 0:
return None
return GdkPixbuf.Pixbuf.new_subpixbuf(
self._browse_pixbuf, self._crop.x, self._crop.y, width, height)
def _on_draw(self, _widget, context):
if self._browse_pixbuf is None:
return False
self._update_pixbufs()
width = self._pixbuf.get_width()
height = self._pixbuf.get_height()
crop = self._crop_to_widget()
ix = self._image.x
iy = self._image.y
Gdk.cairo_set_source_pixbuf(
context, self._color_shifted_pixbuf, ix, iy)
context.rectangle(
ix,
iy,
width,
crop.y - iy)
context.rectangle(
ix,
crop.y,
crop.x - ix,
crop.height)
context.rectangle(
crop.x + crop.width,
crop.y,
width - crop.width - (crop.x - ix),
crop.height)
context.rectangle(
ix,
crop.y + crop.height,
width,
height - crop.height - (crop.y - iy))
context.fill()
Gdk.cairo_set_source_pixbuf(context, self._pixbuf, ix, iy)
context.rectangle(crop.x, crop.y, crop.width, crop.height)
context.fill()
if self._active_region != Loc.OUTSIDE:
context.set_source_rgb(150, 150, 150)
context.set_line_width(1.0)
x1 = crop.x + crop.width / 3.0
x2 = crop.x + 2 * crop.width / 3.0
y1 = crop.y + crop.height / 3.0
y2 = crop.y + 2 * crop.height / 3.0
context.move_to(x1 + 0.5, crop.y)
context.line_to(x1 + 0.5, crop.y + crop.height)
context.move_to(x2 + 0.5, crop.y)
context.line_to(x2 + 0.5, crop.y + crop.height)
context.move_to(crop.x, y1 + 0.5)
context.line_to(crop.x + crop.width, y1 + 0.5)
context.move_to(crop.x, y2 + 0.5)
context.line_to(crop.x + crop.width, y2 + 0.5)
context.stroke()
context.set_source_rgb(1, 1, 1)
context.set_line_width(1.0)
context.rectangle(
crop.x + 0.5,
crop.y + 0.5,
crop.width - 1.0,
crop.height - 1.0)
context.stroke()
context.set_source_rgb(1, 1, 1)
context.set_line_width(2.0)
context.rectangle(
crop.x + 2.0,
crop.y + 2.0,
crop.width - 4.0,
crop.height - 4.0)
context.stroke()
return False
def _on_button_press(self, _widget, event):
if self._browse_pixbuf is None:
return False
crop = self._crop_to_widget()
self._last_press_x = (event.x - self._image.x) / self._scale
self._last_press_y = (event.y - self._image.y) / self._scale
self._active_region = self._find_location(crop, event.x, event.y)
self.queue_draw_area(
crop.x - 1, crop.y - 1, crop.width + 2, crop.height + 2)
return False
def _on_button_release(self, _widget, _event):
if self._browse_pixbuf is None:
return False
crop = self._crop_to_widget()
self._last_press_x = -1
self._last_press_y = -1
self._active_region = Loc.OUTSIDE
self.queue_draw_area(
crop.x - 1, crop.y - 1, crop.width + 2, crop.height + 2)
return False
def _on_motion_notify(self, _widget, event):
# pylint: disable=too-many-boolean-expressions
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
if self._browse_pixbuf is None:
return False
self._update_cursor(event.x, event.y)
damage = self._crop_to_widget()
self.queue_draw_area(
damage.x - 1, damage.y - 1, damage.width + 2, damage.height + 2)
pb_width = self._browse_pixbuf.get_width()
pb_height = self._browse_pixbuf.get_height()
x_coord = int((event.x - self._image.x) / self._scale)
y_coord = int((event.y - self._image.y) / self._scale)
delta_x = int(x_coord - self._last_press_x)
delta_y = int(y_coord - self._last_press_y)
self._last_press_x = x_coord
self._last_press_y = y_coord
left = int(self._crop.x)
right = int(self._crop.x + self._crop.width - 1)
top = int(self._crop.y)
bottom = int(self._crop.y + self._crop.height - 1)
center_x = float((left + right) / 2.0)
center_y = float((top + bottom) / 2.0)
if self._active_region == Loc.INSIDE:
width = right - left + 1
height = bottom - top + 1
left += delta_x
right += delta_x
top += delta_y
bottom += delta_y
if left < 0:
left = 0
if top < 0:
top = 0
if right > pb_width:
right = pb_width
if bottom > pb_height:
bottom = pb_height
adj_width = int(right - left + 1)
adj_height = int(bottom - top + 1)
if adj_width != width:
if delta_x < 0:
right = left + width - 1
else:
left = right - width + 1
if adj_height != height:
if delta_y < 0:
bottom = top + height - 1
else:
top = bottom - height + 1
elif self._active_region == Loc.TOP_LEFT:
if self._aspect < 0:
top = y_coord
left = x_coord
elif y_coord < self._eval_radial_line(
center_x, center_y, left, top, x_coord):
top = y_coord
new_width = float((bottom - top) * self._aspect)
left = right - new_width
else:
left = x_coord
new_height = float((right - left) / self._aspect)
top = bottom - new_height
elif self._active_region == Loc.TOP:
top = y_coord
if self._aspect > 0:
new_width = float((bottom - top) * self._aspect)
right = left + new_width
elif self._active_region == Loc.TOP_RIGHT:
if self._aspect < 0:
top = y_coord
right = x_coord
elif y_coord < self._eval_radial_line(
center_x, center_y, right, top, x_coord):
top = y_coord
new_width = float((bottom - top) * self._aspect)
right = left + new_width
else:
right = x_coord
new_height = float((right - left) / self._aspect)
top = bottom - new_height
elif self._active_region == Loc.LEFT:
left = x_coord
if self._aspect > 0:
new_height = float((right - left) / self._aspect)
bottom = top + new_height
elif self._active_region == Loc.BOTTOM_LEFT:
if self._aspect < 0:
bottom = y_coord
left = x_coord
elif y_coord < self._eval_radial_line(
center_x, center_y, left, bottom, x_coord):
left = x_coord
new_height = float((right - left) / self._aspect)
bottom = top + new_height
else:
bottom = y_coord
new_width = float((bottom - top) * self._aspect)
left = right - new_width
elif self._active_region == Loc.RIGHT:
right = x_coord
if self._aspect > 0:
new_height = float((right - left) / self._aspect)
bottom = top + new_height
elif self._active_region == Loc.BOTTOM_RIGHT:
if self._aspect < 0:
bottom = y_coord
right = x_coord
elif y_coord < self._eval_radial_line(
center_x, center_y, right, bottom, x_coord):
right = x_coord
new_height = float((right - left) / self._aspect)
bottom = top + new_height
else:
bottom = y_coord
new_width = float((bottom - top) * self._aspect)
right = left + new_width
elif self._active_region == Loc.BOTTOM:
bottom = y_coord
if self._aspect > 0:
new_width = float((bottom - top) * self._aspect)
right = left + new_width
else:
return False
min_width = int(self._base_width / self._scale)
min_height = int(self._base_height / self._scale)
width = right - left + 1
height = bottom - top + 1
if self._aspect < 0:
if left < 0:
left = 0
if top < 0:
top = 0
if right > pb_width:
right = pb_width
if bottom > pb_height:
bottom = pb_height
width = right - left + 1
height = bottom - top + 1
if self._active_region in (
Loc.LEFT, Loc.TOP_LEFT, Loc.BOTTOM_LEFT):
if width < min_width:
left = right - min_width
elif self._active_region in (
Loc.RIGHT, Loc.TOP_RIGHT, Loc.BOTTOM_RIGHT):
if width < min_width:
right = left + min_width
if self._active_region in (
Loc.TOP, Loc.TOP_LEFT, Loc.TOP_RIGHT):
if height < min_height:
top = bottom - min_height
elif self._active_region in (
Loc.BOTTOM, Loc.BOTTOM_LEFT, Loc.BOTTOM_RIGHT):
if height < min_height:
bottom = top + min_height
else:
if (left < 0 or top < 0 or
right > pb_width or bottom > pb_height or
width < min_width or height < min_height):
left = self._crop.x
right = self._crop.x + self._crop.width - 1
top = self._crop.y
bottom = self._crop.y + self._crop.height - 1
self._crop.x = left
self._crop.y = top
self._crop.width = right - left + 1
self._crop.height = bottom - top + 1
damage = self._crop_to_widget()
self.queue_draw_area(
damage.x - 1, damage.y - 1, damage.width + 2, damage.height + 2)
return False
def _update_pixbufs(self):
allocation = self.get_allocation()
width = self._browse_pixbuf.get_width()
height = self._browse_pixbuf.get_height()
scale = allocation.height / float(height)
if scale * width > allocation.width:
scale = allocation.width / float(width)
dest_width = width * scale
dest_height = height * scale
if (self._pixbuf is None or
self._pixbuf.get_width != allocation.width or
self._pixbuf.get_height != allocation.height):
self._pixbuf = GdkPixbuf.Pixbuf.new(
GdkPixbuf.Colorspace.RGB,
self._browse_pixbuf.get_has_alpha(),
8,
dest_width,
dest_height)
self._pixbuf.fill(0x0)
self._browse_pixbuf.scale(
self._pixbuf,
0,
0,
dest_width,
dest_height,
0,
0,
scale,
scale,
GdkPixbuf.InterpType.BILINEAR)
self._generate_color_shifted_pixbuf()
if self._scale == 0.0:
scale_to_80 = float(min(
(self._pixbuf.get_width() * 0.8 / self._base_width),
(self._pixbuf.get_height() * 0.8 / self._base_height)))
scale_to_image = float(min(
(dest_width / self._base_width),
(dest_height / self._base_height)))
crop_scale = float(min(scale_to_80, scale_to_image))
self._crop.width = crop_scale * self._base_width / scale
self._crop.height = crop_scale * self._base_height / scale
self._crop.x = (
self._browse_pixbuf.get_width() - self._crop.width) / 2
self._crop.y = (
self._browse_pixbuf.get_height() - self._crop.height) / 2
self._scale = scale
self._image.x = (allocation.width - dest_width) / 2
self._image.y = (allocation.height - dest_height) / 2
self._image.width = dest_width
self._image.height = dest_height
def _crop_to_widget(self):
crop = Gdk.Rectangle()
crop.x = self._image.x + self._crop.x * self._scale
crop.y = self._image.y + self._crop.y * self._scale
crop.width = self._crop.width * self._scale
crop.height = self._crop.height * self._scale
return crop
def _update_cursor(self, x_coord, y_coord):
region = self._active_region
if self._active_region == Loc.OUTSIDE:
crop = self._crop_to_widget()
region = self._find_location(crop, x_coord, y_coord)
if region == Loc.TOP_LEFT:
cursor_type = Gdk.CursorType.TOP_LEFT_CORNER
elif region == Loc.TOP:
cursor_type = Gdk.CursorType.TOP_SIDE
elif region == Loc.TOP_RIGHT:
cursor_type = Gdk.CursorType.TOP_RIGHT_CORNER
elif region == Loc.LEFT:
cursor_type = Gdk.CursorType.LEFT_SIDE
elif region == Loc.INSIDE:
cursor_type = Gdk.CursorType.FLEUR
elif region == Loc.RIGHT:
cursor_type = Gdk.CursorType.RIGHT_SIDE
elif region == Loc.BOTTOM_LEFT:
cursor_type = Gdk.CursorType.BOTTOM_LEFT_CORNER
elif region == Loc.BOTTOM:
cursor_type = Gdk.CursorType.BOTTOM_SIDE
elif region == Loc.BOTTOM_RIGHT:
cursor_type = Gdk.CursorType.BOTTOM_RIGHT_CORNER
else: # Loc.OUTSIDE
cursor_type = Gdk.CursorType.LEFT_PTR
if cursor_type is not self._current_cursor:
cursor = Gdk.Cursor.new_for_display(
Gdk.Display.get_default(),
cursor_type)
self.get_window().set_cursor(cursor)
self._current_cursor = cursor_type
@staticmethod
def _eval_radial_line(center_x, center_y, bounds_x, bounds_y, user_x):
slope_y = float(bounds_y - center_y)
slope_x = bounds_x - center_x
if slope_y == 0 or slope_x == 0:
# Prevent division by zero
return 0
decision_slope = slope_y / slope_x
decision_intercept = - float(decision_slope * bounds_x)
return int(decision_slope * user_x + decision_intercept)
def _find_location(self, rect, x_coord, y_coord):
# pylint: disable=line-too-long
location = [
[Loc.OUTSIDE, Loc.OUTSIDE, Loc.OUTSIDE, Loc.OUTSIDE, Loc.OUTSIDE],
[Loc.OUTSIDE, Loc.TOP_LEFT, Loc.TOP, Loc.TOP_RIGHT, Loc.OUTSIDE],
[Loc.OUTSIDE, Loc.LEFT, Loc.INSIDE, Loc.RIGHT, Loc.OUTSIDE],
[Loc.OUTSIDE, Loc.BOTTOM_LEFT, Loc.BOTTOM, Loc.BOTTOM_RIGHT, Loc.OUTSIDE],
[Loc.OUTSIDE, Loc.OUTSIDE, Loc.OUTSIDE, Loc.OUTSIDE, Loc.OUTSIDE],
]
# pylint: enable=line-too-long
x_range = self._find_range(x_coord, rect.x, rect.x + rect.width)
y_range = self._find_range(y_coord, rect.y, rect.y + rect.height)
return location[y_range][x_range]
@staticmethod
def _find_range(coord, min_v, max_v):
tolerance = 12
if coord < min_v - tolerance:
return Range.BELOW
if coord <= min_v + tolerance:
return Range.LOWER
if coord < max_v - tolerance:
return Range.BETWEEN
if coord <= max_v + tolerance:
return Range.UPPER
return Range.ABOVE
def _generate_color_shifted_pixbuf(self):
# pylint: disable=no-member
surface = cairo.ImageSurface(
cairo.Format.ARGB32,
self._pixbuf.get_width(),
self._pixbuf.get_height())
context = cairo.Context(surface)
# pylint: enable=no-member
Gdk.cairo_set_source_pixbuf(context, self._pixbuf, 0, 0)
context.paint()
context.rectangle(0, 0, 1, 1)
context.set_source_rgba(0, 0, 0, 0.5)
context.paint()
surface = context.get_target()
self._color_shifted_pixbuf = Gdk.pixbuf_get_from_surface(
surface, 0, 0, surface.get_width(), surface.get_height())

135
gajim/gtk/blocking.py Normal file
View file

@ -0,0 +1,135 @@
# 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 logging
from nbxmpp.errors import StanzaError
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import GLib
from gajim.common import app
from gajim.common.i18n import _
from gajim.common.helpers import to_user_string
from .util import get_builder
from .dialogs import HigDialog
log = logging.getLogger('gajim.gui.blocking_list')
class BlockingList(Gtk.ApplicationWindow):
def __init__(self, account):
Gtk.ApplicationWindow.__init__(self)
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_title(_('Blocking List for %s') % account)
self.connect_after('key-press-event', self._on_key_press)
self.account = account
self._con = app.connections[account]
self._prev_blocked_jids = set()
self._ui = get_builder('blocking_list.ui')
self.add(self._ui.blocking_grid)
self._spinner = Gtk.Spinner()
self._ui.overlay.add_overlay(self._spinner)
self._set_grid_state(False)
self._ui.connect_signals(self)
self.show_all()
self._activate_spinner()
self._con.get_module('Blocking').request_blocking_list(
callback=self._on_blocking_list_received)
def _show_error(self, error):
dialog = HigDialog(
self, Gtk.MessageType.INFO, Gtk.ButtonsType.OK,
_('Error!'),
GLib.markup_escape_text(error))
dialog.popup()
def _on_blocking_list_received(self, task):
try:
blocking_list = task.finish()
except StanzaError as error:
self._set_grid_state(False)
self._show_error(to_user_string(error))
return
self._prev_blocked_jids = set(blocking_list)
self._ui.blocking_store.clear()
for item in blocking_list:
self._ui.blocking_store.append((str(item),))
self._set_grid_state(True)
self._disable_spinner()
def _on_save_result(self, task):
try:
_successful = task.finish()
except StanzaError as error:
self._show_error(to_user_string(error))
self._disable_spinner()
self._set_grid_state(True)
return
self.destroy()
def _set_grid_state(self, state):
self._ui.blocking_grid.set_sensitive(state)
def _jid_edited(self, _renderer, path, new_text):
iter_ = self._ui.blocking_store.get_iter(path)
self._ui.blocking_store.set_value(iter_, 0, new_text)
def _on_add(self, _button):
self._ui.blocking_store.append([''])
def _on_remove(self, _button):
mod, paths = self._ui.block_view.get_selection().get_selected_rows()
for path in paths:
iter_ = mod.get_iter(path)
self._ui.blocking_store.remove(iter_)
def _on_save(self, _button):
self._activate_spinner()
self._set_grid_state(False)
blocked_jids = set()
for item in self._ui.blocking_store:
blocked_jids.add(item[0].lower())
unblock_jids = self._prev_blocked_jids - blocked_jids
block_jids = blocked_jids - self._prev_blocked_jids
self._con.get_module('Blocking').update_blocking_list(
block_jids, unblock_jids, callback=self._on_save_result)
def _activate_spinner(self):
self._spinner.show()
self._spinner.start()
def _disable_spinner(self):
self._spinner.hide()
self._spinner.stop()
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()

160
gajim/gtk/bookmarks.py Normal file
View file

@ -0,0 +1,160 @@
# 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 logging
from enum import IntEnum
from gi.repository import Gtk
from gi.repository import Gdk
from nbxmpp.structs import BookmarkData
from nbxmpp.protocol import validate_resourcepart
from nbxmpp.protocol import JID
from gajim.common import app
from gajim.common.helpers import validate_jid
from gajim.common.i18n import _
from .util import get_builder
log = logging.getLogger('gajim.gui.bookmarks')
class Column(IntEnum):
ADDRESS = 0
NAME = 1
NICK = 2
PASSWORD = 3
AUTOJOIN = 4
class Bookmarks(Gtk.ApplicationWindow):
def __init__(self, account):
Gtk.ApplicationWindow.__init__(self)
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_title(_('Bookmarks for %s') % app.get_account_label(account))
self.set_default_size(700, 500)
self.account = account
self._ui = get_builder('bookmarks.ui')
self.add(self._ui.bookmarks_grid)
con = app.connections[account]
for bookmark in con.get_module('Bookmarks').bookmarks:
self._ui.bookmarks_store.append([str(bookmark.jid),
bookmark.name,
bookmark.nick,
bookmark.password,
bookmark.autojoin])
self._ui.bookmarks_view.set_search_equal_func(self._search_func)
self._ui.connect_signals(self)
self.connect_after('key-press-event', self._on_key_press)
self.show_all()
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
def _on_selection_changed(self, selection):
_, iter_ = selection.get_selected()
self._ui.remove_button.set_sensitive(iter_ is not None)
def _on_address_edited(self, _renderer, path, new_value):
iter_ = self._ui.bookmarks_store.get_iter(path)
if not new_value:
return
try:
jid = validate_jid(new_value)
except ValueError as error:
log.warning('Invalid JID: %s (%s)', error, new_value)
return
if not jid.is_bare:
log.warning('Invalid JID: only bare JIDs allowed (%s)', jid)
return
self._ui.bookmarks_store.set_value(iter_,
Column.ADDRESS,
new_value or None)
def _on_name_edited(self, _renderer, path, new_value):
iter_ = self._ui.bookmarks_store.get_iter(path)
self._ui.bookmarks_store.set_value(iter_,
Column.NAME,
new_value or None)
def _on_nick_edited(self, _renderer, path, new_value):
iter_ = self._ui.bookmarks_store.get_iter(path)
if new_value:
try:
validate_resourcepart(new_value)
except ValueError as error:
log.warning('Invalid nickname: %s', error)
return
self._ui.bookmarks_store.set_value(iter_,
Column.NICK,
new_value or None)
def _on_password_edited(self, _renderer, path, new_value):
iter_ = self._ui.bookmarks_store.get_iter(path)
self._ui.bookmarks_store.set_value(iter_,
Column.PASSWORD,
new_value or None)
def _on_autojoin_toggled(self, _renderer, path):
iter_ = self._ui.bookmarks_store.get_iter(path)
new_value = not self._ui.bookmarks_store[iter_][Column.AUTOJOIN]
self._ui.bookmarks_store.set_value(iter_, Column.AUTOJOIN, new_value)
def _on_add_clicked(self, _button):
iter_ = self._ui.bookmarks_store.append([None, None, None, None, False])
self._ui.bookmarks_view.get_selection().select_iter(iter_)
path = self._ui.bookmarks_store.get_path(iter_)
self._ui.bookmarks_view.scroll_to_cell(path, None, False)
def _on_remove_clicked(self, _button):
mod, paths = self._ui.bookmarks_view.get_selection().get_selected_rows()
for path in paths:
iter_ = mod.get_iter(path)
self._ui.bookmarks_store.remove(iter_)
def _on_apply_clicked(self, _button):
bookmarks = []
for row in self._ui.bookmarks_store:
if not row[Column.ADDRESS]:
continue
bookmark = BookmarkData(jid=JID.from_string(row[Column.ADDRESS]),
name=row[Column.NAME],
autojoin=row[Column.AUTOJOIN],
password=row[Column.PASSWORD],
nick=row[Column.NICK])
bookmarks.append(bookmark)
con = app.connections[self.account]
con.get_module('Bookmarks').store_difference(bookmarks)
self.destroy()
@staticmethod
def _search_func(model, _column, search_text, iter_):
return search_text.lower() not in model[iter_][0].lower()

View file

@ -0,0 +1,240 @@
# 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 logging
from gi.repository import Gtk
from nbxmpp.errors import StanzaError
from nbxmpp.errors import ChangePasswordStanzaError
from gajim.common import app
from gajim.common.i18n import _
from gajim.common import passwords
from gajim.common.helpers import to_user_string
from .assistant import Assistant
from .assistant import Page
from .assistant import ErrorPage
from .assistant import SuccessPage
from .dataform import DataFormWidget
from .util import ensure_not_destroyed
log = logging.getLogger('gajim.gui.change_password')
class ChangePassword(Assistant):
def __init__(self, account):
Assistant.__init__(self)
self.account = account
self._client = app.get_client(account)
self._destroyed = False
self.add_button('apply', _('Change'), 'suggested-action',
complete=True)
self.add_button('close', _('Close'))
self.add_button('back', _('Back'))
self.add_pages({'password': EnterPassword(),
'next_stage': NextStage(),
'error': Error(),
'success': Success()})
progress = self.add_default_page('progress')
progress.set_title(_('Changing Password...'))
progress.set_text(_('Trying to change password...'))
self.connect('button-clicked', self._on_button_clicked)
self.connect('destroy', self._on_destroy)
self.show_all()
def _on_button_clicked(self, _assistant, button_name):
page = self.get_current_page()
if button_name == 'apply':
self.show_page('progress', Gtk.StackTransitionType.SLIDE_LEFT)
self._on_apply(next_stage=page == 'next_stage')
elif button_name == 'back':
self.show_page('password', Gtk.StackTransitionType.SLIDE_RIGHT)
elif button_name == 'close':
self.destroy()
def _on_apply(self, next_stage=False):
if next_stage:
form = self.get_page('next_stage').get_submit_form()
self._client.get_module('Register').change_password_with_form(
form, callback=self._on_change_password)
else:
password = self.get_page('password').get_password()
self._client.get_module('Register').change_password(
password, callback=self._on_change_password)
@ensure_not_destroyed
def _on_change_password(self, task):
try:
task.finish()
except ChangePasswordStanzaError as error:
self.get_page('next_stage').set_form(error.get_form())
self.show_page('next_stage', Gtk.StackTransitionType.SLIDE_LEFT)
except StanzaError as error:
error_text = to_user_string(error)
self.get_page('error').set_text(error_text)
self.show_page('error', Gtk.StackTransitionType.SLIDE_LEFT)
else:
password = self.get_page('password').get_password()
passwords.save_password(self.account, password)
self.show_page('success')
def _on_destroy(self, *args):
self._destroyed = True
class EnterPassword(Page):
def __init__(self):
Page.__init__(self)
self.complete = False
self.title = _('Change Password')
heading = Gtk.Label(label=_('Change Password'))
heading.get_style_context().add_class('large-header')
heading.set_max_width_chars(30)
heading.set_line_wrap(True)
heading.set_halign(Gtk.Align.CENTER)
heading.set_justify(Gtk.Justification.CENTER)
label = Gtk.Label(label=_('Please enter your new password.'))
label.set_max_width_chars(50)
label.set_line_wrap(True)
label.set_halign(Gtk.Align.CENTER)
label.set_justify(Gtk.Justification.CENTER)
label.set_margin_bottom(12)
self._password1_entry = Gtk.Entry()
self._password1_entry.set_input_purpose(Gtk.InputPurpose.PASSWORD)
self._password1_entry.set_visibility(False)
self._password1_entry.set_invisible_char('')
self._password1_entry.set_valign(Gtk.Align.END)
self._password1_entry.set_placeholder_text(
_('Enter new password...'))
self._password1_entry.connect('changed', self._on_changed)
self._password2_entry = Gtk.Entry()
self._password2_entry.set_input_purpose(Gtk.InputPurpose.PASSWORD)
self._password2_entry.set_visibility(False)
self._password2_entry.set_invisible_char('')
self._password2_entry.set_activates_default(True)
self._password2_entry.set_valign(Gtk.Align.START)
self._password2_entry.set_placeholder_text(
_('Confirm new password...'))
self._password2_entry.connect('changed', self._on_changed)
self.pack_start(heading, False, True, 0)
self.pack_start(label, False, True, 0)
self.pack_start(self._password1_entry, True, True, 0)
self.pack_start(self._password2_entry, True, True, 0)
self._show_icon(False)
self.show_all()
def _show_icon(self, show):
if show:
self._password2_entry.set_icon_from_icon_name(
Gtk.EntryIconPosition.SECONDARY, 'dialog-warning-symbolic')
self._password2_entry.set_icon_tooltip_text(
Gtk.EntryIconPosition.SECONDARY, _('Passwords do not match'))
else:
self._password2_entry.set_icon_from_icon_name(
Gtk.EntryIconPosition.SECONDARY, None)
def _on_changed(self, _entry):
password1 = self._password1_entry.get_text()
if not password1:
self._show_icon(True)
self._set_complete(False)
return
password2 = self._password2_entry.get_text()
if password1 != password2:
self._show_icon(True)
self._set_complete(False)
return
self._show_icon(False)
self._set_complete(True)
def _set_complete(self, state):
self.complete = state
self.update_page_complete()
def get_password(self):
return self._password1_entry.get_text()
def get_visible_buttons(self):
return ['apply']
class NextStage(Page):
def __init__(self):
Page.__init__(self)
self.set_valign(Gtk.Align.FILL)
self.complete = False
self.title = _('Change Password')
self._current_form = None
self.show_all()
def _on_is_valid(self, _widget, is_valid):
self.complete = is_valid
self.update_page_complete()
def set_form(self, form):
if self._current_form is not None:
self.remove(self._current_form)
self._current_form.destroy()
self._current_form = DataFormWidget(form)
self._current_form.connect('is-valid', self._on_is_valid)
self._current_form.validate()
self.pack_start(self._current_form, True, True, 0)
self._current_form.show_all()
def get_submit_form(self):
return self._current_form.get_submit_form()
def get_visible_buttons(self):
return ['apply']
class Error(ErrorPage):
def __init__(self):
ErrorPage.__init__(self)
self.set_title(_('Password Change Failed'))
self.set_heading(_('Password Change Failed'))
self.set_text(
_('An error occurred while trying to change your password.'))
def get_visible_buttons(self):
return ['back']
class Success(SuccessPage):
def __init__(self):
SuccessPage.__init__(self)
self.set_title(_('Password Changed'))
self.set_heading(_('Password Changed'))
self.set_text(_('Your password has successfully been changed.'))
def get_visible_buttons(self):
return ['close']

134
gajim/gtk/const.py Normal file
View file

@ -0,0 +1,134 @@
# 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/>.
# Constants for the gtk module
from collections import namedtuple
from enum import Enum
from enum import IntEnum
from enum import unique
Filter = namedtuple('Filter', 'name pattern default')
Setting = namedtuple('Setting', 'kind label type value name callback data desc '
'bind inverted enabled_func props')
Setting.__new__.__defaults__ = (None,) * len(Setting._fields) # type: ignore
@unique
class Theme(IntEnum):
NOT_DARK = 0
DARK = 1
SYSTEM = 2
class GajimIconSet(Enum):
BRUNO = 'bruno'
DCRAVEN = 'dcraven'
GNOME = 'gnome'
GOOJIM = 'goojim'
GOTA = 'gota'
JABBERBULB = 'jabberbulb'
SUN = 'sun'
WROOP = 'wroop'
@unique
class SettingKind(IntEnum):
ENTRY = 0
SWITCH = 1
SPIN = 2
ACTION = 3
LOGIN = 4
DIALOG = 5
CALLBACK = 6
HOSTNAME = 8
PRIORITY = 9
FILECHOOSER = 10
CHANGEPASSWORD = 11
COMBO = 12
COLOR = 13
POPOVER = 14
AUTO_AWAY = 15
AUTO_EXTENDED_AWAY = 16
USE_STUN_SERVER = 17
NOTIFICATIONS = 18
@unique
class SettingType(IntEnum):
CONFIG = 0
ACCOUNT_CONFIG = 1
CONTACT = 2
GROUP_CHAT = 3
VALUE = 4
ACTION = 5
DIALOG = 6
class ControlType(Enum):
CHAT = 'chat'
GROUPCHAT = 'gc'
PRIVATECHAT = 'pm'
@property
def is_chat(self):
return self == ControlType.CHAT
@property
def is_groupchat(self):
return self == ControlType.GROUPCHAT
@property
def is_privatechat(self):
return self == ControlType.PRIVATECHAT
def __str__(self):
return self.value
WINDOW_MODULES = {
'AccountsWindow': 'gajim.gui.accounts',
'HistorySyncAssistant': 'gajim.gui.history_sync',
'ServerInfo': 'gajim.gui.server_info',
'MamPreferences': 'gajim.gui.mam_preferences',
'Preferences': 'gajim.gui.preferences',
'CreateGroupchatWindow': 'gajim.gui.groupchat_creation',
'StartChatDialog': 'gajim.gui.start_chat',
'AddNewContactWindow': 'gajim.gui.add_contact',
'SingleMessageWindow': 'gajim.gui.single_message',
'Bookmarks': 'gajim.gui.bookmarks',
'AccountWizard': 'gajim.gui.account_wizard',
'HistoryWindow': 'gajim.gui.history',
'ManageProxies': 'gajim.gui.proxies',
'ManageSounds': 'gajim.gui.manage_sounds',
'ServiceDiscoveryWindow': 'gajim.gui.discovery',
'BlockingList': 'gajim.gui.blocking',
'XMLConsoleWindow': 'gajim.gui.xml_console',
'GroupchatJoin': 'gajim.gui.groupchat_join',
'PEPConfig': 'gajim.gui.pep_config',
'HistoryManager': 'gajim.history_manager',
'GroupchatConfig': 'gajim.gui.groupchat_config',
'ProfileWindow': 'gajim.gui.profile',
'SSLErrorDialog': 'gajim.gui.ssl_error_dialog',
'Themes': 'gajim.gui.themes',
'AdvancedConfig': 'gajim.gui.advanced_config',
'CertificateDialog': 'gajim.gui.dialogs',
'SubscriptionRequest': 'gajim.gui.subscription_request',
'RemoveAccount': 'gajim.gui.remove_account',
'ChangePassword': 'gajim.gui.change_password',
'PluginsWindow': 'gajim.plugins.gui',
'Features': 'gajim.gui.features',
'StatusChange': 'gajim.gui.status_change',
'GroupChatInvitation': 'gajim.gui.groupchat_invitation',
}

548
gajim/gtk/css_config.py Normal file
View file

@ -0,0 +1,548 @@
# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com>
#
# 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, either version 3 of the License, or
# (at your option) any later version.
#
# 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 math
import logging
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import Pango
import css_parser
from gajim.common import app
from gajim.common import configpaths
from gajim.common.const import StyleAttr, CSSPriority
from .const import Theme
log = logging.getLogger('gajim.gui.css')
settings = Gtk.Settings.get_default()
class CSSConfig():
def __init__(self):
"""
CSSConfig handles loading and storing of all relevant Gajim style files
The order in which CSSConfig loads the styles
1. gajim.css
2. gajim-dark.css (Only if gtk-application-prefer-dark-theme = True)
3. default.css or default-dark.css (from gajim/data/style)
4. user-theme.css (from ~/.config/Gajim/theme)
# gajim.css:
This is the main style and the application default
# gajim-dark.css
Has only entries which we want to override in gajim.css
# default.css or default-dark.css
Has all the values that are changeable via UI (see themes.py).
Depending on `gtk-application-prefer-dark-theme` either default.css or
default-dark.css gets loaded
# user-theme.css
These are the themes the Themes Dialog stores. Because they are
loaded at last they overwrite everything else. Users should add custom
css here."""
# Delete empty rules
css_parser.ser.prefs.keepEmptyRules = False
# Holds the currently selected theme in the Theme Editor
self._pre_css = None
self._pre_css_path = None
# Holds the default theme, its used if values are not found
# in the selected theme
self._default_css = None
self._default_css_path = None
# Holds the currently selected theme
self._css = None
self._css_path = None
# User Theme CSS Provider
self._provider = Gtk.CssProvider()
# Used for dynamic classes like account colors
self._dynamic_provider = Gtk.CssProvider()
self._dynamic_dict = {}
self.refresh()
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(),
self._dynamic_provider,
CSSPriority.APPLICATION)
# Cache of recently requested values
self._cache = {}
# Holds all currently available themes
self.themes = []
self.set_dark_theme()
self._load_css()
self._gather_available_themes()
self._load_default()
self._load_selected()
self._activate_theme()
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(),
self._provider,
CSSPriority.USER_THEME)
@property
def prefer_dark(self):
setting = app.settings.get('dark_theme')
if setting == Theme.SYSTEM:
if settings is None:
return False
return settings.get_property('gtk-application-prefer-dark-theme')
return setting == Theme.DARK
def set_dark_theme(self, value=None):
if value is None:
value = app.settings.get('dark_theme')
else:
app.settings.set('dark_theme', value)
if settings is None:
return
if value == Theme.SYSTEM:
settings.reset_property('gtk-application-prefer-dark-theme')
return
settings.set_property('gtk-application-prefer-dark-theme', bool(value))
self._load_css()
def _load_css(self):
self._load_css_from_file('gajim.css', CSSPriority.APPLICATION)
if self.prefer_dark:
self._load_css_from_file('gajim-dark.css',
CSSPriority.APPLICATION_DARK)
self._load_css_from_file('default.css', CSSPriority.DEFAULT_THEME)
if self.prefer_dark:
self._load_css_from_file('default-dark.css',
CSSPriority.DEFAULT_THEME_DARK)
def _load_css_from_file(self, filename, priority):
path = configpaths.get('STYLE') / filename
try:
with open(path, "r") as file_:
css = file_.read()
except Exception as exc:
log.error('Error loading css: %s', exc)
return
self._activate_css(css, priority)
def _activate_css(self, css, priority):
try:
provider = Gtk.CssProvider()
provider.load_from_data(bytes(css.encode('utf-8')))
Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(),
provider,
priority)
self._load_selected()
self._activate_theme()
except Exception:
log.exception('Error loading application css')
@staticmethod
def _pango_to_css_weight(number):
# Pango allows for weight values between 100 and 1000
# CSS allows only full hundred numbers like 100, 200 ..
number = int(number)
if number < 100:
return 100
if number > 900:
return 900
return int(math.ceil(number / 100.0)) * 100
def _gather_available_themes(self):
files = configpaths.get('MY_THEME').iterdir()
self.themes = [file.stem for file in files if file.suffix == '.css']
if 'default' in self.themes:
# Ignore user created themes that are named 'default'
self.themes.remove('default')
def get_theme_path(self, theme, user=True):
if theme == 'default' and self.prefer_dark:
theme = 'default-dark'
if user:
return configpaths.get('MY_THEME') / f'{theme}.css'
return configpaths.get('STYLE') / f'{theme}.css'
def _determine_theme_path(self):
# Gets the path of the currently active theme.
# If it does not exist, it falls back to the default theme
theme = app.settings.get('roster_theme')
if theme == 'default':
return self.get_theme_path(theme, user=False)
theme_path = self.get_theme_path(theme)
if not theme or not theme_path.exists():
log.warning('Theme %s not found, fallback to default', theme)
app.settings.set('roster_theme', 'default')
log.info('Use Theme: default')
return self.get_theme_path('default', user=False)
log.info('Use Theme: %s', theme)
return theme_path
def _load_selected(self, new_path=None):
if new_path is None:
self._css_path = self._determine_theme_path()
else:
self._css_path = new_path
self._css = css_parser.parseFile(self._css_path)
def _load_default(self):
self._default_css_path = self.get_theme_path('default', user=False)
self._default_css = css_parser.parseFile(self._default_css_path)
def _load_pre(self, theme):
log.info('Preload theme %s', theme)
self._pre_css_path = self.get_theme_path(theme)
self._pre_css = css_parser.parseFile(self._pre_css_path)
def _write(self, pre):
path = self._css_path
css = self._css
if pre:
path = self._pre_css_path
css = self._pre_css
with open(path, 'w', encoding='utf-8') as file:
file.write(css.cssText.decode('utf-8'))
active = self._pre_css_path == self._css_path
if not pre or active:
self._load_selected()
self._activate_theme()
def set_value(self, selector, attr, value, pre=False):
if attr == StyleAttr.FONT:
# forward to set_font() for convenience
return self.set_font(selector, value, pre)
if isinstance(attr, StyleAttr):
attr = attr.value
css = self._css
if pre:
css = self._pre_css
for rule in css:
if rule.type != rule.STYLE_RULE:
continue
if rule.selectorText == selector:
log.info('Set %s %s %s', selector, attr, value)
rule.style[attr] = value
if not pre:
self._add_to_cache(selector, attr, value)
self._write(pre)
return None
# The rule was not found, so we add it to this theme
log.info('Set %s %s %s', selector, attr, value)
rule = css_parser.css.CSSStyleRule(selectorText=selector)
rule.style[attr] = value
css.add(rule)
self._write(pre)
return None
def set_font(self, selector, description, pre=False):
css = self._css
if pre:
css = self._pre_css
family, size, style, weight = self._get_attr_from_description(
description)
for rule in css:
if rule.type != rule.STYLE_RULE:
continue
if rule.selectorText == selector:
log.info('Set Font for: %s %s %s %s %s',
selector, family, size, style, weight)
rule.style['font-family'] = family
rule.style['font-style'] = style
rule.style['font-size'] = '%spt' % size
rule.style['font-weight'] = weight
if not pre:
self._add_to_cache(
selector, 'fontdescription', description)
self._write(pre)
return
# The rule was not found, so we add it to this theme
log.info('Set Font for: %s %s %s %s %s',
selector, family, size, style, weight)
rule = css_parser.css.CSSStyleRule(selectorText=selector)
rule.style['font-family'] = family
rule.style['font-style'] = style
rule.style['font-size'] = '%spt' % size
rule.style['font-weight'] = weight
css.add(rule)
self._write(pre)
def _get_attr_from_description(self, description):
size = description.get_size() / Pango.SCALE
style = self._get_string_from_pango_style(description.get_style())
weight = self._pango_to_css_weight(int(description.get_weight()))
family = description.get_family()
return family, size, style, weight
def _get_default_rule(self, selector, _attr):
for rule in self._default_css:
if rule.type != rule.STYLE_RULE:
continue
if rule.selectorText == selector:
log.info('Get Default Rule %s', selector)
return rule
return None
def get_font(self, selector, pre=False):
if pre:
css = self._pre_css
else:
css = self._css
try:
return self._get_from_cache(selector, 'fontdescription')
except KeyError:
pass
if css is None:
return None
for rule in css:
if rule.type != rule.STYLE_RULE:
continue
if rule.selectorText == selector:
log.info('Get Font for: %s', selector)
style = rule.style.getPropertyValue('font-style') or None
size = rule.style.getPropertyValue('font-size') or None
weight = rule.style.getPropertyValue('font-weight') or None
family = rule.style.getPropertyValue('font-family') or None
desc = self._get_description_from_css(
family, size, style, weight)
if not pre:
self._add_to_cache(selector, 'fontdescription', desc)
return desc
self._add_to_cache(selector, 'fontdescription', None)
return None
def _get_description_from_css(self, family, size, style, weight):
if family is None:
return None
desc = Pango.FontDescription()
desc.set_family(family)
if weight is not None:
desc.set_weight(Pango.Weight(int(weight)))
if style is not None:
desc.set_style(self._get_pango_style_from_string(style))
if size is not None:
desc.set_size(int(size[:-2]) * Pango.SCALE)
return desc
@staticmethod
def _get_pango_style_from_string(style: str) -> int:
if style == 'normal':
return Pango.Style(0)
if style == 'oblique':
return Pango.Style(1)
# Pango.Style.ITALIC:
return Pango.Style(2)
@staticmethod
def _get_string_from_pango_style(style: Pango.Style) -> str:
if style == Pango.Style.NORMAL:
return 'normal'
if style == Pango.Style.ITALIC:
return 'italic'
# Pango.Style.OBLIQUE:
return 'oblique'
def get_value(self, selector, attr, pre=False):
if attr == StyleAttr.FONT:
# forward to get_font() for convenience
return self.get_font(selector, pre)
if isinstance(attr, StyleAttr):
attr = attr.value
if pre:
css = self._pre_css
else:
css = self._css
try:
return self._get_from_cache(selector, attr)
except KeyError:
pass
if css is not None:
for rule in css:
if rule.type != rule.STYLE_RULE:
continue
if rule.selectorText == selector:
log.info('Get %s %s: %s',
selector, attr, rule.style[attr] or None)
value = rule.style.getPropertyValue(attr) or None
if not pre:
self._add_to_cache(selector, attr, value)
return value
# We didnt find the selector in the selected theme
# search in default theme
if not pre:
rule = self._get_default_rule(selector, attr)
value = rule if rule is None else rule.style[attr]
self._add_to_cache(selector, attr, value)
return value
return None
def remove_value(self, selector, attr, pre=False):
if attr == StyleAttr.FONT:
# forward to remove_font() for convenience
return self.remove_font(selector, pre)
if isinstance(attr, StyleAttr):
attr = attr.value
css = self._css
if pre:
css = self._pre_css
for rule in css:
if rule.type != rule.STYLE_RULE:
continue
if rule.selectorText == selector:
log.info('Remove %s %s', selector, attr)
rule.style.removeProperty(attr)
break
self._write(pre)
return None
def remove_font(self, selector, pre=False):
css = self._css
if pre:
css = self._pre_css
for rule in css:
if rule.type != rule.STYLE_RULE:
continue
if rule.selectorText == selector:
log.info('Remove Font from: %s', selector)
rule.style.removeProperty('font-family')
rule.style.removeProperty('font-size')
rule.style.removeProperty('font-style')
rule.style.removeProperty('font-weight')
break
self._write(pre)
def change_theme(self, theme):
user = not theme == 'default'
theme_path = self.get_theme_path(theme, user=user)
if not theme_path.exists():
log.error('Change Theme: Theme %s does not exist', theme_path)
return False
self._load_selected(theme_path)
self._activate_theme()
app.settings.set('roster_theme', theme)
log.info('Change Theme: Successful switched to %s', theme)
return True
def change_preload_theme(self, theme):
theme_path = self.get_theme_path(theme)
if not theme_path.exists():
log.error('Change Preload Theme: Theme %s does not exist',
theme_path)
return False
self._load_pre(theme)
log.info('Successful switched to %s', theme)
return True
def rename_theme(self, old_theme, new_theme):
if old_theme not in self.themes:
log.error('Rename Theme: Old theme %s not found', old_theme)
return False
if new_theme in self.themes:
log.error('Rename Theme: New theme %s exists already', new_theme)
return False
old_theme_path = self.get_theme_path(old_theme)
new_theme_path = self.get_theme_path(new_theme)
old_theme_path.rename(new_theme_path)
self.themes.remove(old_theme)
self.themes.append(new_theme)
self._load_pre(new_theme)
log.info('Rename Theme: Successful renamed theme from %s to %s',
old_theme, new_theme)
return True
def _activate_theme(self):
log.info('Activate theme')
self._invalidate_cache()
self._provider.load_from_data(self._css.cssText)
def add_new_theme(self, theme):
theme_path = self.get_theme_path(theme)
if theme_path.exists():
log.error('Add Theme: %s exists already', theme_path)
return False
with open(theme_path, 'w', encoding='utf8'):
pass
self.themes.append(theme)
log.info('Add Theme: Successful added theme %s', theme)
return True
def remove_theme(self, theme):
theme_path = self.get_theme_path(theme)
if theme_path.exists():
theme_path.unlink()
self.themes.remove(theme)
log.info('Remove Theme: Successful removed theme %s', theme)
def _add_to_cache(self, selector, attr, value):
self._cache[selector + attr] = value
def _get_from_cache(self, selector, attr):
return self._cache[selector + attr]
def _invalidate_cache(self):
self._cache = {}
def refresh(self):
css = ''
accounts = app.settings.get_accounts()
for index, account in enumerate(accounts):
color = app.settings.get_account_setting(account, 'account_color')
css_class = 'gajim_class_%s' % index
css += '.%s { background-color: %s }\n' % (css_class, color)
self._dynamic_dict[account] = css_class
self._dynamic_provider.load_from_data(css.encode())
def get_dynamic_class(self, name):
return self._dynamic_dict[name]

715
gajim/gtk/dataform.py Normal file
View file

@ -0,0 +1,715 @@
# 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/>.
from gi.repository import Gtk
from gi.repository import GLib
from gi.repository import GObject
from gi.repository import Pango
from nbxmpp.modules.dataforms import extend_form
from gajim.gtkgui_helpers import scale_pixbuf_from_data
from gajim.common import app
from gajim.common.i18n import _
from gajim.common.helpers import open_uri
from .util import MultiLineLabel
from .util import MaxWidthComboBoxText
from .util import make_href_markup
# Options
# no-scrolling No scrollbars
# form-width Minimal form width
# right-align Right align labels
# hide-fallback-fields Hide fallback fields in IBR form (ejabberd)
# left-width Width for labels
# read-only Read only mode for fields
# entry-activates-default Form entry activates the default widget
class DataFormWidget(Gtk.ScrolledWindow):
__gsignals__ = {'is-valid': (GObject.SignalFlags.RUN_LAST, None, (bool,))}
def __init__(self, form_node, options=None):
Gtk.ScrolledWindow.__init__(self)
self.set_hexpand(True)
self.set_vexpand(True)
self.get_style_context().add_class('data-form-widget')
self.set_overlay_scrolling(False)
if options is None:
options = {}
if options.get('no-scrolling', False):
self.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER)
self._form_node = form_node
self._form_grid = FormGrid(form_node, options)
self.add(self._form_grid)
@property
def title(self):
return self._form_grid.title
@property
def instructions(self):
return self._form_grid.instructions
def validate(self):
return self._form_grid.validate(True)
def get_form(self):
return self._form_node
def get_submit_form(self):
self._form_node.type_ = 'submit'
return self._form_node
def focus_first_entry(self):
for row in range(0, self._form_grid.row_count):
widget = self._form_grid.get_child_at(1, row)
if isinstance(widget, Gtk.Entry):
widget.grab_focus_without_selecting()
break
class FormGrid(Gtk.Grid):
def __init__(self, form_node, options):
Gtk.Grid.__init__(self)
self.set_column_spacing(12)
self.set_row_spacing(12)
self.set_halign(Gtk.Align.CENTER)
self.row_count = 0
self.rows = []
form_width = options.get('form-width', 435)
self.set_size_request(form_width, -1)
self._data_form = form_node
self.title = None
self.instructions = None
self._fields = {
'boolean': BooleanField,
'fixed': FixedField,
'list-single': ListSingleField,
'list-multi': ListMultiField,
'jid-single': JidSingleField,
'jid-multi': JidMultiField,
'text-single': TextSingleField,
'text-private': TextPrivateField,
'text-multi': TextMultiField
}
self._add_row(SizeAdjustment(options))
if form_node.title is not None:
self.title = form_node.title
self._add_row(Title(form_node.title))
if form_node.instructions:
self.instructions = form_node.instructions
self._add_row(Instructions(form_node.instructions))
self._analyse_fields(form_node, options)
self._parse_form(form_node, options)
def _add_row(self, field):
field.add(self, self.row_count)
self.row_count += 1
self.rows.append(field)
@staticmethod
def _analyse_fields(form_node, options):
if 'right-align' in options:
# Dont overwrite option
return
label_lengths = set([0])
for field in form_node.iter_fields():
if field.type_ == 'hidden':
continue
if field.label is None:
continue
label_lengths.add(len(field.label))
options['right-align'] = max(label_lengths) < 30
def _parse_form(self, form_node, options):
for field in form_node.iter_fields():
if field.type_ == 'hidden':
continue
if options.get('hide-fallback-fields'):
if field.var is not None and 'fallback' in field.var:
continue
if field.media:
if not self._add_media_field(field, options):
# We dont understand this media element, ignore it
continue
widget = self._fields[field.type_]
self._add_row(widget(field, self, options))
def _add_media_field(self, field, options):
if not field.type_ in ('text-single', 'text-private', 'text-multi'):
return False
for uri in field.media.uris:
if not uri.type_.startswith('image/'):
continue
if not uri.uri_data.startswith('cid'):
continue
self._add_row(ImageMediaField(uri, self, options))
return True
return False
def validate(self, is_valid):
value = self._data_form.is_valid() if is_valid else False
self.get_parent().get_parent().emit('is-valid', value)
class SizeAdjustment:
def __init__(self, options):
self._left_box = Gtk.Box()
self._right_box = Gtk.Box()
left_width = options.get('left-width', 100)
self._left_box.set_size_request(left_width, -1)
self._right_box.set_hexpand(True)
def add(self, form_grid, row_number):
form_grid.attach(self._left_box, 0, row_number, 1, 1)
form_grid.attach_next_to(self._right_box,
self._left_box,
Gtk.PositionType.RIGHT, 1, 1)
class Title:
def __init__(self, title):
self._label = Gtk.Label(label=title)
self._label.set_line_wrap(True)
self._label.set_line_wrap_mode(Pango.WrapMode.WORD)
self._label.set_justify(Gtk.Justification.CENTER)
self._label.get_style_context().add_class('data-form-title')
def add(self, form_grid, row_number):
form_grid.attach(self._label, 0, row_number, 2, 1)
class Instructions:
def __init__(self, instructions):
self._label = Gtk.Label()
self._label.set_markup(make_href_markup(instructions))
self._label.set_line_wrap(True)
self._label.set_line_wrap_mode(Pango.WrapMode.WORD)
self._label.set_justify(Gtk.Justification.CENTER)
def add(self, form_grid, row_number):
form_grid.attach(self._label, 0, row_number, 2, 1)
class Field:
def __init__(self, field, form_grid, options):
self._widget = None
self._field = field
self._form_grid = form_grid
self._validate_source_id = None
self._read_only = options.get('read-only', False)
self._label = Gtk.Label(label=field.label)
self._label.set_single_line_mode(False)
self._label.set_line_wrap(True)
self._label.set_line_wrap_mode(Pango.WrapMode.WORD)
self._label.set_width_chars(15)
self._label.set_xalign(bool(options.get('right-align')))
self._label.set_tooltip_text(field.description)
self._warning_image = Gtk.Image.new_from_icon_name(
'dialog-warning-symbolic', Gtk.IconSize.MENU)
self._warning_image.get_style_context().add_class('warning-color')
self._warning_image.set_no_show_all(True)
self._warning_image.set_valign(Gtk.Align.CENTER)
self._warning_image.set_tooltip_text(_('Required'))
self._warning_box = Gtk.Box()
self._warning_box.set_size_request(16, -1)
self._warning_box.add(self._warning_image)
@property
def read_only(self):
return self._read_only
def add(self, form_grid, row_number):
form_grid.attach(self._label, 0, row_number, 1, 1)
form_grid.attach_next_to(self._widget,
self._label,
Gtk.PositionType.RIGHT, 1, 1)
if self._field.type_ in ('jid-single',
'jid-multi',
'text-single',
'text-private',
'text-multi',
'list-multi'):
form_grid.attach_next_to(self._warning_box,
self._widget,
Gtk.PositionType.RIGHT, 1, 1)
is_valid, error = self._field.is_valid()
self._set_warning(is_valid, error)
def _set_warning(self, is_valid, error):
if not self._field.required and not is_valid and not error:
# If its not valid and no error is given, its the initial call
# to show all icons on required fields.
return
style = self._warning_image.get_style_context()
if error:
style.remove_class('warning-color')
style.add_class('error-color')
else:
error = _('Required')
style.remove_class('error-color')
style.add_class('warning-color')
self._warning_image.set_tooltip_text(str(error))
self._warning_image.set_visible(not is_valid)
def _validate(self):
self._form_grid.validate(False)
if self._validate_source_id is not None:
GLib.source_remove(self._validate_source_id)
def _start_validation():
is_valid, error = self._field.is_valid()
self._set_warning(is_valid, error)
self._form_grid.validate(is_valid)
self._validate_source_id = None
self._validate_source_id = GLib.timeout_add(500, _start_validation)
class BooleanField(Field):
def __init__(self, field, form_grid, options):
Field.__init__(self, field, form_grid, options)
if self.read_only:
label = _('Yes') if field.value else _('No')
self._widget = Gtk.Label(label=label)
self._widget.set_xalign(0)
else:
self._widget = Gtk.CheckButton()
self._widget.set_active(field.value)
self._widget.connect('toggled', self._toggled)
self._widget.set_valign(Gtk.Align.CENTER)
def _toggled(self, _widget):
self._field.value = self._widget.get_active()
class FixedField(Field):
def __init__(self, field, form_grid, options):
Field.__init__(self, field, form_grid, options)
self._label.set_markup(make_href_markup(field.value))
# If the value is more than 40 chars it proabably isnt
# meant as a section header
if len(field.value) < 40:
self._label.get_style_context().add_class('field-fixed')
else:
self._label.set_xalign(0.5)
def add(self, form_grid, row_number):
if len(self._field.value) < 40:
form_grid.attach(self._label, 0, row_number, 1, 1)
else:
form_grid.attach(self._label, 0, row_number, 2, 1)
class ListSingleField(Field):
def __init__(self, field, form_grid, options):
Field.__init__(self, field, form_grid, options)
self._widget = MaxWidthComboBoxText()
self._widget.set_valign(Gtk.Align.CENTER)
for value, label in field.iter_options():
if not label:
label = value
self._widget.append(value, label)
self._widget.set_active_id(field.value)
self._widget.connect('changed', self._changed)
def _changed(self, widget):
self._field.value = widget.get_active_id()
self._validate()
class ListMultiField(Field):
def __init__(self, field, form_grid, options):
Field.__init__(self, field, form_grid, options)
self._label.set_valign(Gtk.Align.START)
self._treeview = ListMutliTreeView(field, self)
self._widget = Gtk.ScrolledWindow()
self._widget.set_propagate_natural_height(True)
self._widget.set_min_content_height(100)
self._widget.set_max_content_height(300)
self._widget.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self._widget.add(self._treeview)
def validate(self):
self._validate()
class ListMutliTreeView(Gtk.TreeView):
def __init__(self, field, multi_field):
Gtk.TreeView.__init__(self)
self._field = field
self._multi_field = multi_field
# label, value, tooltip, toggled
self._store = Gtk.ListStore(str, str, str, bool)
col = Gtk.TreeViewColumn()
cell = Gtk.CellRendererText()
cell.set_property('ellipsize', Pango.EllipsizeMode.END)
cell.set_property('width-chars', 40)
col.pack_start(cell, True)
col.set_attributes(cell, text=0)
self.append_column(col)
col = Gtk.TreeViewColumn()
cell = Gtk.CellRendererToggle()
cell.set_activatable(True)
cell.set_property('xalign', 1)
cell.set_property('xpad', 10)
cell.connect('toggled', self._toggled)
col.pack_start(cell, True)
col.set_attributes(cell, active=3)
self.append_column(col)
self.set_headers_visible(False)
for option in field.options:
label, value = option
self._store.append(
[label, value, label, value in field.values])
labels_over_max_width = map(lambda x: len(x) > 40,
[option[0] for option in field.options])
if any(labels_over_max_width):
self.set_tooltip_column(2)
self.set_model(self._store)
def _toggled(self, _renderer, path):
iter_ = self._store.get_iter(path)
current_value = self._store[iter_][3]
self._store.set_value(iter_, 3, not current_value)
self._set_values()
self._multi_field.validate()
def _set_values(self):
values = []
for row in self.get_model():
if not row[3]:
continue
values.append(row[2])
self._field.values = values
class JidMultiField(Field):
def __init__(self, field, form_grid, options):
Field.__init__(self, field, form_grid, options)
self._label.set_valign(Gtk.Align.START)
self._treeview = JidMutliTreeView(field, self)
self._add_button = Gtk.ToolButton(icon_name='list-add-symbolic')
self._add_button.connect('clicked', self._add_clicked)
self._remove_button = Gtk.ToolButton(icon_name='list-remove-symbolic')
self._remove_button.connect('clicked', self._remove_clicked)
self._toolbar = Gtk.Toolbar()
self._toolbar.set_icon_size(Gtk.IconSize.MENU)
self._toolbar.set_style(Gtk.ToolbarStyle.ICONS)
self._toolbar.get_style_context().add_class('inline-toolbar')
self._toolbar.add(self._add_button)
self._toolbar.add(self._remove_button)
self._widget = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self._scrolled_window = Gtk.ScrolledWindow()
self._scrolled_window.set_propagate_natural_height(True)
self._scrolled_window.set_min_content_height(100)
self._scrolled_window.set_max_content_height(300)
self._scrolled_window.add(self._treeview)
self._widget.pack_start(self._scrolled_window, True, True, 0)
self._widget.pack_end(self._toolbar, False, False, 0)
def _add_clicked(self, _widget):
self._treeview.get_model().append([''])
def _remove_clicked(self, _widget):
mod, paths = self._treeview.get_selection().get_selected_rows()
for path in paths:
iter_ = mod.get_iter(path)
self._treeview.get_model().remove(iter_)
jids = []
for row in self._treeview.get_model():
if not row[0]:
continue
jids.append(row[0])
self._field.values = jids
self._validate()
def validate(self):
self._validate()
class JidMutliTreeView(Gtk.TreeView):
def __init__(self, field, multi_field):
Gtk.TreeView.__init__(self)
self._field = field
self._multi_field = multi_field
self._store = Gtk.ListStore(str)
col = Gtk.TreeViewColumn()
cell = Gtk.CellRendererText()
cell.set_property('editable', True)
cell.set_property('placeholder-text', 'user@example.org')
cell.connect('edited', self._jid_edited)
col.pack_start(cell, True)
col.set_attributes(cell, text=0)
self.append_column(col)
self.set_headers_visible(False)
for value in field.values:
self._store.append([value])
self.set_model(self._store)
def _jid_edited(self, _renderer, path, new_text):
iter_ = self._store.get_iter(path)
self._store.set_value(iter_, 0, new_text)
self._set_values()
self._multi_field.validate()
def _set_values(self):
jids = []
for row in self._store:
if not row[0]:
continue
jids.append(row[0])
self._field.values = jids
class TextSingleField(Field):
def __init__(self, field, form_grid, options):
Field.__init__(self, field, form_grid, options)
if self.read_only:
self._widget = Gtk.Label(label=field.value)
self._widget.set_xalign(0)
self._widget.set_selectable(True)
else:
self._widget = Gtk.Entry()
self._widget.set_text(field.value)
self._widget.connect('changed', self._changed)
if options.get('entry-activates-default', False):
self._widget.set_activates_default(True)
self._widget.set_valign(Gtk.Align.CENTER)
def _changed(self, _widget):
self._field.value = self._widget.get_text()
self._validate()
class TextPrivateField(TextSingleField):
def __init__(self, field, form_grid, options):
TextSingleField.__init__(self, field, form_grid, options)
self._widget.set_input_purpose(Gtk.InputPurpose.PASSWORD)
self._widget.set_visibility(False)
class JidSingleField(TextSingleField):
def __init__(self, field, form_grid, options):
TextSingleField.__init__(self, field, form_grid, options)
class TextMultiField(Field):
def __init__(self, field, form_grid, options):
Field.__init__(self, field, form_grid, options)
self._label.set_valign(Gtk.Align.START)
self._widget = Gtk.ScrolledWindow()
self._widget.set_policy(Gtk.PolicyType.NEVER,
Gtk.PolicyType.AUTOMATIC)
self._widget.set_propagate_natural_height(True)
self._widget.set_min_content_height(100)
self._widget.set_max_content_height(300)
if self.read_only:
self._textview = MultiLineLabel(label=field.value)
self._textview.set_selectable(True)
self._textview.set_xalign(0)
self._textview.set_yalign(0)
else:
self._textview = Gtk.TextView()
self._textview.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
self._textview.get_buffer().set_text(field.value)
self._textview.get_buffer().connect('changed', self._changed)
self._widget.add(self._textview)
def _changed(self, widget):
self._field.value = widget.get_text(*widget.get_bounds(), False)
self._validate()
class ImageMediaField():
def __init__(self, uri, form_grid, _options):
self._uri = uri
self._form_grid = form_grid
filename = uri.uri_data.split(':')[1].split('@')[0]
data = app.bob_cache.get(filename)
if data is None:
self._image = Gtk.Image()
return
pixbuf = scale_pixbuf_from_data(data, 170)
self._image = Gtk.Image.new_from_pixbuf(pixbuf)
self._image.set_halign(Gtk.Align.CENTER)
self._image.get_style_context().add_class('preview-image')
def add(self, form_grid, row_number):
form_grid.attach(self._image, 1, row_number, 1, 1)
class FakeDataFormWidget(Gtk.ScrolledWindow):
def __init__(self, fields):
Gtk.ScrolledWindow.__init__(self)
self.set_hexpand(True)
self.set_vexpand(True)
self.set_overlay_scrolling(False)
self.get_style_context().add_class('data-form-widget')
self._grid = Gtk.Grid()
self._grid.set_column_spacing(12)
self._grid.set_row_spacing(12)
self._grid.set_halign(Gtk.Align.CENTER)
self._fields = fields
self._entries = {}
self._row_count = 0
instructions = fields.pop('instructions', None)
if instructions is not None:
label = Gtk.Label(label=instructions)
label.set_justify(Gtk.Justification.CENTER)
label.set_max_width_chars(40)
label.set_line_wrap(True)
label.set_line_wrap_mode(Pango.WrapMode.WORD)
self._grid.attach(label, 0, self._row_count, 2, 1)
self._row_count += 1
redirect_url = fields.pop('redirect-url', None)
if not fields and redirect_url is not None:
# Server wants to redirect registration
button = Gtk.Button(label='Register')
button.set_halign(Gtk.Align.CENTER)
button.get_style_context().add_class('suggested-action')
button.connect('clicked', lambda *args: open_uri(redirect_url))
self._grid.attach(button, 0, self._row_count, 2, 1)
else:
self._add_fields()
self.add(self._grid)
self.show_all()
def _add_fields(self):
for name, value in self._fields.items():
if name in ('key', 'x', 'registered'):
continue
label = Gtk.Label(label=name.capitalize())
label.set_xalign(1)
self._grid.attach(label, 0, self._row_count, 1, 1)
self._row_count += 1
entry = Gtk.Entry()
entry.set_text(value)
if name == 'password':
entry.set_input_purpose(Gtk.InputPurpose.PASSWORD)
entry.set_visibility(False)
self._entries[name] = entry
self._grid.attach_next_to(entry, label,
Gtk.PositionType.RIGHT, 1, 1)
def get_submit_form(self):
fields = {}
for name, entry in self._entries.items():
fields[name] = entry.get_text()
return fields
class DataFormDialog(Gtk.Dialog):
def __init__(self, title, transient_for, form, node, submit_callback):
Gtk.Dialog.__init__(self,
title=title,
transient_for=transient_for,
modal=False)
self.set_default_size(600, 500)
self._submit_callback = submit_callback
self._form = DataFormWidget(extend_form(node=form))
self._node = node
self.get_content_area().get_style_context().add_class('dialog-margin')
self.get_content_area().add(self._form)
self.add_button(_('Cancel'), Gtk.ResponseType.CANCEL)
submit_button = self.add_button(_('Submit'), Gtk.ResponseType.OK)
submit_button.get_style_context().add_class('suggested-action')
self.set_default_response(Gtk.ResponseType.OK)
self.connect('response', self._on_response)
self.show_all()
def _on_response(self, _dialog, response):
if response == Gtk.ResponseType.OK:
self._submit_callback(self._form.get_submit_form(), self._node)
self.destroy()

555
gajim/gtk/dialogs.py Normal file
View file

@ -0,0 +1,555 @@
# 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/>.
from datetime import datetime
from collections import namedtuple
from gi.repository import GLib
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import GdkPixbuf
from gi.repository import Pango
from gajim.common import app
from gajim.common.i18n import _
from gajim.common.const import ButtonAction
from gajim.common.helpers import convert_gio_to_openssl_cert
from .util import get_builder
from .util import get_thumbnail_size
class DialogButton(namedtuple('DialogButton', ('response text callback args '
'kwargs action is_default'))):
@classmethod
def make(cls, type_=None, **kwargs):
# Defaults
default_kwargs = {
'response': None,
'text': None,
'callback': None,
'args': [],
'kwargs': {},
'action': None,
'is_default': False
}
if type_ is not None:
if type_ == 'OK':
default_kwargs['response'] = Gtk.ResponseType.OK
default_kwargs['text'] = _('_OK')
elif type_ == 'Cancel':
default_kwargs['response'] = Gtk.ResponseType.CANCEL
default_kwargs['text'] = _('_Cancel')
elif type_ == 'Accept':
default_kwargs['response'] = Gtk.ResponseType.ACCEPT
default_kwargs['text'] = _('_Accept')
default_kwargs['is_default'] = True
default_kwargs['action'] = ButtonAction.SUGGESTED
elif type_ == 'Delete':
default_kwargs['response'] = Gtk.ResponseType.REJECT
default_kwargs['text'] = _('_Delete')
default_kwargs['action'] = ButtonAction.DESTRUCTIVE
elif type_ == 'Remove':
default_kwargs['response'] = Gtk.ResponseType.REJECT
default_kwargs['text'] = _('_Remove')
default_kwargs['action'] = ButtonAction.DESTRUCTIVE
else:
raise ValueError('Unknown button type: %s ' % type_)
default_kwargs.update(kwargs)
return cls(**default_kwargs)
class HigDialog(Gtk.MessageDialog):
def __init__(self,
parent,
type_,
buttons,
pritext,
sectext,
on_response_ok=None,
on_response_cancel=None,
on_response_yes=None,
on_response_no=None):
self.call_cancel_on_destroy = True
Gtk.MessageDialog.__init__(self,
transient_for=parent,
modal=True,
destroy_with_parent=True,
message_type=type_,
buttons=buttons,
text=pritext)
self.format_secondary_markup(sectext)
self.possible_responses = {
Gtk.ResponseType.OK: on_response_ok,
Gtk.ResponseType.CANCEL: on_response_cancel,
Gtk.ResponseType.YES: on_response_yes,
Gtk.ResponseType.NO: on_response_no
}
self.connect('response', self.on_response)
self.connect('destroy', self.on_dialog_destroy)
def on_response(self, dialog, response_id):
if response_id not in self.possible_responses:
return
if not self.possible_responses[response_id]:
self.destroy()
elif isinstance(self.possible_responses[response_id], tuple):
if len(self.possible_responses[response_id]) == 1:
self.possible_responses[response_id][0](dialog)
else:
self.possible_responses[response_id][0](
dialog, *self.possible_responses[response_id][1:])
else:
self.possible_responses[response_id](dialog)
def on_dialog_destroy(self, _widget):
if not self.call_cancel_on_destroy:
return None
cancel_handler = self.possible_responses[Gtk.ResponseType.CANCEL]
if not cancel_handler:
return False
if isinstance(cancel_handler, tuple):
cancel_handler[0](None, *cancel_handler[1:])
else:
cancel_handler(None)
return None
def popup(self):
"""
Show dialog
"""
vb = self.get_children()[0].get_children()[0] # Give focus to top vbox
# vb.set_flags(Gtk.CAN_FOCUS)
vb.grab_focus()
self.show_all()
class WarningDialog(HigDialog):
"""
HIG compliant warning dialog
"""
def __init__(self, pritext, sectext='', transient_for=None):
if transient_for is None:
transient_for = app.app.get_active_window()
HigDialog.__init__(self,
transient_for,
Gtk.MessageType.WARNING,
Gtk.ButtonsType.OK,
pritext,
sectext)
self.set_modal(False)
self.popup()
class InformationDialog(HigDialog):
"""
HIG compliant info dialog
"""
def __init__(self, pritext, sectext='', transient_for=None):
if transient_for is None:
transient_for = app.app.get_active_window()
HigDialog.__init__(self,
transient_for,
Gtk.MessageType.INFO,
Gtk.ButtonsType.OK,
pritext,
sectext)
self.set_modal(False)
self.popup()
class ErrorDialog(HigDialog):
"""
HIG compliant error dialog
"""
def __init__(self,
pritext,
sectext='',
on_response_ok=None,
on_response_cancel=None,
transient_for=None):
if transient_for is None:
transient_for = app.app.get_active_window()
HigDialog.__init__(self,
transient_for,
Gtk.MessageType.ERROR,
Gtk.ButtonsType.OK,
pritext,
sectext,
on_response_ok=on_response_ok,
on_response_cancel=on_response_cancel)
self.popup()
class CertificateDialog(Gtk.ApplicationWindow):
def __init__(self, transient_for, account, cert):
Gtk.ApplicationWindow.__init__(self)
self.account = account
self.set_name('CertificateDialog')
self.set_application(app.app)
self.set_show_menubar(False)
self.set_resizable(False)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_title(_('Certificate'))
self._ui = get_builder('certificate_dialog.ui')
self.add(self._ui.certificate_box)
self.connect('key-press-event', self._on_key_press)
self._clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
cert = convert_gio_to_openssl_cert(cert)
# Get data for labels and copy button
issuer = cert.get_issuer()
subject = cert.get_subject()
self._headline = _('Certificate for \n%s') % self.account
self._it_common_name = subject.commonName or ''
self._it_organization = subject.organizationName or ''
self._it_org_unit = subject.organizationalUnitName or ''
it_serial_no = str(cert.get_serial_number())
it_serial_no_half = int(len(it_serial_no) / 2)
self._it_serial_number = '%s\n%s' % (
it_serial_no[:it_serial_no_half],
it_serial_no[it_serial_no_half:])
self._ib_common_name = issuer.commonName or ''
self._ib_organization = issuer.organizationName or ''
self._ib_org_unit = issuer.organizationalUnitName or ''
issued = datetime.strptime(cert.get_notBefore().decode('ascii'),
'%Y%m%d%H%M%SZ')
self._issued = issued.strftime('%c %Z')
expires = datetime.strptime(cert.get_notAfter().decode('ascii'),
'%Y%m%d%H%M%SZ')
self._expires = expires.strftime('%c %Z')
sha1 = cert.digest('sha1').decode('utf-8')
self._sha1 = '%s\n%s' % (sha1[:29], sha1[30:])
sha256 = cert.digest('sha256').decode('utf-8')
self._sha256 = '%s\n%s\n%s\n%s' % (
sha256[:23], sha256[24:47], sha256[48:71], sha256[72:])
# Set labels
self._ui.label_cert_for_account.set_text(self._headline)
self._ui.data_it_common_name.set_text(self._it_common_name)
self._ui.data_it_organization.set_text(self._it_organization)
self._ui.data_it_organizational_unit.set_text(self._it_org_unit)
self._ui.data_it_serial_number.set_text(self._it_serial_number)
self._ui.data_ib_common_name.set_text(self._ib_common_name)
self._ui.data_ib_organization.set_text(self._ib_organization)
self._ui.data_ib_organizational_unit.set_text(self._ib_org_unit)
self._ui.data_issued_on.set_text(self._issued)
self._ui.data_expires_on.set_text(self._expires)
self._ui.data_sha1.set_text(self._sha1)
self._ui.data_sha256.set_text(self._sha256)
self.set_transient_for(transient_for)
self._ui.connect_signals(self)
self.show_all()
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
def _on_copy_cert_info_button_clicked(self, _widget):
clipboard_text = \
self._headline + '\n\n' + \
_('Issued to\n') + \
_('Common Name (CN): ') + self._it_common_name + '\n' + \
_('Organization (O): ') + self._it_organization + '\n' + \
_('Organizational Unit (OU): ') + self._it_org_unit + '\n' + \
_('Serial Number: ') + self._it_serial_number + '\n\n' + \
_('Issued by\n') + \
_('Common Name (CN): ') + self._ib_common_name + '\n' + \
_('Organization (O): ') + self._ib_organization + '\n' + \
_('Organizational Unit (OU): ') + self._ib_org_unit + '\n\n' + \
_('Validity\n') + \
_('Issued on: ') + self._issued + '\n' + \
_('Expires on: ') + self._expires + '\n\n' + \
_('SHA-1:') + '\n' + \
self._sha1 + '\n' + \
_('SHA-256:') + '\n' + \
self._sha256 + '\n'
self._clipboard.set_text(clipboard_text, -1)
class PassphraseDialog:
"""
Class for Passphrase dialog
"""
def __init__(self, titletext, labeltext, checkbuttontext=None,
ok_handler=None, cancel_handler=None, transient_for=None):
self._ui = get_builder('passphrase_dialog.ui')
self.window = self._ui.get_object('passphrase_dialog')
self.passphrase = -1
self.window.set_title(titletext)
self._ui.message_label.set_text(labeltext)
self._ok = False
self.cancel_handler = cancel_handler
self.ok_handler = ok_handler
self._ui.ok_button.connect('clicked', self.on_okbutton_clicked)
self._ui.cancel_button.connect('clicked', self.on_cancelbutton_clicked)
self._ui.connect_signals(self)
if transient_for is None:
transient_for = app.app.get_active_window()
self.window.set_transient_for(transient_for)
self.window.show_all()
self.check = bool(checkbuttontext)
if self._ui.save_passphrase_checkbutton:
self._ui.save_passphrase_checkbutton.set_label(checkbuttontext)
else:
self._ui.save_passphrase_checkbutton.hide()
def on_okbutton_clicked(self, _widget):
if not self.ok_handler:
return
passph = self._ui.passphrase_entry.get_text()
if self.check:
checked = self._ui.save_passphrase_checkbutton.get_active()
else:
checked = False
self._ok = True
self.window.destroy()
if isinstance(self.ok_handler, tuple):
self.ok_handler[0](passph, checked, *self.ok_handler[1:])
else:
self.ok_handler(passph, checked)
def on_cancelbutton_clicked(self, _widget):
self.window.destroy()
def on_passphrase_dialog_destroy(self, _widget):
if self.cancel_handler and not self._ok:
self.cancel_handler()
class ConfirmationDialog(Gtk.MessageDialog):
def __init__(self, title, text, sec_text, buttons,
modal=True, transient_for=None):
if transient_for is None:
transient_for = app.app.get_active_window()
Gtk.MessageDialog.__init__(self,
title=title,
text=text,
transient_for=transient_for,
message_type=Gtk.MessageType.QUESTION,
modal=modal)
self.get_style_context().add_class('confirmation-dialog')
self._buttons = {}
for button in buttons:
self._buttons[button.response] = button
self.add_button(button.text, button.response)
if button.is_default:
self.set_default_response(button.response)
if button.action is not None:
widget = self.get_widget_for_response(button.response)
widget.get_style_context().add_class(button.action.value)
self.format_secondary_markup(sec_text)
self.connect('response', self._on_response)
def _on_response(self, _dialog, response):
if response == Gtk.ResponseType.DELETE_EVENT:
# Look if DELETE_EVENT is mapped to another response
response = self._buttons.get(response, None)
if response is None:
# If DELETE_EVENT was not mapped we assume CANCEL
response = Gtk.ResponseType.CANCEL
button = self._buttons.get(response, None)
if button is None:
self.destroy()
return
if button.callback is not None:
button.callback(*button.args, **button.kwargs)
self.destroy()
def show(self):
self.show_all()
NewConfirmationDialog = ConfirmationDialog
class ConfirmationCheckDialog(ConfirmationDialog):
def __init__(self, title, text, sec_text, check_text,
buttons, modal=True, transient_for=None):
ConfirmationDialog.__init__(self,
title,
text,
sec_text,
buttons,
transient_for=transient_for,
modal=modal)
self._checkbutton = Gtk.CheckButton.new_with_mnemonic(check_text)
self._checkbutton.set_can_focus(False)
self._checkbutton.set_margin_start(30)
self._checkbutton.set_margin_end(30)
label = self._checkbutton.get_child()
label.set_line_wrap(True)
label.set_max_width_chars(50)
label.set_halign(Gtk.Align.START)
label.set_line_wrap_mode(Pango.WrapMode.WORD)
label.set_margin_start(10)
self.get_content_area().add(self._checkbutton)
def _on_response(self, _dialog, response):
button = self._buttons.get(response)
if button is not None:
button.args.insert(0, self._checkbutton.get_active())
super()._on_response(_dialog, response)
NewConfirmationCheckDialog = ConfirmationCheckDialog
class PastePreviewDialog(ConfirmationCheckDialog):
def __init__(self, title, text, sec_text, check_text, image,
buttons, modal=True, transient_for=None):
ConfirmationCheckDialog.__init__(self,
title,
text,
sec_text,
check_text,
buttons,
transient_for=transient_for,
modal=modal)
preview = Gtk.Image()
preview.set_halign(Gtk.Align.CENTER)
preview.get_style_context().add_class('preview-image')
size = 300
image_width = image.get_width()
image_height = image.get_height()
if size > image_width and size > image_height:
preview.set_from_pixbuf(image)
else:
thumb_width, thumb_height = get_thumbnail_size(image, size)
pixbuf_scaled = image.scale_simple(
thumb_width, thumb_height, GdkPixbuf.InterpType.BILINEAR)
preview.set_from_pixbuf(pixbuf_scaled)
content_area = self.get_content_area()
content_area.pack_start(preview, True, True, 0)
content_area.reorder_child(preview, 2)
class InputDialog(ConfirmationDialog):
def __init__(self, title, text, sec_text, buttons, input_str=None,
transient_for=None, modal=True):
ConfirmationDialog.__init__(self,
title,
text,
sec_text,
buttons,
transient_for=transient_for,
modal=modal)
self._entry = Gtk.Entry()
self._entry.set_activates_default(True)
self._entry.set_margin_start(50)
self._entry.set_margin_end(50)
if input_str:
self._entry.set_text(input_str)
self._entry.select_region(0, -1) # select all
self.get_content_area().add(self._entry)
def _on_response(self, _dialog, response):
button = self._buttons.get(response)
if button is not None:
button.args.insert(0, self._entry.get_text())
super()._on_response(_dialog, response)
class TimeoutWindow:
"""
Class designed to be derivated by other windows
Derived windows close automatically after reaching the timeout
"""
def __init__(self, timeout):
self.title_text = ''
self._countdown_left = timeout
self._timeout_source_id = None
def start_timeout(self):
if self._countdown_left > 0:
self.countdown()
self._timeout_source_id = GLib.timeout_add_seconds(
1, self.countdown)
def stop_timeout(self, *args, **kwargs):
if self._timeout_source_id is not None:
GLib.source_remove(self._timeout_source_id)
self._timeout_source_id = None
self.set_title(self.title_text)
def on_timeout(self):
"""
To be implemented by derivated classes
"""
def countdown(self):
if self._countdown_left <= 0:
self._timeout_source_id = None
self.on_timeout()
return False
self.set_title('%s [%s]' % (
self.title_text, str(self._countdown_left)))
self._countdown_left -= 1
return True
class ShortcutsWindow:
def __init__(self):
transient = app.app.get_active_window()
builder = get_builder('shortcuts_window.ui')
self.window = builder.get_object('shortcuts_window')
self.window.connect('destroy', self._on_window_destroy)
self.window.set_transient_for(transient)
self.window.show_all()
self.window.present()
def _on_window_destroy(self, _widget):
self.window = None

2197
gajim/gtk/discovery.py Normal file

File diff suppressed because it is too large Load diff

412
gajim/gtk/emoji_chooser.py Normal file
View file

@ -0,0 +1,412 @@
# 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 logging
import weakref
from collections import OrderedDict
from gi.repository import Gtk
from gi.repository import GLib
from gi.repository import GdkPixbuf
from gi.repository import Pango
from gajim.common import app
from gajim.common import helpers
from gajim.common import configpaths
from .util import get_builder
from .emoji_data import emoji_data
from .emoji_data import emoji_pixbufs
from .emoji_data import Emoji
MODIFIER_MAX_CHILDREN_PER_LINE = 6
MAX_CHILDREN_PER_LINE = 10
log = logging.getLogger('gajim.emoji')
class Section(Gtk.Box):
def __init__(self, name, search_entry, press_cb, chooser):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
self._chooser = chooser
self._press_cb = press_cb
self.pixbuf_generator = None
self.heading = Gtk.Label(label=name)
self.heading.set_halign(Gtk.Align.START)
self.heading.get_style_context().add_class('emoji-chooser-heading')
self.add(self.heading)
self.flowbox = Gtk.FlowBox()
self.flowbox.get_style_context().add_class('emoji-chooser-flowbox')
self.flowbox.set_max_children_per_line(MAX_CHILDREN_PER_LINE)
self.flowbox.set_filter_func(self._filter_func, search_entry)
self.flowbox.connect('child-activated', press_cb)
self.add(self.flowbox)
self.show_all()
def _filter_func(self, child, search_entry):
name = search_entry.get_text()
if not name:
self.show()
return True
if name in child.desc:
self.show()
return True
return False
def add_emoji(self, codepoint, attrs):
# Return always True, this method is used for the emoji factory
# called by GLib.idle_add()
pixbuf = self._get_next_pixbuf()
variations = attrs.get('variations', None)
if variations is None:
if pixbuf is None:
return True
self.flowbox.add(EmojiChild(codepoint, pixbuf, attrs['desc']))
if pixbuf != 'font':
# We save the pixbuf for fast access if we need to
# replace a codepoint in the textview
emoji_pixbufs[codepoint] = pixbuf
else:
if pixbuf is not None:
chooser = ModifierChooser()
# Iterate over the variations and add the codepoints
for codepoint_ in variations.keys():
pixbuf_ = self._get_next_pixbuf()
if pixbuf_ is None:
continue
if pixbuf_ == 'font':
if not self._chooser.font_supports_codepoint(
codepoint_):
continue
else:
emoji_pixbufs[codepoint_] = pixbuf_
# Only codepoints are added which the
# font or theme supports
chooser.add_emoji(codepoint_, pixbuf_)
# Check if we successfully added codepoints with modifiers
if chooser.has_child:
# If we have children then add a button
# and set the popover
child = EmojiModifierChild(
codepoint, pixbuf, attrs['desc'])
child.button.set_popover(chooser)
chooser.flowbox.connect(
'child-activated', self._press_cb)
else:
# If no children were added, destroy the chooser
# and add a EmojiChild instead of a EmojiModifierChild
chooser.destroy()
child = EmojiChild(codepoint, pixbuf, attrs['desc'])
if pixbuf != 'font':
emoji_pixbufs[codepoint] = pixbuf
self.flowbox.add(child)
else:
# We dont have a image for the base codepoint
# so skip all modifiers of it
for _entry in variations:
pixbuf = self._get_next_pixbuf()
return True
def clear_emojis(self):
def _remove_emoji(emoji):
self.flowbox.remove(emoji)
emoji.destroy()
self.flowbox.foreach(_remove_emoji)
def _get_next_pixbuf(self):
if self.pixbuf_generator is None:
return 'font'
return next(self.pixbuf_generator, False)
class EmojiChild(Gtk.FlowBoxChild):
def __init__(self, codepoint, pixbuf, desc):
Gtk.FlowBoxChild.__init__(self)
self.desc = desc
self.codepoint = codepoint
self.pixbuf = pixbuf
if pixbuf == 'font':
self.add(Gtk.Label(label=codepoint))
else:
self.add(Gtk.Image.new_from_pixbuf(pixbuf))
self.set_tooltip_text(desc)
self.show_all()
def get_emoji(self):
if self.pixbuf != 'font':
pixbuf = self.get_child().get_pixbuf()
pixbuf = pixbuf.scale_simple(Emoji.INPUT_SIZE,
Emoji.INPUT_SIZE,
GdkPixbuf.InterpType.HYPER)
return self.codepoint, pixbuf
return self.codepoint, None
class EmojiModifierChild(Gtk.FlowBoxChild):
def __init__(self, codepoint, pixbuf, desc):
Gtk.FlowBoxChild.__init__(self)
self.desc = desc
self.codepoint = codepoint
self.pixbuf = pixbuf
self.button = Gtk.MenuButton()
self.button.set_relief(Gtk.ReliefStyle.NONE)
self.button.connect('button-press-event', self._button_press)
if pixbuf == 'font':
self.button.remove(self.button.get_child())
label = Gtk.Label(label=codepoint)
self.button.add(label)
else:
self.button.get_child().set_from_pixbuf(pixbuf)
self.set_tooltip_text(desc)
self.add(self.button)
self.show_all()
def _button_press(self, button, event):
if event.button == 3:
button.get_popover().show()
button.get_popover().get_child().unselect_all()
return True
if event.button == 1:
self.get_parent().emit('child-activated', self)
return True
return False
def get_emoji(self):
if self.pixbuf != 'font':
pixbuf = self.button.get_child().get_pixbuf()
pixbuf = pixbuf.scale_simple(Emoji.INPUT_SIZE,
Emoji.INPUT_SIZE,
GdkPixbuf.InterpType.HYPER)
return self.codepoint, pixbuf
return self.codepoint, None
class ModifierChooser(Gtk.Popover):
def __init__(self):
Gtk.Popover.__init__(self)
self.set_name('EmoticonPopover')
self._has_child = False
self.flowbox = Gtk.FlowBox()
self.flowbox.get_style_context().add_class(
'emoji-modifier-chooser-flowbox')
self.flowbox.set_size_request(200, -1)
self.flowbox.set_max_children_per_line(MODIFIER_MAX_CHILDREN_PER_LINE)
self.flowbox.show()
self.add(self.flowbox)
@property
def has_child(self):
return self._has_child
def add_emoji(self, codepoint, pixbuf):
self.flowbox.add(EmojiChild(codepoint, pixbuf, None))
self._has_child = True
class EmojiChooser(Gtk.Popover):
_section_names = [
'Smileys & People',
'Animals & Nature',
'Food & Drink',
'Travel & Places',
'Activities',
'Objects',
'Symbols',
'Flags'
]
def __init__(self):
super().__init__()
self.set_name('EmoticonPopover')
self._text_widget = None
self._load_source_id = None
self._pango_layout = Pango.Layout(self.get_pango_context())
self._builder = get_builder('emoji_chooser.ui')
self._search = self._builder.get_object('search')
self._stack = self._builder.get_object('stack')
self._sections = OrderedDict()
for name in self._section_names:
self._sections[name] = Section(
name, self._search, self._on_emoticon_press, self)
section_box = self._builder.get_object('section_box')
for section in self._sections.values():
section_box.add(section)
self.add(self._builder.get_object('box'))
self.connect('key-press-event', self._key_press)
self._builder.connect_signals(self)
self.show_all()
@property
def text_widget(self):
return self._text_widget
@text_widget.setter
def text_widget(self, value):
# Hold only a weak reference so if we can destroy
# the ChatControl
self._text_widget = weakref.ref(value)
def _key_press(self, _widget, event):
return self._search.handle_event(event)
def _search_changed(self, _entry):
for section in self._sections.values():
section.hide()
section.flowbox.invalidate_filter()
self._switch_stack()
def _clear_sections(self):
for section in self._sections.values():
section.clear_emojis()
def _switch_stack(self):
for section in self._sections.values():
if section.is_visible():
self._stack.set_visible_child_name('list')
return
self._stack.set_visible_child_name('not-found')
@staticmethod
def _get_current_theme():
theme = app.settings.get('emoticons_theme')
themes = helpers.get_available_emoticon_themes()
if theme not in themes:
app.settings.set('emoticons_theme', 'noto')
theme = 'noto'
return theme
@staticmethod
def _get_emoji_theme_path(theme):
if theme == 'font':
return 'font'
base_path = configpaths.get('EMOTICONS')
emoticons_data_path = base_path / theme / f'{theme}.png'
if emoticons_data_path.exists():
return emoticons_data_path
emoticons_user_path = configpaths.get('MY_EMOTS') / f'{theme}.png'
if emoticons_user_path.exists():
return emoticons_user_path
log.warning('Could not find emoji theme: %s', theme)
return None
def load(self):
theme = self._get_current_theme()
path = self._get_emoji_theme_path(theme)
if not theme or path is None:
self._clear_sections()
emoji_pixbufs.clear()
return
# Attach pixbuf generator
pixbuf_generator = None
if theme != 'font':
pixbuf_generator = self._get_next_pixbuf(path)
for section in self._sections.values():
section.pixbuf_generator = pixbuf_generator
if self._load_source_id is not None:
GLib.source_remove(self._load_source_id)
# When we change emoji theme
self._clear_sections()
emoji_pixbufs.clear()
factory = self._emoji_factory(theme == 'font')
self._load_source_id = GLib.idle_add(lambda: next(factory, False),
priority=GLib.PRIORITY_LOW)
def _emoji_factory(self, font):
for codepoint, attrs in emoji_data.items():
if not attrs['fully-qualified']:
# We dont add these to the UI
continue
if font and not self.font_supports_codepoint(codepoint):
continue
section = self._sections[attrs['group']]
yield section.add_emoji(codepoint, attrs)
self._load_source_id = None
emoji_pixbufs.complete = True
def font_supports_codepoint(self, codepoint):
self._pango_layout.set_text(codepoint, -1)
if self._pango_layout.get_unknown_glyphs_count():
return False
if len(codepoint) > 1:
# The font supports each of the codepoints
# Check if the rendered glyph is more than one char
if self._pango_layout.get_size()[0] > 19000:
return False
return True
@staticmethod
def _get_next_pixbuf(path):
src_x = src_y = cur_column = 0
atlas = GdkPixbuf.Pixbuf.new_from_file(str(path))
while True:
src_x = cur_column * Emoji.PARSE_WIDTH
subpixbuf = atlas.new_subpixbuf(src_x, src_y,
Emoji.PARSE_WIDTH,
Emoji.PARSE_HEIGHT)
if list(subpixbuf.get_pixels())[0:4] == [0, 0, 0, 255]:
# top left corner is a black pixel means image is missing
subpixbuf = None
if cur_column == Emoji.PARSE_COLUMNS - 1:
src_y += Emoji.PARSE_WIDTH
cur_column = 0
else:
cur_column += 1
yield subpixbuf
def _on_emoticon_press(self, flowbox, child):
GLib.timeout_add(100, flowbox.unselect_child, child)
codepoint, pixbuf = child.get_emoji()
self._text_widget().insert_emoji(codepoint, pixbuf)
def do_destroy(self):
# Remove the references we hold to other objects
self._text_widget = None
# Never destroy, creating a new EmoticonPopover is expensive
return True
emoji_chooser = EmojiChooser()

26796
gajim/gtk/emoji_data.py Normal file

File diff suppressed because it is too large Load diff

130
gajim/gtk/exception.py Normal file
View file

@ -0,0 +1,130 @@
# Copyright (C) 2016-2018 Philipp Hörist <philipp AT hoerist.com>
# Copyright (C) 2005-2006 Nikos Kouremenos <kourem AT gmail.com>
# Copyright (C) 2005-2014 Yann Leboulanger <asterix AT lagaule.org>
# Copyright (C) 2008 Stephan Erb <steve-e AT h3c.de>
#
# This file is part of Gajim.
#
# Gajim is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation; version 3 only.
#
# Gajim is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
import sys
import os
import traceback
import threading
import webbrowser
import platform
from io import StringIO
from urllib.parse import urlencode
from gi.repository import Gtk
from gi.repository import GObject
from gi.repository import GLib
import nbxmpp
import gajim
from gajim.common import configpaths
from .util import get_builder
_exception_in_progress = threading.Lock()
ISSUE_TEXT = '''## Versions
- OS: {}
- GTK Version: {}
- PyGObject Version: {}
- GLib Version : {}
- python-nbxmpp Version: {}
- Gajim Version: {}
## Traceback
```
{}
```
## Steps to reproduce the problem
...'''
def _hook(type_, value, tb):
if not _exception_in_progress.acquire(False):
# Exceptions have piled up, so we use the default exception
# handler for such exceptions
sys.__excepthook__(type_, value, tb)
return
ExceptionDialog(type_, value, tb)
_exception_in_progress.release()
class ExceptionDialog():
def __init__(self, type_, value, tb):
path = configpaths.get('GUI') / 'exception_dialog.ui'
self._ui = get_builder(path.resolve())
self._ui.connect_signals(self)
self._ui.report_btn.grab_focus()
buffer_ = self._ui.exception_view.get_buffer()
trace = StringIO()
traceback.print_exception(type_, value, tb, None, trace)
self.text = self.get_issue_text(trace.getvalue())
buffer_.set_text(self.text)
print(self.text, file=sys.stderr)
self._ui.exception_view.set_editable(False)
self._ui.exception_dialog.show()
def on_report_clicked(self, *args):
issue_url = 'https://dev.gajim.org/gajim/gajim/issues/new'
params = {'issue[description]': self.text}
url = '{}?{}'.format(issue_url, urlencode(params))
webbrowser.open(url, new=2)
def on_close_clicked(self, *args):
self._ui.exception_dialog.destroy()
@staticmethod
def get_issue_text(traceback_text):
gtk_ver = '%i.%i.%i' % (
Gtk.get_major_version(),
Gtk.get_minor_version(),
Gtk.get_micro_version())
gobject_ver = '.'.join(map(str, GObject.pygobject_version))
glib_ver = '.'.join(map(str, [GLib.MAJOR_VERSION,
GLib.MINOR_VERSION,
GLib.MICRO_VERSION]))
return ISSUE_TEXT.format(get_os_info(),
gtk_ver,
gobject_ver,
glib_ver,
nbxmpp.__version__,
gajim.__version__,
traceback_text)
def init():
if os.name == 'nt' or not sys.stderr.isatty():
sys.excepthook = _hook
def get_os_info():
if os.name == 'nt' or sys.platform == 'darwin':
return platform.system() + " " + platform.release()
if os.name == 'posix':
try:
import distro
return distro.name(pretty=True)
except ImportError:
return platform.system()
return ''

225
gajim/gtk/features.py Normal file
View file

@ -0,0 +1,225 @@
# Copyright (C) 2007 Jean-Marie Traissard <jim AT lapin.org>
# Julien Pivotto <roidelapluie AT gmail.com>
# Stefan Bethge <stefan AT lanpartei.de>
# Stephan Erb <steve-e AT h3c.de>
# Copyright (C) 2007-2014 Yann Leboulanger <asterix AT lagaule.org>
# 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 sys
from collections import namedtuple
from gi.repository import Gtk
from gi.repository import Gdk
from gajim.common import app
from gajim.common.i18n import _
class Features(Gtk.ApplicationWindow):
def __init__(self):
Gtk.ApplicationWindow.__init__(self)
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_name('Features')
self.set_title(_('Features'))
self.set_resizable(False)
self.set_transient_for(app.interface.roster.window)
grid = Gtk.Grid()
grid.set_name('FeaturesInfoGrid')
grid.set_row_spacing(10)
grid.set_hexpand(True)
self.feature_listbox = Gtk.ListBox()
self.feature_listbox.set_selection_mode(Gtk.SelectionMode.NONE)
grid.attach(self.feature_listbox, 0, 0, 1, 1)
box = Gtk.Box()
box.pack_start(grid, True, True, 0)
box.set_property('margin', 12)
box.set_spacing(18)
self.add(box)
self.connect('key-press-event', self._on_key_press)
for feature in self._get_features():
self._add_feature(feature)
self.show_all()
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
def _add_feature(self, feature):
item = FeatureItem(feature)
self.feature_listbox.add(item)
item.get_parent().set_tooltip_text(item.tooltip)
def _get_features(self):
Feature = namedtuple('Feature',
['name', 'available', 'tooltip',
'dependency_u', 'dependency_w', 'enabled'])
notification_sounds_available = (
app.is_installed('GSOUND') or sys.platform in ('win32', 'darwin'))
notification_sounds_enabled = app.settings.get('sounds_on')
spell_check_enabled = app.settings.get('use_speller')
auto_status = [app.settings.get('autoaway'), app.settings.get('autoxa')]
auto_status_enabled = bool(any(auto_status))
return [
Feature(_('Audio / Video'),
app.is_installed('AV'),
_('Enables Gajim to provide Audio and Video chats'),
_('Requires: gir1.2-farstream-0.2, gir1.2-gstreamer-1.0, '
'gstreamer1.0-plugins-base, gstreamer1.0-plugins-ugly, '
'gstreamer1.0-libav, and gstreamer1.0-gtk3'),
_('Feature not available under Windows'),
None),
Feature(_('Automatic Status'),
self._idle_available(),
_('Enables Gajim to measure your computer\'s idle time in '
'order to set your Status automatically'),
_('Requires: libxss'),
_('No additional requirements'),
auto_status_enabled),
Feature(_('Bonjour / Zeroconf (Serverless Chat)'),
app.is_installed('ZEROCONF'),
_('Enables Gajim to automatically detected clients in a '
'local network for serverless chats'),
_('Requires: gir1.2-avahi-0.6'),
_('Requires: pybonjour and bonjour SDK running (%(url)s)')
% {'url': 'https://developer.apple.com/opensource/)'},
None),
Feature(_('Location detection'),
app.is_installed('GEOCLUE'),
_('Enables Gajim to be location-aware, if the user decides '
'to publish the devices location'),
_('Requires: geoclue'),
_('Feature is not available under Windows'),
None),
Feature(_('Notification Sounds'),
notification_sounds_available,
_('Enables Gajim to play sounds for various notifications'),
_('Requires: gsound'),
_('No additional requirements'),
notification_sounds_enabled),
Feature(_('Secure Password Storage'),
self._some_keyring_available(),
_('Enables Gajim to store Passwords securely instead of '
'storing them in plaintext'),
_('Requires: gnome-keyring or kwallet'),
_('Windows Credential Vault is used for secure password '
'storage'),
app.settings.get('use_keyring')),
Feature(_('Spell Checker'),
app.is_installed('GSPELL'),
_('Enables Gajim to spell check your messages while '
'composing'),
_('Requires: Gspell'),
_('Requires: Gspell'),
spell_check_enabled),
Feature(_('UPnP-IGD Port Forwarding'),
app.is_installed('UPNP'),
_('Enables Gajim to request your router to forward ports '
'for file transfers'),
_('Requires: gir1.2-gupnpigd-1.0'),
_('Feature not available under Windows'),
None)
]
@staticmethod
def _some_keyring_available():
import keyring
backends = keyring.backend.get_all_keyring()
return any(keyring.core.recommended(backend) for backend in backends)
@staticmethod
def _idle_available():
from gajim.common import idle
return idle.Monitor.is_available()
class FeatureItem(Gtk.Grid):
def __init__(self, feature):
super().__init__()
self.set_column_spacing(12)
self.tooltip = feature.tooltip
self.feature_dependency_u_text = feature.dependency_u
self.feature_dependency_w_text = feature.dependency_w
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.feature_label = Gtk.Label(label=feature.name)
self.feature_label.set_halign(Gtk.Align.START)
self.box.pack_start(self.feature_label, True, True, 0)
self.feature_dependency_u = Gtk.Label(label=feature.dependency_u)
self.feature_dependency_u.get_style_context().add_class('dim-label')
self.feature_dependency_w = Gtk.Label(label=feature.dependency_w)
self.feature_dependency_w.get_style_context().add_class('dim-label')
if not feature.available:
self.feature_dependency_u.set_halign(Gtk.Align.START)
self.feature_dependency_u.set_xalign(0.0)
self.feature_dependency_u.set_yalign(0.0)
self.feature_dependency_u.set_line_wrap(True)
self.feature_dependency_u.set_max_width_chars(50)
self.feature_dependency_u.set_selectable(True)
self.feature_dependency_w.set_halign(Gtk.Align.START)
self.feature_dependency_w.set_xalign(0.0)
self.feature_dependency_w.set_yalign(0.0)
self.feature_dependency_w.set_line_wrap(True)
self.feature_dependency_w.set_max_width_chars(50)
self.feature_dependency_w.set_selectable(True)
if os.name == 'nt':
self.box.pack_start(self.feature_dependency_w, True, True, 0)
else:
self.box.pack_start(self.feature_dependency_u, True, True, 0)
self.icon = Gtk.Image()
self.label_disabled = Gtk.Label(label=_('Disabled in Preferences'))
self.label_disabled.get_style_context().add_class('dim-label')
self.set_feature(feature.available, feature.enabled)
self.add(self.icon)
self.add(self.box)
def set_feature(self, available, enabled):
self.icon.get_style_context().remove_class('error-color')
self.icon.get_style_context().remove_class('warning-color')
self.icon.get_style_context().remove_class('success-color')
if not available:
self.icon.set_from_icon_name('window-close-symbolic',
Gtk.IconSize.MENU)
self.icon.get_style_context().add_class('error-color')
elif enabled is False:
self.icon.set_from_icon_name('dialog-warning-symbolic',
Gtk.IconSize.MENU)
self.box.pack_start(self.label_disabled, True, True, 0)
self.icon.get_style_context().add_class('warning-color')
else:
self.icon.set_from_icon_name('emblem-ok-symbolic',
Gtk.IconSize.MENU)
self.icon.get_style_context().add_class('success-color')

221
gajim/gtk/filechoosers.py Normal file
View file

@ -0,0 +1,221 @@
# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com>
#
# 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, either version 3 of the License, or
# (at your option) any later version.
#
# 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/>.
from typing import List
import os
import sys
from pathlib import Path
from gi.repository import Gtk
from gi.repository import GdkPixbuf
from gi.repository import GLib
from gajim.common import app
from gajim.common.i18n import _
from .const import Filter
def _require_native() -> bool:
if app.is_flatpak():
return True
if sys.platform in ('win32', 'darwin'):
return True
return False
# Notes: Adding mime types to Gtk.FileFilter forces non-native dialogs
class BaseFileChooser:
def _on_response(self, dialog, response, accept_cb, cancel_cb):
if response == Gtk.ResponseType.ACCEPT:
if self.get_select_multiple():
accept_cb(dialog.get_filenames())
else:
accept_cb(dialog.get_filename())
if response in (Gtk.ResponseType.CANCEL,
Gtk.ResponseType.DELETE_EVENT):
if cancel_cb is not None:
cancel_cb()
def _add_filters(self, filters):
for filterinfo in filters:
filter_ = Gtk.FileFilter()
filter_.set_name(filterinfo.name)
if isinstance(filterinfo.pattern, list):
for mime_type in filterinfo.pattern:
filter_.add_mime_type(mime_type)
else:
filter_.add_pattern(filterinfo.pattern)
self.add_filter(filter_)
if filterinfo.default:
self.set_filter(filter_)
def _update_preview(self, filechooser):
path_to_file = filechooser.get_preview_filename()
preview = filechooser.get_preview_widget()
if path_to_file is None or os.path.isdir(path_to_file):
# nothing to preview
preview.clear()
return
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
path_to_file, *self._preivew_size)
except GLib.GError:
preview.clear()
return
filechooser.get_preview_widget().set_from_pixbuf(pixbuf)
class BaseFileOpenDialog:
_title = _('Choose File to Send…')
_filters = [Filter(_('All files'), '*', True)]
class BaseAvatarChooserDialog:
_title = _('Choose Avatar…')
_preivew_size = (100, 100)
if _require_native():
_filters = [Filter(_('PNG files'), '*.png', True),
Filter(_('JPEG files'), '*.jp*g', False),
Filter(_('SVG files'), '*.svg', False)]
else:
_filters = [Filter(_('Images'), ['image/png',
'image/jpeg',
'image/svg+xml'], True)]
class NativeFileChooserDialog(Gtk.FileChooserNative, BaseFileChooser):
_title = ''
_filters: List[Filter] = []
_action = Gtk.FileChooserAction.OPEN
def __init__(self, accept_cb, cancel_cb=None, transient_for=None,
path=None, file_name=None, select_multiple=False,
modal=False):
if transient_for is None:
transient_for = app.app.get_active_window()
Gtk.FileChooserNative.__init__(self,
title=self._title,
action=self._action,
transient_for=transient_for)
self.set_current_folder(path or str(Path.home()))
if file_name is not None:
self.set_current_name(file_name)
self.set_select_multiple(select_multiple)
self.set_do_overwrite_confirmation(True)
self.set_modal(modal)
self._add_filters(self._filters)
self.connect('response', self._on_response, accept_cb, cancel_cb)
self.show()
class ArchiveChooserDialog(NativeFileChooserDialog):
_title = _('Choose Archive')
_filters = [Filter(_('All files'), '*', False),
Filter(_('ZIP files'), '*.zip', True)]
class FileSaveDialog(NativeFileChooserDialog):
_title = _('Save File as…')
_filters = [Filter(_('All files'), '*', True)]
_action = Gtk.FileChooserAction.SAVE
class NativeFileOpenDialog(BaseFileOpenDialog, NativeFileChooserDialog):
pass
class NativeAvatarChooserDialog(BaseAvatarChooserDialog, NativeFileChooserDialog):
pass
class GtkFileChooserDialog(Gtk.FileChooserDialog, BaseFileChooser):
_title = ''
_filters: List[Filter] = []
_action = Gtk.FileChooserAction.OPEN
_preivew_size = (200, 200)
def __init__(self, accept_cb, cancel_cb=None, transient_for=None,
path=None, file_name=None, select_multiple=False,
preview=True, modal=False):
if transient_for is None:
transient_for = app.app.get_active_window()
Gtk.FileChooserDialog.__init__(
self,
title=self._title,
action=self._action,
transient_for=transient_for)
self.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
open_button = self.add_button(_('_Open'), Gtk.ResponseType.ACCEPT)
open_button.get_style_context().add_class('suggested-action')
self.set_current_folder(path or str(Path.home()))
if file_name is not None:
self.set_current_name(file_name)
self.set_select_multiple(select_multiple)
self.set_do_overwrite_confirmation(True)
self.set_modal(modal)
self._add_filters(self._filters)
if preview:
self.set_use_preview_label(False)
self.set_preview_widget(Gtk.Image())
self.connect('selection-changed', self._update_preview)
self.connect('response', self._on_response, accept_cb, cancel_cb)
self.show()
def _on_response(self, *args):
super()._on_response(*args)
self.destroy()
class GtkFileOpenDialog(BaseFileOpenDialog, GtkFileChooserDialog):
pass
class GtkAvatarChooserDialog(BaseAvatarChooserDialog, GtkFileChooserDialog):
pass
def FileChooserDialog(*args, **kwargs):
if _require_native():
return NativeFileOpenDialog(*args, **kwargs)
return GtkFileOpenDialog(*args, **kwargs)
def AvatarChooserDialog(*args, **kwargs):
if _require_native():
return NativeAvatarChooserDialog(*args, **kwargs)
return GtkAvatarChooserDialog(*args, **kwargs)

1097
gajim/gtk/filetransfer.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,133 @@
# 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 time
from gi.repository import Gtk
from gi.repository import GLib
from gajim.common import app
from gajim.common.i18n import _
from .util import get_builder
from .util import EventHelper
from .dialogs import ErrorDialog
class FileTransferProgress(Gtk.ApplicationWindow, EventHelper):
def __init__(self, transfer):
Gtk.ApplicationWindow.__init__(self)
EventHelper.__init__(self)
self.set_name('FileTransferProgress')
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_title(_('File Transfer'))
self._destroyed = False
self._transfer = transfer
self._transfer.connect('state-changed', self._on_transfer_state_change)
self._transfer.connect('progress', self._on_transfer_progress)
if app.settings.get('use_kib_mib'):
self._units = GLib.FormatSizeFlags.IEC_UNITS
else:
self._units = GLib.FormatSizeFlags.DEFAULT
self._start_time = time.time()
self._ui = get_builder('filetransfer_progress.ui')
self._ui.file_name_label.set_text(transfer.filename)
self.add(self._ui.box)
self._pulse = GLib.timeout_add(100, self._pulse_progressbar)
self.show_all()
self.connect('destroy', self._on_destroy)
self._ui.connect_signals(self)
def _on_transfer_state_change(self, transfer, _signal_name, state):
if self._destroyed:
return
if state.is_error:
ErrorDialog(_('Error'),
transfer.error_text,
transient_for=app.interface.roster.window)
self.destroy()
if state.is_finished or state.is_cancelled:
self.destroy()
return
description = transfer.get_state_description()
if description:
self._ui.label.set_text(description)
def _pulse_progressbar(self):
self._ui.progressbar.pulse()
return True
def _on_cancel_button_clicked(self, _widget):
self.destroy()
def _on_destroy(self, *args):
self._destroyed = True
if self._transfer.state.is_active:
self._transfer.cancel()
self._transfer = None
if self._pulse is not None:
GLib.source_remove(self._pulse)
def _on_transfer_progress(self, transfer, _signal_name):
if self._destroyed:
return
if self._pulse is not None:
GLib.source_remove(self._pulse)
self._pulse = None
time_now = time.time()
bytes_sec = round(transfer.seen / (time_now - self._start_time), 1)
size_progress = GLib.format_size_full(transfer.seen, self._units)
size_total = GLib.format_size_full(transfer.size, self._units)
speed = '%s/s' % GLib.format_size_full(bytes_sec, self._units)
if bytes_sec == 0:
eta = ''
else:
eta = self._format_eta(
round((transfer.size - transfer.seen) / bytes_sec))
self._ui.progress_label.set_text(
_('%(progress)s of %(total)s') % {
'progress': size_progress,
'total': size_total})
self._ui.speed_label.set_text(speed)
self._ui.eta_label.set_text(eta)
self._ui.progressbar.set_fraction(float(transfer.seen) / transfer.size)
@staticmethod
def _format_eta(time_):
times = {'minutes': 0, 'seconds': 0}
time_ = int(time_)
times['seconds'] = time_ % 60
if time_ >= 60:
time_ /= 60
times['minutes'] = round(time_ % 60)
return _('%(minutes)s min %(seconds)s sec') % times

View file

@ -0,0 +1,400 @@
# 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 logging
from nbxmpp.namespaces import Namespace
from nbxmpp.errors import StanzaError
from gi.repository import Gdk
from gi.repository import Gtk
from gajim.common import app
from gajim.common.i18n import _
from gajim.common.const import MUCUser
from .dialogs import ErrorDialog
from .dataform import DataFormWidget
from .util import get_builder
log = logging.getLogger('gajim.gui.groupchat_config')
class GroupchatConfig(Gtk.ApplicationWindow):
def __init__(self, account, jid, own_affiliation, form=None):
Gtk.ApplicationWindow.__init__(self)
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_title(_('Group Chat Configuration'))
self._destroyed = False
self.account = account
self.jid = jid
self._own_affiliation = own_affiliation
self._ui = get_builder('groupchat_config.ui')
self.add(self._ui.grid)
# Activate Add button only for Admins and Owners
if self._own_affiliation in ('admin', 'owner'):
self._ui.add_button.set_sensitive(True)
self._ui.add_button.set_tooltip_text('')
disco_info = app.storage.cache.get_last_disco_info(self.jid)
visible = disco_info.supports(Namespace.REGISTER)
self._ui.reserved_name_column.set_visible(visible)
self._ui.info_button.set_sensitive(False)
self._form = form
self._affiliations = {}
self._new_affiliations = {}
con = app.connections[self.account]
for affiliation in ('owner', 'admin', 'member', 'outcast'):
con.get_module('MUC').get_affiliation(
self.jid,
affiliation,
callback=self._on_affiliations_received,
user_data=affiliation)
if form is not None:
self._ui.stack.set_visible_child_name('config')
self._data_form_widget = DataFormWidget(form)
self._data_form_widget.connect('is-valid', self._on_is_valid)
self._data_form_widget.validate()
self._ui.config_grid.add(self._data_form_widget)
else:
self._ui.stack.get_child_by_name('config').hide()
self._ui.stack.get_child_by_name('config').set_no_show_all(True)
self._ui.stack.set_visible_child_name('affiliation')
self._ui.connect_signals(self)
self.connect('delete-event', self._cancel)
self.connect('destroy', self._on_destroy)
self.connect('key-press-event', self._on_key_press)
self.show_all()
self._ui.stack.notify('visible-child-name')
def _on_is_valid(self, _widget, is_valid):
self._ui.ok_button.set_sensitive(is_valid)
def _get_current_treeview(self):
page_name = self._ui.stack.get_visible_child_name()
return getattr(self._ui, '%s_treeview' % page_name)
def _on_add(self, *args):
page_name = self._ui.stack.get_visible_child_name()
if page_name == 'outcast':
affiliation_edit, jid_edit = self._allowed_to_edit('outcast')
text = None
affiliation = 'outcast'
else:
affiliation_edit, jid_edit = self._allowed_to_edit('member')
text = _('Member')
affiliation = 'member'
treeview = self._get_current_treeview()
iter_ = treeview.get_model().append([None,
None,
None,
affiliation,
text,
affiliation_edit,
jid_edit])
# Scroll to added row
path = treeview.get_model().get_path(iter_)
treeview.scroll_to_cell(path, None, False, 0, 0)
treeview.get_selection().unselect_all()
treeview.get_selection().select_path(path)
def _on_remove(self, *args):
treeview = self._get_current_treeview()
model, paths = treeview.get_selection().get_selected_rows()
owner_count = self._get_owner_count()
references = []
for path in paths:
if model[path][MUCUser.AFFILIATION] == 'owner':
if owner_count < 2:
# There must be at least one owner
ErrorDialog(_('Error'),
_('A Group Chat needs at least one Owner'))
return
owner_count -= 1
references.append(Gtk.TreeRowReference.new(model, path))
for ref in references:
iter_ = model.get_iter(ref.get_path())
model.remove(iter_)
def _on_jid_edited(self, _renderer, path, new_text):
old_text = self._ui.affiliation_store[path][MUCUser.JID]
if new_text == old_text:
return
if self._jid_exists(new_text):
self._raise_error()
return
self._ui.affiliation_store[path][MUCUser.JID] = new_text
def _on_outcast_jid_edited(self, _renderer, path, new_text):
old_text = self._ui.outcast_store[path][MUCUser.JID]
if new_text == old_text:
return
if self._jid_exists(new_text):
self._raise_error()
return
self._ui.outcast_store[path][MUCUser.JID] = new_text
self._ui.outcast_store[path][MUCUser.AFFILIATION] = 'outcast'
def _on_nick_edited(self, _renderer, path, new_text):
self._ui.affiliation_store[path][MUCUser.NICK] = new_text
def _on_reason_edited(self, _renderer, path, new_text):
self._ui.outcast_store[path][MUCUser.REASON] = new_text
def _on_affiliation_changed(self, cell_renderer_combo,
path_string, new_iter):
combo_store = cell_renderer_combo.get_property('model')
affiliation_text = combo_store.get_value(new_iter, 0)
affiliation = combo_store.get_value(new_iter, 1)
store = self._ui.affiliation_treeview.get_model()
store[path_string][MUCUser.AFFILIATION] = affiliation
store[path_string][MUCUser.AFFILIATION_TEXT] = affiliation_text
def _on_selection_changed(self, tree_selection):
sensitive = bool(tree_selection.count_selected_rows())
selected_affiliations = self._get_selected_affiliations(tree_selection)
self._set_remove_button_state(sensitive, selected_affiliations)
def _jid_exists(self, jid):
stores = [self._ui.affiliation_store, self._ui.outcast_store]
for store in stores:
for row in store:
if row[MUCUser.JID] == jid:
return True
return False
@staticmethod
def _get_selected_affiliations(tree_selection):
model, paths = tree_selection.get_selected_rows()
selected_affiliations = set()
for path in paths:
selected_affiliations.add(model[path][MUCUser.AFFILIATION])
return selected_affiliations
def _on_switch_page(self, stack, _pspec):
page_name = stack.get_visible_child_name()
self._set_button_box_state(page_name)
if page_name == 'config':
return
treeview = getattr(self._ui, '%s_treeview' % page_name)
sensitive = bool(treeview.get_selection().count_selected_rows())
selected_affiliations = self._get_selected_affiliations(
treeview.get_selection())
self._set_remove_button_state(sensitive, selected_affiliations)
def _set_button_box_state(self, page_name):
affiliation = self._own_affiliation in ('admin', 'owner')
page = page_name != 'config'
self._ui.treeview_buttonbox.set_visible(affiliation and page)
self._ui.info_button.set_sensitive(page_name == 'outcast')
def _set_remove_button_state(self, sensitive, selected_affiliations):
if self._own_affiliation not in ('admin', 'owner'):
self._ui.remove_button.set_sensitive(False)
return
self._ui.remove_button.set_tooltip_text('')
if not sensitive:
self._ui.remove_button.set_sensitive(False)
return
if self._own_affiliation == 'owner':
self._ui.remove_button.set_sensitive(True)
return
if set(['owner', 'admin']).intersection(selected_affiliations):
self._ui.remove_button.set_sensitive(False)
self._ui.remove_button.set_tooltip_text(
_('You are not allowed to modify the affiliation '
'of Admins and Owners'))
return
self._ui.remove_button.set_sensitive(True)
def _get_owner_count(self):
owner_count = 0
for row in self._ui.affiliation_store:
if row[MUCUser.AFFILIATION] == 'owner':
owner_count += 1
return owner_count
def _allowed_to_edit(self, affiliation):
if self._own_affiliation == 'owner':
return True, True
if self._own_affiliation == 'admin':
if affiliation in ('admin', 'owner'):
return False, False
return False, True
return False, False
def _on_ok(self, *args):
if self._own_affiliation in ('admin', 'owner'):
self._set_affiliations()
if self._form is not None and self._own_affiliation == 'owner':
form = self._data_form_widget.get_submit_form()
con = app.connections[self.account]
con.get_module('MUC').set_config(self.jid, form)
self.destroy()
def _get_diff(self):
stores = [self._ui.affiliation_store, self._ui.outcast_store]
self._new_affiliations = {}
for store in stores:
for row in store:
if not row[MUCUser.JID]:
# Ignore empty JID field
continue
attr = 'nick'
if row[MUCUser.AFFILIATION] == 'outcast':
attr = 'reason'
self._new_affiliations[row[MUCUser.JID]] = {
'affiliation': row[MUCUser.AFFILIATION],
attr: row[MUCUser.NICK_OR_REASON]}
old_jids = set(self._affiliations.keys())
new_jids = set(self._new_affiliations.keys())
remove = old_jids - new_jids
add = new_jids - old_jids
modified = new_jids - remove - add
return add, remove, modified
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self._on_cancel()
def _on_cancel(self, *args):
self._cancel()
self.destroy()
def _cancel(self, *args):
if self._form and self._own_affiliation == 'owner':
con = app.connections[self.account]
con.get_module('MUC').cancel_config(self.jid)
def _on_destroy(self, *args):
self._destroyed = True
def _set_affiliations(self):
add, remove, modified = self._get_diff()
diff_dict = {}
for jid in add:
diff_dict[jid] = self._new_affiliations[jid]
for jid in remove:
diff_dict[jid] = {'affiliation': 'none'}
for jid in modified:
if self._new_affiliations[jid] == self._affiliations[jid]:
# Not modified
continue
diff_dict[jid] = self._new_affiliations[jid]
if self._new_affiliations[jid]['affiliation'] == 'outcast':
# New affiliation is outcast, check if the reason changed.
# In case the affiliation was 'admin', 'owner' or 'member'
# before, there is no reason.
new_reason = self._new_affiliations[jid]['reason']
old_reason = self._affiliations[jid].get('reason')
if new_reason == old_reason:
diff_dict[jid].pop('reason', None)
else:
# New affiliation is not outcast, check if the nick has changed.
# In case the affiliation was 'outcast' there is no nick.
new_nick = self._new_affiliations[jid]['nick']
old_nick = self._affiliations[jid].get('nick')
if new_nick == old_nick:
diff_dict[jid].pop('nick', None)
if not diff_dict:
# No changes were made
return
con = app.connections[self.account]
con.get_module('MUC').set_affiliation(self.jid, diff_dict)
def _on_affiliations_received(self, task):
affiliation = task.get_user_data()
try:
result = task.finish()
except StanzaError as error:
log.info('Error while requesting %s affiliations: %s',
affiliation, error.condition)
return
if affiliation == 'outcast':
self._ui.stack.get_child_by_name('outcast').show()
for jid, attrs in result.users.items():
affiliation_edit, jid_edit = self._allowed_to_edit(affiliation)
if affiliation == 'outcast':
reason = attrs.get('reason')
self._ui.outcast_store.append(
[str(jid),
reason,
None,
affiliation,
None,
affiliation_edit,
jid_edit])
self._affiliations[jid] = {'affiliation': affiliation,
'reason': reason}
else:
nick = attrs.get('nick')
role = attrs.get('role')
self._ui.affiliation_store.append(
[str(jid),
nick,
role,
affiliation,
_(affiliation.capitalize()),
affiliation_edit,
jid_edit])
self._affiliations[jid] = {'affiliation': affiliation,
'nick': nick}
if role is not None:
self._ui.role_column.set_visible(True)
@staticmethod
def _raise_error():
ErrorDialog(_('Error'),
_('An entry with this XMPP Address already exists'))

View file

@ -0,0 +1,242 @@
# 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 logging
import random
from gi.repository import Gtk
from gi.repository import Gdk
from nbxmpp.errors import StanzaError
from gajim.common import app
from gajim.common.const import MUC_CREATION_EXAMPLES
from gajim.common.const import MUC_DISCO_ERRORS
from gajim.common.i18n import _
from gajim.common.helpers import validate_jid
from gajim.common.helpers import to_user_string
from .dialogs import ErrorDialog
from .util import get_builder
from .util import ensure_not_destroyed
log = logging.getLogger('gajim.gui.groupchat_creation')
class CreateGroupchatWindow(Gtk.ApplicationWindow):
def __init__(self, account):
Gtk.ApplicationWindow.__init__(self)
self.set_name('CreateGroupchat')
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
self.set_default_size(500, -1)
self.set_show_menubar(False)
self.set_resizable(True)
self.set_title(_('Create Group Chat'))
self._ui = get_builder('groupchat_creation.ui')
self.add(self._ui.create_group_chat)
self._destroyed = False
self._account = self._fill_account_combo(account)
self._create_entry_completion()
self._fill_placeholders()
self._ui.connect_signals(self)
self.connect('key-press-event', self._on_key_press_event)
self.connect('destroy', self._on_destroy)
self.show_all()
self.set_focus(self._ui.address_entry)
def _get_muc_service_jid(self):
con = app.connections[self._account]
return str(con.get_module('MUC').service_jid or 'muc.example.com')
def _fill_account_combo(self, account):
accounts = app.get_enabled_accounts_with_labels(connected_only=True)
account_liststore = self._ui.account_combo.get_model()
for acc in accounts:
account_liststore.append(acc)
# Hide account combobox if there is only one account
if len(accounts) == 1:
self._ui.account_combo.hide()
self._ui.account_label.hide()
if account is None:
account = accounts[0][0]
self._ui.account_combo.set_active_id(account)
return account
def _create_entry_completion(self):
entry_completion = Gtk.EntryCompletion()
model = Gtk.ListStore(str)
entry_completion.set_model(model)
entry_completion.set_text_column(0)
entry_completion.set_inline_completion(True)
entry_completion.set_popup_single_match(False)
self._ui.address_entry.set_completion(entry_completion)
def _fill_placeholders(self):
placeholder = random.choice(MUC_CREATION_EXAMPLES)
server = self._get_muc_service_jid()
self._ui.name_entry.set_placeholder_text(
placeholder[0] + _(' (optional)...'))
self._ui.description_entry.set_placeholder_text(
placeholder[1] + _(' (optional)...'))
self._ui.address_entry.set_placeholder_text(
'%s@%s' % (placeholder[2], server))
def _on_key_press_event(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
def _on_account_combo_changed(self, combo):
self._account = combo.get_active_id()
self._fill_placeholders()
def _update_entry_completion(self, entry, text):
text = entry.get_text()
if '@' in text:
text = text.split('@', 1)[0]
model = entry.get_completion().get_model()
model.clear()
server = self._get_muc_service_jid()
model.append(['%s@%s' % (text, server)])
def _validate_jid(self, text):
if not text:
self._set_warning_icon(False)
self._ui.create_button.set_sensitive(False)
return
try:
jid = validate_jid(text)
if jid.resource:
raise ValueError
except ValueError:
self._set_warning(_('Invalid Address'))
else:
self._set_warning_icon(False)
self._ui.create_button.set_sensitive(True)
def _set_processing_state(self, enabled):
if enabled:
self._ui.spinner.start()
self._ui.create_button.set_sensitive(False)
else:
self._ui.spinner.stop()
self._ui.grid.set_sensitive(not enabled)
def _set_warning_icon(self, enabled):
icon = 'dialog-warning-symbolic' if enabled else None
self._ui.address_entry.set_icon_from_icon_name(
Gtk.EntryIconPosition.SECONDARY, icon)
def _set_warning_tooltip(self, text):
self._ui.address_entry.set_icon_tooltip_text(
Gtk.EntryIconPosition.SECONDARY, text)
def _set_warning(self, text):
self._set_warning_icon(True)
self._set_warning_tooltip(text)
self._ui.create_button.set_sensitive(False)
def _set_warning_from_error(self, error):
condition = error.condition
if condition == 'gone':
condition = 'already-exists'
text = MUC_DISCO_ERRORS.get(condition, to_user_string(error))
self._set_warning(text)
def _set_warning_from_error_code(self, error_code):
self._set_warning(MUC_DISCO_ERRORS[error_code])
def _on_address_entry_changed(self, entry):
text = entry.get_text()
self._update_entry_completion(entry, text)
self._validate_jid(text)
def _on_address_entry_activate(self, _widget):
self._on_create_clicked()
def _on_create_clicked(self, *args):
if not app.account_is_available(self._account):
ErrorDialog(
_('Not Connected'),
_('You have to be connected to create a group chat.'))
return
room_jid = self._ui.address_entry.get_text()
self._set_processing_state(True)
con = app.connections[self._account]
con.get_module('Discovery').disco_info(
room_jid, callback=self._disco_info_received)
@ensure_not_destroyed
def _disco_info_received(self, task):
try:
result = task.finish()
except StanzaError as error:
if error.condition == 'item-not-found':
self._create_muc(error.jid)
return
self._set_warning_from_error(error)
else:
self._set_warning_from_error_code(
'already-exists' if result.is_muc else 'not-muc-service')
self._set_processing_state(False)
def _create_muc(self, room_jid):
name = self._ui.name_entry.get_text()
description = self._ui.description_entry.get_text()
is_public = self._ui.public_switch.get_active()
config = {
# XEP-0045 options
'muc#roomconfig_roomname': name,
'muc#roomconfig_roomdesc': description,
'muc#roomconfig_publicroom': is_public,
'muc#roomconfig_membersonly': not is_public,
'muc#roomconfig_whois': 'moderators' if is_public else 'anyone',
'muc#roomconfig_changesubject': not is_public,
# Ejabberd options
'public_list': is_public,
}
# Create new group chat by joining
app.interface.create_groupchat(
self._account,
str(room_jid),
config=config)
self.destroy()
def _on_destroy(self, *args):
self._destroyed = True

286
gajim/gtk/groupchat_info.py Normal file
View file

@ -0,0 +1,286 @@
# 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 time
from gi.repository import Gdk
from gi.repository import GLib
from gi.repository import Gtk
from nbxmpp.namespaces import Namespace
from gajim.common import app
from gajim.common.i18n import _
from gajim.common.i18n import Q_
from gajim.common.helpers import open_uri
from gajim.common.helpers import get_groupchat_name
from gajim.common.const import RFC5646_LANGUAGE_TAGS
from gajim.common.const import AvatarSize
from .util import get_builder
from .util import make_href_markup
MUC_FEATURES = {
'muc_open': (
'feather-globe-symbolic',
Q_('?Group chat feature:Open'),
_('Anyone can join this group chat')),
'muc_membersonly': (
'feather-user-check-symbolic',
Q_('?Group chat feature:Members Only'),
_('This group chat is restricted '
'to members only')),
'muc_nonanonymous': (
'feather-shield-off-symbolic',
Q_('?Group chat feature:Not Anonymous'),
_('All other group chat participants '
'can see your XMPP address')),
'muc_semianonymous': (
'feather-shield-symbolic',
Q_('?Group chat feature:Semi-Anonymous'),
_('Only moderators can see your XMPP address')),
'muc_moderated': (
'feather-mic-off-symbolic',
Q_('?Group chat feature:Moderated'),
_('Participants entering this group chat need '
'to request permission to send messages')),
'muc_unmoderated': (
'feather-mic-symbolic',
Q_('?Group chat feature:Not Moderated'),
_('Participants entering this group chat are '
'allowed to send messages')),
'muc_public': (
'feather-eye-symbolic',
Q_('?Group chat feature:Public'),
_('Group chat can be found via search')),
'muc_hidden': (
'feather-eye-off-symbolic',
Q_('?Group chat feature:Hidden'),
_('This group chat can not be found via search')),
'muc_passwordprotected': (
'feather-lock-symbolic',
Q_('?Group chat feature:Password Required'),
_('This group chat '
'does require a password upon entry')),
'muc_unsecured': (
'feather-unlock-symbolic',
Q_('?Group chat feature:No Password Required'),
_('This group chat does not require '
'a password upon entry')),
'muc_persistent': (
'feather-hard-drive-symbolic',
Q_('?Group chat feature:Persistent'),
_('This group chat persists '
'even if there are no participants')),
'muc_temporary': (
'feather-clock-symbolic',
Q_('?Group chat feature:Temporary'),
_('This group chat will be destroyed '
'once the last participant left')),
'mam': (
'feather-server-symbolic',
Q_('?Group chat feature:Archiving'),
_('Messages are archived on the server')),
}
class GroupChatInfoScrolled(Gtk.ScrolledWindow):
def __init__(self, account=None, options=None):
Gtk.ScrolledWindow.__init__(self)
if options is None:
options = {}
self._minimal = options.get('minimal', False)
self.set_size_request(options.get('width', 400), -1)
self.set_halign(Gtk.Align.CENTER)
if self._minimal:
self.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER)
else:
self.set_vexpand(True)
self.set_min_content_height(400)
self.set_policy(Gtk.PolicyType.NEVER,
Gtk.PolicyType.AUTOMATIC)
self._account = account
self._info = None
self._ui = get_builder('groupchat_info_scrolled.ui')
self.add(self._ui.info_grid)
self._ui.connect_signals(self)
self.show_all()
def get_account(self):
return self._account
def set_account(self, account):
self._account = account
def get_jid(self):
return self._info.jid
def set_author(self, author, epoch_timestamp=None):
has_author = bool(author)
if has_author and epoch_timestamp is not None:
time_ = time.strftime('%c', time.localtime(epoch_timestamp))
author = f'{author} - {time_}'
self._ui.author.set_text(author or '')
self._ui.author.set_visible(has_author)
self._ui.author_label.set_visible(has_author)
def set_subject(self, subject):
has_subject = bool(subject)
subject = GLib.markup_escape_text(subject or '')
self._ui.subject.set_markup(make_href_markup(subject))
self._ui.subject.set_visible(has_subject)
self._ui.subject_label.set_visible(has_subject)
def set_from_disco_info(self, info):
self._info = info
# Set name
if self._account is None:
name = info.muc_name
else:
con = app.connections[self._account]
name = get_groupchat_name(con, info.jid)
self._ui.name.set_text(name)
self._ui.name.set_visible(True)
# Set avatar
surface = app.interface.avatar_storage.get_muc_surface(
self._account,
str(info.jid),
AvatarSize.GROUP_INFO,
self.get_scale_factor())
self._ui.avatar_image.set_from_surface(surface)
# Set description
has_desc = bool(info.muc_description)
self._ui.description.set_text(info.muc_description or '')
self._ui.description.set_visible(has_desc)
self._ui.description_label.set_visible(has_desc)
# Set address
self._ui.address.set_text(str(info.jid))
if self._minimal:
return
# Set subject
self.set_subject(info.muc_subject)
# Set user
has_users = info.muc_users is not None
self._ui.users.set_text(info.muc_users or '')
self._ui.users.set_visible(has_users)
self._ui.users_image.set_visible(has_users)
# Set contacts
self._ui.contact_box.foreach(self._ui.contact_box.remove)
has_contacts = bool(info.muc_contacts)
if has_contacts:
for contact in info.muc_contacts:
self._ui.contact_box.add(self._get_contact_button(contact))
self._ui.contact_box.set_visible(has_contacts)
self._ui.contact_label.set_visible(has_contacts)
# Set discussion logs
has_log_uri = bool(info.muc_log_uri)
self._ui.logs.set_uri(info.muc_log_uri or '')
self._ui.logs.set_label(_('Website'))
self._ui.logs.set_visible(has_log_uri)
self._ui.logs_label.set_visible(has_log_uri)
# Set room language
has_lang = bool(info.muc_lang)
lang = ''
if has_lang:
lang = RFC5646_LANGUAGE_TAGS.get(info.muc_lang, info.muc_lang)
self._ui.lang.set_text(lang)
self._ui.lang.set_visible(has_lang)
self._ui.lang_image.set_visible(has_lang)
self._add_features(info.features)
def _add_features(self, features):
grid = self._ui.info_grid
for row in range(30, 9, -1):
# Remove everything from row 30 to 10
# We probably will never have 30 rows and
# there is no method to count grid rows
grid.remove_row(row)
features = list(features)
if Namespace.MAM_2 in features:
features.append('mam')
row = 10
for feature in MUC_FEATURES:
if feature in features:
icon, name, tooltip = MUC_FEATURES.get(feature,
(None, None, None))
if icon is None:
continue
grid.attach(self._get_feature_icon(icon, tooltip), 0, row, 1, 1)
grid.attach(self._get_feature_label(name), 1, row, 1, 1)
row += 1
grid.show_all()
def _on_copy_address(self, _button):
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.set_text(f'xmpp:{self._info.jid}?join', -1)
@staticmethod
def _on_activate_log_link(button):
open_uri(button.get_uri())
return Gdk.EVENT_STOP
def _on_activate_contact_link(self, button):
open_uri(f'xmpp:{button.get_uri()}?message', account=self._account)
return Gdk.EVENT_STOP
@staticmethod
def _on_activate_subject_link(_label, uri):
# We have to use this, because the default GTK handler
# is not cross-platform compatible
open_uri(uri)
return Gdk.EVENT_STOP
@staticmethod
def _get_feature_icon(icon, tooltip):
image = Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.MENU)
image.set_valign(Gtk.Align.CENTER)
image.set_halign(Gtk.Align.END)
image.set_tooltip_text(tooltip)
return image
@staticmethod
def _get_feature_label(text):
label = Gtk.Label(label=text, use_markup=True)
label.set_halign(Gtk.Align.START)
label.set_valign(Gtk.Align.START)
return label
def _get_contact_button(self, contact):
button = Gtk.LinkButton.new(contact)
button.set_halign(Gtk.Align.START)
button.get_style_context().add_class('link-button')
button.connect('activate-link', self._on_activate_contact_link)
button.show()
return button

View file

@ -0,0 +1,127 @@
# 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/>.
from gi.repository import Gdk
from gi.repository import Gtk
from gajim.common import app
from gajim.common.i18n import _
from gajim.common.helpers import get_group_chat_nick
from .groupchat_info import GroupChatInfoScrolled
from .groupchat_nick import NickChooser
from .util import generate_account_badge
class GroupChatInvitation(Gtk.ApplicationWindow):
def __init__(self, account, event):
Gtk.ApplicationWindow.__init__(self)
self.set_name('GroupChatInvitation')
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_title(_('Group Chat Invitation'))
self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
self.account = account
self._room_jid = str(event.muc)
self._from = str(event.from_)
self._password = event.password
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
main_box.set_valign(Gtk.Align.FILL)
main_box.get_style_context().add_class('padding-18')
muc_info_box = GroupChatInfoScrolled(account, {'minimal': True})
muc_info_box.set_from_disco_info(event.info)
main_box.add(muc_info_box)
separator = Gtk.Separator()
contact_label = Gtk.Label(label=event.get_inviter_name())
contact_label.get_style_context().add_class('bold16')
contact_label.set_line_wrap(True)
contact_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
contact_box.set_halign(Gtk.Align.CENTER)
contact_box.add(contact_label)
enabled_accounts = app.get_enabled_accounts_with_labels()
if len(enabled_accounts) > 1:
account_badge = generate_account_badge(account)
account_badge.set_tooltip_text(
_('Account: %s') % app.get_account_label(account))
contact_box.add(account_badge)
invitation_label = Gtk.Label(
label=_('has invited you to a group chat.\nDo you want to join?'))
invitation_label.set_halign(Gtk.Align.CENTER)
invitation_label.set_justify(Gtk.Justification.CENTER)
invitation_label.set_max_width_chars(50)
invitation_label.set_line_wrap(True)
main_box.add(separator)
main_box.add(contact_box)
main_box.add(invitation_label)
decline_button = Gtk.Button.new_with_mnemonic(_('_Decline'))
decline_button.set_halign(Gtk.Align.START)
decline_button.connect('clicked', self._on_decline)
self._nick_chooser = NickChooser()
self._nick_chooser.set_text(
get_group_chat_nick(self.account, event.info.jid))
join_button = Gtk.Button.new_with_mnemonic(_('_Join'))
join_button.set_halign(Gtk.Align.END)
join_button.get_style_context().add_class('suggested-action')
join_button.connect('clicked', self._on_join)
join_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
join_box.get_style_context().add_class('linked')
join_box.set_halign(Gtk.Align.END)
join_box.add(self._nick_chooser)
join_box.add(join_button)
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
button_box.set_margin_top(6)
button_box.pack_start(decline_button, False, False, 0)
button_box.pack_end(join_box, False, False, 0)
main_box.add(button_box)
self.connect('key-press-event', self._on_key_press_event)
self.add(main_box)
join_button.set_can_default(True)
join_button.grab_focus()
self.show_all()
def _on_key_press_event(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
def _on_join(self, _widget):
nickname = self._nick_chooser.get_text()
app.interface.show_or_join_groupchat(self.account,
self._room_jid,
nick=nickname,
password=self._password)
self.destroy()
def _on_decline(self, _widget):
app.connections[self.account].get_module('MUC').decline(
self._room_jid, self._from)
self.destroy()

View file

@ -0,0 +1,358 @@
# 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 locale
from gi.repository import Gdk
from gi.repository import GLib
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Pango
from gajim.common import app
from gajim.common.i18n import _
from gajim.common.const import AvatarSize
from gajim.common.helpers import validate_jid
from .util import get_builder
from .util import generate_account_badge
class GroupChatInvite(Gtk.Box):
__gsignals__ = {
'listbox-changed': (GObject.SignalFlags.RUN_LAST, None, (bool,))
}
def __init__(self, room_jid):
Gtk.Box.__init__(self)
self.set_size_request(-1, 300)
self._ui = get_builder('groupchat_invite.ui')
self.add(self._ui.invite_grid)
self._ui.contacts_listbox.set_filter_func(self._filter_func, None)
self._ui.contacts_listbox.set_sort_func(self._sort_func, None)
self._ui.contacts_listbox.set_placeholder(self._ui.contacts_placeholder)
self._ui.contacts_listbox.connect('row-activated',
self._on_contacts_row_activated)
self._ui.invitees_listbox.set_sort_func(self._sort_func, None)
self._ui.invitees_listbox.set_placeholder(
self._ui.invitees_placeholder)
self._ui.invitees_listbox.connect('row-activated',
self._on_invitees_row_activated)
self._new_contact_row_visible = False
self._room_jid = room_jid
self._ui.search_entry.connect('search-changed',
self._on_search_changed)
self._ui.search_entry.connect('next-match',
self._select_new_match, 'next')
self._ui.search_entry.connect('previous-match',
self._select_new_match, 'prev')
self._ui.search_entry.connect(
'stop-search', lambda *args: self._ui.search_entry.set_text(''))
self._ui.search_entry.connect('activate',
self._on_search_activate)
self.connect('key-press-event', self._on_key_press)
self._ui.connect_signals(self)
self.show_all()
def _add_accounts(self):
for account in self._accounts:
self._ui.account_store.append([None, *account])
def _add_contacts(self):
show_account = len(self._accounts) > 1
our_jids = app.get_our_jids()
for account, _label in self._accounts:
self.new_contact_rows[account] = None
participant_jids = []
for contact in app.contacts.get_gc_contact_list(
account, self._room_jid):
if contact.jid is not None:
participant_jids.append(app.get_jid_without_resource(
contact.jid))
for jid in app.contacts.get_jid_list(account):
contact = app.contacts.get_contact_with_highest_priority(
account, jid)
# Exclude group chats
if contact.is_groupchat:
continue
# Exclude our own jid
if jid in our_jids:
continue
# Exclude group chat participants
if jid in participant_jids:
continue
row = ContactRow(account, contact, jid,
contact.get_shown_name(), show_account)
self._ui.contacts_listbox.add(row)
def _on_contacts_row_activated(self, listbox, row):
if row.new:
jid = row.jid
try:
validate_jid(jid)
except ValueError as error:
icon = 'dialog-warning-symbolic'
self._ui.search_entry.set_icon_from_icon_name(
Gtk.EntryIconPosition.SECONDARY, icon)
self._ui.search_entry.set_icon_tooltip_text(
Gtk.EntryIconPosition.SECONDARY, str(error))
return
self._ui.search_entry.set_icon_from_icon_name(
Gtk.EntryIconPosition.SECONDARY, None)
show_account = len(self._accounts) > 1
row = ContactRow(
row.account, None, '', None, show_account)
row.update_jid(jid)
self._remove_new_jid_row()
else:
listbox.remove(row)
self._ui.invitees_listbox.add(row)
self._ui.invitees_listbox.unselect_row(row)
self._ui.search_entry.set_text('')
GLib.timeout_add(50, self._select_first_row)
self._ui.search_entry.grab_focus()
invitable = self._ui.invitees_listbox.get_row_at_index(0) is not None
self.emit('listbox-changed', invitable)
def _on_invitees_row_activated(self, listbox, row):
listbox.remove(row)
if not row.new:
self._ui.contacts_listbox.add(row)
self._ui.contacts_listbox.unselect_row(row)
self._ui.search_entry.grab_focus()
invitable = listbox.get_row_at_index(0) is not None
self.emit('listbox-changed', invitable)
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Down:
self._ui.search_entry.emit('next-match')
return Gdk.EVENT_STOP
if event.keyval == Gdk.KEY_Up:
self._ui.search_entry.emit('previous-match')
return Gdk.EVENT_STOP
if event.keyval == Gdk.KEY_Return:
row = self._ui.contacts_listbox.get_selected_row()
if row is not None:
row.emit('activate')
return Gdk.EVENT_STOP
self._ui.search_entry.grab_focus_without_selecting()
return Gdk.EVENT_PROPAGATE
def _on_search_activate(self, _entry):
row = self._ui.contacts_listbox.get_selected_row()
if row is not None and row.get_child_visible():
row.emit('activate')
def _on_search_changed(self, entry):
search_text = entry.get_text()
if '@' in search_text:
self._add_new_jid_row()
self._update_new_jid_rows(search_text)
else:
self._remove_new_jid_row()
self._ui.contacts_listbox.invalidate_filter()
def _add_new_jid_row(self):
if self._new_contact_row_visible:
return
for account in self.new_contact_rows:
show_account = len(self._accounts) > 1
row = ContactRow(account, None, '', None, show_account)
self.new_contact_rows[account] = row
self._ui.contacts_listbox.add(row)
row.get_parent().show_all()
self._new_contact_row_visible = True
def _remove_new_jid_row(self):
if not self._new_contact_row_visible:
return
for account in self.new_contact_rows:
self._ui.contacts_listbox.remove(
self.new_contact_rows[account])
self._new_contact_row_visible = False
def _update_new_jid_rows(self, search_text):
for account in self.new_contact_rows:
self.new_contact_rows[account].update_jid(search_text)
def _select_new_match(self, _entry, direction):
selected_row = self._ui.contacts_listbox.get_selected_row()
if selected_row is None:
return
index = selected_row.get_index()
if direction == 'next':
index += 1
else:
index -= 1
while True:
new_selected_row = self._ui.contacts_listbox.get_row_at_index(index)
if new_selected_row is None:
return
if new_selected_row.get_child_visible():
self._ui.contacts_listbox.select_row(new_selected_row)
new_selected_row.grab_focus()
return
if direction == 'next':
index += 1
else:
index -= 1
def _select_first_row(self):
first_row = self._ui.contacts_listbox.get_row_at_y(0)
self._ui.contacts_listbox.select_row(first_row)
def _scroll_to_first_row(self):
self._ui.scrolledwindow.get_vadjustment().set_value(0)
def _filter_func(self, row, _user_data):
search_text = self._ui.search_entry.get_text().lower()
search_text_list = search_text.split()
row_text = row.get_search_text().lower()
for text in search_text_list:
if text not in row_text:
GLib.timeout_add(50, self._select_first_row)
return None
GLib.timeout_add(50, self._select_first_row)
return True
@staticmethod
def _sort_func(row1, row2, _user_data):
name1 = row1.get_search_text()
name2 = row2.get_search_text()
account1 = row1.account
account2 = row2.account
result = locale.strcoll(account1.lower(), account2.lower())
if result != 0:
return result
return locale.strcoll(name1.lower(), name2.lower())
def load_contacts(self):
self._ui.contacts_listbox.foreach(self._ui.contacts_listbox.remove)
self._ui.invitees_listbox.foreach(self._ui.invitees_listbox.remove)
self._accounts = app.get_enabled_accounts_with_labels()
self.new_contact_rows = {}
self._add_accounts()
self._add_contacts()
first_row = self._ui.contacts_listbox.get_row_at_index(0)
self._ui.contacts_listbox.select_row(first_row)
self._ui.search_entry.grab_focus()
self.emit('listbox-changed', False)
def focus_search_entry(self):
self._ui.search_entry.grab_focus()
def get_invitees(self):
invitees = []
for row in self._ui.invitees_listbox.get_children():
invitees.append(row.jid)
return invitees
class ContactRow(Gtk.ListBoxRow):
def __init__(self, account, contact, jid, name, show_account):
Gtk.ListBoxRow.__init__(self)
self.get_style_context().add_class('start-chat-row')
self.account = account
self.account_label = app.get_account_label(account)
self.show_account = show_account
self.jid = jid
self.contact = contact
self.name = name
self.new = jid == ''
show = contact.show if contact else 'offline'
grid = Gtk.Grid()
grid.set_column_spacing(12)
grid.set_size_request(260, -1)
image = self._get_avatar_image(account, jid, show)
image.set_size_request(AvatarSize.ROSTER, AvatarSize.ROSTER)
grid.add(image)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
box.set_hexpand(True)
if self.name is None:
self.name = _('Invite New Contact')
self.name_label = Gtk.Label(label=self.name)
self.name_label.set_ellipsize(Pango.EllipsizeMode.END)
self.name_label.set_xalign(0)
self.name_label.set_width_chars(20)
self.name_label.set_halign(Gtk.Align.START)
self.name_label.get_style_context().add_class('bold16')
name_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
name_box.add(self.name_label)
if show_account:
account_badge = generate_account_badge(account)
account_badge.set_tooltip_text(
_('Account: %s' % self.account_label))
account_badge.set_halign(Gtk.Align.END)
account_badge.set_valign(Gtk.Align.START)
account_badge.set_hexpand(True)
name_box.add(account_badge)
box.add(name_box)
self.jid_label = Gtk.Label(label=jid)
self.jid_label.set_tooltip_text(jid)
self.jid_label.set_ellipsize(Pango.EllipsizeMode.END)
self.jid_label.set_xalign(0)
self.jid_label.set_width_chars(22)
self.jid_label.set_halign(Gtk.Align.START)
self.jid_label.get_style_context().add_class('dim-label')
box.add(self.jid_label)
grid.add(box)
self.add(grid)
self.show_all()
def _get_avatar_image(self, account, jid, show):
if self.new:
icon_name = 'avatar-default'
return Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DND)
scale = self.get_scale_factor()
avatar = app.contacts.get_avatar(
account, jid, AvatarSize.ROSTER, scale, show)
return Gtk.Image.new_from_surface(avatar)
def update_jid(self, jid):
self.jid = jid
self.jid_label.set_text(jid)
def get_search_text(self):
if self.contact is None:
return self.jid
if self.show_account:
return '%s %s %s' % (self.name, self.jid, self.account_label)
return '%s %s' % (self.name, self.jid)

195
gajim/gtk/groupchat_join.py Normal file
View file

@ -0,0 +1,195 @@
# 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/>.
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import Pango
from nbxmpp.errors import StanzaError
from gajim.common import app
from gajim.common.i18n import _
from gajim.common.i18n import get_rfc5646_lang
from gajim.common.helpers import to_user_string
from gajim.common.helpers import get_group_chat_nick
from gajim.common.const import MUC_DISCO_ERRORS
from .groupchat_info import GroupChatInfoScrolled
from .groupchat_nick import NickChooser
from .util import ensure_not_destroyed
class GroupchatJoin(Gtk.ApplicationWindow):
def __init__(self, account, jid):
Gtk.ApplicationWindow.__init__(self)
self.set_name('GroupchatJoin')
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_title(_('Join Group Chat'))
self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
self._destroyed = False
self.account = account
self.jid = jid
self._redirected = False
self._main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL,
spacing=18)
self._main_box.set_valign(Gtk.Align.FILL)
self._muc_info_box = GroupChatInfoScrolled(account)
self._stack = Gtk.Stack()
self._stack.add_named(self._muc_info_box, 'info')
self._stack.add_named(ProgressPage(), 'progress')
self._stack.add_named(ErrorPage(), 'error')
self._stack.set_visible_child_name('progress')
self._stack.get_visible_child().start()
self._stack.connect('notify::visible-child-name',
self._on_page_changed)
self._main_box.add(self._stack)
self._nick_chooser = NickChooser()
self._join_button = Gtk.Button.new_with_mnemonic(_('_Join'))
self._join_button.set_halign(Gtk.Align.END)
self._join_button.set_sensitive(False)
self._join_button.set_can_default(True)
self._join_button.get_style_context().add_class('suggested-action')
self._join_button.connect('clicked', self._on_join)
join_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
join_box.get_style_context().add_class('linked')
join_box.set_halign(Gtk.Align.END)
join_box.add(self._nick_chooser)
join_box.add(self._join_button)
self._main_box.add(join_box)
self.connect('key-press-event', self._on_key_press_event)
self.connect('destroy', self._on_destroy)
self.add(self._main_box)
self.show_all()
con = app.connections[self.account]
con.get_module('Discovery').disco_muc(
jid,
allow_redirect=True,
request_vcard=True,
callback=self._disco_info_received)
def _on_page_changed(self, stack, _param):
name = stack.get_visible_child_name()
self._join_button.set_sensitive(name == 'info')
self._nick_chooser.set_sensitive(name == 'info')
@ensure_not_destroyed
def _disco_info_received(self, task):
try:
result = task.finish()
except StanzaError as error:
self._log.info('Disco %s failed: %s', error.jid, error.get_text())
self._set_error(error)
return
if result.redirected:
self.jid = result.info.jid
if result.info.is_muc:
self._muc_info_box.set_from_disco_info(result.info)
nickname = get_group_chat_nick(self.account, result.info.jid)
self._nick_chooser.set_text(nickname)
self._join_button.grab_default()
self._stack.set_visible_child_name('info')
else:
self._set_error_from_code('not-muc-service')
def _show_error_page(self, text):
self._stack.get_child_by_name('error').set_text(text)
self._stack.set_visible_child_name('error')
def _set_error(self, error):
text = MUC_DISCO_ERRORS.get(error.condition, to_user_string(error))
if error.condition == 'gone':
reason = error.get_text(get_rfc5646_lang())
if reason:
text = '%s:\n%s' % (text, reason)
self._show_error_page(text)
def _set_error_from_code(self, error_code):
self._show_error_page(MUC_DISCO_ERRORS[error_code])
def _on_join(self, *args):
nickname = self._nick_chooser.get_text()
app.interface.show_or_join_groupchat(
self.account, self.jid, nick=nickname)
self.destroy()
def _on_destroy(self, *args):
self._destroyed = True
def _on_key_press_event(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
class ErrorPage(Gtk.Box):
def __init__(self):
Gtk.Box.__init__(self,
orientation=Gtk.Orientation.VERTICAL,
spacing=18)
self.set_vexpand(True)
self.set_homogeneous(True)
error_icon = Gtk.Image.new_from_icon_name(
'dialog-error', Gtk.IconSize.DIALOG)
error_icon.set_valign(Gtk.Align.END)
self._error_label = Gtk.Label()
self._error_label.set_justify(Gtk.Justification.CENTER)
self._error_label.set_valign(Gtk.Align.START)
self._error_label.get_style_context().add_class('bold16')
self._error_label.set_line_wrap(True)
self._error_label.set_line_wrap_mode(Pango.WrapMode.WORD)
self._error_label.set_size_request(150, -1)
self.add(error_icon)
self.add(self._error_label)
self.show_all()
def set_text(self, text):
self._error_label.set_text(text)
class ProgressPage(Gtk.Box):
def __init__(self):
Gtk.Box.__init__(self,
orientation=Gtk.Orientation.VERTICAL,
spacing=18)
self.set_vexpand(True)
self.set_homogeneous(True)
self._spinner = Gtk.Spinner()
self.add(self._spinner)
self.show_all()
def start(self):
self._spinner.start()
def stop(self):
self._spinner.stop()

View file

@ -0,0 +1,57 @@
# 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/>.
from gi.repository import Gtk
from nbxmpp.protocol import InvalidJid
from nbxmpp.protocol import validate_resourcepart
from .util import get_builder
class NickChooser(Gtk.MenuButton):
def __init__(self):
Gtk.MenuButton.__init__(self)
self._ui = get_builder('groupchat_nick_chooser.ui')
self.add(self._ui.button_content)
self.set_receives_default(False)
self.set_popover(self._ui.popover)
self._ui.popover.set_default_widget(
self._ui.apply_button)
self.connect('toggled', self._on_nickname_button_toggled)
self._ui.entry.connect('changed', self._on_nickname_changed)
self._ui.apply_button.connect('clicked', self._on_apply_nickname)
def get_text(self):
return self._ui.entry.get_text()
def set_text(self, text):
self._ui.entry.set_text(text)
self._ui.label.set_text(text)
def _on_nickname_button_toggled(self, _widget):
self._ui.entry.grab_focus()
def _on_nickname_changed(self, entry):
try:
validate_resourcepart(entry.get_text())
self._ui.apply_button.set_sensitive(True)
except InvalidJid:
self._ui.apply_button.set_sensitive(False)
def _on_apply_nickname(self, _button):
nickname = self._ui.entry.get_text()
self._ui.popover.popdown()
self._ui.label.set_text(nickname)

View file

@ -0,0 +1,484 @@
# 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/>.
from typing import Optional
import locale
from enum import IntEnum
from gi.repository import Gtk
from gi.repository import GLib
from gi.repository import GObject
from nbxmpp.const import Role
from nbxmpp.const import Affiliation
from gajim.common import app
from gajim.common import ged
from gajim.common.helpers import get_uf_role
from gajim.common.helpers import get_uf_affiliation
from gajim.common.helpers import jid_is_blocked
from gajim.common.helpers import event_filter
from gajim.common.const import AvatarSize
from gajim.common.const import StyleAttr
from gajim.gui_menu_builder import get_groupchat_roster_menu
from .tooltips import GCTooltip
from .util import get_builder
from .util import EventHelper
AffiliationRoleSortOrder = {
'owner': 0,
'admin': 1,
'moderator': 2,
'participant': 3,
'visitor': 4
}
class Column(IntEnum):
AVATAR = 0
TEXT = 1
EVENT = 2
IS_CONTACT = 3
NICK_OR_GROUP = 4
class GroupchatRoster(Gtk.ScrolledWindow, EventHelper):
__gsignals__ = {
'row-activated': (
GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION,
None, # return value
(str, )) # arguments
}
def __init__(self, account, room_jid, control):
Gtk.ScrolledWindow.__init__(self)
EventHelper.__init__(self)
self.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self.get_style_context().add_class('groupchat-roster')
self._account = account
self.room_jid = room_jid
self._control = control
self._control_id = control.control_id
self._show_roles = True
self._handler_ids = {}
self._tooltip = GCTooltip()
self._ui = get_builder('groupchat_roster.ui')
self._ui.roster_treeview.set_model(None)
self.add(self._ui.roster_treeview)
# Holds the Gtk.TreeRowReference for each contact
self._contact_refs = {}
# Holds the Gtk.TreeRowReference for each group
self._group_refs = {}
self._store = self._ui.participant_store
self._store.set_sort_func(Column.TEXT, self._tree_compare_iters)
self._roster = self._ui.roster_treeview
self._roster.set_search_equal_func(self._search_func)
self._ui.contact_column.set_fixed_width(
app.settings.get('groupchat_roster_width'))
self._ui.contact_column.set_cell_data_func(self._ui.text_renderer,
self._text_cell_data_func)
self.connect('destroy', self._on_destroy)
self._ui.connect_signals(self)
self.register_events([
('theme-update', ged.GUI2, self._on_theme_update),
('update-gc-avatar', ged.GUI1, self._on_avatar_update),
])
@staticmethod
def _on_focus_out(treeview, _param):
treeview.get_selection().unselect_all()
def set_model(self):
self._roster.set_model(self._store)
def set_show_roles(self, enabled):
self._show_roles = enabled
def enable_tooltips(self):
if self._roster.get_tooltip_window():
return
self._roster.set_has_tooltip(True)
id_ = self._roster.connect('query-tooltip', self._query_tooltip)
self._handler_ids[id_] = self._roster
def _query_tooltip(self, widget, x_pos, y_pos, _keyboard_mode, tooltip):
try:
row = self._roster.get_path_at_pos(x_pos, y_pos)[0]
except TypeError:
self._tooltip.clear_tooltip()
return False
if not row:
self._tooltip.clear_tooltip()
return False
iter_ = None
try:
iter_ = self._store.get_iter(row)
except Exception:
self._tooltip.clear_tooltip()
return False
if not self._store[iter_][Column.IS_CONTACT]:
self._tooltip.clear_tooltip()
return False
nickname = self._store[iter_][Column.NICK_OR_GROUP]
contact = app.contacts.get_gc_contact(self._account,
self.room_jid,
nickname)
if contact is None:
self._tooltip.clear_tooltip()
return False
value, widget = self._tooltip.get_tooltip(contact)
tooltip.set_custom(widget)
return value
@staticmethod
def _search_func(model, _column, search_text, iter_):
return search_text.lower() not in model[iter_][1].lower()
def _get_group_iter(self, group_name: str) -> Optional[Gtk.TreeIter]:
try:
ref = self._group_refs[group_name]
except KeyError:
return None
path = ref.get_path()
if path is None:
return None
return self._store.get_iter(path)
def _get_contact_iter(self, nick: str) -> Optional[Gtk.TreeIter]:
try:
ref = self._contact_refs[nick]
except KeyError:
return None
path = ref.get_path()
if path is None:
return None
return self._store.get_iter(path)
def add_contact(self, nick):
contact = app.contacts.get_gc_contact(self._account,
self.room_jid,
nick)
group_name, group_text = self._get_group_from_contact(contact)
# Create Group
group_iter = self._get_group_iter(group_name)
role_path = None
if not group_iter:
group_iter = self._store.append(
None, (None, group_text, None, False, group_name))
role_path = self._store.get_path(group_iter)
group_ref = Gtk.TreeRowReference(self._store, role_path)
self._group_refs[group_name] = group_ref
# Avatar
surface = app.interface.get_avatar(contact,
AvatarSize.ROSTER,
self.get_scale_factor(),
contact.show.value)
iter_ = self._store.append(group_iter,
(surface, nick, None, True, nick))
self._contact_refs[nick] = Gtk.TreeRowReference(
self._store, self._store.get_path(iter_))
self.draw_groups()
self.draw_contact(nick)
if (role_path is not None and
self._roster.get_model() is not None):
self._roster.expand_row(role_path, False)
def remove_contact(self, nick):
"""
Remove a user
"""
iter_ = self._get_contact_iter(nick)
if not iter_:
return
group_iter = self._store.iter_parent(iter_)
if group_iter is None:
raise ValueError('Trying to remove non-child')
self._store.remove(iter_)
del self._contact_refs[nick]
if not self._store.iter_has_child(group_iter):
group = self._store[group_iter][Column.NICK_OR_GROUP]
del self._group_refs[group]
self._store.remove(group_iter)
@staticmethod
def _get_group_from_contact(contact):
if contact.affiliation in (Affiliation.OWNER, Affiliation.ADMIN):
return contact.affiliation.value, get_uf_affiliation(
contact.affiliation, plural=True)
return contact.role.value, get_uf_role(contact.role, plural=True)
@staticmethod
def _text_cell_data_func(_column, renderer, model, iter_, _user_data):
has_parent = bool(model.iter_parent(iter_))
style = 'contact' if has_parent else 'group'
bgcolor = app.css_config.get_value('.gajim-%s-row' % style,
StyleAttr.BACKGROUND)
renderer.set_property('cell-background', bgcolor)
color = app.css_config.get_value('.gajim-%s-row' % style,
StyleAttr.COLOR)
renderer.set_property('foreground', color)
desc = app.css_config.get_font('.gajim-%s-row' % style)
renderer.set_property('font-desc', desc)
if not has_parent:
renderer.set_property('weight', 600)
renderer.set_property('ypad', 6)
def _on_roster_row_activated(self, _treeview, path, _column):
iter_ = self._store.get_iter(path)
if self._store.iter_parent(iter_) is None:
# This is a group row
return
nick = self._store[iter_][Column.NICK_OR_GROUP]
if self._control.nick == nick:
return
self.emit('row-activated', nick)
def _on_roster_button_press_event(self, treeview, event):
if event.button not in (2, 3):
return
pos = treeview.get_path_at_pos(int(event.x), int(event.y))
if pos is None:
return
path, _, _, _ = pos
iter_ = self._store.get_iter(path)
if self._store.iter_parent(iter_) is None:
# Group row
return
nick = self._store[iter_][Column.NICK_OR_GROUP]
if self._control.nick == nick:
return
if event.button == 3: # right click
self._show_contact_menu(nick)
if event.button == 2: # middle click
self.emit('row-activated', nick)
def _show_contact_menu(self, nick):
self_contact = app.contacts.get_gc_contact(
self._account, self.room_jid, self._control.nick)
contact = app.contacts.get_gc_contact(
self._account, self.room_jid, nick)
menu = get_groupchat_roster_menu(self._account,
self._control_id,
self_contact,
contact)
def destroy(menu, _pspec):
visible = menu.get_property('visible')
if not visible:
GLib.idle_add(menu.destroy)
menu.attach_to_widget(self, None)
menu.connect('notify::visible', destroy)
menu.popup_at_pointer()
def _tree_compare_iters(self, model, iter1, iter2, _user_data):
"""
Compare two iterators to sort them
"""
is_contact = model.iter_parent(iter1)
if is_contact:
# Sort contacts with pending events to top
if model[iter1][Column.EVENT] != model[iter2][Column.EVENT]:
return -1 if model[iter1][Column.EVENT] else 1
nick1 = model[iter1][Column.NICK_OR_GROUP]
nick2 = model[iter2][Column.NICK_OR_GROUP]
if not app.settings.get('sort_by_show_in_muc'):
return locale.strcoll(nick1.lower(), nick2.lower())
gc_contact1 = app.contacts.get_gc_contact(self._account,
self.room_jid,
nick1)
gc_contact2 = app.contacts.get_gc_contact(self._account,
self.room_jid,
nick2)
if gc_contact1.show != gc_contact2.show:
return -1 if gc_contact1.show > gc_contact2.show else 1
return locale.strcoll(nick1.lower(), nick2.lower())
# Group
group1 = model[iter1][Column.NICK_OR_GROUP]
group2 = model[iter2][Column.NICK_OR_GROUP]
group1_index = AffiliationRoleSortOrder[group1]
group2_index = AffiliationRoleSortOrder[group2]
return -1 if group1_index < group2_index else 1
def enable_sort(self, enable):
column = Gtk.TREE_SORTABLE_UNSORTED_SORT_COLUMN_ID
if enable:
column = Column.TEXT
self._store.set_sort_column_id(column, Gtk.SortType.ASCENDING)
def invalidate_sort(self):
self.enable_sort(False)
self.enable_sort(True)
def initial_draw(self):
self.enable_sort(True)
self.set_model()
self._roster.expand_all()
def redraw(self):
self._roster.set_model(None)
self._roster.set_model(self._store)
self._roster.expand_all()
def draw_contact(self, nick):
iter_ = self._get_contact_iter(nick)
if not iter_:
return
gc_contact = app.contacts.get_gc_contact(
self._account, self.room_jid, nick)
self.draw_avatar(gc_contact)
if app.events.get_events(self._account, self.room_jid + '/' + nick):
self._store[iter_][Column.EVENT] = True
else:
self._store[iter_][Column.EVENT] = False
name = GLib.markup_escape_text(gc_contact.name)
# Strike name if blocked
fjid = self.room_jid + '/' + nick
if jid_is_blocked(self._account, fjid):
name = '<span strikethrough="true">%s</span>' % name
# add status msg, if not empty, under contact name
status = gc_contact.status
if status is not None:
status = status.strip()
if status and app.settings.get('show_status_msgs_in_roster'):
# Display only first line
status = status.split('\n', 1)[0]
# escape markup entities and make them small italic and fg color
name += ('\n<span size="small" style="italic" alpha="70%">'
'{}</span>'.format(GLib.markup_escape_text(status)))
self._store[iter_][Column.TEXT] = name
def draw_contacts(self):
for nick in self._contact_refs:
self.draw_contact(nick)
def draw_group(self, group):
group_iter = self._get_group_iter(group)
if not group_iter:
return
if group in ('owner', 'admin'):
group_text = get_uf_affiliation(group, plural=True)
else:
group_text = get_uf_role(group, plural=True)
total_users = self._get_total_user_count()
group_users = self._store.iter_n_children(group_iter)
group_text += ' (%s/%s)' % (group_users, total_users)
self._store[group_iter][Column.TEXT] = group_text
def draw_groups(self):
for group in self._group_refs:
self.draw_group(group)
def draw_avatar(self, contact):
iter_ = self._get_contact_iter(contact.name)
if iter_ is None:
return
surface = app.interface.get_avatar(contact,
AvatarSize.ROSTER,
self.get_scale_factor(),
contact.show.value)
self._store[iter_][Column.AVATAR] = surface
def _get_total_user_count(self):
count = 0
for group_row in self._store:
count += self._store.iter_n_children(group_row.iter)
return count
def get_role(self, nick):
gc_contact = app.contacts.get_gc_contact(
self._account, self.room_jid, nick)
if gc_contact:
return gc_contact.role
return Role.VISITOR
def _on_theme_update(self, _event):
self.redraw()
@event_filter(['room_jid'])
def _on_avatar_update(self, event):
self.draw_avatar(event.contact)
def clear(self):
self._contact_refs = {}
self._group_refs = {}
self._store.clear()
def _on_destroy(self, _roster):
for id_ in list(self._handler_ids.keys()):
if self._handler_ids[id_].handler_is_connected(id_):
self._handler_ids[id_].disconnect(id_)
del self._handler_ids[id_]
self._contact_refs = {}
self._group_refs = {}
self._control = None
self._roster.set_model(None)
self._roster = None
self._store.clear()
self._store = None
self._tooltip.destroy()
self._tooltip = None

View file

@ -0,0 +1,89 @@
# 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/>.
from gi.repository import Gtk
from gajim.common.const import THRESHOLD_OPTIONS
from gajim.common.i18n import _
from .const import Setting
from .const import SettingKind
from .const import SettingType
from .settings import SettingsBox
class GroupChatSettings(SettingsBox):
def __init__(self, account, jid):
SettingsBox.__init__(self, account, jid)
self.get_style_context().add_class('settings-border')
self.set_selection_mode(Gtk.SelectionMode.NONE)
self.set_valign(Gtk.Align.START)
self.set_halign(Gtk.Align.CENTER)
chat_state = {
'disabled': _('Disabled'),
'composing_only': _('Composing Only'),
'all': _('All Chat States')
}
settings = [
Setting(SettingKind.SWITCH,
_('Show Join/Leave'),
SettingType.GROUP_CHAT,
'print_join_left'),
Setting(SettingKind.SWITCH,
_('Show Status Changes'),
SettingType.GROUP_CHAT,
'print_status'),
Setting(SettingKind.SWITCH,
_('Notify on all Messages'),
SettingType.GROUP_CHAT,
'notify_on_all_messages'),
Setting(SettingKind.SWITCH,
_('Minimize on Close'),
SettingType.GROUP_CHAT,
'minimize_on_close'),
Setting(SettingKind.SWITCH,
_('Minimize When Joining Automatically'),
SettingType.GROUP_CHAT,
'minimize_on_autojoin'),
Setting(SettingKind.POPOVER,
_('Send Chat State'),
SettingType.GROUP_CHAT,
'send_chatstate',
props={'entries': chat_state}),
Setting(SettingKind.SWITCH,
_('Send Chat Markers'),
SettingType.GROUP_CHAT,
'send_marker',
desc=_('Let others know if you read up to this point')),
Setting(SettingKind.POPOVER,
_('Sync Threshold'),
SettingType.GROUP_CHAT,
'sync_threshold',
props={'entries': THRESHOLD_OPTIONS}),
]
for setting in settings:
self.add_setting(setting)
self.update_states()

39
gajim/gtk/gstreamer.py Normal file
View file

@ -0,0 +1,39 @@
# 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/>.
try:
from gi.repository import Gst
except Exception:
pass
def create_gtk_widget():
gtkglsink = Gst.ElementFactory.make('gtkglsink', None)
if gtkglsink is not None:
glsinkbin = Gst.ElementFactory.make('glsinkbin', None)
if glsinkbin is None:
return None, None, None
glsinkbin.set_property('sink', gtkglsink)
sink = glsinkbin
widget = gtkglsink.get_property('widget')
name = 'gtkglsink'
else:
sink = Gst.ElementFactory.make('gtksink', None)
if sink is None:
return None, None, None
widget = sink.get_property('widget')
name = 'gtksink'
widget.set_visible(True)
widget.set_property('expand', True)
return sink, widget, name

815
gajim/gtk/history.py Normal file
View file

@ -0,0 +1,815 @@
# Copyright (C) 2003-2014 Yann Leboulanger <asterix AT lagaule.org>
# Copyright (C) 2005 Vincent Hanquez <tab AT snarc.org>
# Copyright (C) 2005-2006 Nikos Kouremenos <kourem AT gmail.com>
# Copyright (C) 2006 Dimitur Kirov <dkirov AT gmail.com>
# Travis Shirk <travis AT pobox.com>
# Copyright (C) 2006-2008 Jean-Marie Traissard <jim AT lapin.org>
# Copyright (C) 2007-2008 Stephan Erb <steve-e AT h3c.de>
# Copyright (C) 2008 Brendan Taylor <whateley AT gmail.com>
#
# 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 time
import datetime
from enum import IntEnum
from enum import unique
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import GLib
from gajim.common import app
from gajim.common import helpers
from gajim.common import exceptions
from gajim.common.i18n import _
from gajim.common.const import ShowConstant
from gajim.common.const import KindConstant
from gajim.common.const import StyleAttr
from gajim import conversation_textview
from .util import python_month
from .util import gtk_month
from .util import resize_window
from .util import move_window
from .util import get_icon_name
from .util import get_completion_liststore
from .util import get_builder
from .util import scroll_to_end
from .dialogs import ErrorDialog
@unique
class InfoColumn(IntEnum):
'''Completion dict'''
JID = 0
ACCOUNT = 1
NAME = 2
COMPLETION = 3
@unique
class Column(IntEnum):
LOG_JID = 0
CONTACT_NAME = 1
UNIXTIME = 2
MESSAGE = 3
TIME = 4
LOG_LINE_ID = 5
class HistoryWindow(Gtk.ApplicationWindow):
def __init__(self, jid=None, account=None):
Gtk.ApplicationWindow.__init__(self)
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_title(_('Conversation History'))
self._ui = get_builder('history_window.ui')
self.add(self._ui.history_box)
self.history_textview = conversation_textview.ConversationTextview(
account, used_in_history_window=True)
self._ui.scrolledwindow.add(self.history_textview.tv)
self.history_buffer = self.history_textview.tv.get_buffer()
highlight_color = app.css_config.get_value(
'.gajim-search-highlight', StyleAttr.COLOR)
self.history_buffer.create_tag('highlight', background=highlight_color)
self.history_buffer.create_tag('invisible', invisible=True)
self.clearing_search = False
# jid, contact_name, date, message, time, log_line_id
model = Gtk.ListStore(str, str, str, str, str, int)
self._ui.results_treeview.set_model(model)
col = Gtk.TreeViewColumn(_('Name'))
self._ui.results_treeview.append_column(col)
renderer = Gtk.CellRendererText()
col.pack_start(renderer, True)
col.add_attribute(renderer, 'text', Column.CONTACT_NAME)
# user can click this header and sort
col.set_sort_column_id(Column.CONTACT_NAME)
col.set_resizable(True)
col = Gtk.TreeViewColumn(_('Date'))
self._ui.results_treeview.append_column(col)
renderer = Gtk.CellRendererText()
col.pack_start(renderer, True)
col.add_attribute(renderer, 'text', Column.UNIXTIME)
# user can click this header and sort
col.set_sort_column_id(Column.UNIXTIME)
col.set_resizable(True)
col = Gtk.TreeViewColumn(_('Message'))
self._ui.results_treeview.append_column(col)
renderer = Gtk.CellRendererText()
col.pack_start(renderer, True)
col.add_attribute(renderer, 'text', Column.MESSAGE)
col.set_resizable(True)
self.jid = None # The history we are currently viewing
self.account = account
self.completion_dict = {}
self.accounts_seen_online = [] # Update dict when new accounts connect
self.jids_to_search = []
# This will load history too
task = self._fill_completion_dict()
GLib.idle_add(next, task)
if jid:
self._ui.query_entry.get_child().set_text(jid)
else:
self._load_history(None)
resize_window(self,
app.settings.get('history_window_width'),
app.settings.get('history_window_height'))
move_window(self,
app.settings.get('history_window_x-position'),
app.settings.get('history_window_y-position'))
self._ui.connect_signals(self)
self.connect('delete-event', self._on_delete)
self.connect('destroy', self._on_destroy)
self.connect('key-press-event', self._on_key_press)
self.show_all()
# PluginSystem: adding GUI extension point for
# HistoryWindow instance object
app.plugin_manager.gui_extension_point(
'history_window', self)
def _fill_completion_dict(self):
"""
Fill completion_dict for key auto completion. Then load history for
current jid (by calling another function)
Key will be either jid or full_completion_name (contact name or long
description like "pm-contact from groupchat....").
{key : (jid, account, nick_name, full_completion_name}
This is a generator and does pseudo-threading via idle_add().
"""
liststore = get_completion_liststore(
self._ui.query_entry.get_child())
liststore.set_sort_column_id(1, Gtk.SortType.ASCENDING)
self._ui.query_entry.get_child().get_completion().connect(
'match-selected', self.on_jid_entry_match_selected)
self._ui.query_entry.set_model(liststore)
# Add all jids in logs.db:
db_jids = app.storage.archive.get_jids_in_db()
completion_dict = dict.fromkeys(db_jids)
self.accounts_seen_online = list(app.contacts.get_accounts())
# Enhance contacts of online accounts with contact.
# Needed for mapping below
for account in self.accounts_seen_online:
completion_dict.update(
helpers.get_contact_dict_for_account(account))
muc_active_icon = get_icon_name('muc-active')
online_icon = get_icon_name('online')
keys = list(completion_dict.keys())
# Move the actual jid at first so we load history faster
actual_jid = self._ui.query_entry.get_child().get_text()
if actual_jid in keys:
keys.remove(actual_jid)
keys.insert(0, actual_jid)
if '' in keys:
keys.remove('')
if None in keys:
keys.remove(None)
# Map jid to info tuple
# Warning : This for is time critical with big DB
for key in keys:
completed = key
completed2 = None
contact = completion_dict[completed]
if contact:
info_name = contact.get_shown_name()
info_completion = info_name
info_jid = contact.jid
else:
# Corresponding account is offline, we know nothing
info_name = completed.split('@')[0]
info_completion = completed
info_jid = completed
info_acc = self._get_account_for_jid(info_jid)
if (app.storage.archive.jid_is_room_jid(completed) or
app.storage.archive.jid_is_from_pm(completed)):
icon = muc_active_icon
if app.storage.archive.jid_is_from_pm(completed):
# It's PM. Make it easier to find
room, nick = app.get_room_and_nick_from_fjid(completed)
info_completion = '%s from %s' % (nick, room)
completed = info_completion
info_completion2 = '%s/%s' % (room, nick)
completed2 = info_completion2
info_name = nick
else:
icon = online_icon
if len(completed) > 70:
completed = completed[:70] + '[\u2026]'
liststore.append((icon, completed))
self.completion_dict[key] = (
info_jid, info_acc, info_name, info_completion)
self.completion_dict[completed] = (
info_jid, info_acc, info_name, info_completion)
if completed2:
if len(completed2) > 70:
completed2 = completed2[:70] + '[\u2026]'
liststore.append((icon, completed2))
self.completion_dict[completed2] = (
info_jid, info_acc, info_name, info_completion2)
if key == actual_jid:
self._load_history(info_jid, self.account or info_acc)
yield True
keys.sort()
yield False
def _get_account_for_jid(self, jid):
"""
Return the corresponding account of the jid. May be None if an account
could not be found
"""
accounts = app.contacts.get_accounts()
account = None
for acc in accounts:
jid_list = app.contacts.get_jid_list(acc)
gc_list = app.contacts.get_gc_list(acc)
if jid in jid_list or jid in gc_list:
account = acc
break
return account
def _on_delete(self, widget, *args):
self.save_state()
def _on_destroy(self, widget):
# PluginSystem: removing GUI extension points connected with
# HistoryWindow instance object
app.plugin_manager.remove_gui_extension_point(
'history_window', self)
self.history_textview.del_handlers()
def _on_key_press(self, widget, event):
if event.keyval == Gdk.KEY_Escape:
if self._ui.results_scrolledwindow.get_visible():
self._ui.results_scrolledwindow.set_visible(False)
return
self.save_state()
self.destroy()
def on_jid_entry_match_selected(self, widget, model, iter_, *args):
self._jid_entry_search(model[iter_][1])
return True
def on_jid_entry_changed(self, widget):
# only if selected from combobox
jid = self._ui.query_entry.get_child().get_text()
if jid == self._ui.query_entry.get_active_id():
self._jid_entry_search(jid)
def on_jid_entry_activate(self, widget):
self._jid_entry_search(self._ui.query_entry.get_child().get_text())
def _jid_entry_search(self, jid):
self._load_history(jid, self.account)
self._ui.results_scrolledwindow.set_visible(False)
def _load_history(self, jid_or_name, account=None):
"""
Load history for the given jid/name and show it
"""
if jid_or_name and jid_or_name in self.completion_dict:
# a full qualified jid or a contact name was entered
info_jid, info_account, _info_name, info_completion = self.completion_dict[jid_or_name]
self.jids_to_search = [info_jid]
self.jid = info_jid
if account:
self.account = account
else:
self.account = info_account
if self.account is None:
# We don't know account. Probably a gc not opened or an
# account not connected.
# Disable possibility to say if we want to log or not
self._ui.log_history_checkbutton.set_sensitive(False)
else:
# Are log disabled for account ?
if self.account in app.settings.get_account_setting(
self.account, 'no_log_for').split(' '):
self._ui.log_history_checkbutton.set_active(False)
self._ui.log_history_checkbutton.set_sensitive(False)
else:
# Are log disabled for jid ?
log = True
if self.jid in app.settings.get_account_setting(
self.account, 'no_log_for').split(' '):
log = False
self._ui.log_history_checkbutton.set_active(log)
self._ui.log_history_checkbutton.set_sensitive(True)
self.jids_to_search = [info_jid]
# Get first/last date we have logs with contact
self.first_log = app.storage.archive.get_first_date_that_has_logs(
self.account, self.jid)
self.first_day = self._get_date_from_timestamp(self.first_log)
self.last_log = app.storage.archive.get_last_date_that_has_logs(
self.account, self.jid)
self.last_day = self._get_date_from_timestamp(self.last_log)
# Select logs for last date we have logs with contact
self._ui.search_menu_button.set_sensitive(True)
month = gtk_month(self.last_day.month)
self._ui.calendar.select_month(month, self.last_day.year)
self._ui.calendar.select_day(self.last_day.day)
self._ui.button_previous_day.set_sensitive(True)
self._ui.button_next_day.set_sensitive(True)
self._ui.button_first_day.set_sensitive(True)
self._ui.button_last_day.set_sensitive(True)
self._ui.search_entry.set_sensitive(True)
self._ui.search_entry.grab_focus()
self._ui.query_entry.get_child().set_text(info_completion)
else:
# neither a valid jid, nor an existing contact name was entered
# we have got nothing to show or to search in
self.jid = None
self.account = None
self.history_buffer.set_text('') # clear the buffer
self._ui.search_entry.set_sensitive(False)
self._ui.log_history_checkbutton.set_sensitive(False)
self._ui.search_menu_button.set_sensitive(False)
self._ui.calendar.clear_marks()
self._ui.button_previous_day.set_sensitive(False)
self._ui.button_next_day.set_sensitive(False)
self._ui.button_first_day.set_sensitive(False)
self._ui.button_last_day.set_sensitive(False)
self._ui.results_scrolledwindow.set_visible(False)
def on_calendar_day_selected(self, widget):
if not self.jid:
return
year, month, day = self._ui.calendar.get_date() # integers
month = python_month(month)
date_str = datetime.date(year, month, day).strftime('%x')
self._ui.date_label.set_text(date_str)
self._load_conversation(year, month, day)
GLib.idle_add(scroll_to_end, self._ui.scrolledwindow)
def on_calendar_month_changed(self, widget):
"""
Ask for days in this month, if they have logs it bolds them
(marks them)
"""
if not self.jid:
return
year, month, _day = widget.get_date() # integers
if year < 1900:
widget.select_month(0, 1900)
widget.select_day(1)
return
widget.clear_marks()
month = python_month(month)
try:
log_days = app.storage.archive.get_days_with_logs(
self.account, self.jid, year, month)
except exceptions.PysqliteOperationalError as error:
ErrorDialog(_('Disk Error'), str(error))
return
for date in log_days:
widget.mark_day(date.day)
def _get_date_from_timestamp(self, timestamp):
# Conversion from timestamp to date
log = time.localtime(timestamp)
y, m, d = log[0], log[1], log[2]
date = datetime.datetime(y, m, d)
return date
def _change_date(self, widget):
# Get day selected in calendar
y, m, d = self._ui.calendar.get_date()
py_m = python_month(m)
_date = datetime.datetime(y, py_m, d)
if widget is self._ui.button_first_day:
gtk_m = gtk_month(self.first_day.month)
self._ui.calendar.select_month(gtk_m, self.first_day.year)
self._ui.calendar.select_day(self.first_day.day)
return
if widget is self._ui.button_last_day:
gtk_m = gtk_month(
self.last_day.month)
self._ui.calendar.select_month(gtk_m, self.last_day.year)
self._ui.calendar.select_day(self.last_day.day)
return
if widget is self._ui.button_previous_day:
end_date = self.first_day
timedelta = datetime.timedelta(days=-1)
if end_date >= _date:
return
elif widget is self._ui.button_next_day:
end_date = self.last_day
timedelta = datetime.timedelta(days=1)
if end_date <= _date:
return
# Iterate through days until log entry found or
# supplied end_date (first_log / last_log) reached
logs = None
while logs is None:
_date = _date + timedelta
if _date == end_date:
break
try:
logs = app.storage.archive.get_date_has_logs(
self.account, self.jid, _date)
except exceptions.PysqliteOperationalError as e:
ErrorDialog(_('Disk Error'), str(e))
return
gtk_m = gtk_month(_date.month)
self._ui.calendar.select_month(gtk_m, _date.year)
self._ui.calendar.select_day(_date.day)
def _get_string_show_from_constant_int(self, show):
if show == ShowConstant.ONLINE:
show = 'online'
elif show == ShowConstant.CHAT:
show = 'chat'
elif show == ShowConstant.AWAY:
show = 'away'
elif show == ShowConstant.XA:
show = 'xa'
elif show == ShowConstant.DND:
show = 'dnd'
elif show == ShowConstant.OFFLINE:
show = 'offline'
return show
def _load_conversation(self, year, month, day):
"""
Load the conversation between `self.jid` and `self.account` held on the
given date into the history textbuffer. Values for `month` and `day`
are 1-based.
"""
self.history_buffer.set_text('')
self.last_time_printout = 0
show_status = self._ui.show_status_checkbutton.get_active()
date = datetime.datetime(year, month, day)
conversation = app.storage.archive.get_conversation_for_date(
self.account, self.jid, date)
for message in conversation:
if not show_status and message.kind in (KindConstant.GCSTATUS,
KindConstant.STATUS):
continue
self._add_message(message)
def _add_message(self, msg):
if not msg.message and msg.kind not in (KindConstant.STATUS,
KindConstant.GCSTATUS):
return
tim = msg.time
kind = msg.kind
show = msg.show
message = msg.message
subject = msg.subject
log_line_id = msg.log_line_id
contact_name = msg.contact_name
additional_data = msg.additional_data
buf = self.history_buffer
end_iter = buf.get_end_iter()
# Make the beginning of every message searchable by its log_line_id
buf.create_mark(str(log_line_id), end_iter, left_gravity=True)
if app.settings.get('print_time') == 'always':
timestamp_str = app.settings.get('time_stamp')
timestamp_str = helpers.from_one_line(timestamp_str)
tim = time.strftime(timestamp_str, time.localtime(float(tim)))
buf.insert(end_iter, tim)
elif app.settings.get('print_time') == 'sometimes':
every_foo_seconds = 60 * app.settings.get(
'print_ichat_every_foo_minutes')
seconds_passed = tim - self.last_time_printout
if seconds_passed > every_foo_seconds:
self.last_time_printout = tim
tim = time.strftime('%X ', time.localtime(float(tim)))
buf.insert_with_tags_by_name(
end_iter, tim + '\n', 'time_sometimes')
# print the encryption icon
if kind in (KindConstant.CHAT_MSG_SENT,
KindConstant.CHAT_MSG_RECV):
self.history_textview.print_encryption_status(
end_iter, additional_data)
tag_name = ''
tag_msg = ''
show = self._get_string_show_from_constant_int(show)
if kind == KindConstant.GC_MSG:
tag_name = 'incoming'
elif kind in (KindConstant.SINGLE_MSG_RECV,
KindConstant.CHAT_MSG_RECV):
contact_name = self.completion_dict[self.jid][InfoColumn.NAME]
tag_name = 'incoming'
tag_msg = 'incomingtxt'
elif kind in (KindConstant.SINGLE_MSG_SENT,
KindConstant.CHAT_MSG_SENT):
if self.account:
contact_name = app.nicks[self.account]
else:
# we don't have roster, we don't know our own nick, use first
# account one (urk!)
account = list(app.contacts.get_accounts())[0]
contact_name = app.nicks[account]
tag_name = 'outgoing'
tag_msg = 'outgoingtxt'
elif kind == KindConstant.GCSTATUS:
# message here (if not None) is status message
if message:
message = _('%(nick)s is now %(status)s: %(status_msg)s') % {
'nick': contact_name,
'status': helpers.get_uf_show(show),
'status_msg': message}
else:
message = _('%(nick)s is now %(status)s') % {
'nick': contact_name,
'status': helpers.get_uf_show(show)}
tag_msg = 'status'
else: # 'status'
# message here (if not None) is status message
if show is None: # it means error
if message:
message = _('Error: %s') % message
else:
message = _('Error')
elif message:
message = _('Status is now: %(status)s: %(status_msg)s') % {
'status': helpers.get_uf_show(show),
'status_msg': message}
else:
message = _('Status is now: %(status)s') % {
'status': helpers.get_uf_show(show)}
tag_msg = 'status'
if message.startswith('/me ') or message.startswith('/me\n'):
tag_msg = tag_name
else:
# do not do this if gcstats, avoid dupping contact_name
# eg. nkour: nkour is now Offline
if contact_name and kind != KindConstant.GCSTATUS:
# add stuff before and after contact name
before_str = app.settings.get('before_nickname')
before_str = helpers.from_one_line(before_str)
after_str = app.settings.get('after_nickname')
after_str = helpers.from_one_line(after_str)
format_ = before_str + contact_name + after_str + ' '
if tag_name:
buf.insert_with_tags_by_name(end_iter, format_, tag_name)
else:
buf.insert(end_iter, format_)
if subject:
message = _('Subject: %s\n') % subject + message
if tag_msg:
self.history_textview.print_real_text(
message,
[tag_msg],
name=contact_name,
additional_data=additional_data)
else:
self.history_textview.print_real_text(
message,
name=contact_name,
additional_data=additional_data)
self.history_textview.print_real_text('\n', text_tags=['eol'])
def on_search_complete_history_toggled(self, widget):
self._ui.date_label.get_style_context().remove_class('tagged')
def on_search_in_date_toggled(self, widget):
self._ui.date_label.get_style_context().add_class('tagged')
def on_search_entry_activate(self, widget):
text = self._ui.search_entry.get_text()
model = self._ui.results_treeview.get_model()
self.clearing_search = True
model.clear()
self.clearing_search = False
start = self.history_buffer.get_start_iter()
end = self.history_buffer.get_end_iter()
self.history_buffer.remove_tag_by_name('highlight', start, end)
if text == '':
self._ui.results_scrolledwindow.set_visible(False)
return
self._ui.results_scrolledwindow.set_visible(True)
# perform search in preselected jids
# jids are preselected with the query_entry
for jid in self.jids_to_search:
account = self.completion_dict[jid][InfoColumn.ACCOUNT]
if account is None:
# We do not know an account. This can only happen if
# the contact is offine, or if we browse a groupchat history.
# The account is not needed, a dummy can be set.
# This may leed to wrong self nick in the displayed history
account = list(app.contacts.get_accounts())[0]
date = None
if self._ui.search_in_date.get_active():
year, month, day = self._ui.calendar.get_date() # integers
month = python_month(month)
date = datetime.datetime(year, month, day)
show_status = self._ui.show_status_checkbutton.get_active()
results = app.storage.archive.search_log(account, jid, text, date)
result_found = False
# FIXME:
# add "subject: | message: " in message column if kind is single
# also do we need show at all? (we do not search on subject)
for row in results:
if not show_status and row.kind in (KindConstant.GCSTATUS,
KindConstant.STATUS):
continue
contact_name = row.contact_name
if not contact_name:
if row.kind == KindConstant.CHAT_MSG_SENT:
contact_name = app.nicks[account]
else:
contact_name = self.completion_dict[jid][InfoColumn.NAME]
local_time = time.localtime(row.time)
date = time.strftime('%Y-%m-%d', local_time)
result_found = True
model.append((jid, contact_name, date, row.message,
str(row.time), row.log_line_id))
if result_found:
self._ui.results_treeview.set_cursor(0)
def on_results_treeview_cursor_changed(self, *args):
"""
A row was selected, get date from row, and select it in calendar
which results to showing conversation logs for that date
"""
if self.clearing_search:
return
# get currently selected date
cur_year, cur_month, cur_day = self._ui.calendar.get_date()
cur_month = python_month(cur_month)
model, paths = self._ui.results_treeview.get_selection().get_selected_rows()
if not paths:
return
path = paths[0]
# make it a tuple (Y, M, D, 0, 0, 0...)
tim = time.strptime(model[path][Column.UNIXTIME], '%Y-%m-%d')
year = tim[0]
gtk_m = tim[1]
month = gtk_month(gtk_m)
day = tim[2]
# switch to belonging logfile if necessary
log_jid = model[path][Column.LOG_JID]
if log_jid != self.jid:
self._load_history(log_jid, None)
# avoid rerunning mark days algo if same month and year!
if year != cur_year or gtk_m != cur_month:
self._ui.calendar.select_month(month, year)
if year != cur_year or gtk_m != cur_month or day != cur_day:
self._ui.calendar.select_day(day)
self._scroll_to_message_and_highlight(model[path][Column.LOG_LINE_ID])
def _scroll_to_message_and_highlight(self, log_line_id):
"""
Scroll to a message and highlight it
"""
def iterator_has_mark(iterator, mark_name):
for mark in iterator.get_marks():
if mark.get_name() == mark_name:
return True
return False
# Clear previous search result by removing the highlighting. The scroll
# mark is automatically removed when the new one is set.
start = self.history_buffer.get_start_iter()
end = self.history_buffer.get_end_iter()
self.history_buffer.remove_tag_by_name('highlight', start, end)
log_line_id = str(log_line_id)
line = start
while not iterator_has_mark(line, log_line_id):
if not line.forward_line():
return
match_start = line
match_end = match_start.copy()
match_end.forward_to_tag_toggle(self.history_buffer.eol_tag)
self.history_buffer.apply_tag_by_name(
'highlight', match_start, match_end)
mark = self.history_buffer.create_mark('match', match_start, True)
GLib.idle_add(
self.history_textview.tv.scroll_to_mark, mark, 0, True, 0.0, 0.5)
def on_log_history_checkbutton_toggled(self, widget, *args):
# log conversation history?
oldlog = True
no_log_for = app.settings.get_account_setting(
self.account, 'no_log_for').split()
if self.jid in no_log_for:
oldlog = False
log = widget.get_active()
if not log and self.jid not in no_log_for:
no_log_for.append(self.jid)
if log and self.jid in no_log_for:
no_log_for.remove(self.jid)
if oldlog != log:
app.settings.set_account_setting(
self.account, 'no_log_for', ' '.join(no_log_for))
def on_show_status_checkbutton_toggled(self, widget):
# reload logs
self.on_calendar_day_selected(None)
def open_history(self, jid, account):
"""
Load chat history of the specified jid
"""
self._ui.query_entry.get_child().set_text(jid)
if account and account not in self.accounts_seen_online:
# Update dict to not only show bare jid
GLib.idle_add(next, self._fill_completion_dict())
else:
# Only in that case because it's called by
# self._fill_completion_dict() otherwise
self._load_history(jid, account)
self._ui.results_scrolledwindow.set_visible(False)
def save_state(self):
x, y = self.get_window().get_root_origin()
width, height = self.get_size()
app.settings.set('history_window_x-position', x)
app.settings.set('history_window_y-position', y)
app.settings.set('history_window_width', width)
app.settings.set('history_window_height', height)

296
gajim/gtk/history_sync.py Normal file
View file

@ -0,0 +1,296 @@
# 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 logging
from enum import IntEnum
from datetime import datetime, timedelta
from gi.repository import Gtk
from gi.repository import GLib
from nbxmpp.errors import StanzaError
from nbxmpp.errors import MalformedStanzaError
from gajim.common import app
from gajim.common import ged
from gajim.common.i18n import _
from gajim.common.const import ArchiveState
from gajim.common.helpers import event_filter
from .util import load_icon
from .util import EventHelper
log = logging.getLogger('gajim.gui.history_sync')
class Pages(IntEnum):
TIME = 0
SYNC = 1
SUMMARY = 2
class HistorySyncAssistant(Gtk.Assistant, EventHelper):
def __init__(self, account, parent):
Gtk.Assistant.__init__(self)
EventHelper.__init__(self)
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_name('HistorySyncAssistant')
self.set_default_size(300, -1)
self.set_resizable(False)
self.set_transient_for(parent)
self.account = account
self.con = app.connections[self.account]
self.timedelta = None
self.now = datetime.utcnow()
self.query_id = None
self.start = None
self.end = None
self.next = None
self._hide_buttons()
own_jid = self.con.get_own_jid().bare
mam_start = ArchiveState.NEVER
archive = app.storage.archive.get_archive_infos(own_jid)
if archive is not None and archive.oldest_mam_timestamp is not None:
mam_start = int(float(archive.oldest_mam_timestamp))
if mam_start == ArchiveState.NEVER:
self.current_start = self.now
elif mam_start == ArchiveState.ALL:
self.current_start = datetime.utcfromtimestamp(0)
else:
self.current_start = datetime.fromtimestamp(mam_start)
self.select_time = SelectTimePage(self)
self.append_page(self.select_time)
self.set_page_type(self.select_time, Gtk.AssistantPageType.INTRO)
self.download_history = DownloadHistoryPage(self)
self.append_page(self.download_history)
self.set_page_type(self.download_history,
Gtk.AssistantPageType.PROGRESS)
self.set_page_complete(self.download_history, True)
self.summary = SummaryPage(self)
self.append_page(self.summary)
self.set_page_type(self.summary, Gtk.AssistantPageType.SUMMARY)
self.set_page_complete(self.summary, True)
# pylint: disable=line-too-long
self.register_events([
('archiving-count-received', ged.GUI1, self._received_count),
('archiving-interval-finished', ged.GUI1, self._received_finished),
('mam-message-received', ged.PRECORE, self._nec_mam_message_received),
])
# pylint: enable=line-too-long
self.connect('prepare', self._on_page_change)
self.connect('cancel', self._on_close_clicked)
self.connect('close', self._on_close_clicked)
if mam_start == ArchiveState.ALL:
self.set_current_page(Pages.SUMMARY)
self.summary.nothing_to_do()
self.show_all()
self.set_title(_('Synchronise History'))
def _hide_buttons(self):
'''
Hide some of the standard buttons that are included in Gtk.Assistant
'''
if self.get_property('use-header-bar'):
action_area = self.get_children()[1]
else:
box = self.get_children()[0]
content_box = box.get_children()[1]
action_area = content_box.get_children()[1]
for button in action_area.get_children():
button_name = Gtk.Buildable.get_name(button)
if button_name == 'back':
button.connect('show', self._on_show_button)
elif button_name == 'forward':
self.next = button
button.connect('show', self._on_show_button)
@staticmethod
def _on_show_button(button):
button.hide()
def _prepare_query(self):
if self.timedelta:
self.start = self.now - self.timedelta
self.end = self.current_start
log.info('Get mam_start_date: %s', self.current_start)
log.info('Now: %s', self.now)
log.info('Start: %s', self.start)
log.info('End: %s', self.end)
jid = self.con.get_own_jid().bare
self.con.get_module('MAM').make_query(jid,
start=self.start,
end=self.end,
max_=0,
callback=self._received_count)
def _received_count(self, task):
try:
result = task.finish()
except (StanzaError, MalformedStanzaError):
return
if result.rsm.count is not None:
self.download_history.count = int(result.rsm.count)
self.query_id = self.con.get_module('MAM').request_archive_interval(
self.start, self.end)
@event_filter(['account'])
def _received_finished(self, event):
if event.query_id != self.query_id:
return
self.query_id = None
log.info('Query finished')
GLib.idle_add(self.download_history.finished)
self.set_current_page(Pages.SUMMARY)
self.summary.finished()
@event_filter(['account'])
def _nec_mam_message_received(self, event):
if self.query_id != event.properties.mam.query_id:
return
log.debug('Received message')
GLib.idle_add(self.download_history.set_fraction)
def on_row_selected(self, _listbox, row):
self.timedelta = row.get_child().get_delta()
if row:
self.set_page_complete(self.select_time, True)
else:
self.set_page_complete(self.select_time, False)
def _on_page_change(self, _assistant, page):
if page == self.download_history:
self.next.hide()
self._prepare_query()
self.set_title(_('Synchronise History'))
def _on_close_clicked(self, *args):
self.destroy()
class SelectTimePage(Gtk.Box):
def __init__(self, assistant):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.set_spacing(18)
self.assistant = assistant
label = Gtk.Label(
label=_('How far back should the chat history be synchronised?'))
listbox = Gtk.ListBox()
listbox.set_hexpand(False)
listbox.set_halign(Gtk.Align.CENTER)
listbox.add(TimeOption(_('One Month'), 1))
listbox.add(TimeOption(_('Three Months'), 3))
listbox.add(TimeOption(_('One Year'), 12))
listbox.add(TimeOption(_('Everything')))
listbox.connect('row-selected', assistant.on_row_selected)
for row in listbox.get_children():
option = row.get_child()
if not option.get_delta():
continue
if assistant.now - option.get_delta() > assistant.current_start:
row.set_activatable(False)
row.set_selectable(False)
self.pack_start(label, True, True, 0)
self.pack_start(listbox, False, False, 0)
class DownloadHistoryPage(Gtk.Box):
def __init__(self, assistant):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.set_spacing(18)
self.assistant = assistant
self.count = 0
self.received = 0
surface = load_icon('folder-download-symbolic', self, size=64)
image = Gtk.Image.new_from_surface(surface)
self.progress = Gtk.ProgressBar()
self.progress.set_show_text(True)
self.progress.set_text(_('Connecting...'))
self.progress.set_pulse_step(0.1)
self.progress.set_vexpand(True)
self.progress.set_valign(Gtk.Align.CENTER)
self.pack_start(image, False, False, 0)
self.pack_start(self.progress, False, False, 0)
def set_fraction(self):
self.received += 1
if self.count:
self.progress.set_fraction(self.received / self.count)
self.progress.set_text(_('%(received)s of %(max)s') % {
'received': self.received, 'max': self.count})
else:
self.progress.pulse()
self.progress.set_text(_('Downloaded %s messages') % self.received)
def finished(self):
self.progress.set_fraction(1)
class SummaryPage(Gtk.Box):
def __init__(self, assistant):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.set_spacing(18)
self.assistant = assistant
self.label = Gtk.Label()
self.label.set_name('FinishedLabel')
self.label.set_valign(Gtk.Align.CENTER)
self.pack_start(self.label, True, True, 0)
def finished(self):
received = self.assistant.download_history.received
self.label.set_text(_('Finished synchronising chat history:\n'
'%s messages downloaded') % received)
def nothing_to_do(self):
self.label.set_text(_('Gajim is fully synchronised with the archive.'))
def query_already_running(self):
self.label.set_text(_('There is already a synchronisation in '
'progress. Please try again later.'))
class TimeOption(Gtk.Label):
def __init__(self, label, months=None):
super().__init__(label=label)
self.date = months
if months:
self.date = timedelta(days=30 * months)
def get_delta(self):
return self.date

899
gajim/gtk/htmltextview.py Normal file
View file

@ -0,0 +1,899 @@
# Copyright (C) 2005 Gustavo J. A. M. Carneiro
# Copyright (C) 2006 Santiago Gala
# Copyright (C) 2006-2007 Jean-Marie Traissard <jim AT lapin.org>
# Copyright (C) 2006-2014 Yann Leboulanger <asterix AT lagaule.org>
# Copyright (C) 2007 Nikos Kouremenos <kourem AT gmail.com>
# Copyright (C) 2008 Jonathan Schleifer <js-gajim AT webkeks.org>
# Julien Pivotto <roidelapluie AT gmail.com>
# Stephan Erb <steve-e AT h3c.de>
#
# This file is part of Gajim.
#
# Gajim is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation; version 3 only.
#
# Gajim is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
"""
A Gtk.TextView-based renderer for XHTML-IM, as described in:
http://xmpp.org/extensions/xep-0071.html
Starting with the version posted by Gustavo Carneiro,
I (Santiago Gala) am trying to make it more compatible
with the markup that docutils generate, and also more
modular.
"""
import re
import logging
import xml.sax
import xml.sax.handler
from io import StringIO
from gi.repository import GObject
from gi.repository import Pango
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import GLib
from gajim.common import app
from gajim.common.const import StyleAttr
from gajim.common.helpers import open_uri
from gajim.common.helpers import parse_uri
from gajim.gui_menu_builder import get_conv_context_menu
from .util import get_cursor
log = logging.getLogger('gajim.htmlview')
whitespace_rx = re.compile('\\s+')
allwhitespace_rx = re.compile('^\\s*$')
# embryo of CSS classes
classes = {
#'system-message':';display: none',
'problematic': ';color: red',
}
# styles for elements
_element_styles = {
'u' : ';text-decoration: underline',
'em' : ';font-style: oblique',
'cite' : '; background-color:rgb(170,190,250);'
'font-style: oblique',
'li' : '; margin-left: 1em; margin-right: 10%',
'strong' : ';font-weight: bold',
'pre' : '; background-color:rgb(190,190,190);'
'font-family: monospace; white-space: pre;'
'margin-left: 1em; margin-right: 10%',
'kbd' : ';background-color:rgb(210,210,210);'
'font-family: monospace',
'blockquote' : '; background-color:rgb(170,190,250);'
'margin-left: 2em; margin-right: 10%',
'dt' : ';font-weight: bold; font-style: oblique',
'dd' : ';margin-left: 2em; font-style: oblique'
}
# no difference for the moment
_element_styles['dfn'] = _element_styles['em']
_element_styles['var'] = _element_styles['em']
# deprecated, legacy, presentational
_element_styles['tt'] = _element_styles['kbd']
_element_styles['i'] = _element_styles['em']
_element_styles['b'] = _element_styles['strong']
_supported_style_attrs = [
'background-color', 'color', 'font-family', 'font-size', 'font-style',
'font-weight', 'margin-left', 'margin-right', 'text-align',
'text-decoration', 'white-space', 'display', 'width', 'height'
]
# ==========
# XEP-0071
# ==========
#
# This Integration Set includes a subset of the modules defined for
# XHTML 1.0 but does not redefine any existing modules, nor
# does it define any new modules. Specifically, it includes the
# following modules only:
#
# - Structure
# - Text
#
# * Block
#
# phrasal
# addr, blockquote, pre
# Struct
# div,p
# Heading
# h1, h2, h3, h4, h5, h6
#
# * Inline
#
# phrasal
# abbr, acronym, cite, code, dfn, em, kbd, q, samp, strong, var
# structural
# br, span
#
# - Hypertext (a)
# - List (ul, ol, dl)
# - Image (img)
# - Style Attribute
#
# Therefore XHTML-IM uses the following content models:
#
# Block.mix
# Block-like elements, e.g., paragraphs
# Flow.mix
# Any block or inline elements
# Inline.mix
# Character-level elements
# InlineNoAnchor.class
# Anchor element
# InlinePre.mix
# Pre element
#
# XHTML-IM also uses the following Attribute Groups:
#
# Core.extra.attrib
# TBD
# I18n.extra.attrib
# TBD
# Common.extra
# style
#
#
# ...
# block level:
# Heading h
# ( head = h1 | h2 | h3 | h4 | h5 | h6 )
# Block ( phrasal = address | blockquote | pre )
# NOT ( presentational = hr )
# ( structural = div | p )
# other: section
# Inline ( phrasal = abbr | acronym | cite | code | dfn | em |
# kbd | q | samp | strong | var )
# NOT ( presentational = b | big | i | small | sub | sup | tt )
# ( structural = br | span )
# Param/Legacy param, font, basefont, center, s, strike, u, dir, menu,
# isindex
BLOCK_HEAD = set(('h1', 'h2', 'h3', 'h4', 'h5', 'h6',))
BLOCK_PHRASAL = set(('address', 'blockquote', 'pre',))
BLOCK_PRES = set(('hr', )) #not in xhtml-im
BLOCK_STRUCT = set(('div', 'p', ))
BLOCK_HACKS = set(('table', 'tr')) # at the very least, they will start line ;)
BLOCK = BLOCK_HEAD | BLOCK_PHRASAL | BLOCK_STRUCT | BLOCK_PRES | BLOCK_HACKS
INLINE_PHRASAL = set(['abbr', 'acronym', 'cite', 'code', 'dfn', 'em',
'kbd', 'q', 'samp', 'strong', 'var'])
INLINE_PRES = set(['b', 'i', 'u', 'tt']) #not in xhtml-im
INLINE_STRUCT = set(['br', 'span'])
INLINE = INLINE_PHRASAL | INLINE_PRES | INLINE_STRUCT
LIST_ELEMS = set(['dl', 'ol', 'ul'])
for _name in BLOCK_HEAD:
_num = int(_name[1])
_header_size = (_num - 1) // 2
_weight = (_num - 1) % 2
_element_styles[_name] = '; font-size: %s; %s' % (
('large', 'medium', 'small')[_header_size],
('font-weight: bold', 'font-style: oblique')[_weight])
def _parse_css_color(color):
rgba = Gdk.RGBA()
success = rgba.parse(color)
if not success:
log.warning('Can\'t parse color: %s', color)
return rgba
def style_iter(style):
for item in style.split(';'):
if item.strip():
yield [x.strip() for x in item.split(':', 1)]
class HtmlHandler(xml.sax.handler.ContentHandler):
"""
A handler to display html to a gtk textview
It keeps a stack of "style spans" (start/end element pairs) and a stack of
list counters, for nested lists.
"""
def __init__(self, textview, conv_textview, startiter):
xml.sax.handler.ContentHandler.__init__(self)
self.textbuf = textview.get_buffer()
self.textview = textview
self.iter = startiter
self.conv_textview = conv_textview
self.text = ''
self.starting = True
self.preserve = False
self.styles = [] # a Gtk.TextTag or None, for each span level
self.list_counters = [] # stack (top at head) of list
# counters, or None for unordered list
# build a dictionary mapping styles to methods
self.__style_methods = {}
for style in _supported_style_attrs:
method_names = '_parse_style_%s' % style.replace('-', '_')
self.__style_methods[style] = method_names
def _get_points_from_pixels(self, pixels):
resolution = self.textview.get_screen().get_resolution()
# points = pixels * 72 / resolution
return pixels * 72 / resolution
@staticmethod
def _parse_style_color(tag, value):
color = _parse_css_color(value)
tag.set_property('foreground-rgba', color)
@staticmethod
def _parse_style_background_color(tag, value):
color = _parse_css_color(value)
tag.set_property('background-rgba', color)
tag.set_property('paragraph-background-rgba', color)
@staticmethod
def __parse_length_frac_size_allocate(_textview, allocation, frac,
callback, args):
callback(allocation.width*frac, *args)
def _parse_length(self, value, font_relative, block_relative, minl, maxl,
callback, *args):
"""
Parse/calc length, converting to pixels, calls callback(length, *args)
when the length is first computed or changes
"""
if value.endswith('%'):
val = float(value[:-1])
if val > 0:
sign = 1
elif val < 0:
sign = -1
else:
sign = 0
# limits: 1% to 500%
val = sign*max(1, min(abs(val), 500))
frac = val/100
if font_relative:
callback(frac, '%', *args)
elif block_relative:
# CSS says 'Percentage values: refer to width of the closest
# block-level ancestor'
# This is difficult/impossible to implement, so we use
# textview width instead; a reasonable approximation..
alloc = self.textview.get_allocation()
self.__parse_length_frac_size_allocate(
self.textview, alloc, frac, callback, args)
self.textview.connect('size-allocate',
self.__parse_length_frac_size_allocate,
frac, callback, args)
else:
callback(frac, *args)
return
def get_val(min_val=minl, max_val=maxl):
try:
val = float(value[:-2])
except Exception:
log.warning('Unable to parse length value "%s"', value)
return None
if val > 0:
sign = 1
elif val < 0:
sign = -1
else:
sign = 0
# validate length
return sign*max(min_val, min(abs(val), max_val))
if value.endswith('pt'): # points
size = get_val(5, 50)
if size is None:
return
callback(size, 'pt', *args)
elif value.endswith('em'):
size = get_val(0.3, 4)
if size is None:
return
callback(size, 'em', *args)
elif value.endswith('px'): # pixels
size = get_val(5, 50)
if size is None:
return
callback(size, 'px', *args)
else:
try:
# TODO: isn't "no units" interpreted as pixels?
val = int(value)
if val > 0:
sign = 1
elif val < 0:
sign = -1
else:
sign = 0
# validate length
val = sign*max(5, min(abs(val), 70))
callback(val, 'px', *args)
except Exception:
log.warning('Unable to parse length value "%s"', value)
def __parse_font_size_cb(self, size, type_, tag):
if type_ in ('em', '%'):
tag.set_property('scale', size)
elif type_ == 'pt':
tag.set_property('size-points', size)
elif type_ == 'px':
tag.set_property('size-points', self._get_points_from_pixels(size))
@staticmethod
def _parse_style_display(tag, value):
if value == 'none':
tag.set_property('invisible', 'true')
# FIXME: display: block, inline
def _parse_style_font_size(self, tag, value):
try:
scale = {
'xx-small': 0.5787037037037,
'x-small': 0.6444444444444,
'small': 0.8333333333333,
'medium': 1.0,
'large': 1.2,
'x-large': 1.4399999999999,
'xx-large': 1.728,
}[value]
except KeyError:
pass
else:
tag.set_property('scale', scale)
return
if value == 'smaller':
tag.set_property('scale', 0.8333333333333)
return
if value == 'larger':
tag.set_property('scale', 1.2)
return
# font relative (5 ~ 4pt, 110 ~ 72pt)
self._parse_length(
value, True, False, 5, 110, self.__parse_font_size_cb, tag)
@staticmethod
def _parse_style_font_style(tag, value):
try:
style = {
'normal': Pango.Style.NORMAL,
'italic': Pango.Style.ITALIC,
'oblique': Pango.Style.OBLIQUE,
}[value]
except KeyError:
log.warning('unknown font-style %s', value)
else:
tag.set_property('style', style)
def __frac_length_tag_cb(self, length, tag, propname):
styles = self._get_style_tags()
if styles:
length += styles[-1].get_property(propname)
tag.set_property(propname, length)
def _parse_style_margin_left(self, tag, value):
# block relative
self._parse_length(value, False, True, 1, 1000,
self.__frac_length_tag_cb, tag, 'left-margin')
def _parse_style_margin_right(self, tag, value):
# block relative
self._parse_length(value, False, True, 1, 1000,
self.__frac_length_tag_cb, tag, 'right-margin')
@staticmethod
def _parse_style_font_weight(tag, value):
# TODO: missing 'bolder' and 'lighter'
try:
weight = {
'100': Pango.Weight.ULTRALIGHT,
'200': Pango.Weight.ULTRALIGHT,
'300': Pango.Weight.LIGHT,
'400': Pango.Weight.NORMAL,
'500': Pango.Weight.NORMAL,
'600': Pango.Weight.BOLD,
'700': Pango.Weight.BOLD,
'800': Pango.Weight.ULTRABOLD,
'900': Pango.Weight.HEAVY,
'normal': Pango.Weight.NORMAL,
'bold': Pango.Weight.BOLD,
}[value]
except KeyError:
log.warning('unknown font-style %s', value)
else:
tag.set_property('weight', weight)
@staticmethod
def _parse_style_font_family(tag, value):
tag.set_property('family', value)
@staticmethod
def _parse_style_text_align(tag, value):
try:
align = {
'left': Gtk.Justification.LEFT,
'right': Gtk.Justification.RIGHT,
'center': Gtk.Justification.CENTER,
'justify': Gtk.Justification.FILL,
}[value]
except KeyError:
log.warning('Invalid text-align: %s requested', value)
else:
tag.set_property('justification', align)
@staticmethod
def _parse_style_text_decoration(tag, value):
values = value.split(' ')
if 'none' in values:
tag.set_property('underline', Pango.Underline.NONE)
tag.set_property('strikethrough', False)
if 'underline' in values:
tag.set_property('underline', Pango.Underline.SINGLE)
else:
tag.set_property('underline', Pango.Underline.NONE)
if 'line-through' in values:
tag.set_property('strikethrough', True)
else:
tag.set_property('strikethrough', False)
if 'blink' in values:
log.warning('text-decoration:blink not implemented')
if 'overline' in values:
log.warning('text-decoration:overline not implemented')
@staticmethod
def _parse_style_white_space(tag, value):
if value == 'pre':
tag.set_property('wrap_mode', Gtk.WrapMode.NONE)
elif value == 'normal':
tag.set_property('wrap_mode', Gtk.WrapMode.WORD)
elif value == 'nowrap':
tag.set_property('wrap_mode', Gtk.WrapMode.NONE)
@staticmethod
def __length_tag_cb(value, tag, propname):
try:
tag.set_property(propname, value)
except Exception:
log.warning('Error with prop: %s for tag: %s', propname, str(tag))
def _parse_style_width(self, tag, value):
if value == 'auto':
return
self._parse_length(value, False, False, 1, 1000,
self.__length_tag_cb, tag, "width")
def _parse_style_height(self, tag, value):
if value == 'auto':
return
self._parse_length(value, False, False, 1, 1000,
self.__length_tag_cb, tag, "height")
def _get_style_tags(self):
return [tag for tag in self.styles if tag is not None]
def _create_url(self, href, title, type_, id_):
'''Process a url tag.
'''
tag = self.textbuf.create_tag(id_)
if href and href[0] != '#':
tag.href = href
tag.type_ = type_ # to be used by the URL handler
tag.connect('event', self.textview.hyperlink_handler, 'url')
tag.set_property('foreground',
app.css_config.get_value('.gajim-url',
StyleAttr.COLOR))
tag.set_property('underline', Pango.Underline.SINGLE)
tag.is_anchor = True
if title:
tag.title = title
return tag
def _begin_span(self, style, tag=None, id_=None):
if style is None:
self.styles.append(tag)
return
if tag is None:
if id_:
tag = self.textbuf.create_tag(id_)
else:
tag = self.textbuf.create_tag() # we create anonymous tag
for attr, val in style_iter(style):
attr = attr.lower()
try:
getattr(self, self.__style_methods[attr])(tag, val)
except KeyError:
log.warning('Style attribute "%s" requested '
'but not yet implemented', attr)
self.styles.append(tag)
def _end_span(self):
self.styles.pop()
def _jump_line(self):
self.textbuf.insert_with_tags_by_name(self.iter, '\n', 'eol')
self.starting = True
def _insert_text(self, text, working_iter=None):
if working_iter is None:
working_iter = self.iter
if self.starting and text != '\n':
self.starting = (text[-1] == '\n')
tags = self._get_style_tags()
if tags:
self.textbuf.insert_with_tags(working_iter, text, *tags)
else:
self.textbuf.insert(working_iter, text)
def _starts_line(self):
return self.starting or self.iter.starts_line()
def _flush_text(self):
if not self.text:
return
text, self.text = self.text, ''
if not self.preserve:
text = text.replace('\n', ' ')
self.handle_specials(whitespace_rx.sub(' ', text))
else:
self._insert_text(text.strip('\n'))
def _anchor_event(self, _tag, _textview, event, _iter, href, type_):
if event.type == Gdk.EventType.BUTTON_PRESS:
self.textview.emit('url-clicked', href, type_)
return True
return False
def handle_specials(self, text):
if self.conv_textview:
self.iter = self.conv_textview.detect_and_print_special_text(
text, self._get_style_tags(), iter_=self.iter)
else:
self._insert_text(text)
def characters(self, content):
if self.preserve:
self.text += content
return
if allwhitespace_rx.match(content) is not None and self._starts_line():
return
self.text += content
self.starting = False
def startElement(self, name, attrs):
self._flush_text()
klass = [i for i in attrs.get('class', ' ').split(' ') if i]
style = ''
#Add styles defined for classes
for k in klass:
if k in classes:
style += classes[k]
tag = None
#FIXME: if we want to use id, it needs to be unique across
# the whole textview, so we need to add something like the
# message-id to it.
#id_ = attrs.get('id',None)
id_ = None
if name == 'a':
#TODO: accesskey, charset, hreflang, rel, rev, tabindex, type
href = attrs.get('href', None)
if not href:
href = attrs.get('HREF', None)
# Gaim sends HREF instead of href
title = attrs.get('title', attrs.get('rel', href))
type_ = attrs.get('type', None)
tag = self._create_url(href, title, type_, id_)
elif name == 'blockquote':
cite = attrs.get('cite', None)
if cite:
tag = self.textbuf.create_tag(id_)
tag.title = attrs.get('title', None)
tag.is_anchor = True
elif name in LIST_ELEMS:
style += ';margin-left: 2em'
if name in _element_styles:
style += _element_styles[name]
# so that explicit styles override implicit ones,
# we add the attribute last
style += ";"+attrs.get('style', '')
if style == '':
style = None
self._begin_span(style, tag, id_)
if name == 'br':
pass # handled in endElement
elif name == 'hr':
pass # handled in endElement
elif name in BLOCK:
if not self._starts_line():
self._jump_line()
if name == 'pre':
self.preserve = True
elif name == 'span':
pass
elif name in ('dl', 'ul'):
if not self._starts_line():
self._jump_line()
self.list_counters.append(None)
elif name == 'ol':
if not self._starts_line():
self._jump_line()
self.list_counters.append(0)
elif name == 'li':
if self.list_counters[-1] is None:
li_head = chr(0x2022)
else:
self.list_counters[-1] += 1
li_head = '%i.' % self.list_counters[-1]
self.text = ' '*len(self.list_counters)*4 + li_head + ' '
self._flush_text()
self.starting = True
elif name == 'dd':
self._jump_line()
elif name == 'dt':
if not self.starting:
self._jump_line()
elif name in ('a', 'img', 'body', 'html'):
pass
elif name in INLINE:
pass
else:
log.warning('Unhandled element "%s"', name)
def endElement(self, name):
end_preserving = False
newline = False
if name == 'br':
newline = True
elif name == 'hr':
#FIXME: plenty of unused attributes (width, height,...) :)
self._jump_line()
self._insert_text('\u2015'*40)
self._jump_line()
elif name in LIST_ELEMS:
self.list_counters.pop()
elif name == 'li':
newline = True
elif name == 'img':
pass
elif name in ('body', 'html'):
pass
elif name == 'a':
pass
elif name in INLINE:
pass
elif name in ('dd', 'dt', ):
pass
elif name in BLOCK:
if name == 'pre':
end_preserving = True
elif name in BLOCK_STRUCT:
newline = True
else:
log.warning("Unhandled element '%s'", name)
self._flush_text()
if end_preserving:
self.preserve = False
if newline:
self._jump_line()
self._end_span()
class HtmlTextView(Gtk.TextView):
_tags = ['url', 'mail', 'xmpp', 'sth_at_sth']
def __init__(self, account, standalone=False):
Gtk.TextView.__init__(self)
self.set_has_tooltip(True)
self.set_border_width(1)
self.set_accepts_tab(True)
self.set_editable(False)
self.set_cursor_visible(False)
self.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
self.set_left_margin(2)
self.set_right_margin(2)
self.drag_dest_unset()
self.connect('copy-clipboard', self._on_copy_clipboard)
self.connect('destroy', self._on_destroy)
self.get_buffer().eol_tag = self.get_buffer().create_tag('eol')
self.account = account
self.plugin_modified = False
self._cursor_changed = False
if standalone:
self.connect('query-tooltip', self._query_tooltip)
self.create_tags()
def _on_destroy(self, *args):
# We restore the TextViews drag destination to avoid a GTK warning
# when closing the control. ChatControlBase.shutdown() calls destroy()
# on the controls main box, causing GTK to recursively destroy the
# child widgets. GTK then tries to set a target list on the TextView,
# resulting in a warning because the Widget has no drag destination.
self.drag_dest_set(
Gtk.DestDefaults.ALL,
None,
Gdk.DragAction.DEFAULT)
def create_tags(self):
color = app.css_config.get_value('.gajim-url', StyleAttr.COLOR)
attrs = {'foreground': color,
'underline': Pango.Underline.SINGLE}
for tag in self._tags:
tag_ = self.get_buffer().create_tag(tag, **attrs)
tag_.connect('event', self.hyperlink_handler, tag)
def update_tags(self):
tag_table = self.get_buffer().get_tag_table()
color = app.css_config.get_value('.gajim-url', StyleAttr.COLOR)
for tag in self._tags:
tag_table.lookup(tag).set_property('foreground', color)
def _query_tooltip(self, widget, x_pos, y_pos, _keyboard_mode, tooltip):
window = widget.get_window(Gtk.TextWindowType.TEXT)
x_pos, y_pos = self.window_to_buffer_coords(
Gtk.TextWindowType.TEXT, x_pos, y_pos)
iter_ = self.get_iter_at_position(x_pos, y_pos)[1]
for tag in iter_.get_tags():
if getattr(tag, 'is_anchor', False):
text = getattr(tag, 'title', False)
if text:
if len(text) > 50:
text = text[:47] + ''
tooltip.set_text(text)
window.set_cursor(get_cursor('pointer'))
self._cursor_changed = True
return True
tag_name = tag.get_property('name')
if tag_name in ('url', 'mail', 'xmpp', 'sth_at_sth'):
window.set_cursor(get_cursor('pointer'))
self._cursor_changed = True
return False
if self._cursor_changed:
window.set_cursor(get_cursor('text'))
self._cursor_changed = False
return False
def show_context_menu(self, uri):
menu = get_conv_context_menu(self.account, uri)
if menu is None:
log.warning('No handler for URI type: %s', uri)
return
def destroy(menu, _pspec):
visible = menu.get_property('visible')
if not visible:
GLib.idle_add(menu.destroy)
menu.attach_to_widget(self, None)
menu.connect('notify::visible', destroy)
menu.popup_at_pointer()
def hyperlink_handler(self, texttag, _widget, event, iter_, _kind):
if event.type != Gdk.EventType.BUTTON_PRESS:
return Gdk.EVENT_PROPAGATE
begin_iter = iter_.copy()
# we get the beginning of the tag
while not begin_iter.starts_tag(texttag):
begin_iter.backward_char()
end_iter = iter_.copy()
# we get the end of the tag
while not end_iter.ends_tag(texttag):
end_iter.forward_char()
# Detect XHTML-IM link
word = getattr(texttag, 'href', None)
if not word:
word = self.get_buffer().get_text(begin_iter, end_iter, True)
uri = parse_uri(word)
if event.button.button == 3: # right click
self.show_context_menu(uri)
return Gdk.EVENT_STOP
self.plugin_modified = False
app.plugin_manager.extension_point(
'hyperlink_handler', uri, self, self.get_toplevel())
if self.plugin_modified:
return Gdk.EVENT_STOP
open_uri(uri, account=self.account)
return Gdk.EVENT_STOP
def display_html(self, html, textview, conv_textview, iter_=None):
buffer_ = self.get_buffer()
if iter_:
eob = iter_
else:
eob = buffer_.get_end_iter()
parser = xml.sax.make_parser()
parser.setContentHandler(HtmlHandler(textview, conv_textview, eob))
parser.parse(StringIO(html))
# If the xhtml ends with a BLOCK element we have to remove
# the \n we add after BLOCK elements
self._delete_last_char(buffer_, eob)
@staticmethod
def _delete_last_char(buffer_, iter_):
start_iter = iter_.copy()
start_iter.backward_char()
text = buffer_.get_text(start_iter, iter_, True)
if text == '\n':
buffer_.delete(start_iter, iter_)
@staticmethod
def _on_copy_clipboard(textview):
clipboard = textview.get_clipboard(Gdk.SELECTION_CLIPBOARD)
selected = textview.get_selected_text()
clipboard.set_text(selected, -1)
GObject.signal_stop_emission_by_name(textview, 'copy-clipboard')
def get_selected_text(self):
bounds = self.get_buffer().get_selection_bounds()
selection = ''
if bounds:
(search_iter, end) = bounds
while search_iter.compare(end):
character = search_iter.get_char()
if character == '\ufffc':
anchor = search_iter.get_child_anchor()
if anchor:
text = anchor.plaintext
if text:
selection += text
else:
selection += character
else:
selection += character
search_iter.forward_char()
return selection
def replace_emojis(self, start_mark, end_mark, pixbuf, codepoint):
buffer_ = self.get_buffer()
start_iter = buffer_.get_iter_at_mark(start_mark)
end_iter = buffer_.get_iter_at_mark(end_mark)
buffer_.delete(start_iter, end_iter)
anchor = buffer_.create_child_anchor(start_iter)
anchor.plaintext = codepoint
emoji = Gtk.Image.new_from_pixbuf(pixbuf)
emoji.show()
self.add_child_at_anchor(emoji, anchor)
buffer_.delete_mark(start_mark)
buffer_.delete_mark(end_mark)
def unselect(self):
buffer_ = self.get_buffer()
insert_iter = buffer_.get_iter_at_mark(buffer_.get_insert())
buffer_.select_range(insert_iter, insert_iter)

View file

@ -0,0 +1,160 @@
# 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 logging
from gi.repository import Gtk
from gi.repository import Gdk
from nbxmpp.errors import MalformedStanzaError
from nbxmpp.errors import StanzaError
from gajim.common import app
from gajim.common.i18n import _
from .util import get_builder
from .util import EventHelper
from .dialogs import DialogButton
from .dialogs import ConfirmationDialog
from .dialogs import InformationDialog
log = logging.getLogger('gajim.gui.mam_preferences')
class MamPreferences(Gtk.ApplicationWindow, EventHelper):
def __init__(self, account):
Gtk.ApplicationWindow.__init__(self)
EventHelper.__init__(self)
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_title(_('Archiving Preferences for %s') % account)
self.connect_after('key-press-event', self._on_key_press)
self.account = account
self._con = app.connections[account]
self._destroyed = False
self._ui = get_builder('mam_preferences.ui')
self.add(self._ui.get_object('mam_box'))
self._spinner = Gtk.Spinner()
self._ui.overlay.add_overlay(self._spinner)
self._set_mam_box_state(False)
self.connect('destroy', self._on_destroy)
self._ui.connect_signals(self)
self.show_all()
self._activate_spinner()
self._con.get_module('MAM').request_preferences(
callback=self._mam_prefs_received)
def _on_destroy(self, *args):
self._destroyed = True
def _mam_prefs_received(self, task):
try:
result = task.finish()
except (StanzaError, MalformedStanzaError) as error:
self._on_error(error.get_text())
return
self._disable_spinner()
self._set_mam_box_state(True)
self._ui.default_combo.set_active_id(result.default)
self._ui.preferences_store.clear()
for jid in result.always:
self._ui.preferences_store.append((str(jid), True))
for jid in result.never:
self._ui.preferences_store.append((str(jid), False))
def _mam_prefs_saved(self, task):
try:
task.finish()
except StanzaError as error:
self._on_error(error.get_text())
return
self._disable_spinner()
def _on_ok():
self.destroy()
ConfirmationDialog(
_('Archiving Preferences'),
_('Archiving Preferences Saved'),
_('Your archiving preferences have successfully been saved.'),
[DialogButton.make('OK',
callback=_on_ok)]).show()
def _on_error(self, error):
self._disable_spinner()
InformationDialog(_('Archiving Preferences Error'),
_('Error received: {}').format(error))
self._set_mam_box_state(True)
def _set_mam_box_state(self, state):
self._ui.mam_box.set_sensitive(state)
def _jid_edited(self, _renderer, path, new_text):
iter_ = self._ui.preferences_store.get_iter(path)
self._ui.preferences_store.set_value(iter_, 0, new_text)
def _pref_toggled(self, _renderer, path):
iter_ = self._ui.preferences_store.get_iter(path)
current_value = self._ui.preferences_store[iter_][1]
self._ui.preferences_store.set_value(iter_, 1, not current_value)
def _on_add(self, _button):
self._ui.preferences_store.append(['', False])
def _on_remove(self, _button):
mod, paths = self._ui.pref_view.get_selection().get_selected_rows()
for path in paths:
iter_ = mod.get_iter(path)
self._ui.preferences_store.remove(iter_)
def _on_save(self, _button):
self._activate_spinner()
self._set_mam_box_state(False)
always = []
never = []
default = self._ui.default_combo.get_active_id()
for item in self._ui.preferences_store:
jid, archive = item
if archive:
always.append(jid)
else:
never.append(jid)
self._con.get_module('MAM').set_preferences(
default, always, never, callback=self._mam_prefs_saved)
def _activate_spinner(self):
self._spinner.show()
self._spinner.start()
def _disable_spinner(self):
self._spinner.hide()
self._spinner.stop()
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()

138
gajim/gtk/manage_sounds.py Normal file
View file

@ -0,0 +1,138 @@
# 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
from gi.repository import Gdk
from gi.repository import Gtk
from gajim.common import app
from gajim.common.helpers import play_sound
from gajim.common.helpers import check_soundfile_path
from gajim.common.helpers import strip_soundfile_path
from gajim.common.i18n import _
from .util import get_builder
class ManageSounds(Gtk.ApplicationWindow):
def __init__(self, transient_for):
Gtk.ApplicationWindow.__init__(self)
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_name('ManageSounds')
self.set_default_size(400, 400)
self.set_resizable(True)
self.set_transient_for(transient_for)
self.set_modal(True)
self.set_title(_('Manage Sounds'))
self._ui = get_builder('manage_sounds.ui')
self.add(self._ui.manage_sounds)
filter_ = Gtk.FileFilter()
filter_.set_name(_('All files'))
filter_.add_pattern('*')
self._ui.filechooser.add_filter(filter_)
filter_ = Gtk.FileFilter()
filter_.set_name(_('Wav Sounds'))
filter_.add_pattern('*.wav')
self._ui.filechooser.add_filter(filter_)
self._ui.filechooser.set_filter(filter_)
self._fill_sound_treeview()
self.connect('key-press-event', self._on_key_press)
self._ui.connect_signals(self)
self.show_all()
@staticmethod
def _on_row_changed(model, path, iter_):
sound_event = model[iter_][3]
app.settings.set_soundevent_setting(sound_event,
'enabled',
bool(model[path][0]))
app.settings.set_soundevent_setting(sound_event,
'path',
model[iter_][2])
def _on_toggle(self, _cell, path):
if self._ui.filechooser.get_filename() is None:
return
model = self._ui.sounds_treeview.get_model()
model[path][0] = not model[path][0]
def _fill_sound_treeview(self):
model = self._ui.sounds_treeview.get_model()
model.clear()
# pylint: disable=line-too-long
sounds_dict = {
'attention_received': _('Attention Message Received'),
'first_message_received': _('First Message Received'),
'next_message_received_focused': _('Next Message Received Focused'),
'next_message_received_unfocused': _('Next Message Received Unfocused'),
'contact_connected': _('Contact Connected'),
'contact_disconnected': _('Contact Disconnected'),
'message_sent': _('Message Sent'),
'muc_message_highlight': _('Group Chat Message Highlight'),
'muc_message_received': _('Group Chat Message Received'),
}
# pylint: enable=line-too-long
for sound_event, sound_name in sounds_dict.items():
settings = app.settings.get_soundevent_settings(sound_event)
model.append((settings['enabled'],
sound_name,
settings['path'],
sound_event))
def _on_cursor_changed(self, treeview):
model, iter_ = treeview.get_selection().get_selected()
path_to_snd_file = check_soundfile_path(model[iter_][2])
if path_to_snd_file is None:
self._ui.filechooser.unselect_all()
else:
self._ui.filechooser.set_filename(str(path_to_snd_file))
def _on_file_set(self, button):
model, iter_ = self._ui.sounds_treeview.get_selection().get_selected()
filename = button.get_filename()
directory = os.path.dirname(filename)
app.settings.set('last_sounds_dir', directory)
path_to_snd_file = strip_soundfile_path(filename)
# set new path to sounds_model
model[iter_][2] = str(path_to_snd_file)
# set the sound to enabled
model[iter_][0] = True
def _on_clear(self, *args):
self._ui.filechooser.unselect_all()
model, iter_ = self._ui.sounds_treeview.get_selection().get_selected()
model[iter_][2] = ''
model[iter_][0] = False
def _on_play(self, *args):
model, iter_ = self._ui.sounds_treeview.get_selection().get_selected()
snd_event_config_name = model[iter_][3]
play_sound(snd_event_config_name)
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()

427
gajim/gtk/message_input.py Normal file
View file

@ -0,0 +1,427 @@
# Copyright (C) 2003-2014 Yann Leboulanger <asterix AT lagaule.org>
# Copyright (C) 2005-2007 Nikos Kouremenos <kourem AT gmail.com>
# Copyright (C) 2006 Dimitur Kirov <dkirov AT gmail.com>
# Copyright (C) 2008-2009 Julien Pivotto <roidelapluie AT gmail.com>
#
# 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/>.
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import GLib
from gi.repository import Pango
from nbxmpp.modules.misc import build_xhtml_body
from gajim.common import app
from gajim.common.regex import LINK_REGEX
from .util import scroll_to_end
if app.is_installed('GSPELL'):
from gi.repository import Gspell # pylint: disable=ungrouped-imports
class MessageInputTextView(Gtk.TextView):
"""
Class for the message textview (where user writes new messages) for
chat/groupchat windows
"""
UNDO_LIMIT = 20
def __init__(self):
Gtk.TextView.__init__(self)
# set properties
self.set_border_width(3)
self.set_accepts_tab(True)
self.set_editable(True)
self.set_cursor_visible(True)
self.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
self.set_left_margin(2)
self.set_right_margin(2)
self.set_pixels_above_lines(2)
self.set_pixels_below_lines(2)
self.get_style_context().add_class('gajim-conversation-font')
self.drag_dest_unset()
# set undo list
self.undo_list = []
# needed to know if we undid something
self.undo_pressed = False
self._last_text = ''
self.begin_tags = {}
self.end_tags = {}
self.color_tags = []
self.fonts_tags = []
self.other_tags = {}
buffer_ = self.get_buffer()
self.other_tags['bold'] = buffer_.create_tag('bold')
self.other_tags['bold'].set_property('weight', Pango.Weight.BOLD)
self.begin_tags['bold'] = '<strong>'
self.end_tags['bold'] = '</strong>'
self.other_tags['italic'] = buffer_.create_tag('italic')
self.other_tags['italic'].set_property('style', Pango.Style.ITALIC)
self.begin_tags['italic'] = '<em>'
self.end_tags['italic'] = '</em>'
self.other_tags['underline'] = buffer_.create_tag('underline')
self.other_tags['underline'].set_property('underline',
Pango.Underline.SINGLE)
underline = '<span style="text-decoration: underline;">'
self.begin_tags['underline'] = underline
self.end_tags['underline'] = '</span>'
self.other_tags['strike'] = buffer_.create_tag('strike')
self.other_tags['strike'].set_property('strikethrough', True)
strike = '<span style="text-decoration: line-through;">'
self.begin_tags['strike'] = strike
self.end_tags['strike'] = '</span>'
self.connect_after('paste-clipboard', self._after_paste_clipboard)
self.connect('focus-in-event', self._on_focus_in)
self.connect('focus-out-event', self._on_focus_out)
self.connect('destroy', self._on_destroy)
def _on_destroy(self, *args):
# We restore the TextViews drag destination to avoid a GTK warning
# when closing the control. ChatControlBase.shutdown() calls destroy()
# on the controls main box, causing GTK to recursively destroy the
# child widgets. GTK then tries to set a target list on the TextView,
# resulting in a warning because the Widget has no drag destination.
self.drag_dest_set(
Gtk.DestDefaults.ALL,
None,
Gdk.DragAction.DEFAULT)
def _on_focus_in(self, _widget, _event):
self.toggle_speller(True)
scrolled = self.get_parent()
scrolled.get_style_context().add_class('message-input-focus')
return False
def _on_focus_out(self, _widget, _event):
scrolled = self.get_parent()
scrolled.get_style_context().remove_class('message-input-focus')
if not self.has_text():
self.toggle_speller(False)
return False
def insert_text(self, text):
self.get_buffer().insert_at_cursor(text)
def insert_newline(self):
buffer_ = self.get_buffer()
buffer_.insert_at_cursor('\n')
mark = buffer_.get_insert()
iter_ = buffer_.get_iter_at_mark(mark)
if buffer_.get_end_iter().equal(iter_):
GLib.idle_add(scroll_to_end, self.get_parent())
def has_text(self):
buf = self.get_buffer()
start, end = buf.get_bounds()
text = buf.get_text(start, end, True)
return text != ''
def get_text(self):
buf = self.get_buffer()
start, end = buf.get_bounds()
text = self.get_buffer().get_text(start, end, True)
return text
def toggle_speller(self, activate):
if app.is_installed('GSPELL') and app.settings.get('use_speller'):
spell_view = Gspell.TextView.get_from_gtk_text_view(self)
spell_view.set_inline_spell_checking(activate)
@staticmethod
def _after_paste_clipboard(textview):
buffer_ = textview.get_buffer()
mark = buffer_.get_insert()
iter_ = buffer_.get_iter_at_mark(mark)
if iter_.get_offset() == buffer_.get_end_iter().get_offset():
GLib.idle_add(scroll_to_end, textview.get_parent())
def make_clickable_urls(self, text):
_buffer = self.get_buffer()
start = 0
end = 0
index = 0
new_text = ''
iterator = LINK_REGEX.finditer(text)
for match in iterator:
start, end = match.span()
url = text[start:end]
if start != 0:
text_before_special_text = text[index:start]
else:
text_before_special_text = ''
# we insert normal text
new_text += text_before_special_text + \
'<a href="'+ url +'">' + url + '</a>'
index = end # update index
if end < len(text):
new_text += text[end:]
return new_text # the position after *last* special text
def get_active_tags(self):
start = self.get_active_iters()[0]
active_tags = []
for tag in start.get_tags():
active_tags.append(tag.get_property('name'))
return active_tags
def get_active_iters(self):
_buffer = self.get_buffer()
return_val = _buffer.get_selection_bounds()
if return_val: # if sth was selected
start, finish = return_val[0], return_val[1]
else:
start, finish = _buffer.get_bounds()
return (start, finish)
def set_tag(self, tag):
_buffer = self.get_buffer()
start, finish = self.get_active_iters()
if start.has_tag(self.other_tags[tag]):
_buffer.remove_tag_by_name(tag, start, finish)
else:
if tag == 'underline':
_buffer.remove_tag_by_name('strike', start, finish)
elif tag == 'strike':
_buffer.remove_tag_by_name('underline', start, finish)
_buffer.apply_tag_by_name(tag, start, finish)
def clear_tags(self):
_buffer = self.get_buffer()
start, finish = self.get_active_iters()
_buffer.remove_all_tags(start, finish)
def color_set(self, widget, response):
if response in (-6, -4):
widget.destroy()
return
color = widget.get_property('rgba')
widget.destroy()
_buffer = self.get_buffer()
# Create #aabbcc color string from rgba color
color_string = '#%02X%02X%02X' % (round(color.red*255),
round(color.green*255),
round(color.blue*255))
tag_name = 'color' + color_string
if not tag_name in self.color_tags:
tag_color = _buffer.create_tag(tag_name)
tag_color.set_property('foreground', color_string)
begin = '<span style="color: %s;">' % color_string
self.begin_tags[tag_name] = begin
self.end_tags[tag_name] = '</span>'
self.color_tags.append(tag_name)
start, finish = self.get_active_iters()
for tag in self.color_tags:
_buffer.remove_tag_by_name(tag, start, finish)
_buffer.apply_tag_by_name(tag_name, start, finish)
def font_set(self, widget, response, start, finish):
if response in (-6, -4):
widget.destroy()
return
font = widget.get_font()
font_desc = widget.get_font_desc()
family = font_desc.get_family()
size = font_desc.get_size()
size = size / Pango.SCALE
weight = font_desc.get_weight()
style = font_desc.get_style()
widget.destroy()
_buffer = self.get_buffer()
tag_name = 'font' + font
if not tag_name in self.fonts_tags:
tag_font = _buffer.create_tag(tag_name)
tag_font.set_property('font', family + ' ' + str(size))
self.begin_tags[tag_name] = \
'<span style="font-family: ' + family + '; ' + \
'font-size: ' + str(size) + 'px">'
self.end_tags[tag_name] = '</span>'
self.fonts_tags.append(tag_name)
for tag in self.fonts_tags:
_buffer.remove_tag_by_name(tag, start, finish)
_buffer.apply_tag_by_name(tag_name, start, finish)
if weight == Pango.Weight.BOLD:
_buffer.apply_tag_by_name('bold', start, finish)
else:
_buffer.remove_tag_by_name('bold', start, finish)
if style == Pango.Style.ITALIC:
_buffer.apply_tag_by_name('italic', start, finish)
else:
_buffer.remove_tag_by_name('italic', start, finish)
def get_xhtml(self):
_buffer = self.get_buffer()
old = _buffer.get_start_iter()
tags = {}
tags['bold'] = False
iter_ = _buffer.get_start_iter()
old = _buffer.get_start_iter()
text = ''
modified = False
def xhtml_special(text):
text = text.replace('<', '&lt;')
text = text.replace('>', '&gt;')
text = text.replace('&', '&amp;')
text = text.replace('\n', '<br />')
return text
for tag in iter_.get_toggled_tags(True):
tag_name = tag.get_property('name')
if tag_name not in self.begin_tags:
continue
text += self.begin_tags[tag_name]
modified = True
while (iter_.forward_to_tag_toggle(None) and not iter_.is_end()):
text += xhtml_special(_buffer.get_text(old, iter_, True))
old.forward_to_tag_toggle(None)
new_tags, old_tags, end_tags = [], [], []
for tag in iter_.get_toggled_tags(True):
tag_name = tag.get_property('name')
if tag_name not in self.begin_tags:
continue
new_tags.append(tag_name)
modified = True
for tag in iter_.get_tags():
tag_name = tag.get_property('name')
if (tag_name not in self.begin_tags or
tag_name not in self.end_tags):
continue
if tag_name not in new_tags:
old_tags.append(tag_name)
for tag in iter_.get_toggled_tags(False):
tag_name = tag.get_property('name')
if tag_name not in self.end_tags:
continue
end_tags.append(tag_name)
for tag in old_tags:
text += self.end_tags[tag]
for tag in end_tags:
text += self.end_tags[tag]
for tag in new_tags:
text += self.begin_tags[tag]
for tag in old_tags:
text += self.begin_tags[tag]
buffer_text = _buffer.get_text(old, _buffer.get_end_iter(), True)
text += xhtml_special(buffer_text)
for tag in iter_.get_toggled_tags(False):
tag_name = tag.get_property('name')
if tag_name not in self.end_tags:
continue
text += self.end_tags[tag_name]
if modified:
wrapped_text = '<p>%s</p>' % self.make_clickable_urls(text)
return build_xhtml_body(wrapped_text)
return None
def replace_emojis(self):
theme = app.settings.get('emoticons_theme')
if not theme or theme == 'font':
return
def replace(anchor):
if anchor is None:
return
image = anchor.get_widgets()[0]
if hasattr(image, 'codepoint'):
# found emoji
self.replace_char_at_iter(iter_, image.codepoint)
image.destroy()
iter_ = self.get_buffer().get_start_iter()
replace(iter_.get_child_anchor())
while iter_.forward_char():
replace(iter_.get_child_anchor())
def replace_char_at_iter(self, iter_, new_char):
buffer_ = self.get_buffer()
iter_2 = iter_.copy()
iter_2.forward_char()
buffer_.delete(iter_, iter_2)
buffer_.insert(iter_, new_char)
def insert_emoji(self, codepoint, pixbuf):
buffer_ = self.get_buffer()
if buffer_.get_char_count():
# buffer contains text
buffer_.insert_at_cursor(' ')
insert_mark = buffer_.get_insert()
insert_iter = buffer_.get_iter_at_mark(insert_mark)
if pixbuf is None:
buffer_.insert(insert_iter, codepoint)
else:
anchor = buffer_.create_child_anchor(insert_iter)
image = Gtk.Image.new_from_pixbuf(pixbuf)
image.codepoint = codepoint
image.show()
self.add_child_at_anchor(image, anchor)
buffer_.insert_at_cursor(' ')
def clear(self, _widget=None):
"""
Clear text in the textview
"""
_buffer = self.get_buffer()
start, end = _buffer.get_bounds()
_buffer.delete(start, end)
def save_undo(self, text):
self.undo_list.append(text)
if len(self.undo_list) > self.UNDO_LIMIT:
del self.undo_list[0]
self.undo_pressed = False
def undo(self, _widget=None):
"""
Undo text in the textview
"""
_buffer = self.get_buffer()
if self.undo_list:
_buffer.set_text(self.undo_list.pop())
self.undo_pressed = True

344
gajim/gtk/notification.py Normal file
View file

@ -0,0 +1,344 @@
# Copyright (C) 2005 Sebastian Estienne
# Copyright (C) 2005-2006 Andrew Sayman <lorien420 AT myrealbox.com>
# Copyright (C) 2005-2007 Nikos Kouremenos <kourem AT gmail.com>
# Copyright (C) 2005-2014 Yann Leboulanger <asterix AT lagaule.org>
# Copyright (C) 2006 Travis Shirk <travis AT pobox.com>
# Copyright (C) 2006-2008 Jean-Marie Traissard <jim AT lapin.org>
# Copyright (C) 2007 Julien Pivotto <roidelapluie AT gmail.com>
# Stephan Erb <steve-e AT h3c.de>
# Copyright (C) 2008 Brendan Taylor <whateley AT gmail.com>
# Jonathan Schleifer <js-gajim AT webkeks.org>
# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com>
#
# 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 sys
import logging
from gi.repository import GLib
from gi.repository import Gio
from gi.repository import Gdk
from gi.repository import Gtk
from gajim import gtkgui_helpers
from gajim.common import app
from gajim.common import helpers
from gajim.common import ged
from gajim.common.const import StyleAttr
from gajim.common.i18n import _
from gajim.common.nec import EventHelper
from .util import get_builder
from .util import get_icon_name
from .util import get_monitor_scale_factor
from .util import get_total_screen_geometry
log = logging.getLogger('gajim.gui.notification')
class Notification(EventHelper):
"""
Handle notifications
"""
def __init__(self):
EventHelper.__init__(self)
self._dbus_available = False
self._daemon_capabilities = ['actions']
self._win32_active_popup = None
self._detect_dbus_caps()
self.register_events([
('notification', ged.GUI2, self._nec_notification),
('simple-notification', ged.GUI2, self._on_notification),
('our-show', ged.GUI2, self._nec_our_status),
])
app.events.event_removed_subscribe(self._on_event_removed)
def _detect_dbus_caps(self):
if sys.platform in ('win32', 'darwin'):
return
if app.is_flatpak():
self._dbus_available = True
return
def on_proxy_ready(_source, res, _data=None):
try:
proxy = Gio.DBusProxy.new_finish(res)
self._daemon_capabilities = proxy.GetCapabilities()
except GLib.Error as error:
log.warning('Notifications D-Bus not available: %s', error)
else:
self._dbus_available = True
log.info('Notifications D-Bus connected')
log.info('Connecting to Notifications D-Bus')
Gio.DBusProxy.new_for_bus(Gio.BusType.SESSION,
Gio.DBusProxyFlags.DO_NOT_CONNECT_SIGNALS,
None,
'org.freedesktop.Notifications',
'/org/freedesktop/Notifications',
'org.freedesktop.Notifications',
None,
on_proxy_ready)
def _nec_notification(self, obj):
if obj.do_popup:
icon_name = self._get_icon_name(obj)
self.popup(obj.popup_event_type, str(obj.jid), obj.conn.name,
obj.popup_msg_type, icon_name=icon_name,
title=obj.popup_title, text=obj.popup_text,
timeout=obj.popup_timeout)
if obj.do_sound:
if obj.sound_file:
helpers.play_sound_file(obj.sound_file)
elif obj.sound_event:
helpers.play_sound(obj.sound_event)
if obj.do_command:
try:
helpers.exec_command(obj.command, use_shell=True)
except Exception:
pass
def _on_notification(self, event):
self.popup(event.type_,
None,
event.account,
title=event.title,
text=event.text)
def _on_event_removed(self, event_list):
for event in event_list:
if event.type_ == 'gc-invitation':
self._withdraw('gc-invitation', event.account, event.muc)
if event.type_ in ('normal', 'printed_chat', 'chat',
'printed_pm', 'pm', 'printed_marked_gc_msg',
'printed_gc_msg', 'jingle-incoming'):
self._withdraw('new-message', event.account, event.jid)
def _nec_our_status(self, event):
if app.account_is_connected(event.account):
self._withdraw('connection-failed', event.account)
@staticmethod
def _get_icon_name(obj):
if obj.notif_type == 'msg':
if obj.base_event.properties.is_muc_pm:
return 'gajim-priv_msg_recv'
elif obj.notif_type == 'pres':
if obj.transport_name is not None:
return '%s-%s' % (obj.transport_name, obj.show)
return get_icon_name(obj.show)
return None
def popup(self, event_type, jid, account, type_='', icon_name=None,
title=None, text=None, timeout=-1, room_jid=None):
"""
Notify a user of an event using GNotification and GApplication under
Linux, Use PopupNotificationWindow under Windows
"""
if icon_name is None:
icon_name = 'mail-message-new'
if timeout < 0:
timeout = app.settings.get('notification_timeout')
if sys.platform == 'win32':
self._withdraw()
self._win32_active_popup = PopupNotification(
event_type, jid, account, type_,
icon_name, title, text, timeout)
self._win32_active_popup.connect('destroy', self._on_popup_destroy)
return
if not self._dbus_available:
return
icon = Gio.ThemedIcon.new(icon_name)
notification = Gio.Notification()
if title is not None:
notification.set_title(title)
if text is not None:
notification.set_body(text)
notif_id = None
if event_type in (
_('New Message'), _('New Private Message'),
_('New Group Chat Message'),
_('Contact Changed Status'), _('File Transfer Request'),
_('File Transfer Error'), _('File Transfer Completed'),
_('File Transfer Stopped'), _('Group Chat Invitation'),
_('Connection Failed'), _('Subscription request'),
_('Unsubscribed'), _('Incoming Call')):
if 'actions' in self._daemon_capabilities:
# Create Variant Dict
dict_ = {'account': GLib.Variant('s', account),
'jid': GLib.Variant('s', jid),
'type_': GLib.Variant('s', type_)}
variant_dict = GLib.Variant('a{sv}', dict_)
action = 'app.{}-open-event'.format(account)
# Notification button
notification.add_button_with_target(
_('Open'), action, variant_dict)
notification.set_default_action_and_target(
action, variant_dict)
if event_type in (
_('New Message'),
_('New Private Message'),
_('New Group Chat Message')):
action = 'app.{}-remove-event'.format(account)
notification.add_button_with_target(
_('Mark as Read'), action, variant_dict)
# Only one notification per JID
if event_type == _('Contact Changed Status'):
notif_id = self._make_id('contact-status-changed', account, jid)
elif event_type == _('Group Chat Invitation'):
notif_id = self._make_id('gc-invitation', account, room_jid)
elif event_type == _('Connection Failed'):
notif_id = self._make_id('connection-failed', account)
elif event_type in (_('New Message'),
_('New Private Message'),
_('New Group Chat Message')):
if app.desktop_env == 'gnome':
icon = self._get_avatar_for_notification(account, jid)
notif_id = self._make_id('new-message', account, jid)
notification.set_icon(icon)
notification.set_priority(Gio.NotificationPriority.NORMAL)
app.app.send_notification(notif_id, notification)
@staticmethod
def _get_avatar_for_notification(account, jid):
scale = get_monitor_scale_factor()
contact = app.contacts.get_contact(account, jid)
if contact is None:
return None
return app.interface.get_avatar(contact, 32, scale, pixbuf=True)
def _on_popup_destroy(self, *args):
self._win32_active_popup = None
def _withdraw(self, *args):
if sys.platform == 'win32':
if self._win32_active_popup is not None:
self._win32_active_popup.destroy()
elif self._dbus_available:
app.app.withdraw_notification(self._make_id(*args))
@staticmethod
def _make_id(*args):
return ','.join(map(str, args))
class PopupNotification(Gtk.Window):
def __init__(self, event_type, jid, account, msg_type='',
icon_name=None, title=None, text=None, timeout=-1):
Gtk.Window.__init__(self)
self.set_type_hint(Gdk.WindowTypeHint.NOTIFICATION)
self.set_focus_on_map(False)
self.set_accept_focus(False)
self.set_skip_taskbar_hint(True)
self.set_decorated(False)
self._timeout_id = None
self.account = account
self.jid = jid
self.msg_type = msg_type
self._ui = get_builder('popup_notification_window.ui')
self.add(self._ui.eventbox)
if event_type in (_('New Message'),
_('New Private Message'),
_('New E-mail')):
bg_color = app.css_config.get_value('.gajim-notify-message',
StyleAttr.COLOR)
elif event_type == _('File Transfer Request'):
bg_color = app.css_config.get_value('.gajim-notify-ft-request',
StyleAttr.COLOR)
elif event_type == _('File Transfer Error'):
bg_color = app.css_config.get_value('.gajim-notify-ft-error',
StyleAttr.COLOR)
elif event_type in (_('File Transfer Completed'),
_('File Transfer Stopped')):
bg_color = app.css_config.get_value('.gajim-notify-ft-complete',
StyleAttr.COLOR)
elif event_type == _('Group Chat Invitation'):
bg_color = app.css_config.get_value('.gajim-notify-invite',
StyleAttr.COLOR)
elif event_type == _('Contact Changed Status'):
bg_color = app.css_config.get_value('.gajim-notify-status',
StyleAttr.COLOR)
else: # Unknown event (shouldn't happen, but deal with it)
bg_color = app.css_config.get_value('.gajim-notify-other',
StyleAttr.COLOR)
bar_class = '''
.popup-bar {
background-color: %s
}''' % bg_color
gtkgui_helpers.add_css_to_widget(self._ui.color_bar, bar_class)
self._ui.color_bar.get_style_context().add_class('popup-bar')
if not title:
title = ''
self._ui.event_type_label.set_markup(title)
if not text:
text = app.get_name_from_jid(account, jid) # default value of text
escaped_text = GLib.markup_escape_text(text)
self._ui.event_description_label.set_markup(escaped_text)
self._ui.image.set_from_icon_name(icon_name, Gtk.IconSize.DIALOG)
self.move(*self._get_window_pos())
self._ui.connect_signals(self)
self.connect('button-press-event', self._on_button_press)
self.connect('destroy', self._on_destroy)
self.show_all()
if timeout > 0:
self._timeout_id = GLib.timeout_add_seconds(timeout, self.destroy)
@staticmethod
def _get_window_pos():
pos_x = app.settings.get('notification_position_x')
screen_w, screen_h = get_total_screen_geometry()
if pos_x < 0:
pos_x = screen_w - 312 + pos_x + 1
pos_y = app.settings.get('notification_position_y')
if pos_y < 0:
pos_y = screen_h - 95 - 80 + pos_y + 1
return pos_x, pos_y
def _on_close_button_clicked(self, _widget):
self.destroy()
def _on_button_press(self, _widget, event):
if event.button == 1:
app.interface.handle_event(self.account, self.jid, self.msg_type)
self.destroy()
def _on_destroy(self, *args):
if self._timeout_id is not None:
GLib.source_remove(self._timeout_id)

157
gajim/gtk/pep_config.py Normal file
View file

@ -0,0 +1,157 @@
# 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 logging
from gi.repository import Gdk
from gi.repository import Gtk
from nbxmpp.errors import StanzaError
from gajim.common import app
from gajim.common.i18n import _
from gajim.common.helpers import to_user_string
from .dialogs import ErrorDialog
from .dialogs import WarningDialog
from .dataform import DataFormDialog
from .util import get_builder
log = logging.getLogger('gajim.gui.pep')
class PEPConfig(Gtk.ApplicationWindow):
def __init__(self, account):
Gtk.ApplicationWindow.__init__(self)
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_name('PEPConfig')
self.set_default_size(500, 350)
self.set_resizable(True)
self.set_transient_for(app.interface.roster.window)
self._ui = get_builder('manage_pep_services_window.ui')
self.add(self._ui.manage_pep_services)
self.account = account
self.set_title(_('PEP Service Configuration (%s)') % self.account)
self._con = app.connections[self.account]
self._init_services()
self._ui.services_treeview.get_selection().connect(
'changed', self._on_services_selection_changed)
self.show_all()
self.connect('key-press-event', self._on_key_press_event)
self._ui.connect_signals(self)
def _on_key_press_event(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
def _on_services_selection_changed(self, _selection):
self._ui.configure_button.set_sensitive(True)
self._ui.delete_button.set_sensitive(True)
def _init_services(self):
# service, access_model, group
self.treestore = Gtk.ListStore(str)
self.treestore.set_sort_column_id(0, Gtk.SortType.ASCENDING)
self._ui.services_treeview.set_model(self.treestore)
col = Gtk.TreeViewColumn(_('Service'))
col.set_sort_column_id(0)
self._ui.services_treeview.append_column(col)
cellrenderer_text = Gtk.CellRendererText()
col.pack_start(cellrenderer_text, True)
col.add_attribute(cellrenderer_text, 'text', 0)
jid = self._con.get_own_jid().bare
self._con.get_module('Discovery').disco_items(
jid, callback=self._items_received)
def _items_received(self, task):
try:
result = task.finish()
except StanzaError as error:
ErrorDialog('Error', to_user_string(error))
return
jid = result.jid.bare
for item in result.items:
if item.jid == jid and item.node is not None:
self.treestore.append([item.node])
def _on_node_delete(self, task):
node = task.get_user_data()
try:
task.finish()
except StanzaError as error:
WarningDialog(
_('PEP node was not removed'),
_('PEP node %(node)s was not removed:\n%(message)s') % {
'node': node, 'message': error})
return
model = self._ui.services_treeview.get_model()
iter_ = model.get_iter_first()
while iter_:
if model[iter_][0] == node:
model.remove(iter_)
break
iter_ = model.iter_next(iter_)
def _on_delete_button_clicked(self, _widget):
selection = self._ui.services_treeview.get_selection()
if not selection:
return
model, iter_ = selection.get_selected()
node = model[iter_][0]
con = app.connections[self.account]
con.get_module('PubSub').delete(node,
callback=self._on_node_delete,
user_data=node)
def _on_configure_button_clicked(self, _widget):
selection = self._ui.services_treeview.get_selection()
if not selection:
return
model, iter_ = selection.get_selected()
node = model[iter_][0]
con = app.connections[self.account]
con.get_module('PubSub').get_node_configuration(
node,
callback=self._nec_pep_config_received)
def _on_config_submit(self, form, node):
con = app.connections[self.account]
con.get_module('PubSub').set_node_configuration(node, form)
def _nec_pep_config_received(self, task):
try:
result = task.finish()
except Exception:
log.exception('Failed to retrieve config')
return
DataFormDialog(_('Configure %s') % result.node,
self,
result.form,
result.node,
self._on_config_submit)

1056
gajim/gtk/preferences.py Normal file

File diff suppressed because it is too large Load diff

355
gajim/gtk/profile.py Normal file
View file

@ -0,0 +1,355 @@
import logging
from gi.repository import Gio
from gi.repository import Gdk
from gi.repository import Gtk
from gi.repository import GLib
from nbxmpp.errors import StanzaError
from nbxmpp.namespaces import Namespace
from nbxmpp.modules.vcard4 import VCard
from nbxmpp.modules.user_avatar import Avatar
from gajim.common import app
from gajim.common.const import AvatarSize
from gajim.common.i18n import _
from gajim.common.i18n import Q_
from gajim.gui.avatar import clip_circle
from gajim.gui.avatar_selector import AvatarSelector
from gajim.gui.dialogs import ErrorDialog
from gajim.gui.filechoosers import AvatarChooserDialog
from gajim.gui.util import get_builder
from gajim.gui.vcard_grid import VCardGrid
from gajim.gui.util import scroll_to_end
log = logging.getLogger('gajim.gui.profile')
MENU_DICT = {
'fn': Q_('?profile:Full Name'),
'bday': _('Birthday'),
'gender': Q_('?profile:Gender'),
'adr': Q_('?profile:Address'),
'email': _('Email'),
'impp': Q_('?profile:IM Address'),
'tel': _('Phone No.'),
'org': Q_('?profile:Organisation'),
'title': Q_('?profile:Title'),
'role': Q_('?profile:Role'),
'url': _('URL'),
'key': Q_('?profile:Public Encryption Key'),
'note': Q_('?profile:Note'),
}
class ProfileWindow(Gtk.ApplicationWindow):
def __init__(self, account, *args):
Gtk.ApplicationWindow.__init__(self)
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
self.set_resizable(True)
self.set_default_size(700, 600)
self.set_name('ProfileWindow')
self.set_title(_('Profile'))
self.account = account
self._jid = app.get_jid_from_account(account)
self._ui = get_builder('profile.ui')
menu = Gio.Menu()
for action, label in MENU_DICT.items():
menu.append(label, 'win.add-' + action.lower())
self._ui.add_entry_button.set_menu_model(menu)
self._add_actions()
self._avatar_selector = None
self._current_avatar = None
self._current_vcard = None
self._avatar_nick_public = None
# False - no change to avatar
# None - we want to delete the avatar
# Avatar - upload new avatar
self._new_avatar = False
self._ui.nickname_entry.set_text(app.nicks[account])
self._vcard_grid = VCardGrid(self.account)
self._ui.profile_box.add(self._vcard_grid)
self.add(self._ui.profile_stack)
self.show_all()
self._load_avatar()
client = app.get_client(account)
client.get_module('VCard4').request_vcard(
callback=self._on_vcard_received)
client.get_module('PubSub').get_access_model(
Namespace.VCARD4_PUBSUB,
callback=self._on_access_model_received,
user_data=Namespace.VCARD4_PUBSUB)
client.get_module('PubSub').get_access_model(
Namespace.AVATAR_METADATA,
callback=self._on_access_model_received,
user_data=Namespace.AVATAR_METADATA)
client.get_module('PubSub').get_access_model(
Namespace.AVATAR_DATA,
callback=self._on_access_model_received,
user_data=Namespace.AVATAR_DATA)
client.get_module('PubSub').get_access_model(
Namespace.NICK,
callback=self._on_access_model_received,
user_data=Namespace.NICK)
self._ui.connect_signals(self)
self.connect('key-press-event', self._on_key_press_event)
def _on_access_model_received(self, task):
namespace = task.get_user_data()
try:
result = task.finish()
except StanzaError as error:
log.warning('Unable to get access model for %s: %s',
namespace, error)
return
access_model = result == 'open'
if namespace == Namespace.VCARD4_PUBSUB:
self._set_vcard_access_switch(access_model)
else:
if self._avatar_nick_public is None:
self._avatar_nick_public = access_model
else:
self._avatar_nick_public = (self._avatar_nick_public or
access_model)
self._set_avatar_nick_access_switch(self._avatar_nick_public)
def _on_vcard_received(self, task):
try:
self._current_vcard = task.finish()
except StanzaError as error:
log.info('Error loading VCard: %s', error)
self._current_vcard = VCard()
if self._current_vcard is None:
self._current_vcard = VCard()
self._load_avatar()
self._vcard_grid.set_vcard(self._current_vcard.copy())
self._ui.profile_stack.set_visible_child_name('profile')
self._ui.spinner.stop()
def _load_avatar(self):
scale = self.get_scale_factor()
self._current_avatar = app.contacts.get_avatar(
self.account,
self._jid,
AvatarSize.VCARD,
scale)
self._ui.avatar_image.set_from_surface(self._current_avatar)
self._ui.avatar_image.show()
def _on_key_press_event(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
def _add_actions(self):
for action in MENU_DICT:
action_name = 'add-' + action.lower()
act = Gio.SimpleAction.new(action_name, None)
act.connect('activate', self._on_action)
self.add_action(act)
def _on_action(self, action, _param):
name = action.get_name()
key = name.split('-')[1]
self._vcard_grid.add_new_property(key)
GLib.idle_add(scroll_to_end, self._ui.scrolled)
def _on_edit_clicked(self, *args):
self._vcard_grid.set_editable(True)
self._ui.edit_button.hide()
self._ui.add_entry_button.set_no_show_all(False)
self._ui.add_entry_button.show_all()
self._ui.cancel_button.show()
self._ui.save_button.show()
self._ui.remove_avatar_button.show()
self._ui.edit_avatar_button.show()
self._ui.nickname_entry.set_sensitive(True)
self._ui.privacy_button.show()
def _on_cancel_clicked(self, _widget):
self._vcard_grid.set_editable(False)
self._ui.edit_button.show()
self._ui.add_entry_button.hide()
self._ui.cancel_button.hide()
self._ui.save_button.hide()
self._ui.remove_avatar_button.hide()
self._ui.edit_avatar_button.hide()
self._ui.privacy_button.hide()
self._ui.nickname_entry.set_sensitive(False)
self._ui.avatar_image.set_from_surface(self._current_avatar)
self._ui.nickname_entry.set_text(app.nicks[self.account])
self._vcard_grid.set_vcard(self._current_vcard.copy())
self._new_avatar = False
def _on_save_clicked(self, _widget):
self._ui.spinner.start()
self._ui.profile_stack.set_visible_child_name('spinner')
self._ui.add_entry_button.hide()
self._ui.cancel_button.hide()
self._ui.save_button.hide()
self._ui.edit_button.show()
self._ui.remove_avatar_button.hide()
self._ui.edit_avatar_button.hide()
self._ui.privacy_button.hide()
self._ui.nickname_entry.set_sensitive(False)
self._vcard_grid.validate()
self._vcard_grid.sort()
vcard = self._vcard_grid.get_vcard()
self._current_vcard = vcard.copy()
con = app.connections[self.account]
con.get_module('VCard4').set_vcard(
self._current_vcard,
public=self._ui.vcard_access.get_active(),
callback=self._on_save_finished)
public = self._ui.avatar_nick_access.get_active()
if self._new_avatar is False:
if self._avatar_nick_public != public:
con.get_module('UserAvatar').set_access_model(public)
else:
# Only update avatar if it changed
con.get_module('UserAvatar').set_avatar(
self._new_avatar,
public=public,
callback=self._on_set_avatar)
nick = GLib.markup_escape_text(self._ui.nickname_entry.get_text())
con.get_module('UserNickname').set_nickname(nick, public=public)
if not nick:
nick = app.settings.get_account_setting(
self.account, 'name')
app.nicks[self.account] = nick
def _on_set_avatar(self, task):
try:
task.finish()
except StanzaError as error:
if self._new_avatar is None:
# Trying to remove the avatar but the node does not exist
if error.condition == 'item-not-found':
return
title = _('Error while uploading avatar')
text = error.get_text()
if (error.condition == 'not-acceptable' and
error.app_condition == 'payload-too-big'):
text = _('Avatar file size too big')
ErrorDialog(title, text)
self._ui.avatar_image.set_from_surface(self._current_avatar)
self._new_avatar = False
return
def _on_remove_avatar(self, _button):
contact = app.contacts.create_contact(self._jid, self.account)
scale = self.get_scale_factor()
surface = app.interface.avatar_storage.get_surface(
contact, AvatarSize.VCARD, scale, default=True)
self._ui.avatar_image.set_from_surface(surface)
self._ui.remove_avatar_button.hide()
self._new_avatar = None
def _on_edit_avatar(self, button):
def _on_file_selected(path):
if self._avatar_selector is None:
self._avatar_selector = AvatarSelector()
self._ui.avatar_selector_box.add(self._avatar_selector)
self._avatar_selector.prepare_crop_area(path)
self._ui.avatar_update_button.set_sensitive(
self._avatar_selector.get_prepared())
self._ui.profile_stack.set_visible_child_name('avatar_selector')
AvatarChooserDialog(_on_file_selected,
transient_for=button.get_toplevel())
def _on_cancel_update_avatar(self, _button):
self._ui.profile_stack.set_visible_child_name('profile')
def _on_update_avatar(self, _button):
success, data, width, height = self._avatar_selector.get_avatar_bytes()
if not success:
self._ui.profile_stack.set_visible_child_name('profile')
ErrorDialog(_('Error while processing image'),
_('Failed to generate avatar.'))
return
sha = app.interface.avatar_storage.save_avatar(data)
if sha is None:
self._ui.profile_stack.set_visible_child_name('profile')
ErrorDialog(_('Error while processing image'),
_('Failed to generate avatar.'))
return
self._new_avatar = Avatar()
self._new_avatar.add_image_source(data, 'image/png', height, width)
scale = self.get_scale_factor()
surface = app.interface.avatar_storage.surface_from_filename(
sha, AvatarSize.VCARD, scale)
self._ui.avatar_image.set_from_surface(clip_circle(surface))
self._ui.remove_avatar_button.show()
self._ui.profile_stack.set_visible_child_name('profile')
def _set_vcard_access_switch(self, state):
self._ui.vcard_access.set_active(state)
self._ui.vcard_access_label.set_text(
_('Everyone') if state else _('Contacts'))
def _set_avatar_nick_access_switch(self, state):
self._ui.avatar_nick_access.set_active(state)
self._ui.avatar_nick_access_label.set_text(
_('Everyone') if state else _('Contacts'))
def _access_switch_toggled(self, *args):
state = self._ui.vcard_access.get_active()
self._set_vcard_access_switch(state)
state = self._ui.avatar_nick_access.get_active()
self._set_avatar_nick_access_switch(state)
def _on_save_finished(self, task):
try:
task.finish()
except StanzaError as err:
log.error('Could not publish VCard: %s', err)
# TODO Handle error
return
self._vcard_grid.set_editable(False)
self._ui.profile_stack.set_visible_child_name('profile')
self._ui.spinner.stop()

228
gajim/gtk/proxies.py Normal file
View file

@ -0,0 +1,228 @@
# 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/>.
from gi.repository import Gdk
from gi.repository import Gtk
from gajim.common import app
from gajim.common.i18n import _
from .util import get_builder
from .util import get_app_window
class ManageProxies(Gtk.ApplicationWindow):
def __init__(self):
Gtk.ApplicationWindow.__init__(self)
self.set_name('ManageProxies')
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_default_size(500, -1)
self.set_show_menubar(False)
self.set_title(_('Manage Proxies'))
self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
self.set_modal(True)
self._ui = get_builder('manage_proxies.ui')
self.add(self._ui.box)
self._init_list()
self._block_signal = False
self.connect_after('key-press-event', self._on_key_press)
self.connect('destroy', self._on_destroy)
self._ui.connect_signals(self)
self.show_all()
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
@staticmethod
def _on_destroy(*args):
# Window callbacks for updating proxy comboboxes
window_pref = get_app_window('Preferences')
window_accounts = get_app_window('AccountsWindow')
window_account_wizard = get_app_window('AccountWizard')
if window_pref is not None:
window_pref.update_proxy_list()
if window_accounts is not None:
window_accounts.update_proxy_list()
if window_account_wizard is not None:
window_account_wizard.update_proxy_list()
def _fill_proxies_treeview(self):
model = self._ui.proxies_treeview.get_model()
model.clear()
for proxy in app.settings.get_proxies():
iter_ = model.append()
model.set(iter_, 0, proxy)
def _init_list(self):
self._ui.remove_proxy_button.set_sensitive(False)
self._ui.settings_grid.set_sensitive(False)
model = Gtk.ListStore(str)
self._ui.proxies_treeview.set_model(model)
col = Gtk.TreeViewColumn('Proxies')
self._ui.proxies_treeview.append_column(col)
renderer = Gtk.CellRendererText()
col.pack_start(renderer, True)
col.add_attribute(renderer, 'text', 0)
self._fill_proxies_treeview()
self._ui.proxytype_combobox.set_active(0)
def _on_add_proxy_button_clicked(self, _widget):
model = self._ui.proxies_treeview.get_model()
proxies = app.settings.get_proxies()
i = 1
while 'proxy' + str(i) in proxies:
i += 1
proxy_name = 'proxy' + str(i)
app.settings.add_proxy(proxy_name)
iter_ = model.append()
model.set(iter_, 0, proxy_name)
self._ui.proxies_treeview.set_cursor(model.get_path(iter_))
def _on_remove_proxy_button_clicked(self, _widget):
sel = self._ui.proxies_treeview.get_selection()
if not sel:
return
(model, iter_) = sel.get_selected()
if not iter_:
return
proxy = model[iter_][0]
model.remove(iter_)
app.settings.remove_proxy(proxy)
self._ui.remove_proxy_button.set_sensitive(False)
self._block_signal = True
self._on_proxies_treeview_cursor_changed(self._ui.proxies_treeview)
self._block_signal = False
def _on_useauth_toggled(self, widget):
if self._block_signal:
return
act = widget.get_active()
proxy = self._ui.proxyname_entry.get_text()
app.settings.set_proxy_setting(proxy, 'useauth', act)
self._ui.proxyuser_entry.set_sensitive(act)
self._ui.proxypass_entry.set_sensitive(act)
def _on_proxies_treeview_cursor_changed(self, widget):
self._block_signal = True
self._ui.proxyhost_entry.set_text('')
self._ui.proxyport_entry.set_text('')
self._ui.proxyuser_entry.set_text('')
self._ui.proxypass_entry.set_text('')
sel = widget.get_selection()
if sel:
(model, iter_) = sel.get_selected()
else:
iter_ = None
if not iter_:
self._ui.proxyname_entry.set_text('')
self._ui.settings_grid.set_sensitive(False)
self._block_signal = False
return
proxy = model[iter_][0]
self._ui.proxyname_entry.set_text(proxy)
self._ui.remove_proxy_button.set_sensitive(True)
self._ui.proxyname_entry.set_editable(True)
self._ui.settings_grid.set_sensitive(True)
settings = app.settings.get_proxy_settings(proxy)
self._ui.proxyhost_entry.set_text(settings['host'])
self._ui.proxyport_entry.set_text(str(settings['port']))
self._ui.proxyuser_entry.set_text(settings['user'])
self._ui.proxypass_entry.set_text(settings['pass'])
types = ['http', 'socks5']
self._ui.proxytype_combobox.set_active(types.index(settings['type']))
self._ui.useauth_checkbutton.set_active(settings['useauth'])
act = self._ui.useauth_checkbutton.get_active()
self._ui.proxyuser_entry.set_sensitive(act)
self._ui.proxypass_entry.set_sensitive(act)
self._block_signal = False
def _on_proxies_treeview_key_press_event(self, widget, event):
if event.keyval == Gdk.KEY_Delete:
self._on_remove_proxy_button_clicked(widget)
def _on_proxyname_entry_changed(self, widget):
if self._block_signal:
return
sel = self._ui.proxies_treeview.get_selection()
if not sel:
return
(model, iter_) = sel.get_selected()
if not iter_:
return
old_name = model.get_value(iter_, 0)
new_name = widget.get_text()
if new_name == '':
return
if new_name == old_name:
return
app.settings.rename_proxy(old_name, new_name)
model.set_value(iter_, 0, new_name)
def _on_proxytype_combobox_changed(self, _widget):
if self._block_signal:
return
types = ['http', 'socks5']
type_ = self._ui.proxytype_combobox.get_active()
self._ui.proxyhost_entry.set_sensitive(True)
self._ui.proxyport_entry.set_sensitive(True)
proxy = self._ui.proxyname_entry.get_text()
app.settings.set_proxy_setting(proxy, 'type', types[type_])
def _on_proxyhost_entry_changed(self, entry):
if self._block_signal:
return
value = entry.get_text()
proxy = self._ui.proxyname_entry.get_text()
app.settings.set_proxy_setting(proxy, 'host', value)
def _on_proxyport_entry_changed(self, entry):
if self._block_signal:
return
value = entry.get_text()
try:
value = int(value)
except Exception:
value = 0
proxy = self._ui.proxyname_entry.get_text()
app.settings.set_proxy_setting(proxy, 'port', value)
def _on_proxyuser_entry_changed(self, entry):
if self._block_signal:
return
value = entry.get_text()
proxy = self._ui.proxyname_entry.get_text()
app.settings.set_proxy_setting(proxy, 'user', value)
def _on_proxypass_entry_changed(self, entry):
if self._block_signal:
return
value = entry.get_text()
proxy = self._ui.proxyname_entry.get_text()
app.settings.set_proxy_setting(proxy, 'pass', value)

213
gajim/gtk/remove_account.py Normal file
View file

@ -0,0 +1,213 @@
# 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 logging
from gi.repository import Gtk
from nbxmpp.errors import StanzaError
from gajim.common import app
from gajim.common import ged
from gajim.common.i18n import _
from gajim.common.helpers import to_user_string
from gajim.common.helpers import event_filter
from .assistant import Assistant
from .assistant import Page
from .assistant import ErrorPage
from .assistant import SuccessPage
log = logging.getLogger('gajim.gui.remove_account')
class RemoveAccount(Assistant):
def __init__(self, account):
Assistant.__init__(self)
self.account = account
try:
self._client = app.get_client(account)
except KeyError:
self._client = None
self._destroyed = False
self._account_removed = False
self.add_button('remove', _('Remove'), 'destructive-action')
self.add_button('close', _('Close'))
self.add_button('back', _('Back'))
self.add_pages({'remove_choice': RemoveChoice(account),
'error': Error(),
'success': Success()})
progress = self.add_default_page('progress')
progress.set_title(_('Removing Account...'))
progress.set_text(_('Trying to remove account...'))
self.connect('button-clicked', self._on_button_clicked)
self.connect('destroy', self._on_destroy)
self.register_events([
('account-connected', ged.GUI1, self._on_account_connected),
('account-disconnected', ged.GUI1, self._on_account_disconnected),
])
self._set_remove_from_server_checkbox()
self.show_all()
@event_filter(['account'])
def _on_account_connected(self, _event):
self._client = app.get_client(self.account)
self._set_remove_from_server_checkbox()
@event_filter(['account'])
def _on_account_disconnected(self, _event):
self._set_remove_from_server_checkbox()
if self._account_removed:
self.show_page('success')
app.interface.remove_account(self.account)
def _set_remove_from_server_checkbox(self):
enabled = self._client is not None and self._client.state.is_available
self.get_page('remove_choice').set_remove_from_server(enabled)
def _on_button_clicked(self, _assistant, button_name):
page = self.get_current_page()
if button_name == 'remove':
if page == 'remove_choice':
self.show_page('progress', Gtk.StackTransitionType.SLIDE_LEFT)
self._on_remove()
return
if button_name == 'back':
if page == 'error':
self.show_page('remove_choice',
Gtk.StackTransitionType.SLIDE_RIGHT)
return
if button_name == 'close':
self.destroy()
def _on_remove(self, *args):
if self.get_page('remove_choice').remove_from_server:
self._client.set_remove_account(True)
self._client.get_module('Register').unregister(
callback=self._on_remove_response)
return
if self._client is None or self._client.state.is_disconnected:
app.interface.remove_account(self.account)
self.show_page('success')
return
self._client.disconnect(gracefully=True, reconnect=False)
self._account_removed = True
def _on_remove_response(self, task):
try:
task.finish()
except StanzaError as error:
self._client.set_remove_account(False)
error_text = to_user_string(error)
self.get_page('error').set_text(error_text)
self.show_page('error')
return
self._account_removed = True
def _on_destroy(self, *args):
self._destroyed = True
class RemoveChoice(Page):
def __init__(self, account):
Page.__init__(self)
self.title = _('Remove Account')
heading = Gtk.Label(label=_('Remove Account'))
heading.get_style_context().add_class('large-header')
heading.set_max_width_chars(30)
heading.set_line_wrap(True)
heading.set_halign(Gtk.Align.CENTER)
heading.set_justify(Gtk.Justification.CENTER)
label = Gtk.Label(label=_('This will remove your account from Gajim.'))
label.set_max_width_chars(50)
label.set_line_wrap(True)
label.set_halign(Gtk.Align.CENTER)
label.set_justify(Gtk.Justification.CENTER)
service = app.settings.get_account_setting(account, 'hostname')
check_label = Gtk.Label()
check_label.set_markup(
_('Do you want to unregister your account on <b>%s</b> as '
'well?') % service)
check_label.set_max_width_chars(50)
check_label.set_line_wrap(True)
check_label.set_halign(Gtk.Align.CENTER)
check_label.set_justify(Gtk.Justification.CENTER)
check_label.set_margin_top(40)
self._server = Gtk.CheckButton.new_with_mnemonic(
_('_Unregister account from service'))
self._server.set_halign(Gtk.Align.CENTER)
self.pack_start(heading, False, True, 0)
self.pack_start(label, False, True, 0)
self.pack_start(check_label, False, True, 0)
self.pack_start(self._server, False, True, 0)
self.show_all()
@property
def remove_from_server(self):
return self._server.get_active()
def set_remove_from_server(self, enabled):
self._server.set_sensitive(enabled)
if enabled:
self._server.set_tooltip_text('')
else:
self._server.set_active(False)
self._server.set_tooltip_text(_('Account has to be connected'))
def get_visible_buttons(self):
return ['remove']
class Error(ErrorPage):
def __init__(self):
ErrorPage.__init__(self)
self.set_title(_('Account Removal Failed'))
self.set_heading(_('Account Removal Failed'))
def get_visible_buttons(self):
return ['back']
class Success(SuccessPage):
def __init__(self):
SuccessPage.__init__(self)
self.set_title(_('Account Removed'))
self.set_heading(_('Account Removed'))
self.set_text(
_('Your account has has been removed successfully.'))
def get_visible_buttons(self):
return ['close']

View file

@ -0,0 +1,259 @@
# 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/>.
from gi.repository import Gtk
from gajim.common import app
from gajim.common import i18n
from gajim.common.i18n import _
from .dialogs import InformationDialog
from .util import get_builder
class RosterItemExchangeWindow(Gtk.ApplicationWindow):
"""
Used when someone sends a Roster Item Exchange suggestion (XEP-0144)
"""
def __init__(self, account, action, exchange_list, jid_from,
message_body=None):
Gtk.ApplicationWindow.__init__(self)
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_title(_('Contact List Exchange'))
self.set_name('RosterItemExchangeWindow')
self.account = account
self.action = action
self.exchange_list = exchange_list
self.message_body = message_body
self.jid_from = jid_from
self._ui = get_builder('roster_item_exchange_window.ui')
self.add(self._ui.roster_item_exchange)
# Set label depending on action
if action == 'add':
type_label = _('<b>%s</b> would like to add some '
'contacts to your contact list.') % self.jid_from
elif action == 'modify':
type_label = _('<b>%s</b> would like to modify some '
'contacts in your contact list.') % self.jid_from
elif action == 'delete':
type_label = _('<b>%s</b> would like to delete some '
'contacts from your contact list.') % self.jid_from
self._ui.type_label.set_markup(type_label)
if message_body:
buffer_ = self._ui.body_textview.get_buffer()
buffer_.set_text(self.message_body)
else:
self._ui.body_scrolledwindow.hide()
# Treeview
model = Gtk.ListStore(bool, str, str, str, str)
self._ui.items_list_treeview.set_model(model)
# Columns
renderer1 = Gtk.CellRendererToggle()
renderer1.set_property('activatable', True)
renderer1.connect('toggled', self._toggled)
if self.action == 'add':
title = _('Add')
elif self.action == 'modify':
title = _('Modify')
elif self.action == 'delete':
title = _('Delete')
self._ui.items_list_treeview.insert_column_with_attributes(
-1, title, renderer1, active=0)
renderer2 = Gtk.CellRendererText()
self._ui.items_list_treeview.insert_column_with_attributes(
-1, _('JID'), renderer2, text=1)
renderer3 = Gtk.CellRendererText()
self._ui.items_list_treeview.insert_column_with_attributes(
-1, _('Name'), renderer3, text=2)
renderer4 = Gtk.CellRendererText()
self._ui.items_list_treeview.insert_column_with_attributes(
-1, _('Groups'), renderer4, text=3)
# Init contacts
self.model = self._ui.items_list_treeview.get_model()
self.model.clear()
if action == 'add':
self._add()
elif action == 'modify':
self._modify()
elif action == 'delete':
self._delete()
self._ui.connect_signals(self)
def _toggled(self, cell, path):
model = self._ui.items_list_treeview.get_model()
iter_ = model.get_iter(path)
model[iter_][0] = not cell.get_active()
def _add(self):
for jid in self.exchange_list:
groups = ''
is_in_roster = True
contact = app.contacts.get_contact_with_highest_priority(
self.account, jid)
if not contact or _('Not in contact list') in contact.groups:
is_in_roster = False
name = self.exchange_list[jid][0]
num_list = len(self.exchange_list[jid][1])
current = 0
for group in self.exchange_list[jid][1]:
current += 1
if contact and group not in contact.groups:
is_in_roster = False
if current == num_list:
groups = groups + group
else:
groups = groups + group + ', '
if not is_in_roster:
self.show_all()
iter_ = self.model.append()
self.model.set(iter_, 0, True, 1, jid, 2, name, 3, groups)
self._ui.accept_button.set_label(_('Add'))
def _modify(self):
for jid in self.exchange_list:
groups = ''
is_in_roster = True
is_right = True
contact = app.contacts.get_contact_with_highest_priority(
self.account, jid)
name = self.exchange_list[jid][0]
if not contact:
is_in_roster = False
is_right = False
else:
if name != contact.name:
is_right = False
num_list = len(self.exchange_list[jid][1])
current = 0
for group in self.exchange_list[jid][1]:
current += 1
if contact and group not in contact.groups:
is_right = False
if current == num_list:
groups = groups + group
else:
groups = groups + group + ', '
if not is_right and is_in_roster:
self.show_all()
iter_ = self.model.append()
self.model.set(iter_, 0, True, 1, jid, 2, name, 3, groups)
self._ui.accept_button.set_label(_('Modify'))
def _delete(self):
for jid in self.exchange_list:
groups = ''
is_in_roster = True
contact = app.contacts.get_contact_with_highest_priority(
self.account, jid)
name = self.exchange_list[jid][0]
if not contact:
is_in_roster = False
num_list = len(self.exchange_list[jid][1])
current = 0
for group in self.exchange_list[jid][1]:
current += 1
if current == num_list:
groups = groups + group
else:
groups = groups + group + ', '
if is_in_roster:
self.show_all()
iter_ = self.model.append()
self.model.set(iter_, 0, True, 1, jid, 2, name, 3, groups)
self._ui.accept_button.set_label(_('Delete'))
def _on_accept_button_clicked(self, _widget):
model = self._ui.items_list_treeview.get_model()
iter_ = model.get_iter_first()
if self.action == 'add':
count = 0
while iter_:
if model[iter_][0]:
count += 1
# It is selected
message = _('%s suggested me to add you to my '
'contact list.') % self.jid_from
# Keep same groups and same nickname
groups = model[iter_][3].split(', ')
if groups == ['']:
groups = []
jid = model[iter_][1]
if app.jid_is_transport(self.jid_from):
con = app.connections[self.account]
con.get_module('Presence').automatically_added.append(
jid)
app.interface.roster.req_sub(
self, jid, message, self.account, groups=groups,
nickname=model[iter_][2], auto_auth=True)
iter_ = model.iter_next(iter_)
InformationDialog(i18n.ngettext('Added %d contact',
'Added %d contacts',
count, count, count))
elif self.action == 'modify':
count = 0
while iter_:
if model[iter_][0]:
count += 1
# It is selected
jid = model[iter_][1]
# Keep same groups and same nickname
groups = model[iter_][3].split(', ')
if groups == ['']:
groups = []
for contact in app.contacts.get_contact(self.account, jid):
contact.name = model[iter_][2]
con = app.connections[self.account]
con.get_module('Roster').update_contact(
jid, model[iter_][2], groups)
con.get_module('Roster').draw_contact(jid, self.account)
# Update opened chats
ctrl = app.interface.msg_win_mgr.get_control(jid,
self.account)
if ctrl:
ctrl.update_ui()
win = app.interface.msg_win_mgr.get_window(jid,
self.account)
win.redraw_tab(ctrl)
win.show_title()
iter_ = model.iter_next(iter_)
elif self.action == 'delete':
count = 0
while iter_:
if model[iter_][0]:
count += 1
# It is selected
jid = model[iter_][1]
app.connections[self.account].get_module(
'Presence').unsubscribe(jid)
app.interface.roster.remove_contact(jid, self.account)
app.contacts.remove_jid(self.account, jid)
iter_ = model.iter_next(iter_)
InformationDialog(i18n.ngettext('Removed %d contact',
'Removed %d contacts',
count, count, count))
self.destroy()
def _on_cancel_button_clicked(self, _widget):
self.destroy()

357
gajim/gtk/search.py Normal file
View file

@ -0,0 +1,357 @@
# Copyright (C) 2019 Philipp Hörist <philipp AT hoerist.com>
#
# 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 logging
import itertools
from enum import IntEnum
from gi.repository import Gtk
from nbxmpp.modules import dataforms
from gajim.common import app
from gajim.common import ged
from gajim.common.i18n import _
from gajim.gui_menu_builder import SearchMenu
from .dataform import DataFormWidget
from .util import ensure_not_destroyed
from .util import find_widget
from .util import EventHelper
log = logging.getLogger('gajim.gui.search')
class Page(IntEnum):
REQUEST_FORM = 0
FORM = 1
REQUEST_RESULT = 2
COMPLETED = 3
ERROR = 4
class Search(Gtk.Assistant, EventHelper):
def __init__(self, account, jid, transient_for=None):
Gtk.Assistant.__init__(self)
EventHelper.__init__(self)
self._con = app.connections[account]
self._account = account
self._jid = jid
self._destroyed = False
self.set_application(app.app)
self.set_resizable(True)
self.set_position(Gtk.WindowPosition.CENTER)
if transient_for is not None:
self.set_transient_for(transient_for)
self.set_size_request(500, 400)
self.get_style_context().add_class('dialog-margin')
self._add_page(RequestForm())
self._add_page(Form())
self._add_page(RequestResult())
self._add_page(Completed())
self._add_page(Error())
self.connect('prepare', self._on_page_change)
self.connect('cancel', self._on_cancel)
self.connect('close', self._on_cancel)
self.connect('destroy', self._on_destroy)
self._remove_sidebar()
self._buttons = {}
self._add_custom_buttons()
self.show()
self.register_events([
('search-form-received', ged.GUI1, self._search_form_received),
('search-result-received', ged.GUI1, self._search_result_received),
])
self._request_search_fields()
def _add_custom_buttons(self):
action_area = find_widget('action_area', self)
for button in list(action_area.get_children()):
self.remove_action_widget(button)
search = Gtk.Button(label=_('Search'))
search.connect('clicked', self._execute_search)
search.get_style_context().add_class('suggested-action')
self._buttons['search'] = search
self.add_action_widget(search)
new_search = Gtk.Button(label=_('New Search'))
new_search.get_style_context().add_class('suggested-action')
new_search.connect('clicked',
lambda *args: self.set_current_page(Page.FORM))
self._buttons['new-search'] = new_search
self.add_action_widget(new_search)
def _set_button_visibility(self, page):
for button in self._buttons.values():
button.hide()
if page == Page.FORM:
self._buttons['search'].show()
elif page in (Page.ERROR, Page.COMPLETED):
self._buttons['new-search'].show()
def _add_page(self, page):
self.append_page(page)
self.set_page_type(page, page.type_)
self.set_page_title(page, page.title)
self.set_page_complete(page, page.complete)
def set_stage_complete(self, is_valid):
self._buttons['search'].set_sensitive(is_valid)
def _request_search_fields(self):
self._con.get_module('Search').request_search_fields(self._jid)
def _execute_search(self, *args):
self.set_current_page(Page.REQUEST_RESULT)
form = self.get_nth_page(Page.FORM).get_submit_form()
self._con.get_module('Search').send_search_form(self._jid, form, True)
@ensure_not_destroyed
def _search_form_received(self, event):
if not event.is_dataform:
self.set_current_page(Page.ERROR)
return
self.get_nth_page(Page.FORM).process_search_form(event.data)
self.set_current_page(Page.FORM)
@ensure_not_destroyed
def _search_result_received(self, event):
if event.data is None:
self._on_error('')
return
self.get_nth_page(Page.COMPLETED).process_result(event.data)
self.set_current_page(Page.COMPLETED)
def _remove_sidebar(self):
main_box = self.get_children()[0]
sidebar = main_box.get_children()[0]
main_box.remove(sidebar)
def _on_page_change(self, _assistant, _page):
self._set_button_visibility(self.get_current_page())
def _on_error(self, error_text):
log.info('Show Error page')
page = self.get_nth_page(Page.ERROR)
page.set_text(error_text)
self.set_current_page(Page.ERROR)
def _on_cancel(self, _widget):
self.destroy()
def _on_destroy(self, *args):
self._destroyed = True
class RequestForm(Gtk.Box):
type_ = Gtk.AssistantPageType.CUSTOM
title = _('Request Search Form')
complete = False
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.set_spacing(18)
spinner = Gtk.Spinner()
self.pack_start(spinner, True, True, 0)
spinner.start()
self.show_all()
class Form(Gtk.Box):
type_ = Gtk.AssistantPageType.CUSTOM
title = _('Search')
complete = True
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.set_spacing(18)
self._dataform_widget = None
self.show_all()
@property
def search_form(self):
return self._dataform_widget.get_submit_form()
def clear(self):
self._show_form(None)
def process_search_form(self, form):
self._show_form(form)
def _show_form(self, form):
if self._dataform_widget is not None:
self.remove(self._dataform_widget)
self._dataform_widget.destroy()
if form is None:
return
options = {'form-width': 350}
form = dataforms.extend_form(node=form)
self._dataform_widget = DataFormWidget(form, options=options)
self._dataform_widget.connect('is-valid', self._on_is_valid)
self._dataform_widget.validate()
self._dataform_widget.show_all()
self.add(self._dataform_widget)
def _on_is_valid(self, _widget, is_valid):
self.get_toplevel().set_stage_complete(is_valid)
def get_submit_form(self):
return self._dataform_widget.get_submit_form()
class RequestResult(RequestForm):
type_ = Gtk.AssistantPageType.CUSTOM
title = _('Search…')
complete = False
class Completed(Gtk.Box):
type_ = Gtk.AssistantPageType.CUSTOM
title = _('Search Result')
complete = True
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.set_spacing(12)
self.show_all()
self._label = Gtk.Label(label=_('No results found'))
self._label.get_style_context().add_class('bold16')
self._label.set_no_show_all(True)
self._label.set_halign(Gtk.Align.CENTER)
self._scrolled = Gtk.ScrolledWindow()
self._scrolled.get_style_context().add_class('search-scrolled')
self._scrolled.set_no_show_all(True)
self._treeview = None
self._menu = None
self.add(self._label)
self.add(self._scrolled)
self.show_all()
def process_result(self, form):
if self._treeview is not None:
self._scrolled.remove(self._treeview)
self._treeview.destroy()
self._treeview = None
self._menu = None
self._label.hide()
self._scrolled.hide()
if not form:
self._label.show()
return
form = dataforms.extend_form(node=form)
fieldtypes = []
fieldvars = []
for field in form.reported.iter_fields():
if field.type_ == 'boolean':
fieldtypes.append(bool)
elif field.type_ in ('jid-single', 'text-single'):
fieldtypes.append(str)
else:
log.warning('Not supported field received: %s', field.type_)
continue
fieldvars.append(field.var)
liststore = Gtk.ListStore(*fieldtypes)
for item in form.iter_records():
iter_ = liststore.append()
for field in item.iter_fields():
if field.var in fieldvars:
liststore.set_value(iter_,
fieldvars.index(field.var),
field.value)
self._treeview = Gtk.TreeView()
self._treeview.set_hexpand(True)
self._treeview.set_vexpand(True)
self._treeview.get_style_context().add_class('search-treeview')
self._treeview.connect('button-press-event', self._on_button_press)
self._menu = SearchMenu(self._treeview)
for field, counter in zip(form.reported.iter_fields(),
itertools.count()):
self._treeview.append_column(
Gtk.TreeViewColumn(field.label,
Gtk.CellRendererText(),
text=counter))
self._treeview.set_model(liststore)
self._treeview.show()
self._scrolled.add(self._treeview)
self._scrolled.show()
def _on_button_press(self, treeview, event):
if event.button != 3:
return
path, _column, _x, _y = treeview.get_path_at_pos(event.x, event.y)
if path is None:
return
store = treeview.get_model()
iter_ = store.get_iter(path)
column_values = store[iter_]
text = ' '.join(column_values)
self._menu.set_copy_text(text)
self._menu.popup_at_pointer()
class Error(Gtk.Box):
type_ = Gtk.AssistantPageType.CUSTOM
title = _('Error')
complete = True
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.set_spacing(12)
self.set_homogeneous(True)
icon = Gtk.Image.new_from_icon_name('dialog-error-symbolic',
Gtk.IconSize.DIALOG)
icon.get_style_context().add_class('error-color')
icon.set_valign(Gtk.Align.END)
self._label = Gtk.Label()
self._label.get_style_context().add_class('bold16')
self._label.set_valign(Gtk.Align.START)
self.add(icon)
self.add(self._label)
self.show_all()
def set_text(self, text):
self._label.set_text(text)

370
gajim/gtk/server_info.py Normal file
View file

@ -0,0 +1,370 @@
# 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 logging
from collections import namedtuple
from datetime import timedelta
import nbxmpp
from nbxmpp.errors import StanzaError
from nbxmpp.namespaces import Namespace
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import Pango
from gajim.common import app
from gajim.common import ged
from gajim.common.helpers import open_uri
from gajim.common.i18n import _
from .util import get_builder
from .util import EventHelper
from .util import open_window
log = logging.getLogger('gajim.gui.server_info')
class ServerInfo(Gtk.ApplicationWindow, EventHelper):
def __init__(self, account):
Gtk.ApplicationWindow.__init__(self)
EventHelper.__init__(self)
self.set_name('ServerInfo')
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_default_size(400, 600)
self.set_show_menubar(False)
self.set_title(_('Server Info'))
self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
self.account = account
self._destroyed = False
self._ui = get_builder('server_info.ui')
self.add(self._ui.server_info_notebook)
self.connect('destroy', self.on_destroy)
self.connect('key-press-event', self._on_key_press)
self._ui.connect_signals(self)
self.register_events([
('server-disco-received', ged.GUI1, self._server_disco_received),
])
self.version = ''
self.hostname = app.get_hostname_from_account(account)
self._ui.server_hostname.set_text(self.hostname)
con = app.connections[account]
con.get_module('SoftwareVersion').request_software_version(
self.hostname, callback=self._software_version_received)
self.request_last_activity()
server_info = con.get_module('Discovery').server_info
self._add_contact_addresses(server_info.dataforms)
self.cert = con.certificate
self._add_connection_info()
self.feature_listbox = Gtk.ListBox()
self.feature_listbox.set_name('ServerInfo')
self.feature_listbox.set_selection_mode(Gtk.SelectionMode.NONE)
self._ui.features_scrolled.add(self.feature_listbox)
for feature in self.get_features():
self.add_feature(feature)
self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
self.show_all()
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
def _add_connection_info(self):
# Connection type
nbxmpp_client = app.connections[self.account].connection
address = nbxmpp_client.current_address
self._ui.connection_type.set_text(address.type.value)
if address.type.is_plain:
self._ui.connection_type.get_style_context().add_class(
'error-color')
# Connection proxy
proxy = address.proxy
if proxy is not None:
self._ui.proxy_type.set_text(proxy.type)
self._ui.proxy_host.set_text(proxy.host)
self._ui.cert_button.set_sensitive(self.cert)
self._ui.domain.set_text(address.domain)
visible = address.service is not None
self._ui.dns_label.set_visible(visible)
self._ui.dns.set_visible(visible)
self._ui.dns.set_text(address.service or '')
visible = nbxmpp_client.remote_address is not None
self._ui.ip_port_label.set_visible(visible)
self._ui.ip_port.set_visible(visible)
self._ui.ip_port.set_text(nbxmpp_client.remote_address or '')
visible = address.uri is not None
self._ui.websocket_label.set_visible(visible)
self._ui.websocket.set_visible(visible)
self._ui.websocket.set_text(address.uri or '')
def _on_cert_button_clicked(self, _button):
open_window('CertificateDialog',
account=self.account,
transient_for=self,
cert=self.cert)
def request_last_activity(self):
if not app.account_is_connected(self.account):
return
con = app.connections[self.account]
iq = nbxmpp.Iq(to=self.hostname, typ='get', queryNS=Namespace.LAST)
con.connection.SendAndCallForResponse(iq, self._on_last_activity)
def _add_contact_addresses(self, dataforms):
fields = {
'admin-addresses': _('Admin'),
'status-addresses': _('Status'),
'support-addresses': _('Support'),
'security-addresses': _('Security'),
'feedback-addresses': _('Feedback'),
'abuse-addresses': _('Abuse'),
'sales-addresses': _('Sales'),
}
addresses = self._get_addresses(fields, dataforms)
if addresses is None:
self._ui.no_addresses_label.set_visible(True)
return
row_count = 4
for address_type, values in addresses.items():
label = self._get_address_type_label(fields[address_type])
self._ui.server.attach(label, 0, row_count, 1, 1)
for index, value in enumerate(values):
last = index == len(values) - 1
label = self._get_address_label(value, last=last)
self._ui.server.attach(label, 1, row_count, 1, 1)
row_count += 1
@staticmethod
def _get_addresses(fields, dataforms):
addresses = {}
for form in dataforms:
field = form.vars.get('FORM_TYPE')
if field.value != 'http://jabber.org/network/serverinfo':
continue
for address_type in fields:
field = form.vars.get(address_type)
if field is None:
continue
if field.type_ != 'list-multi':
continue
if not field.values:
continue
addresses[address_type] = field.values
return addresses or None
return None
@staticmethod
def _get_address_type_label(text):
label = Gtk.Label(label=text)
label.set_halign(Gtk.Align.END)
label.set_valign(Gtk.Align.START)
label.get_style_context().add_class('dim-label')
return label
def _get_address_label(self, address, last=False):
label = Gtk.Label()
label.set_markup('<a href="%s">%s</a>' % (address, address))
label.set_ellipsize(Pango.EllipsizeMode.END)
label.set_xalign(0)
label.set_halign(Gtk.Align.START)
label.get_style_context().add_class('link-button')
label.connect('activate-link', self._on_activate_link)
if last:
label.set_margin_bottom(6)
return label
def _on_activate_link(self, label, *args):
open_uri(label.get_text(), account=self.account)
return Gdk.EVENT_STOP
def _on_last_activity(self, _nbxmpp_client, stanza):
if self._destroyed:
# Window got closed in the meantime
return
if not nbxmpp.isResultNode(stanza):
log.warning('Received malformed result: %s', stanza)
return
if stanza.getQueryNS() != Namespace.LAST:
log.warning('Wrong namespace on result: %s', stanza)
return
try:
seconds = int(stanza.getQuery().getAttr('seconds'))
except (ValueError, TypeError, AttributeError):
log.exception('Received malformed last activity result')
else:
delta = timedelta(seconds=seconds)
hours = 0
if seconds >= 3600:
hours = delta.seconds // 3600
uptime = _('%(days)s days, %(hours)s hours') % {
'days': delta.days, 'hours': hours}
self._ui.server_uptime.set_text(uptime)
def _software_version_received(self, task):
try:
result = task.finish()
except StanzaError:
self.version = _('Unknown')
else:
self.version = '%s %s' % (result.name, result.version)
self._ui.server_software.set_text(self.version)
@staticmethod
def update(func, listbox):
for index, item in enumerate(func()):
row = listbox.get_row_at_index(index)
row.get_child().update(item)
row.set_tooltip_text(row.get_child().tooltip)
def _server_disco_received(self, _event):
self.update(self.get_features, self.feature_listbox)
def add_feature(self, feature):
item = FeatureItem(feature)
self.feature_listbox.add(item)
item.get_parent().set_tooltip_text(item.tooltip or '')
def get_features(self):
con = app.connections[self.account]
Feature = namedtuple('Feature',
['name', 'available', 'tooltip', 'enabled'])
Feature.__new__.__defaults__ = (None, None) # type: ignore
# HTTP File Upload
http_upload_info = con.get_module('HTTPUpload').httpupload_namespace
if con.get_module('HTTPUpload').available:
max_file_size = con.get_module('HTTPUpload').max_file_size
if max_file_size is not None:
max_file_size = max_file_size / (1024 * 1024)
http_upload_info = http_upload_info + ' (max. %s MiB)' % \
max_file_size
return [
Feature('XEP-0045: Multi-User Chat',
con.get_module('MUC').supported),
Feature('XEP-0054: vcard-temp',
con.get_module('VCardTemp').supported),
Feature('XEP-0077: In-Band Registration',
con.get_module('Register').supported),
Feature('XEP-0163: Personal Eventing Protocol',
con.get_module('PEP').supported),
Feature('XEP-0163: #publish-options',
con.get_module('PubSub').publish_options),
Feature('XEP-0191: Blocking Command',
con.get_module('Blocking').supported,
Namespace.BLOCKING),
Feature('XEP-0198: Stream Management',
con.features.has_sm, Namespace.STREAM_MGMT),
Feature('XEP-0258: Security Labels in XMPP',
con.get_module('SecLabels').supported,
Namespace.SECLABEL),
Feature('XEP-0280: Message Carbons',
con.get_module('Carbons').supported,
Namespace.CARBONS),
Feature('XEP-0313: Message Archive Management',
con.get_module('MAM').available),
Feature('XEP-0363: HTTP File Upload',
con.get_module('HTTPUpload').available,
http_upload_info),
Feature('XEP-0398: Avatar Conversion',
con.get_module('VCardAvatars').avatar_conversion_available),
Feature('XEP-0411: Bookmarks Conversion',
con.get_module('Bookmarks').conversion),
Feature('XEP-0402: Bookmarks Compat',
con.get_module('Bookmarks').compat),
Feature('XEP-0402: Bookmarks Compat PEP',
con.get_module('Bookmarks').compat_pep)
]
def _on_clipboard_button_clicked(self, _widget):
server_software = 'Server Software: %s\n' % self.version
server_features = ''
for feature in self.get_features():
if feature.available:
available = 'Yes'
else:
available = 'No'
if feature.tooltip is not None:
tooltip = '(%s)' % feature.tooltip
else:
tooltip = ''
server_features += '%s: %s %s\n' % (
feature.name, available, tooltip)
clipboard_text = server_software + server_features
self.clipboard.set_text(clipboard_text, -1)
def on_destroy(self, *args):
self._destroyed = True
class FeatureItem(Gtk.Grid):
def __init__(self, feature):
super().__init__()
self.tooltip = feature.tooltip
self.set_column_spacing(6)
self.icon = Gtk.Image()
self.feature_label = Gtk.Label(label=feature.name)
self.set_feature(feature.available, feature.enabled)
self.add(self.icon)
self.add(self.feature_label)
def set_feature(self, available, enabled):
self.icon.get_style_context().remove_class('error-color')
self.icon.get_style_context().remove_class('warning-color')
self.icon.get_style_context().remove_class('success-color')
if not available:
self.icon.set_from_icon_name('window-close-symbolic',
Gtk.IconSize.MENU)
self.icon.get_style_context().add_class('error-color')
elif enabled is False:
self.icon.set_from_icon_name('dialog-warning-symbolic',
Gtk.IconSize.MENU)
self.tooltip += _('\nDisabled in preferences')
self.icon.get_style_context().add_class('warning-color')
else:
self.icon.set_from_icon_name('emblem-ok-symbolic',
Gtk.IconSize.MENU)
self.icon.get_style_context().add_class('success-color')
def update(self, feature):
self.tooltip = feature.tooltip
self.set_feature(feature.available, feature.enabled)

View file

@ -0,0 +1,228 @@
# 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 logging
from enum import IntEnum
from gi.repository import Gtk
from nbxmpp.errors import StanzaError
from nbxmpp.errors import MalformedStanzaError
from nbxmpp.errors import RegisterStanzaError
from gajim.common import app
from gajim.common.i18n import _
from .dataform import DataFormWidget
log = logging.getLogger('gajim.gui.registration')
class Page(IntEnum):
REQUEST = 0
FORM = 1
SENDING = 2
SUCCESS = 3
ERROR = 4
class ServiceRegistration(Gtk.Assistant):
def __init__(self, account, agent):
Gtk.Assistant.__init__(self)
self._con = app.connections[account]
self._agent = agent
self._account = account
self._data_form_widget = None
self.set_application(app.app)
self.set_resizable(True)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_default_size(600, 400)
self.get_style_context().add_class('dialog-margin')
self._add_page(RequestPage())
self._add_page(FormPage())
self._add_page(SendingPage())
self._add_page(SuccessfulPage())
self._add_page(ErrorPage())
self.connect('prepare', self._on_page_change)
self.connect('cancel', self._on_cancel)
self.connect('close', self._on_cancel)
self._remove_sidebar()
self.show_all()
def _add_page(self, page):
self.append_page(page)
self.set_page_type(page, page.type_)
self.set_page_title(page, page.title)
self.set_page_complete(page, page.complete)
def _remove_sidebar(self):
main_box = self.get_children()[0]
sidebar = main_box.get_children()[0]
main_box.remove(sidebar)
def _on_page_change(self, _assistant, _page):
if self.get_current_page() == Page.REQUEST:
self._con.get_module('Register').request_register_form(
self._agent, callback=self._on_register_form)
elif self.get_current_page() == Page.SENDING:
self._register()
self.commit()
def _on_register_form(self, task):
try:
result = task.finish()
except (StanzaError, MalformedStanzaError) as error:
self.get_nth_page(Page.ERROR).set_text(error.get_text())
self.set_current_page(Page.ERROR)
return
form = result.form
if result.form is None:
form = result.fields_form
self._data_form_widget = DataFormWidget(form)
self._data_form_widget.connect('is-valid', self._on_is_valid)
self._data_form_widget.validate()
self.get_nth_page(Page.FORM).set_form(self._data_form_widget)
self.set_current_page(Page.FORM)
def _on_is_valid(self, _widget, is_valid):
self.set_page_complete(self.get_nth_page(Page.FORM), is_valid)
def _on_cancel(self, _widget):
self.destroy()
def _register(self):
form = self._data_form_widget.get_submit_form()
self._con.get_module('Register').submit_register_form(
form,
self._agent,
callback=self._on_register_result)
def _on_register_result(self, task):
try:
task.finish()
except (StanzaError,
MalformedStanzaError,
RegisterStanzaError) as error:
self.get_nth_page(Page.ERROR).set_text(error.get_text())
self.set_current_page(Page.ERROR)
return
self.set_current_page(Page.SUCCESS)
class RequestPage(Gtk.Box):
type_ = Gtk.AssistantPageType.INTRO
title = _('Register')
complete = False
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.set_spacing(18)
spinner = Gtk.Spinner()
self.pack_start(spinner, True, True, 0)
spinner.start()
class SendingPage(RequestPage):
type_ = Gtk.AssistantPageType.PROGRESS
title = _('Register')
complete = False
class FormPage(Gtk.Box):
type_ = Gtk.AssistantPageType.INTRO
title = _('Register')
complete = True
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self._form = None
self._label = Gtk.Label()
self._label.set_no_show_all(True)
self._label.get_style_context().add_class('error-color')
self.pack_end(self._label, False, False, 0)
def set_form(self, form, error_text=None):
if self._form is not None:
self.remove(self._form)
self._form.destroy()
self._label.hide()
self._form = form
if error_text is not None:
self._label.set_text(error_text)
self._label.show()
self.pack_start(form, True, True, 0)
self._form.show_all()
class SuccessfulPage(Gtk.Box):
type_ = Gtk.AssistantPageType.SUMMARY
title = _('Registration successful')
complete = True
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.set_spacing(12)
self.set_homogeneous(True)
icon = Gtk.Image.new_from_icon_name('object-select-symbolic',
Gtk.IconSize.DIALOG)
icon.get_style_context().add_class('success-color')
icon.set_valign(Gtk.Align.END)
label = Gtk.Label(label=_('Registration successful'))
label.get_style_context().add_class('bold16')
label.set_valign(Gtk.Align.START)
self.add(icon)
self.add(label)
class ErrorPage(Gtk.Box):
type_ = Gtk.AssistantPageType.SUMMARY
title = _('Registration failed')
complete = True
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.set_spacing(12)
self.set_homogeneous(True)
icon = Gtk.Image.new_from_icon_name('dialog-error-symbolic',
Gtk.IconSize.DIALOG)
icon.get_style_context().add_class('error-color')
icon.set_valign(Gtk.Align.END)
self._label = Gtk.Label()
self._label.get_style_context().add_class('bold16')
self._label.set_valign(Gtk.Align.START)
self.add(icon)
self.add(self._label)
def set_text(self, text):
self._label.set_text(text)

811
gajim/gtk/settings.py Normal file
View file

@ -0,0 +1,811 @@
# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com>
#
# 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/>.
from gi.repository import Gtk
from gi.repository import GLib
from gi.repository import Gdk
from gi.repository import Pango
from gajim.common import app
from gajim.common import passwords
from gajim.common.i18n import _
from gajim.common.i18n import Q_
from gajim import gtkgui_helpers
from .util import get_image_button
from .util import MaxWidthComboBoxText
from .util import open_window
from .const import SettingKind
from .const import SettingType
class SettingsDialog(Gtk.ApplicationWindow):
def __init__(self, parent, title, flags, settings, account,
extend=None):
Gtk.ApplicationWindow.__init__(self)
self.set_application(app.app)
self.set_show_menubar(False)
self.set_title(title)
self.set_transient_for(parent)
self.set_resizable(False)
self.set_default_size(250, -1)
self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
self.get_style_context().add_class('settings-dialog')
self.account = account
if flags == Gtk.DialogFlags.MODAL:
self.set_modal(True)
elif flags == Gtk.DialogFlags.DESTROY_WITH_PARENT:
self.set_destroy_with_parent(True)
self.listbox = SettingsBox(account, extend=extend)
self.listbox.set_hexpand(True)
self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
for setting in settings:
self.listbox.add_setting(setting)
self.listbox.update_states()
self.add(self.listbox)
self.show_all()
self.connect_after('key-press-event', self.on_key_press)
def on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
def get_setting(self, name):
return self.listbox.get_setting(name)
class SettingsBox(Gtk.ListBox):
def __init__(self, account=None, jid=None, extend=None):
Gtk.ListBox.__init__(self)
self.get_style_context().add_class('settings-box')
self.account = account
self.jid = jid
self.named_settings = {}
self.map = {
SettingKind.SWITCH: SwitchSetting,
SettingKind.SPIN: SpinSetting,
SettingKind.DIALOG: DialogSetting,
SettingKind.ENTRY: EntrySetting,
SettingKind.COLOR: ColorSetting,
SettingKind.ACTION: ActionSetting,
SettingKind.LOGIN: LoginSetting,
SettingKind.FILECHOOSER: FileChooserSetting,
SettingKind.CALLBACK: CallbackSetting,
SettingKind.PRIORITY: PrioritySetting,
SettingKind.HOSTNAME: CutstomHostnameSetting,
SettingKind.CHANGEPASSWORD: ChangePasswordSetting,
SettingKind.COMBO: ComboSetting,
SettingKind.POPOVER: PopoverSetting,
SettingKind.AUTO_AWAY: CutstomAutoAwaySetting,
SettingKind.AUTO_EXTENDED_AWAY: CutstomAutoExtendedAwaySetting,
SettingKind.USE_STUN_SERVER: CustomStunServerSetting,
SettingKind.NOTIFICATIONS: NotificationsSetting,
}
if extend is not None:
for setting, callback in extend:
self.map[setting] = callback
self.connect('row-activated', self.on_row_activated)
@staticmethod
def on_row_activated(_listbox, row):
row.on_row_activated()
def add_setting(self, setting):
if not isinstance(setting, Gtk.ListBoxRow):
if setting.props is not None:
listitem = self.map[setting.kind](self.account,
self.jid,
*setting[1:-1],
**setting.props)
else:
listitem = self.map[setting.kind](self.account,
self.jid,
*setting[1:-1])
if setting.name is not None:
self.named_settings[setting.name] = listitem
self.add(listitem)
def get_setting(self, name):
return self.named_settings[name]
def update_states(self):
for row in self.get_children():
row.update_activatable()
class GenericSetting(Gtk.ListBoxRow):
def __init__(self,
account,
jid,
label,
type_,
value,
name,
callback,
data,
desc,
bind,
inverted,
enabled_func):
Gtk.ListBoxRow.__init__(self)
self._grid = Gtk.Grid()
self._grid.set_size_request(-1, 30)
self._grid.set_column_spacing(12)
self.callback = callback
self.type_ = type_
self.value = value
self.data = data
self.label = label
self.account = account
self.jid = jid
self.name = name
self.bind = bind
self.inverted = inverted
self.enabled_func = enabled_func
self.setting_value = self.get_value()
description_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=0)
description_box.set_valign(Gtk.Align.CENTER)
settingtext = Gtk.Label(label=label)
settingtext.set_hexpand(True)
settingtext.set_halign(Gtk.Align.START)
settingtext.set_valign(Gtk.Align.CENTER)
settingtext.set_vexpand(True)
description_box.add(settingtext)
if desc is not None:
description = Gtk.Label(label=desc)
description.set_name('SubDescription')
description.set_hexpand(True)
description.set_halign(Gtk.Align.START)
description.set_valign(Gtk.Align.CENTER)
description.set_xalign(0)
description.set_line_wrap(True)
description.set_line_wrap_mode(Pango.WrapMode.WORD)
description.set_max_width_chars(50)
description_box.add(description)
self._grid.add(description_box)
self.setting_box = Gtk.Box(spacing=12)
self.setting_box.set_size_request(200, -1)
self.setting_box.set_valign(Gtk.Align.CENTER)
self.setting_box.set_name('GenericSettingBox')
self._grid.add(self.setting_box)
self.add(self._grid)
self._bind_sensitive_state()
def _bind_sensitive_state(self):
if self.bind is None:
return
bind_setting_type, setting, account, jid = self._parse_bind()
app.settings.bind_signal(setting,
self,
'set_sensitive',
account=account,
jid=jid,
inverted=self.inverted)
if bind_setting_type == SettingType.CONTACT:
value = app.settings.get_contact_setting(account, jid, setting)
elif bind_setting_type == SettingType.GROUP_CHAT:
value = app.settings.get_group_chat_setting(account, jid, setting)
elif bind_setting_type == SettingType.ACCOUNT_CONFIG:
value = app.settings.get_account_setting(account, setting)
else:
value = app.settings.get(setting)
if self.inverted:
value = not value
self.set_sensitive(value)
def _parse_bind(self):
if '::' not in self.bind:
return SettingType.CONFIG, self.bind, None, None
bind_setting_type, setting = self.bind.split('::')
if bind_setting_type == 'account':
return SettingType.ACCOUNT_CONFIG, setting, self.account, None
if bind_setting_type == 'contact':
return SettingType.CONTACT, setting, self.account, self.jid
if bind_setting_type == 'group_chat':
return SettingType.GROUP_CHAT, setting, self.account, self.jid
raise ValueError(f'Invalid bind argument: {self.bind}')
def get_value(self):
return self.__get_value(self.type_,
self.value,
self.account,
self.jid)
@staticmethod
def __get_value(type_, value, account, jid):
if value is None:
return None
if type_ == SettingType.VALUE:
return value
if type_ == SettingType.CONTACT:
return app.settings.get_contact_setting(account, jid, value)
if type_ == SettingType.GROUP_CHAT:
return app.settings.get_group_chat_setting(
account, jid, value)
if type_ == SettingType.CONFIG:
return app.settings.get(value)
if type_ == SettingType.ACCOUNT_CONFIG:
if value == 'password':
return passwords.get_password(account)
if value == 'no_log_for':
no_log = app.settings.get_account_setting(
account, 'no_log_for').split()
return account not in no_log
return app.settings.get_account_setting(account, value)
if type_ == SettingType.ACTION:
if value.startswith('-'):
return account + value
return value
raise ValueError('Wrong SettingType?')
def set_value(self, state):
if self.type_ == SettingType.CONFIG:
app.settings.set(self.value, state)
elif self.type_ == SettingType.ACCOUNT_CONFIG:
if self.value == 'password':
passwords.save_password(self.account, state)
if self.value == 'no_log_for':
self.set_no_log_for(self.account, state)
else:
app.settings.set_account_setting(self.account,
self.value,
state)
elif self.type_ == SettingType.CONTACT:
app.settings.set_contact_setting(
self.account, self.jid, self.value, state)
elif self.type_ == SettingType.GROUP_CHAT:
app.settings.set_group_chat_setting(
self.account, self.jid, self.value, state)
if self.callback is not None:
self.callback(state, self.data)
@staticmethod
def set_no_log_for(account, state):
no_log = app.settings.get_account_setting(account, 'no_log_for').split()
if state and account in no_log:
no_log.remove(account)
elif not state and account not in no_log:
no_log.append(account)
app.settings.set_account_setting(account,
'no_log_for',
' '.join(no_log))
def on_row_activated(self):
raise NotImplementedError
def update_activatable(self):
if self.enabled_func is None:
return
enabled_func_value = self.enabled_func()
self.set_activatable(enabled_func_value)
self.set_sensitive(enabled_func_value)
def _add_action_button(self, kwargs):
icon_name = kwargs.get('button-icon-name')
button_text = kwargs.get('button-text')
tooltip_text = kwargs.get('button-tooltip') or ''
style = kwargs.get('button-style')
if icon_name is not None:
button = Gtk.Button.new_from_icon_name(icon_name, Gtk.IconSize.MENU)
elif button_text is not None:
button = Gtk.Button(label=button_text)
else:
return
if style is not None:
for css_class in style.split(' '):
button.get_style_context().add_class(css_class)
button.connect('clicked', kwargs['button-callback'])
button.set_tooltip_text(tooltip_text)
self.setting_box.add(button)
class SwitchSetting(GenericSetting):
def __init__(self, *args, **kwargs):
GenericSetting.__init__(self, *args)
self.switch = Gtk.Switch()
if self.type_ == SettingType.ACTION:
self.switch.set_action_name('app.%s' % self.setting_value)
state = app.app.get_action_state(self.setting_value)
self.switch.set_active(state.get_boolean())
else:
self.switch.set_active(self.setting_value)
self.switch.connect('notify::active', self.on_switch)
self.switch.set_hexpand(True)
self.switch.set_halign(Gtk.Align.END)
self.switch.set_valign(Gtk.Align.CENTER)
self._switch_state_label = Gtk.Label()
self._switch_state_label.set_xalign(1)
self._switch_state_label.set_valign(Gtk.Align.CENTER)
self._set_label(self.setting_value)
box = Gtk.Box(spacing=12)
box.set_halign(Gtk.Align.END)
box.add(self._switch_state_label)
box.add(self.switch)
self.setting_box.add(box)
self._add_action_button(kwargs)
self.show_all()
def on_row_activated(self):
state = self.switch.get_active()
self.switch.set_active(not state)
def on_switch(self, switch, *args):
value = switch.get_active()
self.set_value(value)
self._set_label(value)
def _set_label(self, active):
text = Q_('?switch:On') if active else Q_('?switch:Off')
self._switch_state_label.set_text(text)
class EntrySetting(GenericSetting):
def __init__(self, *args):
GenericSetting.__init__(self, *args)
self.entry = Gtk.Entry()
self.entry.set_text(str(self.setting_value))
self.entry.connect('notify::text', self.on_text_change)
self.entry.set_valign(Gtk.Align.CENTER)
self.entry.set_alignment(1)
if self.value == 'password':
self.entry.set_invisible_char('*')
self.entry.set_visibility(False)
self.setting_box.pack_end(self.entry, True, True, 0)
self.show_all()
def on_text_change(self, *args):
text = self.entry.get_text()
self.set_value(text)
def on_row_activated(self):
self.entry.grab_focus()
class ColorSetting(GenericSetting):
def __init__(self, *args):
GenericSetting.__init__(self, *args)
rgba = Gdk.RGBA()
rgba.parse(self.setting_value)
self.color_button = Gtk.ColorButton()
self.color_button.set_rgba(rgba)
self.color_button.connect('color-set', self.on_color_set)
self.color_button.set_valign(Gtk.Align.CENTER)
self.color_button.set_halign(Gtk.Align.END)
self.setting_box.pack_end(self.color_button, True, True, 0)
self.show_all()
def on_color_set(self, button):
rgba = button.get_rgba()
self.set_value(rgba.to_string())
app.css_config.refresh()
def on_row_activated(self):
self.color_button.grab_focus()
class DialogSetting(GenericSetting):
def __init__(self, *args, dialog):
GenericSetting.__init__(self, *args)
self.dialog = dialog
self.setting_value = Gtk.Label()
self.setting_value.set_text(self.get_setting_value())
self.setting_value.set_halign(Gtk.Align.END)
self.setting_box.pack_start(self.setting_value, True, True, 0)
self.show_all()
def show_dialog(self, parent):
if self.dialog:
dialog = self.dialog(self.account, parent)
dialog.connect('destroy', self.on_destroy)
def on_destroy(self, *args):
self.setting_value.set_text(self.get_setting_value())
def get_setting_value(self):
self.setting_value.hide()
return ''
def on_row_activated(self):
self.show_dialog(self.get_toplevel())
class SpinSetting(GenericSetting):
def __init__(self, *args, range_):
GenericSetting.__init__(self, *args)
lower, upper = range_
adjustment = Gtk.Adjustment(value=0,
lower=lower,
upper=upper,
step_increment=1,
page_increment=10,
page_size=0)
self.spin = Gtk.SpinButton()
self.spin.set_adjustment(adjustment)
self.spin.set_numeric(True)
self.spin.set_update_policy(Gtk.SpinButtonUpdatePolicy.IF_VALID)
self.spin.set_value(self.setting_value)
self.spin.set_halign(Gtk.Align.FILL)
self.spin.set_valign(Gtk.Align.CENTER)
self.spin.connect('notify::value', self.on_value_change)
self.setting_box.pack_start(self.spin, True, True, 0)
self.show_all()
def on_row_activated(self):
self.spin.grab_focus()
def on_value_change(self, spin, *args):
value = spin.get_value_as_int()
self.set_value(value)
class FileChooserSetting(GenericSetting):
def __init__(self, *args, filefilter):
GenericSetting.__init__(self, *args)
button = Gtk.FileChooserButton(title=self.label,
action=Gtk.FileChooserAction.OPEN)
button.set_halign(Gtk.Align.END)
# GTK Bug: The FileChooserButton expands without limit
# get the label and use set_max_wide_chars()
label = button.get_children()[0].get_children()[0].get_children()[1]
label.set_max_width_chars(20)
if filefilter:
name, pattern = filefilter
filter_ = Gtk.FileFilter()
filter_.set_name(name)
filter_.add_pattern(pattern)
button.add_filter(filter_)
button.set_filter(filter_)
filter_ = Gtk.FileFilter()
filter_.set_name(_('All files'))
filter_.add_pattern('*')
button.add_filter(filter_)
if self.setting_value:
button.set_filename(self.setting_value)
button.connect('selection-changed', self.on_select)
clear_button = get_image_button(
'edit-clear-all-symbolic', _('Clear File'))
clear_button.connect('clicked', lambda *args: button.unselect_all())
self.setting_box.pack_start(button, True, True, 0)
self.setting_box.pack_start(clear_button, False, False, 0)
self.show_all()
def on_select(self, filechooser):
self.set_value(filechooser.get_filename() or '')
def on_row_activated(self):
pass
class CallbackSetting(GenericSetting):
def __init__(self, *args, callback):
GenericSetting.__init__(self, *args)
self.callback = callback
self.show_all()
def on_row_activated(self):
self.callback()
class ActionSetting(GenericSetting):
def __init__(self, *args, account):
GenericSetting.__init__(self, *args)
action_name = '%s%s' % (account, self.value)
self.action = gtkgui_helpers.get_action(action_name)
self.variant = GLib.Variant.new_string(account)
self.on_enable()
self.show_all()
self.action.connect('notify::enabled', self.on_enable)
def on_enable(self, *args):
self.set_sensitive(self.action.get_enabled())
def on_row_activated(self):
self.action.activate(self.variant)
class LoginSetting(DialogSetting):
def __init__(self, *args, **kwargs):
DialogSetting.__init__(self, *args, **kwargs)
self.setting_value.set_selectable(True)
def get_setting_value(self):
jid = app.get_jid_from_account(self.account)
return jid
class PopoverSetting(GenericSetting):
def __init__(self, *args, entries, **kwargs):
GenericSetting.__init__(self, *args)
self._entries = self._convert_to_dict(entries)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL,
spacing=12)
box.set_halign(Gtk.Align.END)
box.set_hexpand(True)
self._default_text = kwargs.get('default-text')
self._current_label = Gtk.Label()
self._current_label.set_valign(Gtk.Align.CENTER)
image = Gtk.Image.new_from_icon_name('pan-down-symbolic',
Gtk.IconSize.MENU)
image.set_valign(Gtk.Align.CENTER)
box.add(self._current_label)
box.add(image)
self._menu_listbox = Gtk.ListBox()
self._menu_listbox.set_selection_mode(Gtk.SelectionMode.NONE)
self._add_menu_entries()
self._menu_listbox.connect('row-activated',
self._on_menu_row_activated)
scrolled_window = Gtk.ScrolledWindow()
scrolled_window.set_propagate_natural_height(True)
scrolled_window.set_propagate_natural_width(True)
scrolled_window.set_max_content_height(400)
scrolled_window.set_policy(Gtk.PolicyType.NEVER,
Gtk.PolicyType.AUTOMATIC)
scrolled_window.add(self._menu_listbox)
scrolled_window.show_all()
self._popover = Gtk.Popover()
self._popover.get_style_context().add_class('combo')
self._popover.set_relative_to(image)
self._popover.set_position(Gtk.PositionType.BOTTOM)
self._popover.add(scrolled_window)
self.setting_box.add(box)
self._add_action_button(kwargs)
text = self._entries.get(self.setting_value, self._default_text or '')
self._current_label.set_text(text)
app.settings.connect_signal(self.value,
self._on_setting_changed,
account=self.account,
jid=self.jid)
self.connect('destroy', self._on_destroy)
self.show_all()
@staticmethod
def _convert_to_dict(entries):
if isinstance(entries, list):
entries = {key: key for key in entries}
return entries
def _on_setting_changed(self, value, *args):
text = self._entries.get(value)
if text is None:
text = self._default_text or ''
self._current_label.set_text(text)
def _add_menu_entries(self):
if self._default_text is not None:
self._menu_listbox.add(PopoverRow(self._default_text, ''))
for value, label in self._entries.items():
self._menu_listbox.add(PopoverRow(label, value))
self._menu_listbox.show_all()
def _on_menu_row_activated(self, listbox, row):
listbox.unselect_all()
self._popover.popdown()
self.set_value(row.value)
def on_row_activated(self):
self._popover.popup()
def update_entries(self, entries):
self._entries = self._convert_to_dict(entries)
self._menu_listbox.foreach(self._menu_listbox.remove)
self._add_menu_entries()
def _on_destroy(self, *args):
app.settings.disconnect_signals(self)
class PopoverRow(Gtk.ListBoxRow):
def __init__(self, label, value):
Gtk.ListBoxRow.__init__(self)
self.label = label
self.value = value
label = Gtk.Label(label=label)
label.set_xalign(0)
self.add(label)
class ComboSetting(GenericSetting):
def __init__(self, *args, combo_items):
GenericSetting.__init__(self, *args)
self.combo = MaxWidthComboBoxText()
self.combo.set_valign(Gtk.Align.CENTER)
for index, value in enumerate(combo_items):
if isinstance(value, tuple):
value, label = value
self.combo.append(value, _(label))
else:
self.combo.append(value, value)
if value == self.setting_value or index == 0:
self.combo.set_active(index)
self.combo.connect('changed', self.on_value_change)
self.setting_box.pack_start(self.combo, True, True, 0)
self.show_all()
def on_value_change(self, combo):
self.set_value(combo.get_active_id())
def on_row_activated(self):
pass
class PrioritySetting(DialogSetting):
def __init__(self, *args, **kwargs):
DialogSetting.__init__(self, *args, **kwargs)
def get_setting_value(self):
adjust = app.settings.get_account_setting(
self.account, 'adjust_priority_with_status')
if adjust:
return _('Adjust to Status')
priority = app.settings.get_account_setting(self.account, 'priority')
return str(priority)
class CutstomHostnameSetting(DialogSetting):
def __init__(self, *args, **kwargs):
DialogSetting.__init__(self, *args, **kwargs)
def get_setting_value(self):
custom = app.settings.get_account_setting(self.account,
'use_custom_host')
return Q_('?switch:On') if custom else Q_('?switch:Off')
class ChangePasswordSetting(DialogSetting):
def __init__(self, *args, **kwargs):
DialogSetting.__init__(self, *args, **kwargs)
def show_dialog(self, parent):
parent.destroy()
open_window('ChangePassword', account=self.account)
def update_activatable(self):
activatable = False
if self.account in app.connections:
con = app.connections[self.account]
activatable = (con.state.is_available and
con.get_module('Register').supported)
self.set_activatable(activatable)
class CutstomAutoAwaySetting(DialogSetting):
def __init__(self, *args, **kwargs):
DialogSetting.__init__(self, *args, **kwargs)
def get_setting_value(self):
value = app.settings.get('autoaway')
return Q_('?switch:On') if value else Q_('?switch:Off')
class CutstomAutoExtendedAwaySetting(DialogSetting):
def __init__(self, *args, **kwargs):
DialogSetting.__init__(self, *args, **kwargs)
def get_setting_value(self):
value = app.settings.get('autoxa')
return Q_('?switch:On') if value else Q_('?switch:Off')
class CustomStunServerSetting(DialogSetting):
def __init__(self, *args, **kwargs):
DialogSetting.__init__(self, *args, **kwargs)
def get_setting_value(self):
value = app.settings.get('use_stun_server')
return Q_('?switch:On') if value else Q_('?switch:Off')
class NotificationsSetting(DialogSetting):
def __init__(self, *args, **kwargs):
DialogSetting.__init__(self, *args, **kwargs)
def get_setting_value(self):
value = app.settings.get('show_notifications')
return Q_('?switch:On') if value else Q_('?switch:Off')

View file

@ -0,0 +1,57 @@
# 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/>.
from gi.repository import Gtk
class SideBarSwitcher(Gtk.ListBox):
def __init__(self):
Gtk.ListBox.__init__(self)
self.set_vexpand(True)
self.get_style_context().add_class('settings-menu')
self.connect('row-activated', self._on_row_activated)
self._stack = None
def set_stack(self, stack):
self._stack = stack
for page in self._stack.get_children():
attributes = ['name', 'title', 'icon-name']
properties = self._stack.child_get(page, *attributes)
self.add(Row(*properties))
self._select_first_row()
def _on_row_activated(self, _listbox, row):
self._stack.set_visible_child_name(row.name)
def _select_first_row(self):
self.select_row(self.get_row_at_index(0))
class Row(Gtk.ListBoxRow):
def __init__(self, name, title, icon_name):
Gtk.ListBoxRow.__init__(self)
self.name = name
box = Gtk.Box()
if icon_name:
image = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.MENU)
image.get_style_context().add_class('dim-label')
box.add(image)
label = Gtk.Label(label=title)
label.set_xalign(0)
box.add(label)
self.add(box)

338
gajim/gtk/single_message.py Normal file
View file

@ -0,0 +1,338 @@
# 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/>.
from typing import List # pylint: disable=unused-import
from gi.repository import Gdk
from gi.repository import GLib
from gi.repository import Gtk
from gajim import vcard
from gajim.common import app
from gajim.common import helpers
from gajim.common.i18n import _
from gajim.common.structs import OutgoingMessage
from gajim.conversation_textview import ConversationTextview
from .dialogs import ErrorDialog
from .util import get_builder
from .util import get_icon_name
from .util import get_completion_liststore
from .util import move_window
from .util import resize_window
if app.is_installed('GSPELL'):
from gi.repository import Gspell # pylint: disable=ungrouped-imports
class SingleMessageWindow(Gtk.ApplicationWindow):
"""
SingleMessageWindow can send or show a received singled message depending on
action argument which can be 'send' or 'receive'
"""
def __init__(self, account, to='', action='', from_whom='', subject='',
message='', resource='', session=None):
Gtk.ApplicationWindow.__init__(self)
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_title(_('Send Single Message'))
self.set_name('SendSingleMessageWindow')
self.account = account
self._action = action
self._subject = subject
self._message = message
self._to = to
self._from_whom = from_whom
self._resource = resource
self._session = session
self._ui = get_builder('single_message_window.ui')
self._message_tv_buffer = self._ui.message_textview.get_buffer()
self._conversation_textview = ConversationTextview(
account, used_in_history_window=True)
self._conversation_textview.tv.show()
self._conversation_textview.tv.set_left_margin(6)
self._conversation_textview.tv.set_right_margin(6)
self._conversation_textview.tv.set_top_margin(6)
self._conversation_textview.tv.set_bottom_margin(6)
self._ui.conversation_scrolledwindow.add(
self._conversation_textview.tv)
self._message_tv_buffer.connect('changed', self._update_char_counter)
if isinstance(to, list):
jid = ', '.join([i[0].jid for i in to])
self._ui.to_entry.set_text(jid)
else:
self._ui.to_entry.set_text(to)
if (app.settings.get('use_speller') and
app.is_installed('GSPELL') and
action == 'send'):
lang = app.settings.get('speller_language')
gspell_lang = Gspell.language_lookup(lang)
if gspell_lang is None:
gspell_lang = Gspell.language_get_default()
spell_buffer = Gspell.TextBuffer.get_from_gtk_text_buffer(
self._ui.message_textview.get_buffer())
spell_buffer.set_spell_checker(Gspell.Checker.new(gspell_lang))
spell_view = Gspell.TextView.get_from_gtk_text_view(
self._ui.message_textview)
spell_view.set_inline_spell_checking(True)
spell_view.set_enable_language_menu(True)
self._prepare_widgets_for(self._action)
# set_text(None) raises TypeError exception
if self._subject is None:
self._subject = _('(No subject)')
self._ui.subject_entry.set_text(self._subject)
self._ui.subject_from_entry_label.set_text(self._subject)
if to == '':
liststore = get_completion_liststore(self._ui.to_entry)
self._completion_dict = helpers.get_contact_dict_for_account(
account)
keys = sorted(self._completion_dict.keys())
for jid in keys:
contact = self._completion_dict[jid]
status_icon = get_icon_name(contact.show)
liststore.append((status_icon, jid))
else:
self._completion_dict = {}
self.add(self._ui.box)
self.connect('delete-event', self._on_delete)
self.connect('destroy', self._on_destroy)
self.connect('key-press-event', self._on_key_press_event)
self._ui.to_entry.connect('changed', self._on_to_entry_changed)
self._ui.connect_signals(self)
# get window position and size from config
resize_window(self,
app.settings.get('single-msg-width'),
app.settings.get('single-msg-height'))
move_window(self,
app.settings.get('single-msg-x-position'),
app.settings.get('single-msg-y-position'))
self.show_all()
def _set_cursor_to_end(self):
end_iter = self._message_tv_buffer.get_end_iter()
self._message_tv_buffer.place_cursor(end_iter)
def _save_position(self):
# save the window size and position
x_pos, y_pos = self.get_position()
app.settings.set('single-msg-x-position', x_pos)
app.settings.set('single-msg-y-position', y_pos)
width, height = self.get_size()
app.settings.set('single-msg-width', width)
app.settings.set('single-msg-height', height)
def _on_to_entry_changed(self, _widget):
entry = self._ui.to_entry.get_text()
is_empty = bool(not entry == '' and not ',' in entry)
self._ui.show_contact_info_button.set_sensitive(is_empty)
def _prepare_widgets_for(self, action):
if len(app.connections) > 1:
if action == 'send':
title = _('Single Message using account %s') % self.account
else:
title = _('Single Message in account %s') % self.account
else:
title = _('Single Message')
if action == 'send': # prepare UI for Sending
title = _('Send %s') % title
self._ui.send_button.show()
self._ui.send_and_close_button.show()
self._ui.reply_button.hide()
self._ui.close_button.hide()
self._ui.send_grid.show()
self._ui.received_grid.hide()
if self._message: # we come from a reply?
self._ui.show_contact_info_button.set_sensitive(True)
self._ui.message_textview.grab_focus()
self._message_tv_buffer.set_text(self._message)
GLib.idle_add(self._set_cursor_to_end)
else: # we write a new message (not from reply)
if self._to: # do we already have jid?
self._ui.subject_entry.grab_focus()
elif action == 'receive': # prepare UI for Receiving
title = _('Received %s') % title
self._ui.reply_button.show()
self._ui.close_button.show()
self._ui.send_button.hide()
self._ui.send_and_close_button.hide()
self._ui.reply_button.grab_focus()
self._ui.received_grid.show()
self._ui.send_grid.hide()
if self._message:
self._conversation_textview.print_real_text(self._message)
fjid = self._from_whom
if self._resource:
fjid += '/' + self._resource
self._ui.from_entry_label.set_text(fjid)
self.set_title(title)
def _update_char_counter(self, _widget):
characters_no = self._message_tv_buffer.get_char_count()
self._ui.count_chars_label.set_text(
_('Characters typed: %s') % str(characters_no))
def _send_single_message(self):
if not app.account_is_available(self.account):
# if offline or connecting
ErrorDialog(_('Connection not available'),
_('Please make sure you are connected with "%s".') %
self.account)
return True
if isinstance(self._to, list):
sender_list = []
for i in self._to:
if i[0].resource:
sender_list.append(i[0].jid + '/' + i[0].resource)
else:
sender_list.append(i[0].jid)
else:
sender_list = [j.strip() for j
in self._ui.to_entry.get_text().split(',')]
subject = self._ui.subject_entry.get_text()
begin, end = self._message_tv_buffer.get_bounds()
message = self._message_tv_buffer.get_text(begin, end, True)
recipient_list = []
for to_whom_jid in sender_list:
if to_whom_jid in self._completion_dict:
to_whom_jid = self._completion_dict[to_whom_jid].jid
try:
to_whom_jid = helpers.parse_jid(to_whom_jid)
except helpers.InvalidFormat:
ErrorDialog(
_('Invalid XMPP Address'),
_('It is not possible to send a message to %s, this '
'XMPP Address is not valid.') % to_whom_jid)
return True
if '/announce/' in to_whom_jid:
con = app.connections[self.account]
con.get_module('Announce').set_announce(
to_whom_jid, subject, message)
continue
recipient_list.append(to_whom_jid)
message = OutgoingMessage(account=self.account,
contact=None,
message=message,
type_='normal',
subject=subject)
con = app.connections[self.account]
con.send_messages(recipient_list, message)
self._ui.subject_entry.set_text('') # we sent ok, clear the subject
self._message_tv_buffer.set_text('') # we sent ok, clear the textview
return False
def _on_destroy(self, _widget):
contact = app.contacts.get_contact_with_highest_priority(
self.account, self._from_whom)
if not contact:
# Groupchat is maybe already destroyed
return
controls = app.interface.minimized_controls[self.account]
events = app.events.get_nb_roster_events(self.account,
self._from_whom,
types=['chat', 'normal'])
if (contact.is_groupchat and
self._from_whom not in controls and
self._action == 'receive' and
events == 0):
app.interface.roster.remove_groupchat(self._from_whom, self.account)
def _on_delete(self, *args):
self._save_position()
def _on_contact_info_clicked(self, _widget):
"""
Ask for vCard
"""
entry = self._ui.to_entry.get_text().strip()
keys = sorted(self._completion_dict.keys())
for key in keys:
contact = self._completion_dict[key]
if entry in key:
entry = contact.jid
break
if entry in app.interface.instances[self.account]['infos']:
app.interface.instances[self.account]['infos'][entry].\
window.present()
else:
contact = app.contacts.create_contact(jid=entry,
account=self.account)
app.interface.instances[self.account]['infos'][entry] = \
vcard.VcardWindow(contact, self.account)
# Remove xmpp page
app.interface.instances[self.account]['infos'][entry].xml.\
get_object('information_notebook').remove_page(0)
def _on_close_clicked(self, _widget):
self._save_position()
self.destroy()
def _on_send_clicked(self, _widget):
self._send_single_message()
def _on_reply_clicked(self, _widget):
# we create a new blank window to send and we preset RE: and to jid
self._subject = _('RE: %s') % self._subject
self._message = _('%s wrote:\n') % self._from_whom + self._message
# add > at the beginning of each line
self._message = self._message.replace('\n', '\n> ') + '\n\n'
self.destroy()
SingleMessageWindow(self.account,
to=self._from_whom,
action='send',
from_whom=self._from_whom,
subject=self._subject,
message=self._message,
session=self._session)
def _on_send_and_close_clicked(self, _widget):
if self._send_single_message():
return
self._save_position()
self.destroy()
def _on_key_press_event(self, _widget, event):
if event.keyval == Gdk.KEY_Escape: # ESCAPE
self._save_position()
self.destroy()

View file

@ -0,0 +1,90 @@
# 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/>.
from gi.repository import Gtk
from gi.repository import Gio
from gajim.common import app
from gajim.common.const import GIO_TLS_ERRORS
from gajim.common.i18n import _
from .util import get_builder
from .util import open_window
class SSLErrorDialog(Gtk.ApplicationWindow):
def __init__(self, account, client, cert, error):
Gtk.ApplicationWindow.__init__(self)
self.set_name('SSLErrorDialog')
self.set_application(app.app)
self.set_show_menubar(False)
self.set_resizable(False)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_title(_('SSL Certificate Verification Error'))
self._ui = get_builder('ssl_error_dialog.ui')
self.add(self._ui.ssl_error_box)
self.account = account
self._error = error
self._client = client
self._cert = cert
self._server = app.settings.get_account_setting(self.account,
'hostname')
self._process_error()
self._ui.connect_signals(self)
self.show_all()
def _process_error(self):
self._ui.intro_text.set_text(
_('There was an error while attempting to verify the SSL '
'certificate of your XMPP server (%s).') % self._server)
unknown_error = _('Unknown SSL error \'%s\'') % self._error
ssl_error = GIO_TLS_ERRORS.get(self._error, unknown_error)
self._ui.ssl_error.set_text(ssl_error)
if self._error == Gio.TlsCertificateFlags.UNKNOWN_CA:
self._ui.add_certificate_checkbutton.show()
elif self._error == Gio.TlsCertificateFlags.EXPIRED:
self._ui.connect_button.set_sensitive(True)
else:
self._ui.connect_button.set_no_show_all(True)
self._ui.connect_button.hide()
def _on_view_cert_clicked(self, _button):
open_window('CertificateDialog',
account=self.account,
transient_for=self,
cert=self._cert)
def _on_add_certificate_toggled(self, checkbutton):
self._ui.connect_button.set_sensitive(checkbutton.get_active())
def _on_connect_clicked(self, _button):
if self._ui.add_certificate_checkbutton.get_active():
app.cert_store.add_certificate(self._cert)
ignored_tls_errors = None
if self._error == Gio.TlsCertificateFlags.EXPIRED:
ignored_tls_errors = set([Gio.TlsCertificateFlags.EXPIRED])
self.destroy()
self._client.connect(ignored_tls_errors=ignored_tls_errors)

834
gajim/gtk/start_chat.py Normal file
View file

@ -0,0 +1,834 @@
# 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 locale
from enum import IntEnum
from gi.repository import Gdk
from gi.repository import Gtk
from gi.repository import GLib
from gi.repository import Pango
from nbxmpp.errors import is_error
from nbxmpp.errors import StanzaError
from nbxmpp.errors import CancelledError
from gajim.common import app
from gajim.common.helpers import validate_jid
from gajim.common.helpers import to_user_string
from gajim.common.helpers import get_groupchat_name
from gajim.common.helpers import get_group_chat_nick
from gajim.common.i18n import _
from gajim.common.i18n import get_rfc5646_lang
from gajim.common.const import AvatarSize
from gajim.common.const import MUC_DISCO_ERRORS
from gajim.common.modules.util import as_task
from .groupchat_info import GroupChatInfoScrolled
from .groupchat_nick import NickChooser
from .util import get_builder
from .util import get_icon_name
from .util import generate_account_badge
class Search(IntEnum):
CONTACT = 0
GLOBAL = 1
class StartChatDialog(Gtk.ApplicationWindow):
def __init__(self):
Gtk.ApplicationWindow.__init__(self)
self.set_name('StartChatDialog')
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_show_menubar(False)
self.set_title(_('Start / Join Chat'))
self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
self.set_default_size(-1, 600)
self.ready_to_destroy = False
self._parameter_form = None
self._keywords = []
self._destroyed = False
self._search_stopped = False
self._redirected = False
self._source_id = None
self._ui = get_builder('start_chat_dialog.ui')
self.add(self._ui.stack)
self._nick_chooser = NickChooser()
self._ui.join_box.pack_start(self._nick_chooser, True, False, 0)
self.new_contact_row_visible = False
self.new_contact_rows = {}
self.new_groupchat_rows = {}
self._accounts = app.get_enabled_accounts_with_labels()
rows = []
self._add_accounts()
self._add_contacts(rows)
self._add_groupchats(rows)
self._ui.search_entry.connect('search-changed',
self._on_search_changed)
self._ui.search_entry.connect('next-match',
self._select_new_match, 'next')
self._ui.search_entry.connect('previous-match',
self._select_new_match, 'prev')
self._ui.search_entry.connect(
'stop-search', lambda *args: self._ui.search_entry.set_text(''))
self._ui.listbox.set_placeholder(self._ui.placeholder)
self._ui.listbox.set_filter_func(self._filter_func, None)
self._ui.listbox.connect('row-activated', self._on_row_activated)
self._global_search_listbox = GlobalSearch()
self._global_search_listbox.connect('row-activated',
self._on_row_activated)
self._current_listbox = self._ui.listbox
self._muc_info_box = GroupChatInfoScrolled()
self._ui.info_box.add(self._muc_info_box)
self._ui.infobar.set_revealed(app.settings.get('show_help_start_chat'))
self.connect('key-press-event', self._on_key_press)
self.connect('destroy', self._destroy)
self.select_first_row()
self._ui.connect_signals(self)
self.show_all()
if rows:
self._load_contacts(rows)
def set_search_text(self, text):
self._ui.search_entry.set_text(text)
def _global_search_active(self):
return self._ui.global_search_toggle.get_active()
def _add_accounts(self):
for account in self._accounts:
self._ui.account_store.append([None, *account])
def _add_contacts(self, rows):
show_account = len(self._accounts) > 1
for account, _label in self._accounts:
self.new_contact_rows[account] = None
for jid in app.contacts.get_jid_list(account):
contact = app.contacts.get_contact_with_highest_priority(
account, jid)
if contact.is_groupchat:
continue
rows.append(ContactRow(account, contact, jid,
contact.get_shown_name(), show_account))
def _add_groupchats(self, rows):
show_account = len(self._accounts) > 1
for account, _label in self._accounts:
self.new_groupchat_rows[account] = None
con = app.connections[account]
bookmarks = con.get_module('Bookmarks').bookmarks
for bookmark in bookmarks:
jid = str(bookmark.jid)
name = get_groupchat_name(con, jid)
rows.append(ContactRow(account, None, jid, name, show_account,
groupchat=True))
def _load_contacts(self, rows):
generator = self._incremental_add(rows)
self._source_id = GLib.idle_add(lambda: next(generator, False),
priority=GLib.PRIORITY_LOW)
def _incremental_add(self, rows):
for row in rows:
self._ui.listbox.add(row)
yield True
self._ui.listbox.set_sort_func(self._sort_func, None)
self._source_id = None
def _on_page_changed(self, stack, _param):
if stack.get_visible_child_name() == 'account':
self._ui.account_view.grab_focus()
def _on_row_activated(self, _listbox, row):
if self._current_listbox_is(Search.GLOBAL):
self._select_muc()
else:
self._start_new_chat(row)
def _select_muc(self):
if len(self._accounts) > 1:
self._ui.stack.set_visible_child_name('account')
else:
self._on_select_clicked()
def _on_key_press(self, _widget, event):
is_search = self._ui.stack.get_visible_child_name() == 'search'
if event.keyval in (Gdk.KEY_Down, Gdk.KEY_Tab):
if not is_search:
return Gdk.EVENT_PROPAGATE
if self._global_search_active():
self._global_search_listbox.select_next()
else:
self._ui.search_entry.emit('next-match')
return Gdk.EVENT_STOP
if (event.state == Gdk.ModifierType.SHIFT_MASK and
event.keyval == Gdk.KEY_ISO_Left_Tab):
if not is_search:
return Gdk.EVENT_PROPAGATE
if self._global_search_active():
self._global_search_listbox.select_prev()
else:
self._ui.search_entry.emit('previous-match')
return Gdk.EVENT_STOP
if event.keyval == Gdk.KEY_Up:
if not is_search:
return Gdk.EVENT_PROPAGATE
if self._global_search_active():
self._global_search_listbox.select_prev()
else:
self._ui.search_entry.emit('previous-match')
return Gdk.EVENT_STOP
if event.keyval == Gdk.KEY_Escape:
if self._ui.stack.get_visible_child_name() == 'progress':
self.destroy()
return Gdk.EVENT_STOP
if self._ui.stack.get_visible_child_name() == 'account':
self._on_back_clicked()
return Gdk.EVENT_STOP
if self._ui.stack.get_visible_child_name() in ('error', 'info'):
self._ui.stack.set_visible_child_name('search')
return Gdk.EVENT_STOP
self._search_stopped = True
self._ui.search_entry.grab_focus()
self._scroll_to_first_row()
self._global_search_listbox.remove_all()
if self._ui.search_entry.get_text() != '':
self._ui.search_entry.emit('stop-search')
else:
self.destroy()
return Gdk.EVENT_STOP
if event.keyval == Gdk.KEY_Return:
if self._ui.stack.get_visible_child_name() == 'progress':
return Gdk.EVENT_STOP
if self._ui.stack.get_visible_child_name() == 'account':
self._on_select_clicked()
return Gdk.EVENT_STOP
if self._ui.stack.get_visible_child_name() == 'error':
self._ui.stack.set_visible_child_name('search')
return Gdk.EVENT_STOP
if self._ui.stack.get_visible_child_name() == 'info':
self._on_join_clicked()
return Gdk.EVENT_STOP
if self._current_listbox_is(Search.GLOBAL):
if self._ui.search_entry.is_focus():
self._global_search_listbox.remove_all()
self._start_search()
elif self._global_search_listbox.get_selected_row() is not None:
self._select_muc()
return Gdk.EVENT_STOP
row = self._ui.listbox.get_selected_row()
if row is not None:
row.emit('activate')
return Gdk.EVENT_STOP
if is_search:
self._ui.search_entry.grab_focus_without_selecting()
return Gdk.EVENT_PROPAGATE
def _on_infobar_response(self, _widget, response):
if response == Gtk.ResponseType.CLOSE:
self._ui.infobar.set_revealed(False)
app.settings.set('show_help_start_chat', False)
def _start_new_chat(self, row):
if row.new:
try:
validate_jid(row.jid)
except ValueError as error:
self._show_error_page(error)
return
if row.groupchat:
if not app.account_is_available(row.account):
self._show_error_page(_('You can not join a group chat '
'unless you are connected.'))
return
self.ready_to_destroy = True
if app.interface.show_groupchat(row.account, row.jid):
return
self.ready_to_destroy = False
self._redirected = False
self._disco_muc(row.account, row.jid, request_vcard=row.new)
else:
app.interface.new_chat_from_jid(row.account, row.jid)
self.ready_to_destroy = True
def _disco_muc(self, account, jid, request_vcard):
self._ui.stack.set_visible_child_name('progress')
con = app.connections[account]
con.get_module('Discovery').disco_muc(
jid,
request_vcard=request_vcard,
allow_redirect=True,
callback=self._disco_info_received,
user_data=account)
def _disco_info_received(self, task):
try:
result = task.finish()
except StanzaError as error:
self._set_error(error)
return
account = task.get_user_data()
if result.info.is_muc:
self._muc_info_box.set_account(account)
self._muc_info_box.set_from_disco_info(result.info)
self._nick_chooser.set_text(get_group_chat_nick(
account, result.info.jid))
self._ui.stack.set_visible_child_name('info')
else:
self._set_error_from_code('not-muc-service')
def _set_error(self, error):
text = MUC_DISCO_ERRORS.get(error.condition, to_user_string(error))
if error.condition == 'gone':
reason = error.get_text(get_rfc5646_lang())
if reason:
text = '%s:\n%s' % (text, reason)
self._show_error_page(text)
def _set_error_from_code(self, error_code):
self._show_error_page(MUC_DISCO_ERRORS[error_code])
def _show_error_page(self, text):
self._ui.error_label.set_text(str(text))
self._ui.stack.set_visible_child_name('error')
def _on_join_clicked(self, _button=None):
account = self._muc_info_box.get_account()
jid = self._muc_info_box.get_jid()
nickname = self._nick_chooser.get_text()
app.interface.show_or_join_groupchat(
account, str(jid), nick=nickname)
self.ready_to_destroy = True
def _on_back_clicked(self, _button=None):
self._ui.stack.set_visible_child_name('search')
def _on_select_clicked(self, *args):
model, iter_ = self._ui.account_view.get_selection().get_selected()
if iter_ is not None:
account = model[iter_][1]
elif len(self._accounts) == 1:
account = self._accounts[0][0]
else:
return
selected_row = self._global_search_listbox.get_selected_row()
if selected_row is None:
return
if not app.account_is_available(account):
self._show_error_page(_('You can not join a group chat '
'unless you are connected.'))
return
self._redirected = False
self._disco_muc(account, selected_row.jid, request_vcard=True)
def _set_listbox(self, listbox):
if self._current_listbox == listbox:
return
viewport = self._ui.scrolledwindow.get_child()
viewport.remove(viewport.get_child())
self._ui.scrolledwindow.remove(viewport)
self._ui.scrolledwindow.add(listbox)
self._current_listbox = listbox
def _current_listbox_is(self, box):
if self._current_listbox == self._ui.listbox:
return box == Search.CONTACT
return box == Search.GLOBAL
def _on_global_search_toggle(self, button):
self._ui.search_entry.grab_focus()
image_style_context = button.get_children()[0].get_style_context()
if button.get_active():
image_style_context.add_class('selected-color')
self._set_listbox(self._global_search_listbox)
if self._ui.search_entry.get_text():
self._start_search()
self._remove_new_jid_row()
self._ui.listbox.invalidate_filter()
else:
self._ui.search_entry.set_text('')
image_style_context.remove_class('selected-color')
self._set_listbox(self._ui.listbox)
self._global_search_listbox.remove_all()
def _on_search_changed(self, entry):
if self._global_search_active():
return
search_text = entry.get_text()
if '@' in search_text:
self._add_new_jid_row()
self._update_new_jid_rows(search_text)
else:
self._remove_new_jid_row()
self._ui.listbox.invalidate_filter()
def _add_new_jid_row(self):
if self.new_contact_row_visible:
return
for account in self.new_contact_rows:
show_account = len(self._accounts) > 1
row = ContactRow(account, None, '', None, show_account)
self.new_contact_rows[account] = row
group_row = ContactRow(account, None, '', None, show_account,
groupchat=True)
self.new_groupchat_rows[account] = group_row
self._ui.listbox.add(row)
self._ui.listbox.add(group_row)
row.get_parent().show_all()
self.new_contact_row_visible = True
def _remove_new_jid_row(self):
if not self.new_contact_row_visible:
return
for account in self.new_contact_rows:
self._ui.listbox.remove(
self.new_contact_rows[account])
self._ui.listbox.remove(
self.new_groupchat_rows[account])
self.new_contact_row_visible = False
def _update_new_jid_rows(self, search_text):
for account in self.new_contact_rows:
self.new_contact_rows[account].update_jid(search_text)
self.new_groupchat_rows[account].update_jid(search_text)
def _select_new_match(self, _entry, direction):
selected_row = self._ui.listbox.get_selected_row()
if selected_row is None:
return
index = selected_row.get_index()
if direction == 'next':
index += 1
else:
index -= 1
while True:
new_selected_row = self._ui.listbox.get_row_at_index(index)
if new_selected_row is None:
return
if new_selected_row.get_child_visible():
self._ui.listbox.select_row(new_selected_row)
new_selected_row.grab_focus()
return
if direction == 'next':
index += 1
else:
index -= 1
def select_first_row(self):
first_row = self._ui.listbox.get_row_at_y(0)
self._ui.listbox.select_row(first_row)
def _scroll_to_first_row(self):
self._ui.scrolledwindow.get_vadjustment().set_value(0)
def _filter_func(self, row, _user_data):
search_text = self._ui.search_entry.get_text().lower()
search_text_list = search_text.split()
row_text = row.get_search_text().lower()
for text in search_text_list:
if text not in row_text:
GLib.timeout_add(50, self.select_first_row)
return None
GLib.timeout_add(50, self.select_first_row)
return True
@staticmethod
def _sort_func(row1, row2, _user_data):
name1 = row1.get_search_text()
name2 = row2.get_search_text()
account1 = row1.account
account2 = row2.account
is_groupchat1 = row1.groupchat
is_groupchat2 = row2.groupchat
new1 = row1.new
new2 = row2.new
result = locale.strcoll(account1.lower(), account2.lower())
if result != 0:
return result
if new1 != new2:
return 1 if new1 else -1
if is_groupchat1 != is_groupchat2:
return 1 if is_groupchat1 else -1
return locale.strcoll(name1.lower(), name2.lower())
def _start_search(self):
self._search_stopped = False
accounts = app.get_connected_accounts()
if not accounts:
return
con = app.connections[accounts[0]].connection
text = self._ui.search_entry.get_text().strip()
self._global_search_listbox.start_search()
if app.settings.get('muclumbus_api_pref') == 'http':
self._start_http_search(con, text)
else:
self._start_iq_search(con, text)
@as_task
def _start_iq_search(self, con, text):
_task = yield
if self._parameter_form is None:
result = yield con.get_module('Muclumbus').request_parameters(
app.settings.get('muclumbus_api_jid'))
self._process_search_result(result, parameters=True)
self._parameter_form = result
self._parameter_form.type_ = 'submit'
self._parameter_form.vars['q'].value = text
result = yield con.get_module('Muclumbus').set_search(
app.settings.get('muclumbus_api_jid'),
self._parameter_form)
self._process_search_result(result)
while not result.end:
result = yield con.get_module('Muclumbus').set_search(
app.settings.get('muclumbus_api_jid'),
self._parameter_form,
items_per_page=result.max,
after=result.last)
self._process_search_result(result)
self._global_search_listbox.end_search()
@as_task
def _start_http_search(self, con, text):
_task = yield
self._keywords = text.split(' ')
result = yield con.get_module('Muclumbus').set_http_search(
app.settings.get('muclumbus_api_http_uri'),
self._keywords)
self._process_search_result(result)
while not result.end:
result = yield con.get_module('Muclumbus').set_http_search(
app.settings.get('muclumbus_api_http_uri'),
self._keywords,
after=result.last)
self._process_search_result(result)
self._global_search_listbox.end_search()
def _process_search_result(self, result, parameters=False):
if self._search_stopped:
raise CancelledError()
if is_error(result):
self._global_search_listbox.remove_progress()
self._show_error_page(to_user_string(result))
raise result
if parameters:
return
for item in result.items:
self._global_search_listbox.add(ResultRow(item))
def _destroy(self, *args):
if self._source_id is not None:
GLib.source_remove(self._source_id)
self._destroyed = True
app.cancel_tasks(self)
class ContactRow(Gtk.ListBoxRow):
def __init__(self, account, contact, jid, name, show_account,
groupchat=False):
Gtk.ListBoxRow.__init__(self)
self.get_style_context().add_class('start-chat-row')
self.account = account
self.account_label = app.get_account_label(account)
self.show_account = show_account
self.jid = jid
self.contact = contact
self.name = name
self.groupchat = groupchat
self.new = jid == ''
show = contact.show if contact else 'offline'
grid = Gtk.Grid()
grid.set_column_spacing(12)
grid.set_size_request(260, -1)
image = self._get_avatar_image(account, jid, show)
image.set_size_request(AvatarSize.CHAT, AvatarSize.CHAT)
grid.add(image)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
box.set_hexpand(True)
if self.name is None:
if self.groupchat:
self.name = _('Join Group Chat')
else:
self.name = _('Start Chat')
self.name_label = Gtk.Label(label=self.name)
self.name_label.set_ellipsize(Pango.EllipsizeMode.END)
self.name_label.set_xalign(0)
self.name_label.set_width_chars(20)
self.name_label.set_halign(Gtk.Align.START)
self.name_label.get_style_context().add_class('bold16')
name_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
name_box.add(self.name_label)
if show_account:
account_badge = generate_account_badge(account)
account_badge.set_tooltip_text(
_('Account: %s') % self.account_label)
account_badge.set_halign(Gtk.Align.END)
account_badge.set_valign(Gtk.Align.START)
account_badge.set_hexpand(True)
name_box.add(account_badge)
box.add(name_box)
self.jid_label = Gtk.Label(label=jid)
self.jid_label.set_tooltip_text(jid)
self.jid_label.set_ellipsize(Pango.EllipsizeMode.END)
self.jid_label.set_xalign(0)
self.jid_label.set_width_chars(22)
self.jid_label.set_halign(Gtk.Align.START)
self.jid_label.get_style_context().add_class('dim-label')
box.add(self.jid_label)
grid.add(box)
self.add(grid)
self.show_all()
def _get_avatar_image(self, account, jid, show):
if self.new:
icon_name = 'avatar-default'
if self.groupchat:
icon_name = get_icon_name('muc-inactive')
return Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DND)
scale = self.get_scale_factor()
if self.groupchat:
surface = app.interface.avatar_storage.get_muc_surface(
account, jid, AvatarSize.CHAT, scale)
return Gtk.Image.new_from_surface(surface)
avatar = app.contacts.get_avatar(
account, jid, AvatarSize.CHAT, scale, show)
return Gtk.Image.new_from_surface(avatar)
def update_jid(self, jid):
self.jid = jid
self.jid_label.set_text(jid)
def get_search_text(self):
if self.contact is None and not self.groupchat:
return self.jid
if self.show_account:
return '%s %s %s' % (self.name, self.jid, self.account_label)
return '%s %s' % (self.name, self.jid)
class GlobalSearch(Gtk.ListBox):
def __init__(self):
Gtk.ListBox.__init__(self)
self.set_has_tooltip(True)
self.set_activate_on_single_click(False)
self._progress = None
self._add_placeholder()
self.show_all()
def _add_placeholder(self):
placeholder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
placeholder.set_halign(Gtk.Align.CENTER)
placeholder.set_valign(Gtk.Align.CENTER)
icon = Gtk.Image.new_from_icon_name('system-search-symbolic',
Gtk.IconSize.DIALOG)
icon.get_style_context().add_class('dim-label')
label = Gtk.Label(label=_('Search for group chats globally\n'
'(press Return to start search)'))
label.get_style_context().add_class('dim-label')
label.set_justify(Gtk.Justification.CENTER)
label.set_max_width_chars(35)
placeholder.add(icon)
placeholder.add(label)
placeholder.show_all()
self.set_placeholder(placeholder)
def remove_all(self):
def remove(row):
self.remove(row)
row.destroy()
self.foreach(remove)
def remove_progress(self):
self.remove(self._progress)
self._progress.destroy()
def start_search(self):
self._progress = ProgressRow()
super().add(self._progress)
def end_search(self):
self._progress.stop()
def add(self, row):
super().add(row)
if self.get_selected_row() is None:
row = self.get_row_at_index(1)
if row is not None:
self.select_row(row)
row.grab_focus()
self._progress.update()
def _select(self, direction):
selected_row = self.get_selected_row()
if selected_row is None:
return
index = selected_row.get_index()
if direction == 'next':
index += 1
else:
index -= 1
new_selected_row = self.get_row_at_index(index)
if new_selected_row is None:
return
self.select_row(new_selected_row)
new_selected_row.grab_focus()
def select_next(self):
self._select('next')
def select_prev(self):
self._select('prev')
class ResultRow(Gtk.ListBoxRow):
def __init__(self, item):
Gtk.ListBoxRow.__init__(self)
self.set_activatable(True)
self.get_style_context().add_class('start-chat-row')
self.new = False
self.jid = item.jid
self.groupchat = True
name_label = Gtk.Label(label=item.name)
name_label.set_halign(Gtk.Align.START)
name_label.set_ellipsize(Pango.EllipsizeMode.END)
name_label.set_max_width_chars(40)
name_label.get_style_context().add_class('bold16')
jid_label = Gtk.Label(label=item.jid)
jid_label.set_halign(Gtk.Align.START)
jid_label.set_ellipsize(Pango.EllipsizeMode.END)
jid_label.set_max_width_chars(40)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
box.add(name_label)
box.add(jid_label)
self.add(box)
self.show_all()
class ProgressRow(Gtk.ListBoxRow):
def __init__(self):
Gtk.ListBoxRow.__init__(self)
self.set_selectable(False)
self.set_activatable(False)
self.get_style_context().add_class('start-chat-row')
self._text = _('%s group chats found')
self._count = 0
self._spinner = Gtk.Spinner()
self._spinner.start()
self._count_label = Gtk.Label(label=self._text % 0)
self._count_label.get_style_context().add_class('bold')
self._finished_image = Gtk.Image.new_from_icon_name(
'emblem-ok-symbolic', Gtk.IconSize.MENU)
self._finished_image.get_style_context().add_class('success-color')
self._finished_image.set_no_show_all(True)
box = Gtk.Box()
box.set_spacing(6)
box.add(self._finished_image)
box.add(self._spinner)
box.add(self._count_label)
self.add(box)
self.show_all()
def update(self):
self._count += 1
self._count_label.set_text(self._text % self._count)
def stop(self):
self._spinner.stop()
self._spinner.hide()
self._finished_image.show()

567
gajim/gtk/status_change.py Normal file
View file

@ -0,0 +1,567 @@
# 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/>.
from gi.repository import Gdk
from gi.repository import Gtk
from gajim.common import app
from gajim.common.const import ACTIVITIES
from gajim.common.const import MOODS
from gajim.common.helpers import from_one_line
from gajim.common.helpers import to_one_line
from gajim.common.helpers import remove_invalid_xml_chars
from gajim.common.i18n import _
from .dialogs import TimeoutWindow
from .dialogs import DialogButton
from .dialogs import ConfirmationDialog
from .dialogs import InputDialog
from .util import get_builder
from .util import get_activity_icon_name
if app.is_installed('GSPELL'):
from gi.repository import Gspell # pylint: disable=ungrouped-imports
ACTIVITY_PAGELIST = [
'doing_chores',
'drinking',
'eating',
'exercising',
'grooming',
'having_appointment',
'inactive',
'relaxing',
'talking',
'traveling',
'working',
]
class StatusChange(Gtk.ApplicationWindow, TimeoutWindow):
def __init__(self, callback=None, account=None, status=None, show_pep=True):
Gtk.ApplicationWindow.__init__(self)
countdown_time = app.settings.get('change_status_window_timeout')
TimeoutWindow.__init__(self, countdown_time)
self.set_name('StatusChange')
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_default_size(400, 350)
self.set_show_menubar(False)
self.set_transient_for(app.interface.roster.window)
self.title_text = _('Status Message') # TimeoutWindow
self.account = account
self._callback = callback
self._status = status
self._show_pep = show_pep
self._ui = get_builder('status_change_window.ui')
self.add(self._ui.status_stack)
self._status_message = ''
self._pep_dict = {
'activity': '',
'subactivity': '',
'mood': '',
}
self._get_current_status_data()
self._presets = {}
self._get_presets()
if self._status:
self._ui.activity_switch.set_active(self._pep_dict['activity'])
self._ui.activity_page_button.set_sensitive(
self._pep_dict['activity'])
self._ui.mood_switch.set_active(self._pep_dict['mood'])
self._ui.mood_page_button.set_sensitive(self._pep_dict['mood'])
self._message_buffer = self._ui.message_textview.get_buffer()
self._apply_speller()
self._message_buffer.set_text(from_one_line(self._status_message))
self._activity_btns = {}
self._mood_btns = {}
if show_pep:
self._init_activities()
self._draw_activity()
self._init_moods()
self._draw_mood()
else:
self._ui.pep_grid.set_no_show_all(True)
self._ui.pep_grid.hide()
self._message_buffer.connect('changed', self.stop_timeout)
self.connect('key-press-event', self._on_key_press)
self._ui.connect_signals(self)
self.show_all()
self.start_timeout()
def on_timeout(self):
self._change_status()
def _on_key_press(self, _widget, event):
self.stop_timeout()
if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
if event.get_state() & Gdk.ModifierType.CONTROL_MASK:
self._change_status()
if event.keyval == Gdk.KEY_Escape:
self.destroy()
def _apply_speller(self):
if app.settings.get('use_speller') and app.is_installed('GSPELL'):
lang = app.settings.get('speller_language')
gspell_lang = Gspell.language_lookup(lang)
if gspell_lang is None:
gspell_lang = Gspell.language_get_default()
spell_buffer = Gspell.TextBuffer.get_from_gtk_text_buffer(
self._message_buffer)
spell_buffer.set_spell_checker(Gspell.Checker.new(gspell_lang))
spell_view = Gspell.TextView.get_from_gtk_text_view(
self._ui.message_textview)
spell_view.set_inline_spell_checking(True)
spell_view.set_enable_language_menu(True)
def _get_current_status_data(self):
'''
Gathers status/pep data for a given account or checks if all accounts
are synchronized. If not, no status message/pep data will be displayed.
'''
if self.account:
client = app.get_client(self.account)
self._status_message = client.status_message
activity_data = client.get_module(
'UserActivity').get_current_activity()
mood_data = client.get_module('UserMood').get_current_mood()
if activity_data:
self._pep_dict['activity'] = activity_data.activity
self._pep_dict['subactivity'] = activity_data.subactivity
if mood_data:
self._pep_dict['mood'] = mood_data.mood
else:
status_messages = []
activities = []
subactivities = []
moods = []
for account in app.connections:
client = app.get_client(account)
if not app.settings.get_account_setting(
client.account, 'sync_with_global_status'):
continue
status_messages.append(client.status_message)
activity_data = client.get_module(
'UserActivity').get_current_activity()
mood_data = client.get_module('UserMood').get_current_mood()
if activity_data:
activities.append(activity_data.activity)
subactivities.append(activity_data.subactivity)
if mood_data:
moods.append(mood_data.mood)
equal_messages = all(x == status_messages[0] for x in
status_messages)
equal_activities = all(x == activities[0] for x in activities)
equal_subactivities = all(x == subactivities[0] for x in
subactivities)
equal_moods = all(x == moods[0] for x in moods)
if status_messages and equal_messages:
self._status_message = status_messages[0]
if activities and equal_activities:
self._pep_dict['activity'] = activities[0]
if subactivities and equal_subactivities:
self._pep_dict['subactivity'] = subactivities[0]
if moods and equal_moods:
self._pep_dict['mood'] = moods[0]
def _get_presets(self):
self._presets = {}
for preset_name in app.settings.get_status_presets():
preset = app.settings.get_status_preset_settings(preset_name)
opts = list(preset.values())
opts[0] = from_one_line(opts[0])
self._presets[preset_name] = opts
self._build_preset_popover()
def _build_preset_popover(self):
child = self._ui.preset_popover.get_children()
if child:
self._ui.preset_popover.remove(child[0])
preset_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
preset_box.get_style_context().add_class('margin-3')
self._ui.preset_popover.add(preset_box)
for preset in self._presets:
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
preset_button = Gtk.Button()
preset_button.set_name(preset)
preset_button.set_relief(Gtk.ReliefStyle.NONE)
preset_button.set_hexpand(True)
preset_button.add(Gtk.Label(label=preset, halign=Gtk.Align.START))
preset_button.connect('clicked', self._on_preset_select)
button_box.add(preset_button)
remove_button = Gtk.Button()
remove_button.set_name(preset)
remove_button.set_relief(Gtk.ReliefStyle.NONE)
remove_button.set_halign(Gtk.Align.END)
remove_button.add(Gtk.Image.new_from_icon_name(
'edit-delete-symbolic', Gtk.IconSize.MENU))
remove_button.connect('clicked', self._on_preset_remove)
button_box.add(remove_button)
preset_box.add(button_box)
preset_box.show_all()
def _init_activities(self):
group = None
for category in ACTIVITIES:
icon_name = get_activity_icon_name(category)
item = self._ui.get_object(category + '_image')
item.set_from_icon_name(icon_name, Gtk.IconSize.MENU)
item.set_tooltip_text(ACTIVITIES[category]['category'])
category_box = self._ui.get_object(category + '_box')
# Other
act = category + '_other'
if group:
self._activity_btns[act] = Gtk.RadioButton()
self._activity_btns[act].join_group(group)
else:
self._activity_btns[act] = group = Gtk.RadioButton()
icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.MENU)
icon_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL,
spacing=6)
icon_box.pack_start(icon, False, False, 0)
label = Gtk.Label(
label='<b>%s</b>' % ACTIVITIES[category]['category'])
label.set_use_markup(True)
icon_box.pack_start(label, False, False, 0)
self._activity_btns[act].add(icon_box)
self._activity_btns[act].join_group(self._ui.no_activity_button)
self._activity_btns[act].connect(
'toggled', self._on_activity_toggled, [category, 'other'])
category_box.pack_start(self._activity_btns[act], False, False, 0)
activities = list(ACTIVITIES[category].keys())
activities.sort()
for activity in activities:
if activity == 'category':
continue
act = category + '_' + activity
if group:
self._activity_btns[act] = Gtk.RadioButton()
self._activity_btns[act].join_group(group)
else:
self._activity_btns[act] = group = Gtk.RadioButton()
icon_name = get_activity_icon_name(category, activity)
icon = Gtk.Image.new_from_icon_name(
icon_name, Gtk.IconSize.MENU)
label = Gtk.Label(label=ACTIVITIES[category][activity])
icon_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL,
spacing=6)
icon_box.pack_start(icon, False, False, 0)
icon_box.pack_start(label, False, False, 0)
self._activity_btns[act].join_group(
self._ui.no_activity_button)
self._activity_btns[act].connect(
'toggled', self._on_activity_toggled, [category, activity])
self._activity_btns[act].add(icon_box)
category_box.pack_start(
self._activity_btns[act], False, False, 0)
if not self._pep_dict['activity']:
self._ui.no_activity_button.set_active(True)
if self._pep_dict['activity'] in ACTIVITIES:
if self._pep_dict['subactivity'] not in ACTIVITIES[
self._pep_dict['activity']]:
self._pep_dict['subactivity'] = 'other'
self._activity_btns[
self._pep_dict['activity'] + '_' + self._pep_dict[
'subactivity']].set_active(True)
self._ui.activity_notebook.set_current_page(
ACTIVITY_PAGELIST.index(self._pep_dict['activity']))
def _draw_activity(self):
if self._pep_dict['activity'] in ACTIVITIES:
if (self._pep_dict['subactivity'] in
ACTIVITIES[self._pep_dict['activity']]):
icon_name = get_activity_icon_name(
self._pep_dict['activity'],
self._pep_dict['subactivity'])
self._ui.activity_image.set_from_icon_name(
icon_name, Gtk.IconSize.MENU)
self._ui.activity_button_label.set_text(
ACTIVITIES[self._pep_dict['activity']][
self._pep_dict['subactivity']])
self._activity_btns[
self._pep_dict['activity'] + '_' + self._pep_dict[
'subactivity']].set_active(True)
self._ui.activity_notebook.set_current_page(
ACTIVITY_PAGELIST.index(self._pep_dict['activity']))
else:
icon_name = get_activity_icon_name(self._pep_dict['activity'])
self._ui.activity_image.set_from_icon_name(
icon_name, Gtk.IconSize.MENU)
self._ui.activity_button_label.set_text(
ACTIVITIES[self._pep_dict['activity']]['category'])
else:
self._ui.activity_image.set_from_pixbuf(None)
self._ui.activity_button_label.set_text(_('No activity'))
def _init_moods(self):
self._ui.no_mood_button.set_mode(False)
self._ui.no_mood_button.connect(
'clicked', self._on_mood_button_clicked, None)
x_position = 1
y_position = 0
# Order them first
moods = []
for mood in MOODS:
moods.append(mood)
moods.sort()
for mood in moods:
image = Gtk.Image.new_from_icon_name(
'mood-%s' % mood, Gtk.IconSize.MENU)
self._mood_btns[mood] = Gtk.RadioButton()
self._mood_btns[mood].join_group(self._ui.no_mood_button)
self._mood_btns[mood].set_mode(False)
self._mood_btns[mood].add(image)
self._mood_btns[mood].set_relief(Gtk.ReliefStyle.NONE)
self._mood_btns[mood].set_tooltip_text(MOODS[mood])
self._mood_btns[mood].connect(
'clicked', self._on_mood_button_clicked, mood)
self._ui.moods_grid.attach(
self._mood_btns[mood], x_position, y_position, 1, 1)
# Calculate the next position
x_position += 1
if x_position >= 11:
x_position = 0
y_position += 1
if self._pep_dict['mood'] in MOODS:
self._mood_btns[self._pep_dict['mood']].set_active(True)
self._ui.mood_label.set_text(MOODS[self._pep_dict['mood']])
else:
self._ui.mood_label.set_text(_('No mood selected'))
def _draw_mood(self):
if self._pep_dict['mood'] in MOODS:
self._ui.mood_image.set_from_icon_name(
'mood-%s' % self._pep_dict['mood'], Gtk.IconSize.MENU)
self._ui.mood_button_label.set_text(
MOODS[self._pep_dict['mood']])
self._mood_btns[self._pep_dict['mood']].set_active(True)
self._ui.mood_label.set_text(MOODS[self._pep_dict['mood']])
else:
self._ui.mood_image.set_from_pixbuf(None)
self._ui.mood_button_label.set_text(_('No mood'))
self._ui.mood_label.set_text(_('No mood selected'))
def _on_preset_select(self, widget):
self.stop_timeout()
self._ui.preset_popover.popdown()
name = widget.get_name()
self._message_buffer.set_text(self._presets[name][0])
self._pep_dict['activity'] = self._presets[name][1]
self._pep_dict['subactivity'] = self._presets[name][2]
self._pep_dict['mood'] = self._presets[name][3]
self._draw_activity()
self._draw_mood()
self._ui.activity_switch.set_active(self._pep_dict['activity'])
self._ui.activity_page_button.set_sensitive(self._pep_dict['activity'])
self._ui.mood_switch.set_active(self._pep_dict['mood'])
self._ui.mood_page_button.set_sensitive(self._pep_dict['mood'])
def _on_preset_remove(self, widget):
self.stop_timeout()
name = widget.get_name()
app.settings.remove_status_preset(name)
self._get_presets()
def _on_save_as_preset_clicked(self, _widget):
self.stop_timeout()
start_iter, finish_iter = self._message_buffer.get_bounds()
message_text = self._message_buffer.get_text(
start_iter, finish_iter, True)
def _on_save_preset(preset_name):
msg_text_one_line = to_one_line(message_text)
if not preset_name:
preset_name = msg_text_one_line
def _on_set_config():
activity = ''
subactivity = ''
mood = ''
if self._ui.activity_switch.get_active():
activity = self._pep_dict['activity']
subactivity = self._pep_dict['subactivity']
if self._ui.mood_switch.get_active():
mood = self._pep_dict['mood']
app.settings.set_status_preset_setting(
preset_name, 'message', msg_text_one_line)
app.settings.set_status_preset_setting(
preset_name, 'activity', activity)
app.settings.set_status_preset_setting(
preset_name, 'subactivity', subactivity)
app.settings.set_status_preset_setting(
preset_name, 'mood', mood)
self._get_presets()
if preset_name in self._presets:
ConfirmationDialog(
_('Overwrite'),
_('Overwrite Status Message?'),
_('This name is already in use. Do you want to '
'overwrite this preset?'),
[DialogButton.make('Cancel'),
DialogButton.make('Remove',
text=_('_Overwrite'),
callback=_on_set_config)],
transient_for=self).show()
return
_on_set_config()
InputDialog(
_('Status Preset'),
_('Save status as preset'),
_('Please assign a name to this status message preset'),
[DialogButton.make('Cancel'),
DialogButton.make('Accept',
text=_('_Save'),
callback=_on_save_preset)],
input_str=_('New Status'),
transient_for=self).show()
def _on_activity_page_clicked(self, _widget):
self.stop_timeout()
self._ui.status_stack.set_visible_child_full(
'activity-page',
Gtk.StackTransitionType.SLIDE_LEFT)
def _on_activity_toggled(self, widget, data):
if widget.get_active():
self._pep_dict['activity'] = data[0]
self._pep_dict['subactivity'] = data[1]
def _on_no_activity_toggled(self, _widget):
self._pep_dict['activity'] = ''
self._pep_dict['subactivity'] = ''
def _on_mood_page_clicked(self, _widget):
self.stop_timeout()
self._ui.status_stack.set_visible_child_full(
'mood-page',
Gtk.StackTransitionType.SLIDE_LEFT)
def _on_mood_button_clicked(self, _widget, data):
if data:
self._ui.mood_label.set_text(MOODS[data])
else:
self._ui.mood_label.set_text(_('No mood selected'))
self._pep_dict['mood'] = data
def _on_back_clicked(self, _widget):
self._ui.status_stack.set_visible_child_full(
'status-page',
Gtk.StackTransitionType.SLIDE_RIGHT)
self._draw_activity()
self._draw_mood()
def _on_activity_switch(self, switch, *args):
self.stop_timeout()
self._ui.activity_page_button.set_sensitive(switch.get_active())
def _on_mood_switch(self, switch, *args):
self.stop_timeout()
self._ui.mood_page_button.set_sensitive(switch.get_active())
def _send_user_mood(self):
mood = None
if self._ui.mood_switch.get_active():
mood = self._pep_dict['mood']
if self.account is None:
for client in app.get_available_clients():
if not app.settings.get_account_setting(
client.account, 'sync_with_global_status'):
continue
client.set_user_mood(mood)
else:
client = app.get_client(self.account)
client.set_user_mood(mood)
def _send_user_activity(self):
activity = None
if self._ui.activity_switch.get_active():
activity = (self._pep_dict['activity'],
self._pep_dict['subactivity'])
if self.account is None:
for client in app.get_available_clients():
if not app.settings.get_account_setting(
client.account, 'sync_with_global_status'):
continue
client.set_user_activity(activity)
else:
client = app.get_client(self.account)
client.set_user_activity(activity)
def _send_status_and_message(self, message):
if self.account is not None:
app.interface.roster.send_status(self.account,
self._status,
message)
return
for account in app.connections:
if not app.settings.get_account_setting(
account, 'sync_with_global_status'):
continue
app.interface.roster.send_status(account, self._status, message)
def _change_status(self, *args):
self.stop_timeout()
beg, end = self._message_buffer.get_bounds()
message = self._message_buffer.get_text(beg, end, True).strip()
message = remove_invalid_xml_chars(message)
if self._show_pep:
self._send_user_activity()
self._send_user_mood()
if self._callback is not None:
self._callback(message)
else:
self._send_status_and_message(message)
self.destroy()

View file

@ -0,0 +1,131 @@
# 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/>.
from gi.repository import Gtk
from gi.repository import Pango
from gajim.common import app
from gajim.common.helpers import get_uf_show
from gajim.common.helpers import get_global_show
from gajim.common.helpers import statuses_unified
from gajim.common.i18n import _
from .util import get_icon_name
class StatusSelector(Gtk.MenuButton):
def __init__(self, compact=False):
Gtk.MenuButton.__init__(self)
self.set_direction(Gtk.ArrowType.UP)
self._compact = compact
self._create_popover()
self.set_no_show_all(True)
self._current_show_icon = Gtk.Image()
self._current_show_icon.set_from_icon_name(
get_icon_name('offline'), Gtk.IconSize.MENU)
box = Gtk.Box(spacing=6)
box.add(self._current_show_icon)
if not self._compact:
self._current_show_label = Gtk.Label(label=get_uf_show('offline'))
self._current_show_label.set_ellipsize(Pango.EllipsizeMode.END)
self._current_show_label.set_halign(Gtk.Align.START)
self._current_show_label.set_xalign(0)
box.add(self._current_show_label)
box.show_all()
self.add(box)
def _create_popover(self):
popover_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
popover_box.get_style_context().add_class('margin-3')
popover_items = [
'online',
'away',
'xa',
'dnd',
'separator',
'change_status_message',
'separator',
'offline',
]
for item in popover_items:
if item == 'separator':
popover_box.add(Gtk.Separator())
continue
show_icon = Gtk.Image()
show_label = Gtk.Label()
show_label.set_halign(Gtk.Align.START)
if item == 'change_status_message':
show_icon.set_from_icon_name('document-edit-symbolic',
Gtk.IconSize.MENU)
show_label.set_text_with_mnemonic(_('_Change Status Message'))
else:
show_icon.set_from_icon_name(get_icon_name(item),
Gtk.IconSize.MENU)
show_label.set_text_with_mnemonic(
get_uf_show(item, use_mnemonic=True))
show_box = Gtk.Box(spacing=6)
show_box.add(show_icon)
show_box.add(show_label)
button = Gtk.Button()
button.set_name(item)
button.set_relief(Gtk.ReliefStyle.NONE)
button.add(show_box)
button.connect('clicked', self._on_change_status)
if item == 'change_status_message':
self._change_status_message = button
popover_box.add(button)
popover_box.show_all()
self._status_popover = Gtk.Popover()
self._status_popover.add(popover_box)
self.set_popover(self._status_popover)
def _on_change_status(self, button):
self._status_popover.popdown()
new_status = button.get_name()
if new_status == 'change_status_message':
new_status = None
app.interface.change_status(status=new_status)
def update(self):
if not app.connections:
self.hide()
return
self.show()
show = get_global_show()
uf_show = get_uf_show(show)
self._current_show_icon.set_from_icon_name(
get_icon_name(show), Gtk.IconSize.MENU)
if statuses_unified():
self._current_show_icon.set_tooltip_text(_('Status: %s') % uf_show)
if not self._compact:
self._current_show_label.set_text(uf_show)
else:
show_label = _('%s (desynced)') % uf_show
self._current_show_icon.set_tooltip_text(
_('Status: %s') % show_label)
if not self._compact:
self._current_show_label.set_text(show_label)
self._change_status_message.set_sensitive(show != 'offline')

363
gajim/gtk/statusicon.py Normal file
View file

@ -0,0 +1,363 @@
# Copyright (C) 2006 Nikos Kouremenos <kourem AT gmail.com>
# Copyright (C) 2006-2007 Jean-Marie Traissard <jim AT lapin.org>
# Copyright (C) 2006-2014 Yann Leboulanger <asterix AT lagaule.org>
# Copyright (C) 2007 Lukas Petrovicky <lukas AT petrovicky.net>
# Julien Pivotto <roidelapluie AT gmail.com>
# 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
from gi.repository import Gtk
from gi.repository import GLib
from gajim.common import app
from gajim.common import helpers
from gajim.common.i18n import _
from gajim.common.helpers import save_roster_position
from .util import get_builder
from .util import get_icon_name
from .util import restore_roster_position
from .util import open_window
from .single_message import SingleMessageWindow
from .tooltips import NotificationAreaTooltip
class StatusIcon:
"""
Class for the notification area icon
"""
def __init__(self):
self._single_message_handler_id = None
self._show_roster_handler_id = None
# click somewhere else does not popdown menu. workaround this.
self.added_hide_menuitem = False
self.status = 'offline'
self._ui = get_builder('systray_context_menu.ui')
self.systray_context_menu = self._ui.systray_context_menu
self._ui.connect_signals(self)
self.popup_menus = []
self.status_icon = None
self.tooltip = NotificationAreaTooltip()
self._icon_size = '16'
def show_icon(self):
if not self.status_icon:
self.status_icon = Gtk.StatusIcon()
self.status_icon.set_property('has-tooltip', True)
self.status_icon.connect('activate', self._on_activate)
self.status_icon.connect('popup-menu', self._on_popup_menu)
self.status_icon.connect('query-tooltip', self._on_query_tooltip)
self.status_icon.connect('size-changed', self.set_img)
self.set_img()
self._subscribe_events()
def hide_icon(self):
self.status_icon.set_visible(False)
self._unsubscribe_events()
def change_status(self, global_status):
"""
Set tray image to 'global_status'
"""
# change image and status, only if it is different
if global_status is not None and self.status != global_status:
self.status = global_status
self.set_img()
def _subscribe_events(self):
"""
Register listeners to the events class
"""
app.events.event_added_subscribe(self._on_event_added)
app.events.event_removed_subscribe(self._on_event_removed)
def _unsubscribe_events(self):
"""
Unregister listeners to the events class
"""
app.events.event_added_unsubscribe(self._on_event_added)
app.events.event_removed_unsubscribe(self._on_event_removed)
def _on_event_added(self, event):
"""
Called when an event is added to the event list
"""
if event.show_in_systray:
self.set_img()
def _on_event_removed(self, _event_list):
"""
Called when one or more events are removed from the event list
"""
self.set_img()
def _on_query_tooltip(self, _status_icon, _x, _y, _keyboard_mode, tooltip):
tooltip.set_custom(self.tooltip.get_tooltip())
return True
def _on_popup_menu(self, _status_icon, button, activate_time):
if button == 1: # Left click
self._on_left_click()
elif button == 2: # middle click
self._on_middle_click()
elif button == 3: # right click
self._make_menu(button, activate_time)
def _on_activate(self, _status_icon):
self._on_left_click()
def on_status_icon_size_changed(self, _statusicon, size):
if size > 31:
self._icon_size = '32'
elif size > 23:
self._icon_size = '24'
else:
self._icon_size = '16'
if os.environ.get('KDE_FULL_SESSION') == 'true':
# detect KDE session. see #5476
self._icon_size = '32'
self.set_img()
def set_img(self, *args):
"""
Apart from image, we also update tooltip text here
"""
if not app.interface.systray_enabled:
return
if app.settings.get('trayicon') == 'always':
self.status_icon.set_visible(True)
if app.events.get_nb_systray_events():
self.status_icon.set_visible(True)
icon_name = get_icon_name('event')
self.status_icon.set_from_icon_name(icon_name)
return
if app.settings.get('trayicon') == 'on_event':
self.status_icon.set_visible(False)
icon_name = get_icon_name(self.status)
self.status_icon.set_from_icon_name(icon_name)
@staticmethod
def _on_single_message(_widget, account):
SingleMessageWindow(account, action='send')
@staticmethod
def _on_new_chat(_widget):
app.app.activate_action('start-chat', GLib.Variant('s', ''))
def _make_menu(self, _event_button, event_time):
"""
Create chat with and new message (sub) menus/menuitems
"""
for menu in self.popup_menus:
menu.destroy()
start_chat_menuitem = self._ui.start_chat_menuitem
single_message_menuitem = self._ui.single_message_menuitem
status_menuitem = self._ui.status_menu
sounds_mute_menuitem = self._ui.sounds_mute_menuitem
show_roster_menuitem = self._ui.show_roster_menuitem
if self._single_message_handler_id:
single_message_menuitem.handler_disconnect(
self._single_message_handler_id)
self._single_message_handler_id = None
sub_menu = Gtk.Menu()
self.popup_menus.append(sub_menu)
status_menuitem.set_submenu(sub_menu)
for show in ('online', 'away', 'xa', 'dnd'):
uf_show = helpers.get_uf_show(show, use_mnemonic=True)
item = Gtk.MenuItem.new_with_mnemonic(uf_show)
sub_menu.append(item)
item.connect('activate', self._on_show, show)
item = Gtk.SeparatorMenuItem.new()
sub_menu.append(item)
item = Gtk.MenuItem.new_with_mnemonic(_('_Change Status Message…'))
sub_menu.append(item)
item.connect('activate', self._on_change_status)
connected_accounts = app.get_number_of_connected_accounts()
if connected_accounts < 1:
item.set_sensitive(False)
item = Gtk.SeparatorMenuItem.new()
sub_menu.append(item)
uf_show = helpers.get_uf_show('offline', use_mnemonic=True)
item = Gtk.MenuItem.new_with_mnemonic(uf_show)
sub_menu.append(item)
item.connect('activate', self._on_show, 'offline')
is_zeroconf = connected_accounts == 1 and app.zeroconf_is_connected()
iskey = connected_accounts > 0 and not is_zeroconf
start_chat_menuitem.set_sensitive(iskey)
single_message_menuitem.set_sensitive(iskey)
accounts_list = sorted(app.contacts.get_accounts())
# menu items that don't apply to zeroconf connections
if connected_accounts == 1 or (connected_accounts == 2 and \
app.zeroconf_is_connected()):
# only one 'real' (non-zeroconf) account is connected, don't need
# submenus
for account in app.connections:
if app.account_is_available(account) and \
not app.settings.get_account_setting(account, 'is_zeroconf'):
# for single message
single_message_menuitem.set_submenu(None)
self._single_message_handler_id = single_message_menuitem.\
connect('activate',
self._on_single_message, account)
break # No other account connected
else:
# 2 or more 'real' accounts are connected, make submenus
account_menu_for_single_message = Gtk.Menu()
single_message_menuitem.set_submenu(
account_menu_for_single_message)
self.popup_menus.append(account_menu_for_single_message)
for account in accounts_list:
account_label = app.get_account_label(account)
if app.connections[account].is_zeroconf or \
not app.account_is_available(account):
continue
# for single message
item = Gtk.MenuItem.new_with_label(
_('using account %s') % account_label)
item.connect('activate',
self._on_single_message, account)
account_menu_for_single_message.append(item)
sounds_mute_menuitem.set_active(not app.settings.get('sounds_on'))
win = app.interface.roster.window
if self._show_roster_handler_id:
show_roster_menuitem.handler_disconnect(
self._show_roster_handler_id)
if win.get_property('has-toplevel-focus'):
show_roster_menuitem.get_children()[0].set_label(
_('Hide _Contact List'))
self._show_roster_handler_id = show_roster_menuitem.connect(
'activate', self._on_hide_roster)
else:
show_roster_menuitem.get_children()[0].set_label(
_('Show _Contact List'))
self._show_roster_handler_id = show_roster_menuitem.connect(
'activate', self._on_show_roster)
if os.name == 'nt':
if self.added_hide_menuitem is False:
self.systray_context_menu.prepend(Gtk.SeparatorMenuItem.new())
item = Gtk.MenuItem.new_with_label(
_('Hide this menu'))
self.systray_context_menu.prepend(item)
self.added_hide_menuitem = True
self.systray_context_menu.show_all()
self.systray_context_menu.popup(None, None, None, None, 0, event_time)
@staticmethod
def _on_show_all_events(_widget):
events = app.events.get_systray_events()
for account in events:
for jid in events[account]:
for event in events[account][jid]:
app.interface.handle_event(account, jid, event.type_)
@staticmethod
def _on_sounds_mute(widget):
app.settings.set('sounds_on', not widget.get_active())
@staticmethod
def _on_show_roster(_widget):
win = app.interface.roster.window
win.present()
@staticmethod
def _on_hide_roster(_widget):
win = app.interface.roster.window
win.hide()
@staticmethod
def _on_preferences(_widget):
open_window('Preferences')
@staticmethod
def _on_quit(_widget):
app.interface.roster.on_quit_request()
def _on_left_click(self):
win = app.interface.roster.window
if app.events.get_systray_events():
self._handle_first_event()
return
if win.get_property('has-toplevel-focus'):
save_roster_position(win)
win.hide()
return
visible = win.get_property('visible')
win.show_all()
if not visible:
# Window was minimized
restore_roster_position(win)
if not app.settings.get('roster_window_skip_taskbar'):
win.set_property('skip-taskbar-hint', False)
win.present_with_time(Gtk.get_current_event_time())
@staticmethod
def _handle_first_event():
account, jid, event = app.events.get_first_systray_event()
if not event:
return
win = app.interface.roster.window
if not win.get_property('visible'):
# Needed if we are in one window mode
restore_roster_position(win)
app.interface.handle_event(account, jid, event.type_)
@staticmethod
def _on_middle_click():
"""
Middle click raises window to have complete focus (fe. get kbd events)
but if already raised, it hides it
"""
win = app.interface.roster.window
if win.is_active():
win.hide()
else:
win.present()
@staticmethod
def _on_show(_widget, show):
app.interface.change_status(status=show)
@staticmethod
def _on_change_status(_widget):
app.interface.change_status()

View file

@ -0,0 +1,129 @@
# 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/>.
from gi.repository import Gtk
from gi.repository import Gdk
from gajim import vcard
from gajim.common import app
from gajim.common.i18n import _
from .add_contact import AddNewContactWindow
from .util import get_builder
class SubscriptionRequest(Gtk.ApplicationWindow):
def __init__(self, account, jid, text, user_nick=None):
Gtk.ApplicationWindow.__init__(self)
self.set_name('SubscriptionRequest')
self.set_application(app.app)
self.set_show_menubar(False)
self.set_resizable(False)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_title(_('Subscription Request'))
self.jid = jid
self.account = account
self.user_nick = user_nick
self._ui = get_builder('subscription_request_window.ui')
self.add(self._ui.subscription_box)
self._ui.authorize_button.grab_default()
if len(app.connections) >= 2:
prompt_text = _(
'Subscription request for account %(account)s from '
'%(jid)s') % {'account': self.account, 'jid': self.jid}
else:
prompt_text = _('Subscription request from %s') % self.jid
self._ui.request_label.set_text(prompt_text)
self._ui.subscription_text.set_text(text)
con = app.connections[self.account]
if con.get_module('Blocking').supported:
self._ui.block_button.set_sensitive(True)
self._ui.report_button.set_sensitive(True)
self.connect('key-press-event', self._on_key_press)
self._ui.connect_signals(self)
self.show_all()
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
def _on_authorize_clicked(self, _widget):
"""
Accept the request
"""
con = app.connections[self.account]
con.get_module('Presence').subscribed(self.jid)
self.destroy()
contact = app.contacts.get_contact(self.account, self.jid)
if not contact or _('Not in contact list') in contact.groups:
AddNewContactWindow(self.account, self.jid, self.user_nick)
def _on_contact_info_clicked(self, _widget):
"""
Ask for vCard
"""
open_windows = app.interface.instances[self.account]['infos']
if self.jid in open_windows:
open_windows[self.jid].window.present()
else:
contact = app.contacts.create_contact(jid=self.jid,
account=self.account)
app.interface.instances[self.account]['infos'][self.jid] = \
vcard.VcardWindow(contact, self.account)
# Remove xmpp page
app.interface.instances[self.account]['infos'][self.jid].xml.\
get_object('information_notebook').remove_page(0)
def _on_start_chat_clicked(self, _widget):
"""
Open chat
"""
app.interface.new_chat_from_jid(self.account, self.jid)
def _on_deny_clicked(self, _widget):
self._deny_request()
self._remove_contact()
def _on_block_clicked(self, _widget):
app.events.remove_events(self.account, self.jid)
self._deny_request()
con = app.connections[self.account]
con.get_module('Blocking').block([self.jid])
self._remove_contact()
def _on_report_clicked(self, _widget):
app.events.remove_events(self.account, self.jid)
self._deny_request()
con = app.connections[self.account]
con.get_module('Blocking').block([self.jid], report='spam')
self._remove_contact()
def _deny_request(self):
con = app.connections[self.account]
con.get_module('Presence').unsubscribed(self.jid)
def _remove_contact(self):
contact = app.contacts.get_contact(self.account, self.jid)
if contact and _('Not in contact list') in contact.get_shown_groups():
app.interface.roster.remove_contact(
self.jid, self.account, force=True, backend=True)
self.destroy()

456
gajim/gtk/themes.py Normal file
View file

@ -0,0 +1,456 @@
# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com>
#
# 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/>.
from collections import namedtuple
from enum import IntEnum
from gi.repository import Gtk
from gi.repository import Gdk
from gajim.common import app
from gajim.common.nec import NetworkEvent
from gajim.common.i18n import _
from gajim.common.const import StyleAttr
from .dialogs import ErrorDialog
from .dialogs import DialogButton
from .dialogs import ConfirmationDialog
from .util import get_builder
from .util import get_app_window
StyleOption = namedtuple('StyleOption', 'label selector attr')
CSS_STYLE_OPTIONS = [
StyleOption(_('Chatstate Composing'),
'.gajim-state-composing',
StyleAttr.COLOR),
StyleOption(_('Chatstate Inactive'),
'.gajim-state-inactive',
StyleAttr.COLOR),
StyleOption(_('Chatstate Gone'),
'.gajim-state-gone',
StyleAttr.COLOR),
StyleOption(_('Chatstate Paused'),
'.gajim-state-paused',
StyleAttr.COLOR),
StyleOption(_('Group Chat Tab New Directed Message'),
'.gajim-state-tab-muc-directed-msg',
StyleAttr.COLOR),
StyleOption(_('Group Chat Tab New Message'),
'.gajim-state-tab-muc-msg',
StyleAttr.COLOR),
StyleOption(_('Banner Foreground Color'),
'.gajim-banner',
StyleAttr.COLOR),
StyleOption(_('Banner Background Color'),
'.gajim-banner',
StyleAttr.BACKGROUND),
StyleOption(_('Banner Font'),
'.gajim-banner',
StyleAttr.FONT),
StyleOption(_('Account Row Foreground Color'),
'.gajim-account-row',
StyleAttr.COLOR),
StyleOption(_('Account Row Background Color'),
'.gajim-account-row',
StyleAttr.BACKGROUND),
StyleOption(_('Account Row Font'),
'.gajim-account-row',
StyleAttr.FONT),
StyleOption(_('Group Row Foreground Color'),
'.gajim-group-row',
StyleAttr.COLOR),
StyleOption(_('Group Row Background Color'),
'.gajim-group-row',
StyleAttr.BACKGROUND),
StyleOption(_('Group Row Font'),
'.gajim-group-row',
StyleAttr.FONT),
StyleOption(_('Contact Row Foreground Color'),
'.gajim-contact-row',
StyleAttr.COLOR),
StyleOption(_('Contact Row Background Color'),
'.gajim-contact-row',
StyleAttr.BACKGROUND),
StyleOption(_('Contact Row Font'),
'.gajim-contact-row',
StyleAttr.FONT),
StyleOption(_('Conversation Font'),
'.gajim-conversation-font',
StyleAttr.FONT),
StyleOption(_('Incoming Nickname Color'),
'.gajim-incoming-nickname',
StyleAttr.COLOR),
StyleOption(_('Outgoing Nickname Color'),
'.gajim-outgoing-nickname',
StyleAttr.COLOR),
StyleOption(_('Incoming Message Text Color'),
'.gajim-incoming-message-text',
StyleAttr.COLOR),
StyleOption(_('Incoming Message Text Font'),
'.gajim-incoming-message-text',
StyleAttr.FONT),
StyleOption(_('Outgoing Message Text Color'),
'.gajim-outgoing-message-text',
StyleAttr.COLOR),
StyleOption(_('Outgoing Message Text Font'),
'.gajim-outgoing-message-text',
StyleAttr.FONT),
StyleOption(_('Status Message Color'),
'.gajim-status-message',
StyleAttr.COLOR),
StyleOption(_('Status Message Font'),
'.gajim-status-message',
StyleAttr.FONT),
StyleOption(_('URL Color'),
'.gajim-url',
StyleAttr.COLOR),
StyleOption(_('Highlight Message Color'),
'.gajim-highlight-message',
StyleAttr.COLOR),
StyleOption(_('Message Correcting'),
'.gajim-msg-correcting text',
StyleAttr.BACKGROUND),
StyleOption(_('Contact Disconnected Background'),
'.gajim-roster-disconnected',
StyleAttr.BACKGROUND),
StyleOption(_('Contact Connected Background '),
'.gajim-roster-connected',
StyleAttr.BACKGROUND),
StyleOption(_('Status Online Color'),
'.gajim-status-online',
StyleAttr.COLOR),
StyleOption(_('Status Away Color'),
'.gajim-status-away',
StyleAttr.COLOR),
StyleOption(_('Status DND Color'),
'.gajim-status-dnd',
StyleAttr.COLOR),
StyleOption(_('Status Offline Color'),
'.gajim-status-offline',
StyleAttr.COLOR),
]
class Column(IntEnum):
THEME = 0
class Themes(Gtk.ApplicationWindow):
def __init__(self, transient):
Gtk.ApplicationWindow.__init__(self)
self.set_application(app.app)
self.set_title(_('Gajim Themes'))
self.set_name('ThemesWindow')
self.set_show_menubar(False)
self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
self.set_transient_for(transient)
self.set_resizable(True)
self.set_default_size(600, 400)
self.set_modal(True)
self._ui = get_builder('themes_window.ui')
self.add(self._ui.theme_grid)
self._get_themes()
self._ui.option_listbox.set_placeholder(self._ui.placeholder)
self._ui.connect_signals(self)
self.connect_after('key-press-event', self._on_key_press)
self.show_all()
self._fill_choose_listbox()
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
def _get_themes(self):
current_theme = app.settings.get('roster_theme')
for theme in app.css_config.themes:
if theme == current_theme:
self._ui.theme_store.prepend([theme])
continue
self._ui.theme_store.append([theme])
def _on_theme_name_edit(self, _renderer, path, new_name):
iter_ = self._ui.theme_store.get_iter(path)
old_name = self._ui.theme_store[iter_][Column.THEME]
if new_name == 'default':
ErrorDialog(
_('Invalid Name'),
_('Name <b>default</b> is not allowed'),
transient_for=self)
return
if ' ' in new_name:
ErrorDialog(
_('Invalid Name'),
_('Spaces are not allowed'),
transient_for=self)
return
if new_name == '':
return
result = app.css_config.rename_theme(old_name, new_name)
if result is False:
return
app.settings.set('roster_theme', new_name)
self._ui.theme_store.set_value(iter_, Column.THEME, new_name)
def _select_theme_row(self, iter_):
self._ui.theme_treeview.get_selection().select_iter(iter_)
def _on_theme_selected(self, tree_selection):
store, iter_ = tree_selection.get_selected()
if iter_ is None:
self._clear_options()
return
theme = store[iter_][Column.THEME]
app.css_config.change_preload_theme(theme)
self._ui.remove_theme_button.set_sensitive(True)
self._load_options()
self._apply_theme(theme)
app.nec.push_incoming_event(NetworkEvent('style-changed'))
def _load_options(self):
self._ui.option_listbox.foreach(self._remove_option)
for option in CSS_STYLE_OPTIONS:
value = app.css_config.get_value(
option.selector, option.attr, pre=True)
if value is None:
continue
row = Option(option, value)
self._ui.option_listbox.add(row)
def _add_option(self, _listbox, row):
# Add theme if there is none
store, _ = self._ui.theme_treeview.get_selection().get_selected()
first = store.get_iter_first()
if first is None:
self._on_add_new_theme()
# Don't add an option twice
for option in self._ui.option_listbox.get_children():
if option == row:
return
# Get default value if it exists
value = app.css_config.get_value(
row.option.selector, row.option.attr)
row = Option(row.option, value)
self._ui.option_listbox.add(row)
self._ui.option_popover.popdown()
def _clear_options(self):
self._ui.option_listbox.foreach(self._remove_option)
def _fill_choose_listbox(self):
for option in CSS_STYLE_OPTIONS:
self._ui.choose_option_listbox.add(ChooseOption(option))
def _remove_option(self, row):
self._ui.option_listbox.remove(row)
row.destroy()
def _on_add_new_theme(self, *args):
name = self._create_theme_name()
if not app.css_config.add_new_theme(name):
return
self._update_preferences_window()
self._ui.remove_theme_button.set_sensitive(True)
iter_ = self._ui.theme_store.append([name])
self._select_theme_row(iter_)
self._apply_theme(name)
@staticmethod
def _apply_theme(theme):
app.settings.set('roster_theme', theme)
app.css_config.change_theme(theme)
app.nec.push_incoming_event(NetworkEvent('theme-update'))
# Begin repainting themed widgets throughout
app.interface.roster.repaint_themed_widgets()
app.interface.roster.change_roster_style(None)
@staticmethod
def _update_preferences_window():
window = get_app_window('Preferences')
if window is not None:
window.update_theme_list()
@staticmethod
def _create_theme_name():
i = 0
while 'newtheme%s' % i in app.css_config.themes:
i += 1
return 'newtheme%s' % i
def _on_remove_theme(self, *args):
store, iter_ = self._ui.theme_treeview.get_selection().get_selected()
if iter_ is None:
return
theme = store[iter_][Column.THEME]
def _remove_theme():
if theme == app.settings.get('roster_theme'):
self._apply_theme('default')
app.nec.push_incoming_event(NetworkEvent('style-changed'))
app.css_config.remove_theme(theme)
self._update_preferences_window()
store.remove(iter_)
first = store.get_iter_first()
if first is None:
self._ui.remove_theme_button.set_sensitive(False)
self._clear_options()
text = _('Do you want to delete this theme?')
if theme == app.settings.get('roster_theme'):
text = _('This is the theme you are currently using.\n'
'Do you want to delete this theme?')
ConfirmationDialog(
_('Delete'),
_('Delete Theme'),
text,
[DialogButton.make('Cancel'),
DialogButton.make('Delete',
callback=_remove_theme)],
transient_for=self).show()
class Option(Gtk.ListBoxRow):
def __init__(self, option, value):
Gtk.ListBoxRow.__init__(self)
self.option = option
self._box = Gtk.Box(spacing=12)
label = Gtk.Label()
label.set_text(option.label)
label.set_hexpand(True)
label.set_halign(Gtk.Align.START)
self._box.add(label)
if option.attr in (StyleAttr.COLOR, StyleAttr.BACKGROUND):
self._init_color(value)
elif option.attr == StyleAttr.FONT:
self._init_font(value)
remove_button = Gtk.Button.new_from_icon_name(
'list-remove-symbolic', Gtk.IconSize.MENU)
remove_button.set_tooltip_text(_('Remove Setting'))
remove_button.get_style_context().add_class('theme_remove_button')
remove_button.connect('clicked', self._on_remove)
self._box.add(remove_button)
self.add(self._box)
self.show_all()
def _init_color(self, color):
color_button = Gtk.ColorButton()
if color is not None:
rgba = Gdk.RGBA()
rgba.parse(color)
color_button.set_rgba(rgba)
color_button.set_halign(Gtk.Align.END)
color_button.connect('color-set', self._on_color_set)
self._box.add(color_button)
def _init_font(self, desc):
font_button = Gtk.FontButton()
if desc is not None:
font_button.set_font_desc(desc)
font_button.set_halign(Gtk.Align.END)
font_button.connect('font-set', self._on_font_set)
self._box.add(font_button)
def _on_color_set(self, color_button):
color = color_button.get_rgba()
color_string = color.to_string()
app.css_config.set_value(
self.option.selector, self.option.attr, color_string, pre=True)
app.nec.push_incoming_event(NetworkEvent('style-changed'))
def _on_font_set(self, font_button):
desc = font_button.get_font_desc()
app.css_config.set_font(self.option.selector, desc, pre=True)
app.nec.push_incoming_event(NetworkEvent('style-changed'))
def _on_remove(self, *args):
self.get_parent().remove(self)
app.css_config.remove_value(
self.option.selector, self.option.attr, pre=True)
app.nec.push_incoming_event(NetworkEvent('style-changed'))
self.destroy()
def __eq__(self, other):
if isinstance(other, ChooseOption):
return other.option == self.option
return other.option == self.option
class ChooseOption(Gtk.ListBoxRow):
def __init__(self, option):
Gtk.ListBoxRow.__init__(self)
self.option = option
label = Gtk.Label(label=option.label)
label.set_xalign(0)
self.add(label)
self.show_all()

602
gajim/gtk/tooltips.py Normal file
View file

@ -0,0 +1,602 @@
# Copyright (C) 2005 Alex Mauer <hawke AT hawkesnest.net>
# Stéphan Kochen <stephan AT kochen.nl>
# Copyright (C) 2005-2006 Dimitur Kirov <dkirov AT gmail.com>
# Copyright (C) 2005-2007 Nikos Kouremenos <kourem AT gmail.com>
# Copyright (C) 2005-2014 Yann Leboulanger <asterix AT lagaule.org>
# Copyright (C) 2006 Travis Shirk <travis AT pobox.com>
# Stefan Bethge <stefan AT lanpartei.de>
# Copyright (C) 2006-2007 Jean-Marie Traissard <jim AT lapin.org>
# Copyright (C) 2007 Julien Pivotto <roidelapluie AT gmail.com>
# Copyright (C) 2007-2008 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
import logging
from datetime import datetime
from gi.repository import Gtk
from gi.repository import GLib
from gi.repository import Pango
from gajim.common import app
from gajim.common import helpers
from gajim.common.helpers import get_connection_status
from gajim.common.const import AvatarSize
from gajim.common.const import PEPEventType
from gajim.common.i18n import Q_
from gajim.common.i18n import _
from gajim.gtkgui_helpers import add_css_class
from .util import get_builder
from .util import get_icon_name
from .util import format_mood
from .util import format_activity
from .util import format_tune
from .util import format_location
from .util import get_css_show_class
log = logging.getLogger('gajim.gui.tooltips')
class StatusTable:
"""
Contains methods for creating status table. This is used in Roster and
NotificationArea tooltips
"""
def __init__(self):
self.current_row = 0
self.table = None
self.text_label = None
self.spacer_label = ' '
def create_table(self):
self.table = Gtk.Grid()
self.table.insert_column(0)
self.table.set_property('column-spacing', 3)
def add_text_row(self, text, col_inc=0):
self.table.insert_row(self.current_row)
self.text_label = Gtk.Label()
self.text_label.set_line_wrap(True)
self.text_label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
self.text_label.set_lines(3)
self.text_label.set_ellipsize(Pango.EllipsizeMode.END)
self.text_label.set_max_width_chars(30)
self.text_label.set_halign(Gtk.Align.START)
self.text_label.set_valign(Gtk.Align.START)
self.text_label.set_xalign(0)
self.text_label.set_selectable(False)
self.text_label.set_text(text)
self.table.attach(self.text_label, 1 + col_inc,
self.current_row,
3 - col_inc,
1)
self.current_row += 1
def add_status_row(self, show, str_status, indent=True, transport=None):
"""
Append a new row with status icon to the table
"""
self.table.insert_row(self.current_row)
image = Gtk.Image()
icon_name = get_icon_name(show, transport=transport)
image.set_from_icon_name(icon_name, Gtk.IconSize.MENU)
spacer = Gtk.Label(label=self.spacer_label)
image.set_halign(Gtk.Align.START)
image.set_valign(Gtk.Align.CENTER)
if indent:
self.table.attach(spacer, 1, self.current_row, 1, 1)
self.table.attach(image, 2, self.current_row, 1, 1)
status_label = Gtk.Label()
status_label.set_text(str_status)
status_label.set_halign(Gtk.Align.START)
status_label.set_valign(Gtk.Align.START)
status_label.set_xalign(0)
status_label.set_line_wrap(True)
status_label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
status_label.set_lines(3)
status_label.set_ellipsize(Pango.EllipsizeMode.END)
status_label.set_max_width_chars(30)
self.table.attach(status_label, 3, self.current_row, 1, 1)
self.current_row += 1
def fill_table_with_accounts(self, accounts):
for acct in accounts:
message = acct['message']
message = helpers.reduce_chars_newlines(message, 100, 1)
message = GLib.markup_escape_text(message)
account_label = GLib.markup_escape_text(acct['account_label'])
if message:
status = '%s - %s' % (account_label, message)
else:
status = account_label
self.add_status_row(acct['show'], status, indent=False)
for line in acct['event_lines']:
self.add_text_row(' ' + line, 1)
class NotificationAreaTooltip(StatusTable):
"""
Tooltip that is shown in the notification area
"""
def __init__(self):
StatusTable.__init__(self)
def get_tooltip(self):
self.create_table()
accounts = helpers.get_notification_icon_tooltip_dict()
self.fill_table_with_accounts(accounts)
self.table.set_property('column-spacing', 1)
hbox = Gtk.HBox()
hbox.add(self.table)
hbox.show_all()
return hbox
class GCTooltip():
def __init__(self):
self.contact = None
self._ui = get_builder('tooltip_gc_contact.ui')
def clear_tooltip(self):
self.contact = None
def get_tooltip(self, contact):
if self.contact == contact:
return True, self._ui.tooltip_grid
self._populate_grid(contact)
self.contact = contact
return False, self._ui.tooltip_grid
def _hide_grid_childs(self):
"""
Hide all Elements of the Tooltip Grid
"""
for child in self._ui.tooltip_grid.get_children():
child.hide()
def _populate_grid(self, contact):
"""
Populate the Tooltip Grid with data of from the contact
"""
self._hide_grid_childs()
self._ui.nick.set_text(contact.get_shown_name())
self._ui.nick.show()
# Status Message
if contact.status:
status = contact.status.strip()
if status != '':
self._ui.status.set_text(status)
self._ui.status.show()
# Status
show = helpers.get_uf_show(contact.show.value)
self._ui.user_show.set_text(show)
colorize_status(self._ui.user_show, contact.show.value)
self._ui.user_show.show()
# JID
if contact.jid is not None:
self._ui.jid.set_text(str(contact.jid))
self._ui.jid.show()
# Affiliation
if not contact.affiliation.is_none:
uf_affiliation = helpers.get_uf_affiliation(contact.affiliation)
uf_affiliation = \
_('%(owner_or_admin_or_member)s of this group chat') \
% {'owner_or_admin_or_member': uf_affiliation}
self._ui.affiliation.set_text(uf_affiliation)
self._ui.affiliation.show()
# Avatar
if contact.avatar_sha is not None:
app.log('avatar').debug(
'Load GCTooltip: %s %s', contact.name, contact.avatar_sha)
scale = self._ui.tooltip_grid.get_scale_factor()
surface = app.interface.get_avatar(
contact, AvatarSize.TOOLTIP, scale)
self._ui.avatar.set_from_surface(surface)
self._ui.avatar.show()
self._ui.fillelement.show()
app.plugin_manager.gui_extension_point(
'gc_tooltip_populate', self, contact, self._ui.tooltip_grid)
def destroy(self):
self._ui.tooltip_grid.destroy()
class RosterTooltip(StatusTable):
def __init__(self):
StatusTable.__init__(self)
self.create_table()
self.account = None
self.row = None
self.contact_jid = None
self.prim_contact = None
self.last_widget = None
self.num_resources = 0
self._ui = get_builder('tooltip_roster_contact.ui')
def clear_tooltip(self):
"""
Hide all Elements of the Tooltip Grid
"""
for child in self._ui.tooltip_grid.get_children():
child.hide()
status_table = self._ui.tooltip_grid.get_child_at(1, 3)
if status_table:
status_table.destroy()
self.create_table()
self.row = None
def get_tooltip(self, row, connected_contacts, account, typ):
if self.row == row:
return True, self._ui.tooltip_grid
self._populate_grid(connected_contacts, account, typ)
self.row = row
return False, self._ui.tooltip_grid
def _populate_grid(self, contacts, account, typ):
"""
Populate the Tooltip Grid with data of from the contact
"""
self.current_row = 0
self.account = account
if self.last_widget:
self.last_widget.set_vexpand(False)
self.clear_tooltip()
if account == 'all':
# Tooltip for merged accounts row
self._show_merged_account_tooltip()
return
if typ == 'account':
jid = app.get_jid_from_account(account)
contacts = []
connection = app.connections[account]
# get our current contact info
nbr_on, nbr_total = app.\
contacts.get_nb_online_total_contacts(accounts=[account])
account_name = app.get_account_label(account)
if app.account_is_available(account):
account_name += ' (%s/%s)' % (repr(nbr_on), repr(nbr_total))
contact = app.contacts.create_self_contact(
jid=jid,
account=account,
name=account_name,
show=get_connection_status(account),
status=connection.status_message,
resource=connection.get_own_jid().resource,
priority=connection.priority)
contacts.append(contact)
# Username/Account/Groupchat
self.prim_contact = app.contacts.get_highest_prio_contact_from_contacts(
contacts)
if self.prim_contact is None:
log.error('No contact for Roster tooltip found')
log.error('contacts: %s, typ: %s, account: %s',
contacts, typ, account)
return
self.contact_jid = self.prim_contact.jid
name = GLib.markup_escape_text(self.prim_contact.get_shown_name())
if app.settings.get('mergeaccounts'):
name = GLib.markup_escape_text(
self.prim_contact.account.name)
self._ui.name.set_markup(name)
self._ui.name.show()
self.num_resources = 0
# put contacts in dict, where key is priority
contacts_dict = {}
for contact in contacts:
if contact.resource:
self.num_resources += 1
priority = int(contact.priority)
if priority in contacts_dict:
contacts_dict[priority].append(contact)
else:
contacts_dict[priority] = [contact]
if self.num_resources > 1:
transport = app.get_transport_name_from_jid(self.prim_contact.jid)
if transport == 'jabber':
transport = None
contact_keys = sorted(contacts_dict.keys())
contact_keys.reverse()
for priority in contact_keys:
for acontact in contacts_dict[priority]:
show = self._get_icon_name_for_tooltip(acontact)
status = acontact.status
resource_line = '%s (%s)' % (acontact.resource,
str(acontact.priority))
self.add_status_row(
show, resource_line, transport=transport)
if status:
self.add_text_row(status, 2)
self._ui.tooltip_grid.attach(self.table, 1, 3, 2, 1)
self.table.show_all()
else: # only one resource
if contact.is_groupchat:
disco_info = app.storage.cache.get_last_disco_info(contact.jid)
if disco_info is not None:
description = disco_info.muc_description
if description:
self._ui.status.set_text(description)
self._ui.status.show()
elif contact.show and contact.status:
status = contact.status.strip()
if status:
self._ui.status.set_text(status)
self._ui.status.show()
# PEP Info
self._append_pep_info(contact)
# JID
self._ui.jid.set_text(self.prim_contact.jid)
self._ui.jid.show()
# contact has only one resource
if self.num_resources == 1 and contact.resource:
res = GLib.markup_escape_text(contact.resource)
prio = str(contact.priority)
self._ui.resource.set_text("{} ({})".format(res, prio))
self._ui.resource.show()
self._ui.resource_label.show()
if self.prim_contact.jid not in app.gc_connected[account]:
if (account and
self.prim_contact.sub and
self.prim_contact.sub != 'both'):
# ('both' is the normal sub so we don't show it)
self._ui.sub.set_text(helpers.get_uf_sub(self.prim_contact.sub))
self._ui.sub.show()
self._ui.sub_label.show()
self._set_idle_time(contact)
# Avatar
scale = self._ui.tooltip_grid.get_scale_factor()
surface = app.contacts.get_avatar(
account, self.prim_contact.jid, AvatarSize.TOOLTIP, scale)
self._ui.avatar.set_from_surface(surface)
self._ui.avatar.show()
app.plugin_manager.gui_extension_point(
'roster_tooltip_populate', self, contacts, self._ui.tooltip_grid)
# Sets the Widget that is at the bottom to expand.
# This is needed in case the Picture takes more Space than the Labels
i = 1
while i < 15:
if self._ui.tooltip_grid.get_child_at(1, i):
if self._ui.tooltip_grid.get_child_at(1, i).get_visible():
self.last_widget = self._ui.tooltip_grid.get_child_at(1, i)
i += 1
self.last_widget.set_vexpand(True)
def _show_merged_account_tooltip(self):
accounts = helpers.get_notification_icon_tooltip_dict()
self.spacer_label = ''
self.fill_table_with_accounts(accounts)
self._ui.tooltip_grid.attach(self.table, 1, 3, 2, 1)
self.table.show_all()
def _append_pep_info(self, contact):
"""
Append Tune, Mood, Activity, Location information of the
specified contact to the given property list.
"""
if PEPEventType.MOOD in contact.pep:
mood = format_mood(*contact.pep[PEPEventType.MOOD])
self._ui.mood.set_markup(mood)
self._ui.mood.show()
self._ui.mood_label.show()
if PEPEventType.ACTIVITY in contact.pep:
activity = format_activity(*contact.pep[PEPEventType.ACTIVITY])
self._ui.activity.set_markup(activity)
self._ui.activity.show()
self._ui.activity_label.show()
if PEPEventType.TUNE in contact.pep:
tune = format_tune(*contact.pep[PEPEventType.TUNE])
self._ui.tune.set_markup(tune)
self._ui.tune.show()
self._ui.tune_label.show()
if PEPEventType.LOCATION in contact.pep:
location = format_location(contact.pep[PEPEventType.LOCATION])
self._ui.location.set_markup(location)
self._ui.location.show()
self._ui.location_label.show()
def _set_idle_time(self, contact):
if contact.idle_time:
idle_time = contact.idle_time
idle_time = time.localtime(contact.idle_time)
idle_time = datetime(*(idle_time[:6]))
current = datetime.now()
if idle_time.date() == current.date():
formatted = idle_time.strftime('%X')
else:
formatted = idle_time.strftime('%c')
self._ui.idle_since.set_text(formatted)
self._ui.idle_since.show()
self._ui.idle_since_label.show()
if contact.show and self.num_resources < 2:
show = helpers.get_uf_show(contact.show)
# Contact is Groupchat
if (self.account and
self.prim_contact.jid in app.gc_connected[self.account]):
if app.gc_connected[self.account][self.prim_contact.jid]:
show = _('Connected')
else:
show = _('Disconnected')
colorize_status(self._ui.user_show, contact.show)
self._ui.user_show.set_text(show)
self._ui.user_show.show()
@staticmethod
def _get_icon_name_for_tooltip(contact):
"""
Helper function used for tooltip contacts/accounts
Tooltip on account has fake contact with sub == '', in this case we show
real status of the account
"""
if contact.ask == 'subscribe':
return 'requested'
if contact.sub in ('both', 'to', ''):
return contact.show
return 'not in roster'
class FileTransfersTooltip():
def __init__(self):
self.sid = None
self.widget = None
if app.settings.get('use_kib_mib'):
self.units = GLib.FormatSizeFlags.IEC_UNITS
else:
self.units = GLib.FormatSizeFlags.DEFAULT
def clear_tooltip(self):
self.sid = None
self.widget = None
def get_tooltip(self, file_props, sid):
if self.sid == sid:
return True, self.widget
self.widget = self._create_tooltip(file_props, sid)
self.sid = sid
return False, self.widget
def _create_tooltip(self, file_props, _sid):
ft_grid = Gtk.Grid.new()
ft_grid.insert_column(0)
ft_grid.set_row_spacing(6)
ft_grid.set_column_spacing(12)
current_row = 0
properties = []
name = file_props.name
if file_props.type_ == 'r':
file_name = os.path.split(file_props.file_name)[1]
else:
file_name = file_props.name
properties.append((_('File Name: '),
GLib.markup_escape_text(file_name)))
if file_props.type_ == 'r':
type_ = Q_('?Noun:Download')
actor = _('Sender: ')
sender = file_props.sender.split('/')[0]
name = app.contacts.get_first_contact_from_jid(
file_props.tt_account, sender).get_shown_name()
else:
type_ = Q_('?Noun:Upload')
actor = _('Recipient: ')
receiver = file_props.receiver
if hasattr(receiver, 'name'):
name = receiver.get_shown_name()
else:
name = receiver.split('/')[0]
properties.append((Q_('?transfer type:Type: '), type_))
properties.append((actor, GLib.markup_escape_text(name)))
transfered_len = file_props.received_len
if not transfered_len:
transfered_len = 0
properties.append((Q_('?transfer status:Transferred: '),
GLib.format_size_full(transfered_len, self.units)))
status = self._get_current_status(file_props)
properties.append((Q_('?transfer status:Status: '), status))
file_desc = file_props.desc or ''
properties.append((_('Description: '),
GLib.markup_escape_text(file_desc)))
while properties:
property_ = properties.pop(0)
label = Gtk.Label()
label.set_halign(Gtk.Align.END)
label.set_valign(Gtk.Align.CENTER)
label.set_markup(property_[0])
ft_grid.attach(label, 0, current_row, 1, 1)
label = Gtk.Label()
label.set_halign(Gtk.Align.START)
label.set_valign(Gtk.Align.START)
label.set_line_wrap(True)
label.set_markup(property_[1])
ft_grid.attach(label, 1, current_row, 1, 1)
current_row += 1
ft_grid.show_all()
return ft_grid
@staticmethod
def _get_current_status(file_props):
if file_props.stopped:
return Q_('?transfer status:Aborted')
if file_props.completed:
return Q_('?transfer status:Completed')
if file_props.paused:
return Q_('?transfer status:Paused')
if file_props.stalled:
# stalled is not paused. it is like 'frozen' it stopped alone
return Q_('?transfer status:Stalled')
if file_props.connected:
if file_props.started:
return Q_('?transfer status:Transferring')
return Q_('?transfer status:Not started')
return Q_('?transfer status:Not started')
def colorize_status(widget, show):
"""
Colorize the status message inside the tooltip by it's semantics.
"""
css_class = get_css_show_class(show)[14:]
add_css_class(widget, css_class, prefix='gajim-status-')

15
gajim/gtk/types.py Normal file
View file

@ -0,0 +1,15 @@
# 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/>.
# Types for typechecking

845
gajim/gtk/util.py Normal file
View file

@ -0,0 +1,845 @@
# Copyright (C) 2018 Marcin Mielniczuk <marmistrz.dev AT zoho.eu>
# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com>
#
# 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/>.
from typing import Any
from typing import List
from typing import Tuple
from typing import Optional
import sys
import weakref
import logging
import math
import textwrap
import functools
from importlib import import_module
import xml.etree.ElementTree as ET
from functools import wraps
from functools import lru_cache
try:
from PIL import Image
except Exception:
pass
from gi.repository import Gdk
from gi.repository import Gtk
from gi.repository import GLib
from gi.repository import Gio
from gi.repository import Pango
from gi.repository import GdkPixbuf
import nbxmpp
import cairo
from gajim.common import app
from gajim.common import configpaths
from gajim.common import i18n
from gajim.common.i18n import _
from gajim.common.helpers import URL_REGEX
from gajim.common.const import MOODS
from gajim.common.const import ACTIVITIES
from gajim.common.const import LOCATION_DATA
from gajim.common.const import Display
from gajim.common.const import StyleAttr
from gajim.common.nec import EventHelper as CommonEventHelper
from .const import GajimIconSet
from .const import WINDOW_MODULES
_icon_theme = Gtk.IconTheme.get_default()
if _icon_theme is not None:
_icon_theme.append_search_path(str(configpaths.get('ICONS')))
log = logging.getLogger('gajim.gui.util')
class NickCompletionGenerator:
def __init__(self, self_nick: str) -> None:
self.nick = self_nick
self.sender_list = [] # type: List[str]
self.attention_list = [] # type: List[str]
def change_nick(self, new_nick: str) -> None:
self.nick = new_nick
def record_message(self, contact: str, highlight: bool) -> None:
if contact == self.nick:
return
log.debug('Recorded a message from %s, highlight; %s', contact,
highlight)
if highlight:
try:
self.attention_list.remove(contact)
except ValueError:
pass
if len(self.attention_list) > 6:
self.attention_list.pop(0) # remove older
self.attention_list.append(contact)
# TODO implement it in a more efficient way
# Currently it's O(n*m + n*s), where n is the number of participants and
# m is the number of messages processed, s - the number of times the
# suggestions are requested
#
# A better way to do it would be to keep a dict: contact -> timestamp
# with expected O(1) insert, and sort it by timestamps in O(n log n)
# for each suggestion (currently generating the suggestions is O(n))
# this would give the expected complexity of O(m + s * n log n)
try:
self.sender_list.remove(contact)
except ValueError:
pass
self.sender_list.append(contact)
def contact_renamed(self, contact_old: str, contact_new: str) -> None:
log.debug('Contact %s renamed to %s', contact_old, contact_new)
for lst in (self.attention_list, self.sender_list):
for idx, contact in enumerate(lst):
if contact == contact_old:
lst[idx] = contact_new
def generate_suggestions(self, nicks: List[str],
beginning: str) -> List[str]:
"""
Generate the order of suggested MUC autocompletions
`nicks` is the list of contacts currently participating in a MUC
`beginning` is the text already typed by the user
"""
def nick_matching(nick: str) -> bool:
return nick != self.nick \
and nick.lower().startswith(beginning.lower())
if beginning == '':
# empty message, so just suggest recent mentions
potential_matches = self.attention_list
else:
# nick partially typed, try completing it
potential_matches = self.sender_list
potential_matches_set = set(potential_matches)
log.debug('Priority matches: %s', potential_matches_set)
matches = [n for n in potential_matches if nick_matching(n)]
# the most recent nick is the last one on the list
matches.reverse()
# handle people who have not posted/mentioned us
other_nicks = [
n for n in nicks
if nick_matching(n) and n not in potential_matches_set
]
other_nicks.sort(key=str.lower)
log.debug('Other matches: %s', other_nicks)
return matches + other_nicks
class Builder:
def __init__(self,
filename: str,
widgets: List[str] = None,
domain: str = None,
gettext_: Any = None) -> None:
self._builder = Gtk.Builder()
if domain is None:
domain = i18n.DOMAIN
self._builder.set_translation_domain(domain)
if gettext_ is None:
gettext_ = _
xml_text = self._load_string_from_filename(filename, gettext_)
if widgets is not None:
self._builder.add_objects_from_string(xml_text, widgets)
else:
self._builder.add_from_string(xml_text)
@staticmethod
@functools.lru_cache(maxsize=None)
def _load_string_from_filename(filename, gettext_):
file_path = str(configpaths.get('GUI') / filename)
if sys.platform == "win32":
# This is a workaround for non working translation on Windows
tree = ET.parse(file_path)
for node in tree.iter():
if 'translatable' in node.attrib and node.text is not None:
node.text = gettext_(node.text)
return ET.tostring(tree.getroot(),
encoding='unicode',
method='xml')
file = Gio.File.new_for_path(file_path)
content = file.load_contents(None)
return content[1].decode()
def __getattr__(self, name):
try:
return getattr(self._builder, name)
except AttributeError:
return self._builder.get_object(name)
def get_builder(file_name: str, widgets: List[str] = None) -> Builder:
return Builder(file_name, widgets)
def set_urgency_hint(window: Any, setting: bool) -> None:
if app.settings.get('use_urgency_hint'):
window.set_urgency_hint(setting)
def icon_exists(name: str) -> bool:
return _icon_theme.has_icon(name)
def load_icon(icon_name, widget=None, size=16, pixbuf=False,
scale=None, flags=Gtk.IconLookupFlags.FORCE_SIZE):
if widget is not None:
scale = widget.get_scale_factor()
if not scale:
log.warning('Could not determine scale factor')
scale = 1
try:
iconinfo = _icon_theme.lookup_icon_for_scale(
icon_name, size, scale, flags)
if iconinfo is None:
log.info('No icon found for %s', icon_name)
return
if pixbuf:
return iconinfo.load_icon()
return iconinfo.load_surface(None)
except GLib.GError as error:
log.error('Unable to load icon %s: %s', icon_name, str(error))
def get_app_icon_list(scale_widget):
pixbufs = []
for size in (16, 32, 48, 64, 128):
pixbuf = load_icon('org.gajim.Gajim', scale_widget, size, pixbuf=True)
if pixbuf is not None:
pixbufs.append(pixbuf)
return pixbufs
def get_icon_name(name: str,
iconset: Optional[str] = None,
transport: Optional[str] = None) -> str:
if name == 'not in roster':
name = 'notinroster'
if iconset is not None:
return '%s-%s' % (iconset, name)
if transport is not None:
return '%s-%s' % (transport, name)
iconset = app.settings.get('iconset')
if not iconset:
iconset = 'dcraven'
return '%s-%s' % (iconset, name)
def load_user_iconsets():
iconsets_path = configpaths.get('MY_ICONSETS')
if not iconsets_path.exists():
return
for path in iconsets_path.iterdir():
if not path.is_dir():
continue
log.info('Found iconset: %s', path.stem)
_icon_theme.append_search_path(str(path))
def get_available_iconsets():
iconsets = []
for iconset in GajimIconSet:
iconsets.append(iconset.value)
iconsets_path = configpaths.get('MY_ICONSETS')
if not iconsets_path.exists():
return iconsets
for path in iconsets_path.iterdir():
if not path.is_dir():
continue
iconsets.append(path.stem)
return iconsets
def get_total_screen_geometry() -> Tuple[int, int]:
total_width = 0
total_height = 0
display = Gdk.Display.get_default()
monitors = display.get_n_monitors()
for num in range(0, monitors):
monitor = display.get_monitor(num)
geometry = monitor.get_geometry()
total_width += geometry.width
total_height = max(total_height, geometry.height)
log.debug('Get screen geometry: %s %s', total_width, total_height)
return total_width, total_height
def resize_window(window: Gtk.Window, width: int, height: int) -> None:
"""
Resize window, but also checks if huge window or negative values
"""
screen_w, screen_h = get_total_screen_geometry()
if not width or not height:
return
if width > screen_w:
width = screen_w
if height > screen_h:
height = screen_h
window.resize(abs(width), abs(height))
def move_window(window: Gtk.Window, pos_x: int, pos_y: int) -> None:
"""
Move the window, but also check if out of screen
"""
screen_w, screen_h = get_total_screen_geometry()
if pos_x < 0:
pos_x = 0
if pos_y < 0:
pos_y = 0
width, height = window.get_size()
if pos_x + width > screen_w:
pos_x = screen_w - width
if pos_y + height > screen_h:
pos_y = screen_h - height
window.move(pos_x, pos_y)
def restore_roster_position(window):
if not app.settings.get('save-roster-position'):
return
if app.is_display(Display.WAYLAND):
return
move_window(window,
app.settings.get('roster_x-position'),
app.settings.get('roster_y-position'))
def get_completion_liststore(entry: Gtk.Entry) -> Gtk.ListStore:
"""
Create a completion model for entry widget completion list consists of
(Pixbuf, Text) rows
"""
completion = Gtk.EntryCompletion()
liststore = Gtk.ListStore(str, str)
render_pixbuf = Gtk.CellRendererPixbuf()
completion.pack_start(render_pixbuf, False)
completion.add_attribute(render_pixbuf, 'icon_name', 0)
render_text = Gtk.CellRendererText()
completion.pack_start(render_text, True)
completion.add_attribute(render_text, 'text', 1)
completion.set_property('text_column', 1)
completion.set_model(liststore)
entry.set_completion(completion)
return liststore
def get_cursor(name: str) -> Gdk.Cursor:
display = Gdk.Display.get_default()
cursor = Gdk.Cursor.new_from_name(display, name)
if cursor is not None:
return cursor
return Gdk.Cursor.new_from_name(display, 'default')
def scroll_to_end(widget: Gtk.ScrolledWindow) -> bool:
"""Scrolls to the end of a GtkScrolledWindow.
Args:
widget (GtkScrolledWindow)
Returns:
bool: The return value is False so it can be used with GLib.idle_add.
"""
adj_v = widget.get_vadjustment()
if adj_v is None:
# This can happen when the Widget is already destroyed when called
# from GLib.idle_add
return False
max_scroll_pos = adj_v.get_upper() - adj_v.get_page_size()
adj_v.set_value(max_scroll_pos)
adj_h = widget.get_hadjustment()
adj_h.set_value(0)
return False
def at_the_end(widget: Gtk.ScrolledWindow) -> bool:
"""Determines if a Scrollbar in a GtkScrolledWindow is at the end.
Args:
widget (GtkScrolledWindow)
Returns:
bool: The return value is True if at the end, False if not.
"""
adj_v = widget.get_vadjustment()
max_scroll_pos = adj_v.get_upper() - adj_v.get_page_size()
return adj_v.get_value() == max_scroll_pos
def get_image_button(icon_name, tooltip, toggle=False):
if toggle:
button = Gtk.ToggleButton()
image = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.MENU)
button.set_image(image)
else:
button = Gtk.Button.new_from_icon_name(icon_name, Gtk.IconSize.MENU)
button.set_tooltip_text(tooltip)
return button
def get_image_from_icon_name(icon_name: str, scale: int) -> Any:
icon = get_icon_name(icon_name)
surface = _icon_theme.load_surface(icon, 16, scale, None, 0)
return Gtk.Image.new_from_surface(surface)
def python_month(month: int) -> int:
return month + 1
def gtk_month(month: int) -> int:
return month - 1
def convert_rgb_to_hex(rgb_string: str) -> str:
rgb = Gdk.RGBA()
rgb.parse(rgb_string)
rgb.to_color()
red = int(rgb.red * 255)
green = int(rgb.green * 255)
blue = int(rgb.blue * 255)
return '#%02x%02x%02x' % (red, green, blue)
@lru_cache(maxsize=1024)
def convert_rgb_string_to_float(rgb_string: str) -> Tuple[float, float, float]:
rgba = Gdk.RGBA()
rgba.parse(rgb_string)
return (rgba.red, rgba.green, rgba.blue)
def get_monitor_scale_factor() -> int:
display = Gdk.Display.get_default()
monitor = display.get_primary_monitor()
if monitor is None:
log.warning('Could not determine scale factor')
return 1
return monitor.get_scale_factor()
def get_metacontact_surface(icon_name, expanded, scale):
icon_size = 16
state_surface = _icon_theme.load_surface(
icon_name, icon_size, scale, None, 0)
if 'event' in icon_name:
return state_surface
if expanded:
icon = get_icon_name('opened')
expanded_surface = _icon_theme.load_surface(
icon, icon_size, scale, None, 0)
else:
icon = get_icon_name('closed')
expanded_surface = _icon_theme.load_surface(
icon, icon_size, scale, None, 0)
ctx = cairo.Context(state_surface)
ctx.rectangle(0, 0, icon_size, icon_size)
ctx.set_source_surface(expanded_surface)
ctx.fill()
return state_surface
def get_show_in_roster(event, session=None):
"""
Return True if this event must be shown in roster, else False
"""
if event == 'gc_message_received':
return True
if event == 'message_received':
if session and session.control:
return False
return True
def get_show_in_systray(type_, account, jid):
"""
Return True if this event must be shown in systray, else False
"""
if type_ == 'printed_gc_msg':
contact = app.contacts.get_groupchat_contact(account, jid)
if contact is not None:
return contact.can_notify()
# it's not an highlighted message, don't show in systray
return False
return app.settings.get('trayicon_notification_on_events')
def get_primary_accel_mod():
"""
Returns the primary Gdk.ModifierType modifier.
cmd on osx, ctrl everywhere else.
"""
return Gtk.accelerator_parse("<Primary>")[1]
def get_hardware_key_codes(keyval):
keymap = Gdk.Keymap.get_for_display(Gdk.Display.get_default())
valid, key_map_keys = keymap.get_entries_for_keyval(keyval)
if not valid:
return []
return [key.keycode for key in key_map_keys]
def ensure_not_destroyed(func):
@wraps(func)
def func_wrapper(self, *args, **kwargs):
if self._destroyed: # pylint: disable=protected-access
return None
return func(self, *args, **kwargs)
return func_wrapper
def format_mood(mood, text):
if mood is None:
return ''
mood = MOODS[mood]
markuptext = '<b>%s</b>' % GLib.markup_escape_text(mood)
if text is not None:
markuptext += ' (%s)' % GLib.markup_escape_text(text)
return markuptext
def get_account_mood_icon_name(account):
client = app.get_client(account)
mood = client.get_module('UserMood').get_current_mood()
return f'mood-{mood.mood}' if mood is not None else mood
def format_activity(activity, subactivity, text):
if subactivity in ACTIVITIES[activity]:
subactivity = ACTIVITIES[activity][subactivity]
activity = ACTIVITIES[activity]['category']
markuptext = '<b>' + GLib.markup_escape_text(activity)
if subactivity:
markuptext += ': ' + GLib.markup_escape_text(subactivity)
markuptext += '</b>'
if text:
markuptext += ' (%s)' % GLib.markup_escape_text(text)
return markuptext
def get_activity_icon_name(activity, subactivity=None):
icon_name = 'activity-%s' % activity.replace('_', '-')
if subactivity is not None:
icon_name += '-%s' % subactivity.replace('_', '-')
return icon_name
def get_account_activity_icon_name(account):
client = app.get_client(account)
activity = client.get_module('UserActivity').get_current_activity()
if activity is None:
return None
return get_activity_icon_name(activity.activity, activity.subactivity)
def format_tune(artist, _length, _rating, source, title, _track, _uri):
artist = GLib.markup_escape_text(artist or _('Unknown Artist'))
title = GLib.markup_escape_text(title or _('Unknown Title'))
source = GLib.markup_escape_text(source or _('Unknown Source'))
tune_string = _('<b>"%(title)s"</b> by <i>%(artist)s</i>\n'
'from <i>%(source)s</i>') % {'title': title,
'artist': artist,
'source': source}
return tune_string
def get_account_tune_icon_name(account):
client = app.get_client(account)
tune = client.get_module('UserTune').get_current_tune()
return None if tune is None else 'audio-x-generic'
def format_location(location):
location = location._asdict()
location_string = ''
for attr, value in location.items():
if value is None:
continue
text = GLib.markup_escape_text(value)
# Translate standard location tag
tag = LOCATION_DATA.get(attr)
if tag is None:
continue
location_string += '\n<b>%(tag)s</b>: %(text)s' % {
'tag': tag.capitalize(), 'text': text}
return location_string.strip()
def get_account_location_icon_name(account):
client = app.get_client(account)
location = client.get_module('UserLocation').get_current_location()
return None if location is None else 'applications-internet'
def format_fingerprint(fingerprint):
fplen = len(fingerprint)
wordsize = fplen // 8
buf = ''
for char in range(0, fplen, wordsize):
buf += '{0} '.format(fingerprint[char:char + wordsize])
buf = textwrap.fill(buf, width=36)
return buf.rstrip().upper()
def find_widget(name, container):
for child in container.get_children():
if Gtk.Buildable.get_name(child) == name:
return child
if isinstance(child, Gtk.Box):
return find_widget(name, child)
return None
class MultiLineLabel(Gtk.Label):
def __init__(self, *args, **kwargs):
Gtk.Label.__init__(self, *args, **kwargs)
self.set_line_wrap(True)
self.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
self.set_single_line_mode(False)
class MaxWidthComboBoxText(Gtk.ComboBoxText):
def __init__(self, *args, **kwargs):
Gtk.ComboBoxText.__init__(self, *args, **kwargs)
self._max_width = 100
text_renderer = self.get_cells()[0]
text_renderer.set_property('ellipsize', Pango.EllipsizeMode.END)
def set_max_size(self, size):
self._max_width = size
def do_get_preferred_width(self):
minimum_width, natural_width = Gtk.ComboBoxText.do_get_preferred_width(
self)
if natural_width > self._max_width:
natural_width = self._max_width
if minimum_width > self._max_width:
minimum_width = self._max_width
return minimum_width, natural_width
def text_to_color(text):
if app.css_config.prefer_dark:
background = (0, 0, 0) # RGB (0, 0, 0) black
else:
background = (1, 1, 1) # RGB (255, 255, 255) white
return nbxmpp.util.text_to_color(text, background)
def get_color_for_account(account: str) -> str:
col_r, col_g, col_b = text_to_color(account)
rgba = Gdk.RGBA(red=col_r, green=col_g, blue=col_b)
return rgba.to_string()
def generate_account_badge(account):
account_label = app.get_account_label(account)
badge = Gtk.Label(label=account_label)
badge.set_ellipsize(Pango.EllipsizeMode.END)
badge.set_max_width_chars(12)
badge.set_size_request(50, -1)
account_class = app.css_config.get_dynamic_class(account)
badge_context = badge.get_style_context()
badge_context.add_class(account_class)
badge_context.add_class('badge')
return badge
@lru_cache(maxsize=16)
def get_css_show_class(show):
if show in ('online', 'chat'):
return '.gajim-status-online'
if show == 'away':
return '.gajim-status-away'
if show in ('dnd', 'xa'):
return '.gajim-status-dnd'
# 'offline', 'not in roster', 'requested'
return '.gajim-status-offline'
def scale_with_ratio(size, width, height):
if height == width:
return size, size
if height > width:
ratio = height / float(width)
return int(size / ratio), size
ratio = width / float(height)
return size, int(size / ratio)
def load_pixbuf(path, size=None):
try:
if size is None:
return GdkPixbuf.Pixbuf.new_from_file(str(path))
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
str(path), size, size, True)
except GLib.GError:
try:
with open(path, 'rb') as im_handle:
img = Image.open(im_handle)
avatar = img.convert("RGBA")
except (NameError, OSError):
log.warning('Pillow convert failed: %s', path)
log.debug('Error', exc_info=True)
return None
array = GLib.Bytes.new(avatar.tobytes())
width, height = avatar.size
pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(
array, GdkPixbuf.Colorspace.RGB, True,
8, width, height, width * 4)
if size is not None:
width, height = scale_with_ratio(size, width, height)
return pixbuf.scale_simple(width,
height,
GdkPixbuf.InterpType.BILINEAR)
return pixbuf
except RuntimeError as error:
log.warning('Loading pixbuf failed: %s', error)
return None
def get_thumbnail_size(pixbuf, size):
# Calculates the new thumbnail size while preserving the aspect ratio
image_width = pixbuf.get_width()
image_height = pixbuf.get_height()
if image_width > image_height:
if image_width > size:
image_height = math.ceil(
(size / float(image_width) * image_height))
image_width = int(size)
else:
if image_height > size:
image_width = math.ceil(
(size / float(image_height) * image_width))
image_height = int(size)
return image_width, image_height
def make_href_markup(string):
url_color = app.css_config.get_value('.gajim-url', StyleAttr.COLOR)
color = convert_rgb_to_hex(url_color)
def _to_href(match):
url = match.group()
if '://' not in url:
url = 'https://' + url
return '<a href="%s"><span foreground="%s">%s</span></a>' % (
url, color, match.group())
return URL_REGEX.sub(_to_href, string)
def get_app_windows(account):
windows = []
for win in app.app.get_windows():
if hasattr(win, 'account'):
if win.account == account:
windows.append(win)
return windows
def get_app_window(name, account=None, jid=None):
for win in app.app.get_windows():
if type(win).__name__ != name:
continue
if account is not None:
if account != win.account:
continue
if jid is not None:
if jid != win.jid:
continue
return win
return None
def open_window(name, **kwargs):
window = get_app_window(name,
kwargs.get('account'),
kwargs.get('jid'))
if window is None:
module = import_module(WINDOW_MODULES[name])
window_cls = getattr(module, name)
window = window_cls(**kwargs)
else:
window.present()
return window
class EventHelper(CommonEventHelper):
def __init__(self):
CommonEventHelper.__init__(self)
self.connect('destroy', self.__on_destroy) # pylint: disable=no-member
def __on_destroy(self, *args):
self.unregister_events()
def check_destroy(widget):
def _destroy(*args):
print('DESTROYED', args)
widget.connect('destroy', _destroy)
def check_finalize(obj, name):
weakref.finalize(obj, print, f'{name} has been finalized')

778
gajim/gtk/vcard_grid.py Normal file
View file

@ -0,0 +1,778 @@
# 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 datetime
from gi.repository import Gdk
from gi.repository import GLib
from gi.repository import Gtk
from gi.repository import GObject
from gajim.common.i18n import _
from gajim.common.i18n import Q_
from gajim.common.const import URIType
from gajim.common.helpers import open_uri
from gajim.common.helpers import parse_uri
from gajim.common.structs import URI
from gajim.gui.util import gtk_month
from gajim.gui.util import python_month
LABEL_DICT = {
'fn': _('Full Name'),
'n': _('Name'),
'bday': _('Birthday'),
'gender': _('Gender'),
'adr': Q_('?profile:Address'),
'tel': _('Phone No.'),
'email': _('Email'),
'impp': _('IM Address'),
'title': Q_('?profile:Title'),
'role': Q_('?profile:Role'),
'org': _('Organisation'),
'note': Q_('?profile:Note'),
'url': _('URL'),
'key': Q_('?profile:Public Encryption Key'),
}
FIELD_TOOLTIPS = {
'key': _('Your public key or authentication certificate')
}
ADR_FIELDS = ['street', 'ext', 'pobox', 'code', 'locality', 'region', 'country']
ADR_PLACEHOLDER_TEXT = {
'pobox': _('Post Office Box'),
'street': _('Street'),
'ext': _('Extended Address'),
'locality': _('City'),
'region': _('State'),
'code': _('Postal Code'),
'country': _('Country'),
}
DEFAULT_KWARGS = {
'fn': {'value': ''},
'bday': {'value': '', 'value_type': 'date'},
'gender': {'sex': '', 'identity': ''},
'adr': {},
'email': {'value': ''},
'impp': {'value': ''},
'tel': {'value': '', 'value_type': 'text'},
'org': {'values': []},
'title': {'value': ''},
'role': {'value': ''},
'url': {'value': ''},
'key': {'value': '', 'value_type': 'text'},
'note': {'value': ''},
}
PROPERTIES_WITH_TYPE = [
'adr',
'email',
'impp',
'tel',
'key',
]
ORDER = [
'fn',
'gender',
'bday',
'adr',
'email',
'impp',
'tel',
'org',
'title',
'role',
'url',
'key',
'note',
]
SEX_VALUES = {
'M': _('Male'),
'F': _('Female'),
'O': Q_('?Gender:Other'),
'N': Q_('?Gender:None'),
'U': _('Unknown')
}
TYPE_VALUES = {
'-': None,
'home': _('Home'),
'work': _('Work')
}
class VCardGrid(Gtk.Grid):
def __init__(self, account):
Gtk.Grid.__init__(self)
self._callbacks = {
'fn': TextEntryProperty,
'bday': DateProperty,
'gender': GenderProperty,
'adr': AdrProperty,
'tel': TextEntryProperty,
'email': TextEntryProperty,
'impp': TextEntryProperty,
'title': TextEntryProperty,
'role': TextEntryProperty,
'org': TextEntryProperty,
'url': TextEntryProperty,
'key': KeyProperty,
'note': MultiLineProperty,
}
self.set_column_spacing(12)
self.set_row_spacing(12)
self.set_no_show_all(True)
self.set_visible(True)
self.set_halign(Gtk.Align.CENTER)
self._account = account
self._row_count = 0
self._vcard = None
self._props = []
def set_editable(self, enabled):
for prop in self._props:
prop.set_editable(enabled)
def set_vcard(self, vcard):
self.clear()
self._vcard = vcard
for entry in ORDER:
for prop in vcard.get_properties():
if entry != prop.name:
continue
self.add_property(prop)
def get_vcard(self):
return self._vcard
def validate(self):
for prop in list(self._props):
base_prop = prop.get_base_property()
if base_prop.is_empty:
self.remove_property(prop)
def add_new_property(self, name):
kwargs = DEFAULT_KWARGS[name]
prop = self._vcard.add_property(name, **kwargs)
self.add_property(prop, editable=True)
def add_property(self, prop, editable=False):
prop_class = self._callbacks.get(prop.name)
if prop_class is None:
return
prop_obj = prop_class(prop, self._account)
prop_obj.set_editable(editable)
prop_obj.add_to_grid(self, self._row_count)
self._props.append(prop_obj)
self._row_count += 1
def remove_property(self, prop):
self.remove_row(prop.row_number)
self._props.remove(prop)
self._vcard.remove_property(prop.get_base_property())
def clear(self):
self._vcard = None
self._row_count = 0
for prop in list(self._props):
self.remove_row(prop.row_number)
self._props = []
def sort(self):
self.set_vcard(self._vcard)
class DescriptionLabel(Gtk.Label):
def __init__(self, value):
Gtk.Label.__init__(self, label=LABEL_DICT[value])
if value == 'adr':
self.set_valign(Gtk.Align.START)
else:
self.set_valign(Gtk.Align.CENTER)
self.get_style_context().add_class('dim-label')
self.get_style_context().add_class('margin-right18')
self.set_visible(True)
self.set_xalign(1)
self.set_tooltip_text(FIELD_TOOLTIPS.get(value, ''))
class ValueLabel(Gtk.Label):
def __init__(self, prop, account):
Gtk.Label.__init__(self)
self._prop = prop
self._uri = None
self._account = account
self.set_selectable(True)
self.set_xalign(0)
self.set_max_width_chars(50)
self.set_valign(Gtk.Align.CENTER)
self.set_halign(Gtk.Align.START)
self.connect('activate-link', self._on_activate_link)
if prop.name == 'org':
self.set_value(prop.values[0] if prop.values else '')
else:
self.set_value(prop.value)
def set_value(self, value):
if self._prop.name == 'email':
self._uri = URI(type=URIType.MAIL, data=value)
self.set_markup(value)
elif self._prop.name in ('impp', 'tel'):
self._uri = parse_uri(value)
self.set_markup(value)
else:
self.set_text(value)
def set_markup(self, text):
if not text:
self.set_text('')
return
super().set_markup('<a href="{}">{}</a>'.format(
GLib.markup_escape_text(text),
GLib.markup_escape_text(text)))
def _on_activate_link(self, _label, _value):
open_uri(self._uri, self._account)
return Gdk.EVENT_STOP
class SexLabel(Gtk.Label):
def __init__(self, prop):
Gtk.Label.__init__(self)
self._prop = prop
self.set_selectable(True)
self.set_xalign(0)
self.set_max_width_chars(50)
self.set_valign(Gtk.Align.CENTER)
self.set_halign(Gtk.Align.START)
self.set_text(prop.sex)
def set_text(self, value):
if not value or value == '-':
super().set_text('')
else:
super().set_text(SEX_VALUES[value])
class IdentityLabel(Gtk.Label):
def __init__(self, prop):
Gtk.Label.__init__(self)
self._prop = prop
self.set_selectable(True)
self.set_xalign(0)
self.set_max_width_chars(50)
self.set_valign(Gtk.Align.CENTER)
self.set_halign(Gtk.Align.START)
self.set_text(prop.identity)
def set_text(self, value):
super().set_text('' if not value else value)
class ValueEntry(Gtk.Entry):
def __init__(self, prop):
Gtk.Entry.__init__(self)
self.set_valign(Gtk.Align.CENTER)
self.set_max_width_chars(50)
if prop.name == 'org':
self.set_text(prop.values[0] if prop.values else '')
else:
self.set_text(prop.value)
class AdrEntry(Gtk.Entry):
def __init__(self, prop, type_):
Gtk.Entry.__init__(self)
self.set_valign(Gtk.Align.CENTER)
self.set_max_width_chars(50)
values = getattr(prop, type_)
if not values:
value = ''
else:
value = values[0]
self.set_text(value)
self.set_placeholder_text(ADR_PLACEHOLDER_TEXT.get(type_))
class IdentityEntry(Gtk.Entry):
def __init__(self, prop):
Gtk.Entry.__init__(self)
self.set_valign(Gtk.Align.CENTER)
self.set_max_width_chars(50)
self.set_text('' if not prop.identity else prop.identity)
class AdrBox(Gtk.Box):
__gsignals__ = {
'field-changed': (
GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION,
None, # return value
(str, str) # arguments
)}
def __init__(self, prop):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL, spacing=6)
for field in ADR_FIELDS:
entry = AdrEntry(prop, field)
entry.connect('notify::text', self._on_text_changed, field)
self.add(entry)
self.show_all()
def _on_text_changed(self, entry, _param, field):
self.emit('field-changed', field, entry.get_text())
class AdrLabel(Gtk.Label):
def __init__(self, prop, type_):
Gtk.Label.__init__(self)
self.set_selectable(True)
self.set_xalign(0)
self.set_max_width_chars(50)
self.set_valign(Gtk.Align.CENTER)
self.set_halign(Gtk.Align.START)
values = getattr(prop, type_)
if not values:
value = ''
else:
value = values[0]
self.set_text(value)
def set_text(self, value):
self.set_visible(bool(value))
super().set_text(value)
class AdrBoxReadOnly(Gtk.Box):
def __init__(self, prop):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL, spacing=6)
self._labels = {}
for field in ADR_FIELDS:
label = AdrLabel(prop, field)
self._labels[field] = label
self.add(label)
def set_field(self, field, value):
self._labels[field].set_text(value)
class ValueTextView(Gtk.TextView):
def __init__(self, prop):
Gtk.TextView.__init__(self)
self.props.right_margin = 8
self.props.left_margin = 8
self.props.top_margin = 8
self.props.bottom_margin = 8
self.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
self.set_hexpand(True)
self.set_valign(Gtk.Align.FILL)
self._prop = prop
self.get_buffer().set_text(prop.value)
self.get_buffer().connect('notify::text', self._on_text_changed)
def get_text(self):
start_iter, end_iter = self.get_buffer().get_bounds()
return self.get_buffer().get_text(start_iter, end_iter, False)
def _on_text_changed(self, _buffer, _param):
self._prop.value = self.get_text()
class TypeComboBox(Gtk.ComboBoxText):
def __init__(self, parameters):
Gtk.ComboBoxText.__init__(self)
self.set_valign(Gtk.Align.CENTER)
self._parameters = parameters
self.append('-', '-')
self.append('home', _('Home'))
self.append('work', _('Work'))
values = self._parameters.get_types()
if 'home' in values:
self.set_active_id('home')
elif 'work' in values:
self.set_active_id('work')
else:
self.set_active_id('-')
self.connect('notify::active-id', self._on_active_id_changed)
def _on_active_id_changed(self, _combobox, _param):
type_ = self.get_active_id()
if type_ == '-':
self._parameters.remove_types(['work', 'home'])
elif type_ == 'work':
self._parameters.add_types(['work'])
self._parameters.remove_types(['home'])
elif type_ == 'home':
self._parameters.add_types(['home'])
self._parameters.remove_types(['work'])
def get_text(self):
type_value = self.get_active_id()
if type_value == '-':
return ''
return self.get_active_text()
class GenderComboBox(Gtk.ComboBoxText):
def __init__(self, prop):
Gtk.ComboBoxText.__init__(self)
self.set_valign(Gtk.Align.CENTER)
self.set_halign(Gtk.Align.START)
self._prop = prop
self.append('-', '-')
for key, value in SEX_VALUES.items():
self.append(key, value)
if not prop.sex:
self.set_active_id('-')
else:
self.set_active_id(prop.sex)
class RemoveButton(Gtk.Button):
def __init__(self):
Gtk.Button.__init__(self)
self.set_valign(Gtk.Align.CENTER)
self.set_halign(Gtk.Align.START)
image = Gtk.Image.new_from_icon_name('user-trash-symbolic',
Gtk.IconSize.MENU)
self.set_image(image)
self.set_no_show_all(True)
class VCardProperty:
def __init__(self, prop):
self._prop = prop
self._second_column = []
self._third_column = []
self._desc_label = DescriptionLabel(prop.name)
self._remove_button = RemoveButton()
self._remove_button.connect('clicked', self._on_remove_clicked)
self._edit_widgets = [self._remove_button]
self._read_widgets = []
if prop.name in PROPERTIES_WITH_TYPE:
self._type_combobox = TypeComboBox(prop.parameters)
self._type_combobox.connect('notify::active-id',
self._on_type_changed)
type_ = self._type_combobox.get_active_id()
icon_name = self._get_icon_name(type_)
self._type_image = Gtk.Image.new_from_icon_name(
icon_name, Gtk.IconSize.MENU)
self._type_image.set_tooltip_text(TYPE_VALUES[type_])
if prop.name == 'adr':
self._type_image.set_valign(Gtk.Align.START)
self._type_combobox.set_valign(Gtk.Align.START)
self._edit_widgets.append(self._type_combobox)
self._read_widgets.append(self._type_image)
self._second_column.extend([self._type_combobox, self._type_image])
@staticmethod
def _get_icon_name(type_):
if type_ == 'home':
return 'feather-home'
if type_ == 'work':
return 'feather-briefcase'
return None
def _on_type_changed(self, _combobox, _param):
type_ = self._type_combobox.get_active_id()
icon_name = self._get_icon_name(type_)
self._type_image.set_from_icon_name(icon_name, Gtk.IconSize.MENU)
self._type_image.set_tooltip_text(TYPE_VALUES[type_])
def _on_remove_clicked(self, button):
button.get_parent().remove_property(self)
@property
def row_number(self):
grid = self._desc_label.get_parent()
return grid.child_get_property(self._desc_label, 'top-attach')
def get_base_property(self):
return self._prop
def set_editable(self, enabled):
for widget in self._edit_widgets:
widget.set_visible(enabled)
for widget in self._read_widgets:
widget.set_visible(not enabled)
def add_to_grid(self, grid, row_number):
# child, left, top, width, height
grid.attach(self._desc_label, 0, row_number, 1, 1)
for widget in self._second_column:
grid.attach(widget, 1, row_number, 1, 1)
for widget in self._third_column:
grid.attach(widget, 2, row_number, 1, 1)
grid.attach(self._remove_button, 3, row_number, 1, 1)
class TextEntryProperty(VCardProperty):
def __init__(self, prop, account):
VCardProperty.__init__(self, prop)
self._value_entry = ValueEntry(prop)
self._value_entry.connect('notify::text', self._on_text_changed)
self._value_label = ValueLabel(prop, account)
self._edit_widgets.append(self._value_entry)
self._read_widgets.append(self._value_label)
self._third_column = [self._value_entry, self._value_label]
def _on_text_changed(self, entry, _param):
text = entry.get_text()
if self._prop.name == 'org':
self._prop.values = [text]
else:
self._prop.value = text
self._value_label.set_value(text)
class MultiLineProperty(VCardProperty):
def __init__(self, prop, _account):
VCardProperty.__init__(self, prop)
self._edit_text_view = ValueTextView(prop)
self._edit_text_view.show()
self._edit_scrolled = Gtk.ScrolledWindow()
self._edit_scrolled.set_policy(Gtk.PolicyType.NEVER,
Gtk.PolicyType.AUTOMATIC)
self._edit_scrolled.add(self._edit_text_view)
self._edit_scrolled.set_valign(Gtk.Align.CENTER)
self._edit_scrolled.set_size_request(350, 100)
self._edit_scrolled.get_style_context().add_class('profile-scrolled')
self._read_text_view = ValueTextView(prop)
self._read_text_view.set_sensitive(False)
self._read_text_view.set_left_margin(0)
self._read_text_view.show()
self._read_scrolled = Gtk.ScrolledWindow()
self._read_scrolled.set_policy(Gtk.PolicyType.NEVER,
Gtk.PolicyType.AUTOMATIC)
self._read_scrolled.add(self._read_text_view)
self._read_scrolled.set_valign(Gtk.Align.CENTER)
self._read_scrolled.set_size_request(350, 100)
self._read_scrolled.get_style_context().add_class(
'profile-scrolled-read')
self._edit_widgets.append(self._edit_scrolled)
self._read_widgets.append(self._read_scrolled)
self._third_column = [self._edit_scrolled, self._read_scrolled]
class DateProperty(VCardProperty):
def __init__(self, prop, account):
VCardProperty.__init__(self, prop)
self._box = Gtk.Box(spacing=6)
self._value_entry = ValueEntry(prop)
self._value_entry.set_placeholder_text(_('YYYY-MM-DD'))
self._value_entry.connect('notify::text', self._on_text_changed)
self._calendar_button = Gtk.MenuButton()
image = Gtk.Image.new_from_icon_name(
'x-office-calendar-symbolic', Gtk.IconSize.BUTTON)
self._calendar_button.set_image(image)
self._calendar_button.connect(
'clicked', self._on_calendar_button_clicked)
self._box.add(self._value_entry)
self._box.add(self._calendar_button)
self._box.show_all()
self.calendar = Gtk.Calendar(year=1980, month=5, day=15)
self.calendar.set_visible(True)
self.calendar.connect(
'day-selected', self._on_calendar_day_selected)
popover = Gtk.Popover()
popover.add(self.calendar)
self._calendar_button.set_popover(popover)
self._value_label = ValueLabel(prop, account)
self._edit_widgets.append(self._box)
self._read_widgets.append(self._value_label)
self._third_column = [self._box, self._value_label]
def _on_text_changed(self, entry, _param):
text = entry.get_text()
self._prop.value = text
self._value_label.set_value(text)
def _on_calendar_button_clicked(self, _widget):
birthday = self._value_entry.get_text()
if not birthday:
return
try:
date = datetime.datetime.strptime(birthday, '%Y-%m-%d')
except ValueError:
return
month = gtk_month(date.month)
self.calendar.select_month(month, date.year)
self.calendar.select_day(date.day)
def _on_calendar_day_selected(self, _widget):
year, month, day = self.calendar.get_date() # Integers
month = python_month(month)
date_str = datetime.date(year, month, day).strftime('%Y-%m-%d')
self._value_entry.set_text(date_str)
class KeyProperty(VCardProperty):
def __init__(self, prop, _account):
VCardProperty.__init__(self, prop)
self._value_text_view = ValueTextView(prop)
self._value_text_view.show()
self._scrolled_window = Gtk.ScrolledWindow()
self._scrolled_window.set_policy(Gtk.PolicyType.NEVER,
Gtk.PolicyType.AUTOMATIC)
self._scrolled_window.add(self._value_text_view)
self._scrolled_window.set_valign(Gtk.Align.CENTER)
self._scrolled_window.set_size_request(350, 200)
self._scrolled_window.get_style_context().add_class('profile-scrolled')
self._copy_button = Gtk.Button.new_from_icon_name('edit-copy-symbolic',
Gtk.IconSize.MENU)
self._copy_button.connect('clicked', self._on_copy_clicked)
self._copy_button.set_halign(Gtk.Align.START)
self._copy_button.set_valign(Gtk.Align.CENTER)
self._edit_widgets.append(self._scrolled_window)
self._read_widgets.append(self._copy_button)
self._third_column = [self._scrolled_window, self._copy_button]
def _on_copy_clicked(self, _button):
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.set_text(self._value_text_view.get_text(), -1)
class GenderProperty(VCardProperty):
def __init__(self, prop, _account):
VCardProperty.__init__(self, prop)
value_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
self._value_combobox = GenderComboBox(prop)
self._value_combobox.connect('notify::active-id',
self._on_active_id_changed)
self._value_combobox.show()
self._value_entry = IdentityEntry(prop)
self._value_entry.show()
self._value_entry.connect('notify::text', self._on_text_changed)
value_box.add(self._value_combobox)
value_box.add(self._value_entry)
label_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
self._identity_label = IdentityLabel(prop)
self._identity_label.show()
self._sex_label = SexLabel(prop)
self._sex_label.show()
label_box.add(self._sex_label)
label_box.add(self._identity_label)
self._edit_widgets.append(value_box)
self._read_widgets.append(label_box)
self._third_column = [value_box, label_box]
def _on_text_changed(self, entry, _param):
text = entry.get_text()
self._prop.identity = text
self._identity_label.set_text(text)
def _on_active_id_changed(self, combobox, _param):
sex = combobox.get_active_id()
self._prop.sex = None if sex == '-' else sex
self._sex_label.set_text(sex)
class AdrProperty(VCardProperty):
def __init__(self, prop, _account):
VCardProperty.__init__(self, prop)
self._entry_box = AdrBox(prop)
self._entry_box.connect('field-changed', self._on_field_changed)
self._read_box = AdrBoxReadOnly(prop)
self._edit_widgets.append(self._entry_box)
self._read_widgets.append(self._read_box)
self._third_column = [self._entry_box, self._read_box]
def _on_field_changed(self, _box, field, value):
setattr(self._prop, field, [value])
self._read_box.set_field(field, value)

133
gajim/gtk/video_preview.py Normal file
View file

@ -0,0 +1,133 @@
# 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 logging
from gi.repository import GLib
from gajim.common import app
from gajim.common.i18n import _
from . import gstreamer
from .util import get_builder
try:
from gi.repository import Gst # pylint: disable=ungrouped-imports
except Exception:
pass
log = logging.getLogger('gajim.gui.preview')
class VideoPreview:
def __init__(self):
self._ui = get_builder('video_preview.ui')
self._active = False
self._av_pipeline = None
self._av_src = None
self._av_sink = None
self._av_widget = None
@property
def widget(self):
return self._ui.video_preview_box
@property
def is_active(self):
return self._active
def toggle_preview(self, value):
self._active = value
if value:
return self._enable_preview()
return self._disable_preview()
def _enable_preview(self):
src_name = app.settings.get('video_input_device')
try:
self._av_src = Gst.parse_bin_from_description(src_name, True)
except GLib.Error as error:
log.error(error)
log.error('Failed to parse "%s" as Gstreamer element', src_name)
self._set_error_text()
return
sink, widget, name = gstreamer.create_gtk_widget()
if sink is None:
log.error('Failed to obtain a working Gstreamer GTK+ sink, '
'video support will be disabled')
self._set_error_text()
return
self._set_sink_text(name)
if self._av_pipeline is None:
self._av_pipeline = Gst.Pipeline.new('preferences-pipeline')
else:
self._av_pipeline.set_state(Gst.State.NULL)
self._av_pipeline.add(sink)
self._av_sink = sink
if self._av_widget is not None:
self._ui.video_preview_box.remove(self._av_widget)
self._ui.video_preview_placeholder.set_visible(False)
self._ui.video_preview_box.pack_end(widget, True, True, 0)
self._av_widget = widget
self._av_pipeline.add(self._av_src)
self._av_src.link(self._av_sink)
self._av_pipeline.set_state(Gst.State.PLAYING)
def _disable_preview(self):
if self._av_pipeline is not None:
self._av_pipeline.set_state(Gst.State.NULL)
if self._av_src is not None:
self._av_pipeline.remove(self._av_src)
if self._av_sink is not None:
self._av_pipeline.remove(self._av_sink)
self._av_src = None
self._av_sink = None
if self._av_widget is not None:
self._ui.video_preview_box.remove(self._av_widget)
self._ui.video_preview_placeholder.set_visible(True)
self._av_widget = None
self._av_pipeline = None
def _set_sink_text(self, sink_name):
text = ''
if sink_name == 'gtkglsink':
text = _('<span color="green" font-weight="bold">'
'OpenGL</span> accelerated')
elif sink_name == 'gtksink':
text = _('<span color="yellow" font-weight="bold">'
'Not accelerated</span>')
self._ui.video_source_label.set_markup(text)
def _set_error_text(self):
self._ui.video_source_label.set_text(
_('Something went wrong. Video feature disabled.'))
def refresh(self):
self.toggle_preview(False)
self.toggle_preview(True)

384
gajim/gtk/xml_console.py Normal file
View file

@ -0,0 +1,384 @@
# 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 time
import nbxmpp
from gi.repository import Gdk
from gi.repository import Gtk
from gi.repository import GLib
from gajim.common import app
from gajim.common import ged
from gajim.common.i18n import _
from gajim.common.const import StyleAttr
from . import util
from .util import get_builder
from .util import MaxWidthComboBoxText
from .util import EventHelper
from .dialogs import ErrorDialog
from .settings import SettingsDialog
from .const import Setting
from .const import SettingKind
from .const import SettingType
class XMLConsoleWindow(Gtk.ApplicationWindow, EventHelper):
def __init__(self):
Gtk.ApplicationWindow.__init__(self)
EventHelper.__init__(self)
self.set_application(app.app)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_default_size(600, 600)
self.set_resizable(True)
self.set_show_menubar(False)
self.set_name('XMLConsoleWindow')
self.selected_account = None
self._selected_send_account = None
self.presence = True
self.message = True
self.iq = True
self.stream = True
self.incoming = True
self.outgoing = True
self.filter_dialog = None
self.last_stanza = None
self.last_search = ''
self._ui = get_builder('xml_console.ui')
self.set_titlebar(self._ui.headerbar)
self._set_titlebar()
self.add(self._ui.box)
self._ui.paned.set_position(self._ui.paned.get_property('max-position'))
self._combo = MaxWidthComboBoxText()
self._combo.set_max_size(200)
self._combo.set_hexpand(False)
self._combo.set_halign(Gtk.Align.END)
self._combo.set_no_show_all(True)
self._combo.set_visible(False)
self._combo.connect('changed', self._on_value_change)
for account, label in self._get_accounts():
self._combo.append(account, label)
self._ui.actionbar.pack_end(self._combo)
self._create_tags()
self.show_all()
self.connect('key_press_event', self._on_key_press_event)
self._ui.connect_signals(self)
self.register_events([
('stanza-received', ged.GUI1, self._nec_stanza_received),
('stanza-sent', ged.GUI1, self._nec_stanza_sent),
])
def _on_value_change(self, combo):
self._selected_send_account = combo.get_active_id()
def _set_titlebar(self):
if self.selected_account is None:
title = _('All Accounts')
else:
title = app.get_jid_from_account(self.selected_account)
self._ui.headerbar.set_subtitle(title)
def _create_tags(self):
buffer_ = self._ui.textview.get_buffer()
in_color = app.css_config.get_value(
'.gajim-incoming-nickname', StyleAttr.COLOR)
out_color = app.css_config.get_value(
'.gajim-outgoing-nickname', StyleAttr.COLOR)
tags = ['presence', 'message', 'stream', 'iq']
tag = buffer_.create_tag('incoming')
tag.set_property('foreground', in_color)
tag = buffer_.create_tag('outgoing')
tag.set_property('foreground', out_color)
for tag_name in tags:
buffer_.create_tag(tag_name)
def _on_key_press_event(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
if self._ui.search_revealer.get_child_revealed():
self._ui.search_revealer.set_reveal_child(False)
return
self.destroy()
if (event.get_state() & Gdk.ModifierType.CONTROL_MASK and
event.keyval == Gdk.KEY_Return or
event.keyval == Gdk.KEY_KP_Enter):
self._on_send()
if (event.get_state() & Gdk.ModifierType.CONTROL_MASK and
event.keyval == Gdk.KEY_Up):
self._on_paste_last()
if (event.get_state() & Gdk.ModifierType.CONTROL_MASK and
event.keyval == Gdk.KEY_f):
self._ui.search_toggle.set_active(
not self._ui.search_revealer.get_child_revealed())
if event.keyval == Gdk.KEY_F3:
self._find(True)
def _on_row_activated(self, _listbox, row):
text = row.get_child().get_text()
input_text = None
if text == 'Presence':
input_text = (
'<presence xmlns="jabber:client">\n'
'<show></show>\n'
'<status></status>\n'
'<priority></priority>\n'
'</presence>')
elif text == 'Message':
input_text = (
'<message to="" type="" xmlns="jabber:client">\n'
'<body></body>\n'
'</message>')
elif text == 'Iq':
input_text = (
'<iq to="" type="" xmlns="jabber:client">\n'
'<query xmlns=""></query>\n'
'</iq>')
if input_text is not None:
buffer_ = self._ui.input_entry.get_buffer()
buffer_.set_text(input_text)
self._ui.input_entry.grab_focus()
def _on_send(self, *args):
if not self._selected_send_account:
return
if not app.account_is_available(self._selected_send_account):
# If offline or connecting
ErrorDialog(
_('Connection not available'),
_('Please make sure you are connected with \'%s\'.') %
self._selected_send_account)
return
buffer_ = self._ui.input_entry.get_buffer()
begin_iter, end_iter = buffer_.get_bounds()
stanza = buffer_.get_text(begin_iter, end_iter, True)
if stanza:
try:
node = nbxmpp.Node(node=stanza)
except Exception as error:
ErrorDialog(_('Invalid Node'), str(error))
return
if node.getName() in ('message', 'presence', 'iq'):
# Parse stanza again if its a message, presence or iq and
# set jabber:client as toplevel namespace
# Use type Protocol so nbxmpp counts the stanza for
# stream management
node = nbxmpp.Protocol(node=stanza,
attrs={'xmlns': 'jabber:client'})
app.connections[self._selected_send_account].connection.send(node)
self.last_stanza = stanza
buffer_.set_text('')
def _on_paste_last(self, *args):
buffer_ = self._ui.input_entry.get_buffer()
if buffer_ is not None and self.last_stanza is not None:
buffer_.set_text(self.last_stanza)
self._ui.input_entry.grab_focus()
def _on_input(self, button, *args):
if button.get_active():
self._ui.paned.get_child2().show()
self._ui.send.show()
self._ui.paste.show()
self._combo.show()
self._ui.menubutton.show()
self._ui.input_entry.grab_focus()
else:
self._ui.paned.get_child2().hide()
self._ui.send.hide()
self._ui.paste.hide()
self._combo.hide()
self._ui.menubutton.hide()
def _on_search_toggled(self, button):
self._ui.search_revealer.set_reveal_child(button.get_active())
self._ui.search_entry.grab_focus()
def _on_search_activate(self, _widget):
self._find(True)
def _on_search_clicked(self, button):
forward = bool(button is self._ui.search_forward)
self._find(forward)
def _find(self, forward):
search_str = self._ui.search_entry.get_text()
textbuffer = self._ui.textview.get_buffer()
cursor_mark = textbuffer.get_insert()
current_pos = textbuffer.get_iter_at_mark(cursor_mark)
if current_pos.get_offset() == textbuffer.get_char_count():
current_pos = textbuffer.get_start_iter()
last_pos_mark = textbuffer.get_mark('last_pos')
if last_pos_mark is not None:
current_pos = textbuffer.get_iter_at_mark(last_pos_mark)
if search_str != self.last_search:
current_pos = textbuffer.get_start_iter()
if forward:
match = current_pos.forward_search(
search_str,
Gtk.TextSearchFlags.VISIBLE_ONLY |
Gtk.TextSearchFlags.CASE_INSENSITIVE,
None)
else:
current_pos.backward_cursor_position()
match = current_pos.backward_search(
search_str,
Gtk.TextSearchFlags.VISIBLE_ONLY |
Gtk.TextSearchFlags.CASE_INSENSITIVE,
None)
if match is not None:
match_start, match_end = match
textbuffer.select_range(match_start, match_end)
mark = textbuffer.create_mark('last_pos', match_end, True)
self._ui.textview.scroll_to_mark(mark, 0, True, 0.5, 0.5)
self.last_search = search_str
@staticmethod
def _get_accounts():
accounts = app.get_accounts_sorted()
combo_accounts = []
for account in accounts:
label = app.get_account_label(account)
combo_accounts.append((account, label))
combo_accounts.append(('AccountWizard', 'Account Wizard'))
return combo_accounts
def _on_filter_options(self, *args):
if self.filter_dialog:
self.filter_dialog.present()
return
combo_accounts = self._get_accounts()
combo_accounts.insert(0, (None, _('All Accounts')))
settings = [
Setting(SettingKind.COMBO, _('Account'),
SettingType.VALUE, self.selected_account,
callback=self._set_account,
props={'combo_items': combo_accounts}),
Setting(SettingKind.SWITCH, 'Presence',
SettingType.VALUE, self.presence,
callback=self._on_setting, data='presence'),
Setting(SettingKind.SWITCH, 'Message',
SettingType.VALUE, self.message,
callback=self._on_setting, data='message'),
Setting(SettingKind.SWITCH, 'IQ', SettingType.VALUE, self.iq,
callback=self._on_setting, data='iq'),
Setting(SettingKind.SWITCH, 'Stream Management',
SettingType.VALUE, self.stream,
callback=self._on_setting, data='stream'),
Setting(SettingKind.SWITCH, 'In', SettingType.VALUE, self.incoming,
callback=self._on_setting, data='incoming'),
Setting(SettingKind.SWITCH, 'Out', SettingType.VALUE, self.outgoing,
callback=self._on_setting, data='outgoing'),
]
self.filter_dialog = SettingsDialog(self, _('Filter'),
Gtk.DialogFlags.DESTROY_WITH_PARENT,
settings, self.selected_account)
self.filter_dialog.connect('destroy', self._on_filter_destroyed)
def _on_filter_destroyed(self, _win):
self.filter_dialog = None
def _on_clear(self, *args):
self._ui.textview.get_buffer().set_text('')
def _set_account(self, value, _data):
self.selected_account = value
self._set_titlebar()
def _on_setting(self, value, data):
setattr(self, data, value)
value = not value
table = self._ui.textview.get_buffer().get_tag_table()
tag = table.lookup(data)
if data in ('incoming', 'outgoing'):
if value:
tag.set_priority(table.get_size() - 1)
else:
tag.set_priority(0)
tag.set_property('invisible', value)
def _nec_stanza_received(self, event):
if self.selected_account is not None:
if event.account != self.selected_account:
return
self._print_stanza(event, 'incoming')
def _nec_stanza_sent(self, event):
if self.selected_account is not None:
if event.account != self.selected_account:
return
self._print_stanza(event, 'outgoing')
def _print_stanza(self, event, kind):
if event.account == 'AccountWizard':
account_label = 'Account Wizard'
else:
account_label = app.get_account_label(event.account)
stanza = event.stanza
if not isinstance(stanza, str):
stanza = stanza.__str__(fancy=True)
if not stanza:
return
at_the_end = util.at_the_end(self._ui.scrolled)
buffer_ = self._ui.textview.get_buffer()
end_iter = buffer_.get_end_iter()
type_ = kind
if stanza.startswith('<presence'):
type_ = 'presence'
elif stanza.startswith('<message'):
type_ = 'message'
elif stanza.startswith('<iq'):
type_ = 'iq'
elif stanza.startswith('<r') or stanza.startswith('<a'):
type_ = 'stream'
stanza = '<!-- {kind} {time} ({account}) -->\n{stanza}\n\n'.format(
kind=kind.capitalize(),
time=time.strftime('%c'),
account=account_label,
stanza=stanza)
buffer_.insert_with_tags_by_name(end_iter, stanza, type_, kind)
if at_the_end:
GLib.idle_add(util.scroll_to_end, self._ui.scrolled)