# 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 __future__ import annotations from typing import cast import logging from enum import IntEnum from gi.repository import Gdk from gi.repository import GdkPixbuf from gi.repository import GObject from gi.repository import Gtk from nbxmpp.errors import StanzaError from nbxmpp.modules.vcard4 import VCard from nbxmpp.structs import AnnotationNote from nbxmpp.structs import SoftwareVersionResult from nbxmpp.task import Task from gajim.common import app from gajim.common import ged from gajim.common import types from gajim.common.const import AvatarSize from gajim.common.const import SimpleClientState from gajim.common.events import SubscribedPresenceReceived from gajim.common.events import UnsubscribedPresenceReceived from gajim.common.ged import EventHelper from gajim.common.helpers import get_uf_affiliation from gajim.common.helpers import get_uf_role from gajim.common.helpers import get_uf_show from gajim.common.i18n import _ from gajim.common.modules.contacts import BareContact from gajim.common.modules.contacts import GroupchatParticipant from gajim.common.modules.contacts import ResourceContact from gajim.gtk.builder import get_builder from gajim.gtk.contact_settings import ContactSettings from gajim.gtk.dialogs import ConfirmationDialog from gajim.gtk.dialogs import DialogButton from gajim.gtk.omemo_trust_manager import OMEMOTrustManager from gajim.gtk.sidebar_switcher import SideBarSwitcher from gajim.gtk.structs import RemoveHistoryActionParams from gajim.gtk.util import connect_destroy from gajim.gtk.vcard_grid import VCardGrid log = logging.getLogger('gajim.gtk.contact_info') ContactT = BareContact | GroupchatParticipant class Column(IntEnum): IN_GROUP = 0 GROUP_NAME = 1 class ContactInfo(Gtk.ApplicationWindow, EventHelper): def __init__(self, account: str, contact: ContactT, page: str | None = None) -> 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_type_hint(Gdk.WindowTypeHint.DIALOG) self.set_resizable(True) self.set_default_size(700, 600) self.set_name('ContactInfo') self.set_title(_('Contact Information')) # Set account and jid for window management self.account = account self.contact = contact self._client = app.get_client(account) self._client.connect_signal( 'state-changed', self._on_client_state_changed) self._ui = get_builder('contact_info.ui') self._tasks: list[Task] = [] self._devices: dict[str, DeviceGrid] = {} self._switcher = SideBarSwitcher(width=250) self._switcher.set_stack(self._ui.main_stack, rows_visible=False) self._ui.main_grid.attach(self._switcher, 0, 0, 1, 1) self._ui.main_stack.connect('notify::visible-child-name', self._on_stack_child_changed) self._ui.name_entry.set_text(contact.name) if contact.is_in_roster: self._ui.edit_name_button.show() self._load_avatar() self._fill_information_page(self.contact) if isinstance(self.contact, BareContact): self._fill_settings_page(self.contact) self._fill_encryption_page(self.contact) self._fill_device_info(self.contact) self._fill_groups_page(self.contact) self._fill_note_page(self.contact) if page is not None: self._switcher.set_row(page) self._ui.connect_signals(self) self.connect('key-press-event', self._on_key_press) self.connect('destroy', self._on_destroy) connect_destroy(self._ui.tree_selection, 'changed', self._on_group_selection_changed) connect_destroy(self._ui.toggle_renderer, 'toggled', self._on_group_toggled) connect_destroy(self._ui.text_renderer, 'edited', self._on_group_name_edited) self.register_events([ ('subscribed-presence-received', ged.GUI1, self._on_subscribed_presence_received), ('unsubscribed-presence-received', ged.GUI1, self._on_unsubscribed_presence_received), ]) self.add(self._ui.main_grid) self.show_all() def _on_key_press(self, _widget: ContactInfo, event: Gdk.EventKey) -> None: if event.keyval == Gdk.KEY_Escape: self.destroy() def _on_client_state_changed(self, _client: types.Client, _signal_name: str, state: SimpleClientState ) -> None: self._ui.edit_name_button.set_sensitive(state.is_connected) self._ui.edit_name_button.set_tooltip_text( _('Not connected') if not state.is_connected else _('Edit Nameā€¦')) self._ui.subscription_listbox.set_sensitive(state.is_connected) if state.is_connected: self._ui.groups_page_stack.set_visible_child_name('groups') self._ui.notes_page_stack.set_visible_child_name('notes') else: self._ui.groups_page_stack.set_visible_child_name('offline') self._ui.notes_page_stack.set_visible_child_name('offline') def _on_stack_child_changed(self, _widget: Gtk.Stack, _pspec: GObject.ParamSpec) -> None: name = self._ui.main_stack.get_visible_child_name() self._ui.header_revealer.set_reveal_child(name != 'information') def _on_destroy(self, _widget: ContactInfo) -> None: for task in self._tasks: task.cancel() if self.contact.is_in_roster and self._client.state.is_available: self._save_annotation() for device_grid in self._devices.values(): device_grid.destroy() self._devices.clear() self.unregister_events() app.check_finalize(self) def _fill_information_page(self, contact: ContactT) -> None: self._vcard_grid = VCardGrid(self.account) self._ui.vcard_box.add(self._vcard_grid) if self._client.state.is_available: self._client.get_module('VCard4').request_vcard( jid=self.contact.jid, callback=self._on_vcard_received) jid = str(contact.get_address()) self._ui.contact_name_label.set_text(contact.name) self._ui.edit_name_button.set_sensitive( self._client.state.is_available) self._ui.contact_jid_label.set_text(jid) self._ui.contact_jid_label.set_tooltip_text(jid) if isinstance(contact, GroupchatParticipant): self._ui.role_label.set_text(get_uf_role(contact.role)) self._ui.affiliation_label.set_text( get_uf_affiliation(contact.affiliation)) self._ui.group_chat_grid.show() self._switcher.set_row_visible('information', True) def _fill_encryption_page(self, contact: ContactT) -> None: self._ui.encryption_box.add( OMEMOTrustManager(self.contact.account, self.contact)) self._switcher.set_row_visible('encryption-omemo', True) def _fill_note_page(self, contact: BareContact) -> None: if not contact.is_in_roster: return note = self._client.get_module('Annotations').get_note(contact.jid) if note is not None: self._ui.textview_annotation.get_buffer().set_text(note.data) if app.account_supports_private_storage(self.account): # Hide the Notes page if private storage is not available, because # roster notes cannot be stored without. # Since there is no disco mechanism for private storage, we rely on # Delimiter as a "proxy" for the availability of private storage. self._switcher.set_row_visible('notes', True) def _fill_settings_page(self, contact: BareContact) -> None: if not contact.is_in_roster: return self._ui.from_subscription_switch.set_state(contact.is_subscribed) self._ui.subscription_listbox.set_sensitive( self._client.state.is_available) if contact.subscription in ('to', 'both'): self._ui.to_subscription_stack.set_visible_child_name('checkmark') elif contact.ask == 'subscribe': self._ui.to_subscription_stack.set_visible_child_name('request') self._ui.request_stack.set_visible_child_name('requested') else: self._ui.to_subscription_stack.set_visible_child_name('request') self._ui.request_stack.set_visible_child_name('cross') self._switcher.set_row_visible('settings', True) contact_settings = ContactSettings(self.account, contact.jid) self._ui.contact_settings_box.add(contact_settings) params = RemoveHistoryActionParams( account=self.account, jid=self.contact.jid) self._ui.remove_history_button.set_action_name('app.remove-history') self._ui.remove_history_button.set_action_target_value( params.to_variant()) def _fill_groups_page(self, contact: BareContact) -> None: if not contact.is_in_roster or not self._client.state.is_available: return model = self._ui.groups_treeview.get_model() assert isinstance(model, Gtk.ListStore) model.set_sort_column_id(Column.GROUP_NAME, Gtk.SortType.ASCENDING) groups = self._client.get_module('Roster').get_groups() for group in groups: is_in_group = group in contact.groups model.append([is_in_group, group]) self._switcher.set_row_visible('groups', True) def _fill_device_info(self, contact: BareContact) -> None: contacts = list(contact.iter_resources()) if not contacts: return for resource_contact in contacts: device_grid = DeviceGrid(resource_contact) self._devices[resource_contact.resource] = device_grid self._ui.devices_box.add(device_grid.widget) self._query_device(resource_contact) self._ui.devices_stack.set_visible_child_name('devices') self._switcher.set_row_visible('devices', True) def _query_device(self, contact: ResourceContact) -> None: software_module = self._client.get_module('SoftwareVersion') task = software_module.request_software_version( contact.jid, callback=self._set_os_info, user_data=contact.resource) self._tasks.append(task) task = self._client.get_module('EntityTime').request_entity_time( contact.jid, callback=self._set_entity_time, user_data=contact.resource) self._tasks.append(task) def _on_vcard_received(self, task: Task) -> None: try: vcard = cast(VCard | None, task.finish()) except StanzaError as err: log.info('Error loading VCard: %s', err) vcard = None if vcard is None: vcard = VCard() self._vcard_grid.set_vcard(vcard) def _load_avatar(self) -> None: scale = self.get_scale_factor() surface_1 = self.contact.get_avatar(AvatarSize.VCARD, scale, add_show=False) surface_2 = self.contact.get_avatar(AvatarSize.VCARD_HEADER, scale, add_show=False) assert not isinstance(surface_1, GdkPixbuf.Pixbuf) assert not isinstance(surface_2, GdkPixbuf.Pixbuf) self._ui.avatar_image.set_from_surface(surface_1) self._ui.header_image.set_from_surface(surface_2) def _set_os_info(self, task: Task) -> None: self._tasks.remove(task) try: result = cast(SoftwareVersionResult, task.finish()) except Exception as err: log.warning('Could not retrieve software version: %s', err) result = None resource = task.get_user_data() device_grid = self._devices[resource] device_grid.set_software(result) def _set_entity_time(self, task: Task) -> None: self._tasks.remove(task) try: entity_time = cast(str, task.finish()) except Exception as err: log.warning('Could not retrieve entity time: %s', err) entity_time = None resource = task.get_user_data() device_grid = self._devices[resource] device_grid.set_entity_time(entity_time) def _save_annotation(self) -> None: buffer_ = self._ui.textview_annotation.get_buffer() new_annotation = buffer_.get_property('text') note = self._client.get_module('Annotations').get_note( self.contact.jid) if note is None or new_annotation != note.data: new_note = AnnotationNote( jid=self.contact.jid, data=new_annotation) self._client.get_module('Annotations').set_note(new_note) def _on_edit_name_toggled(self, widget: Gtk.ToggleButton) -> None: active = widget.get_active() self._ui.name_entry.set_sensitive(active) if active: self._ui.name_entry.grab_focus() self._ui.edit_name_button_image.set_from_icon_name( 'document-save-symbolic', Gtk.IconSize.BUTTON) else: self._ui.edit_name_button_image.set_from_icon_name( 'document-edit-symbolic', Gtk.IconSize.BUTTON) name = self._ui.name_entry.get_text() if name == self.contact.name: return assert isinstance(self.contact, BareContact) self._client.get_module('Roster').set_item( self.contact.jid, name, self.contact.groups) self._ui.contact_name_label.set_text(name) def _on_name_entry_activate(self, _widget: Gtk.Entry) -> None: self._ui.edit_name_button.set_active(False) def _on_from_subscription_switch_toggled(self, switch: Gtk.Switch, state: bool) -> int: def _stop_sharing(): self._client.get_module('Presence').unsubscribed(self.contact.jid) switch.set_state(state) if state: self._client.get_module('Presence').subscribed(self.contact.jid) switch.set_state(state) else: ConfirmationDialog( _('Online Status'), _('Stop sharing online status?'), _('The contact will be informed that you stopped sharing your ' 'status. Please note that this can have other side effects.'), [DialogButton.make('Cancel', callback=lambda: switch.set_active(True)), DialogButton.make('Remove', text=_('_Stop Sharing'), callback=_stop_sharing)], transient_for=self).show() return Gdk.EVENT_STOP def _on_to_subscription_button_clicked(self, _widget: Gtk.Button) -> None: # Save auto_auth if switch for disclosing presence is active self._client.get_module('Presence').subscribe( self.contact.jid, auto_auth=self._ui.from_subscription_switch.get_state()) self._ui.request_stack.set_visible_child_name('requested') def _on_subscribed_presence_received(self, _event: SubscribedPresenceReceived ) -> None: self._ui.to_subscription_stack.set_visible_child_name('checkmark') def _on_unsubscribed_presence_received(self, _event: UnsubscribedPresenceReceived ) -> None: self._ui.to_subscription_stack.set_visible_child_name('request') self._ui.request_stack.set_visible_child_name('cross') def _on_group_selection_changed(self, _widget: Gtk.TreeSelection) -> None: selection = self._ui.groups_treeview.get_selection() (_model, iter_) = selection.get_selected() self._ui.group_remove_button.set_sensitive(bool(iter_)) def _on_group_name_edited(self, _renderer: Gtk.CellRendererText, path: str, new_name: str) -> None: if new_name == '': return model = self._ui.groups_treeview.get_model() assert model is not None assert isinstance(model, Gtk.ListStore) selected_iter_ = model.get_iter(path) old_name = model[selected_iter_][Column.GROUP_NAME] # Check if group already exists iter_ = model.get_iter_first() while iter_: group_name = model.get_value(iter_, Column.GROUP_NAME) if group_name == new_name: return iter_ = model.iter_next(iter_) model.set_value(selected_iter_, Column.GROUP_NAME, new_name) self._client.get_module('Roster').rename_group(old_name, new_name) def _on_group_remove_button_clicked(self, _widget: Gtk.Button) -> int: selection = self._ui.groups_treeview.get_selection() model, iter_ = selection.get_selected() assert iter_ is not None group = model[iter_][Column.GROUP_NAME] def _remove_group(): self._client.get_module('Roster').remove_group(group) del model[iter_] ConfirmationDialog( _('Remove Group'), _('Remove Group'), _('Do you really want to remove "%s"?') % group, [DialogButton.make('Cancel'), DialogButton.make('Remove', callback=_remove_group)], transient_for=self).show() return Gdk.EVENT_STOP def _on_group_toggled(self, _renderer: Gtk.CellRendererToggle, path: str) -> None: model = self._ui.groups_treeview.get_model() assert model is not None model[path][Column.IN_GROUP] = not model[path][Column.IN_GROUP] groups: set[str] = set() iter_ = model.get_iter_first() while iter_: if model.get_value(iter_, Column.IN_GROUP): group_name = model.get_value(iter_, Column.GROUP_NAME) groups.add(group_name) iter_ = model.iter_next(iter_) self._client.get_module('Roster').set_groups(self.contact.jid, groups) def _on_group_add_button_clicked(self, _widget: Gtk.Button) -> None: default_name = _('New Group') model = self._ui.groups_treeview.get_model() assert model is not None assert isinstance(model, Gtk.ListStore) # Check if default_name group already exists iter_ = model.get_iter_first() while iter_: group_name = model.get_value(iter_, Column.GROUP_NAME) if group_name == default_name: default_name += '_' iter_ = model.iter_next(iter_) new_iter_ = model.append([False, default_name]) path = model.get_path(new_iter_) column = self._ui.groups_treeview.get_column(Column.GROUP_NAME) self._ui.groups_treeview.set_cursor(path, column, True) class DeviceGrid: def __init__(self, contact: ResourceContact) -> None: self._contact = contact self._ui = get_builder('contact_info.ui', ['devices_grid']) self._spinner = Gtk.Spinner() self._spinner.start() self._ui.resource_label.set_text(_('Device "%s"') % contact.resource) self._ui.status_value.set_text(get_uf_show(contact.show.value)) self._ui.priority_value.set_text(str(self._contact.priority)) self._ui.priority_value.show() self._ui.priority_label.show() self._ui.resource_box.add(self._spinner) self._ui.devices_grid.show_all() self._waiting_for_info = 2 @property def widget(self) -> Gtk.Grid: return self._ui.devices_grid def set_entity_time(self, entity_time: str | None) -> None: if entity_time is not None: self._ui.time_value.set_text(entity_time) self._ui.time_value.show() self._ui.time_label.show() self._check_complete() def set_software(self, software: SoftwareVersionResult | None) -> None: if software is not None: software_string = f'{software.name} {software.version}' self._ui.software_value.set_text(software_string) self._ui.software_value.show() self._ui.software_label.show() if software.os is not None: self._ui.system_value.set_text(software.os) self._ui.system_value.show() self._ui.system_label.show() self._check_complete() def _check_complete(self) -> None: self._waiting_for_info -= 1 if not self._waiting_for_info: self._spinner.stop() def destroy(self) -> None: self._spinner.stop() del self._spinner del self._ui del self._contact