gajim3/gajim/gtk/start_chat.py

835 lines
28 KiB
Python

# 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()