# This file is part of Gajim. # # Gajim is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published # by the Free Software Foundation; version 3 only. # # Gajim is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Gajim. If not, see . import 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()