# 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 . 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 = '%s' % 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' '{}'.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