diff options
50 files changed, 3359 insertions, 4922 deletions
diff --git a/gajim/command_system/implementation/middleware.py b/gajim/command_system/implementation/middleware.py index d03dc3a37..d1f9bb9c5 100644 --- a/gajim/command_system/implementation/middleware.py +++ b/gajim/command_system/implementation/middleware.py @@ -56,7 +56,6 @@ class ChatCommandProcessor(CommandProcessor): parents = super(ChatCommandProcessor, self) flag = parents.process_as_command(text) if flag and self.command_succeeded: - self.add_history(text) self.clear_input() return flag @@ -147,13 +146,6 @@ class CommandTools: """ self.set_input(str()) - def add_history(self, text): - """ - Add given text to the input history, so user can scroll through - it using ctrl + up/down arrow keys. - """ - self.save_message(text, 'sent') - @property def connection(self): """ diff --git a/gajim/common/const.py b/gajim/common/const.py index 15feaffe0..b92962d9d 100644 --- a/gajim/common/const.py +++ b/gajim/common/const.py @@ -246,6 +246,7 @@ class MUCJoinedState(Enum): CREATING = 'creating' CAPTCHA_REQUEST = 'captcha in progress' CAPTCHA_FAILED = 'captcha failed' + PASSWORD_REQUEST = 'password request' def __str__(self): return self.name @@ -274,6 +275,10 @@ class MUCJoinedState(Enum): def is_captcha_failed(self): return self == MUCJoinedState.CAPTCHA_FAILED + @property + def is_password_request(self): + return self == MUCJoinedState.PASSWORD_REQUEST + class ClientState(IntEnum): DISCONNECTING = 0 diff --git a/gajim/common/modules/httpupload.py b/gajim/common/modules/httpupload.py index a0bbc3052..8114eaa16 100644 --- a/gajim/common/modules/httpupload.py +++ b/gajim/common/modules/httpupload.py @@ -95,9 +95,6 @@ class HTTPUpload(BaseModule): GLib.FormatSizeFlags.IEC_UNITS) self._log.info('Component has a maximum file size of: %s', size) - for ctrl in app.window.get_controls(account=self._account): - ctrl.update_actions() - def make_transfer(self, path: str, encryption: Optional[str], diff --git a/gajim/common/modules/muc.py b/gajim/common/modules/muc.py index 2a8a94ac3..36ccefd34 100644 --- a/gajim/common/modules/muc.py +++ b/gajim/common/modules/muc.py @@ -34,6 +34,7 @@ from nbxmpp.namespaces import Namespace from nbxmpp.protocol import JID from nbxmpp.protocol import Message from nbxmpp.protocol import Presence +from nbxmpp.structs import CommonError from nbxmpp.structs import DiscoInfo from nbxmpp.structs import MessageProperties from nbxmpp.structs import PresenceProperties @@ -267,7 +268,8 @@ class MUC(BaseModule): # join a different nickname, so update MUCData here. muc_data.nick = nick - if not muc_data.state.is_not_joined: + if muc_data.state not in (MUCJoinedState.NOT_JOINED, + MUCJoinedState.PASSWORD_REQUEST): self._log.warning('Can’t join MUC %s, state: %s', jid, muc_data.state) return @@ -503,21 +505,31 @@ class MUC(BaseModule): elif properties.error.condition == 'not-authorized': self._remove_rejoin_timeout(room_jid) - self._set_muc_state(room_jid, MUCJoinedState.NOT_JOINED) + self._set_muc_state(room_jid, MUCJoinedState.PASSWORD_REQUEST) room.notify('room-password-required', properties) else: self._set_muc_state(room_jid, MUCJoinedState.NOT_JOINED) if room_jid not in self._rejoin_muc: + muc_data.error = 'join-failed' + assert isinstance(properties.error, CommonError) + muc_data.error_text = helpers.to_user_string( + properties.error) room.notify('room-join-failed', properties.error) elif muc_data.state == MUCJoinedState.CREATING: self._set_muc_state(room_jid, MUCJoinedState.NOT_JOINED) + muc_data.error = 'creation-failed' + assert isinstance(properties.error, CommonError) + muc_data.error_text = helpers.to_user_string(properties.error) room.notify('room-creation-failed', properties) elif muc_data.state == MUCJoinedState.CAPTCHA_REQUEST: self._set_muc_state(room_jid, MUCJoinedState.CAPTCHA_FAILED) self._set_muc_state(room_jid, MUCJoinedState.NOT_JOINED) + muc_data.error = 'captcha-failed' + assert isinstance(properties.error, CommonError) + muc_data.error_text = helpers.to_user_string(properties.error) room.notify('room-captcha-error', properties.error) elif muc_data.state == MUCJoinedState.CAPTCHA_FAILED: @@ -767,6 +779,9 @@ class MUC(BaseModule): room = self._get_contact(room_jid) room.notify('room-joined') + def cancel_password_request(self, room_jid: str) -> None: + self._set_muc_state(room_jid, MUCJoinedState.NOT_JOINED) + def _room_join_complete(self, muc_data: MUCData): self._remove_join_timeout(muc_data.jid) self._set_muc_state(muc_data.jid, MUCJoinedState.JOINED) @@ -820,8 +835,11 @@ class MUC(BaseModule): return self._log.info('Captcha challenge received from %s', properties.jid) + + assert properties.captcha is not None store_bob_data(properties.captcha.bob_data) muc_data.captcha_id = properties.id + muc_data.captcha_form = properties.captcha.form self._set_muc_state(properties.jid, MUCJoinedState.CAPTCHA_REQUEST) self._remove_rejoin_timeout(properties.jid) diff --git a/gajim/common/setting_values.py b/gajim/common/setting_values.py index 74d49fae0..ccf14f1da 100644 --- a/gajim/common/setting_values.py +++ b/gajim/common/setting_values.py @@ -385,13 +385,13 @@ BoolGroupChatSettings = Literal[ 'notify_on_all_messages', 'print_join_left', 'print_status', - 'send_chatstate', 'send_marker', ] StringGroupChatSettings = Literal[ 'encryption', 'speller_language', + 'send_chatstate', ] IntGroupChatSettings = Literal[ diff --git a/gajim/common/storage/archive.py b/gajim/common/storage/archive.py index e3dac697e..bf8961911 100644 --- a/gajim/common/storage/archive.py +++ b/gajim/common/storage/archive.py @@ -993,6 +993,34 @@ class MessageArchiveStorage(SqliteStorage): return False @timeit + def get_last_correctable_message(self, + account: str, + jid: JID, + message_id: str + ) -> Optional[LastConversationRow]: + ''' + Load the last correctable message of a conversation by message_id. + Conditions: max 5 min old + ''' + jids = [jid] + account_id = self.get_account_id(account) + min_time = time.time() - 5 * 60 + + sql = ''' + SELECT contact_name, time, kind, message, stanza_id, message_id, + additional_data + FROM logs NATURAL JOIN jids WHERE jid IN ({jids}) + AND account_id = {account_id} + AND message_id = ? + AND time > ? + '''.format(jids=', '.join('?' * len(jids)), + account_id=account_id) + + return self._con.execute( + sql, + tuple(jids) + (message_id, min_time)).fetchone() + + @timeit def store_message_correction(self, account: str, jid: JID, diff --git a/gajim/common/structs.py b/gajim/common/structs.py index 8b0a43bef..a02a99334 100644 --- a/gajim/common/structs.py +++ b/gajim/common/structs.py @@ -31,7 +31,10 @@ from nbxmpp.const import Affiliation from nbxmpp.const import Chatstate from nbxmpp.const import PresenceShow from nbxmpp.const import Role +from nbxmpp.modules.dataforms import SimpleDataForm +from nbxmpp.modules.security_labels import SecurityLabel from nbxmpp.protocol import JID +from nbxmpp.structs import MucSubject from nbxmpp.structs import PresenceProperties from gajim.common import types @@ -55,7 +58,7 @@ class MUCData: def __init__(self, room_jid: str, nick: str, - password: str, + password: Optional[str], config: Optional[dict[str, Any]] = None ) -> None: @@ -65,8 +68,11 @@ class MUCData: self.password = password self.state = MUCJoinedState.NOT_JOINED # Message id of the captcha challenge - self.captcha_id = None - self.subject = None + self.captcha_id: Optional[str] = None + self.captcha_form: Optional[SimpleDataForm] = None + self.error: Optional[str] = None + self.error_text: Optional[str] = None + self.subject: Optional[MucSubject] = None @property def jid(self) -> JID: @@ -92,7 +98,7 @@ class OutgoingMessage: marker: Optional[tuple[str, str]] = None, resource: Optional[str] = None, user_nick: Optional[str] = None, - label: Optional[str] = None, + label: Optional[SecurityLabel] = None, control: Optional[Any] = None, attention: Optional[bool] = None, correct_id: Optional[str] = None, diff --git a/gajim/data/gui/chat_banner.ui b/gajim/data/gui/chat_banner.ui new file mode 100644 index 000000000..953d5661a --- /dev/null +++ b/gajim/data/gui/chat_banner.ui @@ -0,0 +1,274 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Generated with glade 3.38.2 --> +<interface> + <requires lib="gtk+" version="3.24"/> + <object class="GtkBox" id="banner_box"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="spacing">12</property> + <child> + <object class="GtkImage" id="avatar_image"> + <property name="visible">True</property> + <property name="can-focus">False</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkBox"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="spacing">12</property> + <child> + <object class="GtkBox"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="valign">center</property> + <property name="orientation">vertical</property> + <property name="spacing">3</property> + <child> + <object class="GtkLabel" id="name_label"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="halign">start</property> + <property name="hexpand">True</property> + <property name="ellipsize">end</property> + <property name="single-line-mode">True</property> + <property name="xalign">0</property> + <style> + <class name="large-header"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkImage" id="phone_image"> + <property name="can-focus">False</property> + <property name="no-show-all">True</property> + <property name="tooltip-text" translatable="yes">The last message was written on a mobile client</property> + <property name="halign">start</property> + <property name="margin-end">4</property> + <property name="icon-name">phone-apple-iphone-symbolic</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkButton" id="toggle_roster_button"> + <property name="can-focus">True</property> + <property name="receives-default">True</property> + <property name="no-show-all">True</property> + <property name="tooltip-text" translatable="yes">Toggle participants list</property> + <property name="valign">center</property> + <property name="relief">none</property> + <signal name="clicked" handler="_on_toggle_roster_clicked" swapped="no"/> + <child> + <object class="GtkBox"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="spacing">3</property> + <child> + <object class="GtkImage"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="icon-name">system-users-symbolic</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkImage" id="toggle_roster_image"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="icon-name">go-previous-symbolic</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + <style> + <class name="flat"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="pack-type">end</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkBox" id="account_badge_box"> + <property name="can-focus">False</property> + <property name="no-show-all">True</property> + <property name="halign">end</property> + <property name="valign">center</property> + <property name="orientation">vertical</property> + <child> + <placeholder/> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="pack-type">end</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkBox" id="visitor_box"> + <property name="can-focus">False</property> + <property name="no-show-all">True</property> + <property name="spacing">12</property> + <child> + <object class="GtkLabel"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="label" translatable="yes">You are a visitor</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkMenuButton" id="visitor_menu_button"> + <property name="visible">True</property> + <property name="can-focus">True</property> + <property name="focus-on-click">False</property> + <property name="receives-default">True</property> + <property name="tooltip-text" translatable="yes">What does this mean?</property> + <property name="valign">center</property> + <property name="popover">visitor_popover</property> + <child> + <object class="GtkImage"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="icon-name">dialog-information-symbolic</property> + </object> + </child> + <style> + <class name="suggested-action"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="pack-type">end</property> + <property name="position">3</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + <style> + <class name="gajim-banner"/> + <class name="padding-6"/> + </style> + </object> + <object class="GtkPopover" id="visitor_popover"> + <property name="can-focus">False</property> + <property name="relative-to">visitor_menu_button</property> + <property name="position">bottom</property> + <child> + <object class="GtkBox"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="orientation">vertical</property> + <property name="spacing">6</property> + <child> + <object class="GtkLabel"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="label" translatable="yes">You are a visitor</property> + <style> + <class name="bold"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkLabel"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="label" translatable="yes">In order to write messages in this chat, you need to request voice first. +A moderator will process your request.</property> + <property name="wrap">True</property> + <property name="max-width-chars">30</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkButton"> + <property name="label" translatable="yes">_Request</property> + <property name="visible">True</property> + <property name="can-focus">True</property> + <property name="receives-default">False</property> + <property name="halign">center</property> + <property name="use-underline">True</property> + <signal name="clicked" handler="_on_request_voice_clicked" swapped="no"/> + <style> + <class name="suggested-action"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="pack-type">end</property> + <property name="position">2</property> + </packing> + </child> + <style> + <class name="padding-12"/> + </style> + </object> + </child> + </object> +</interface> diff --git a/gajim/data/gui/chat_control.ui b/gajim/data/gui/chat_control.ui index 9312b9e1c..3e8f18905 100644 --- a/gajim/data/gui/chat_control.ui +++ b/gajim/data/gui/chat_control.ui @@ -2,485 +2,34 @@ <!-- Generated with glade 3.38.2 --> <interface> <requires lib="gtk+" version="3.24"/> - <object class="GtkBox" id="drop_area"> - <property name="name">DropArea</property> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="no-show-all">True</property> - <property name="opacity">0.90</property> - <property name="hexpand">True</property> - <property name="vexpand">True</property> - <property name="orientation">vertical</property> - <property name="spacing">18</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="valign">end</property> - <property name="vexpand">True</property> - <property name="icon-name">mail-attachment-symbolic</property> - <property name="icon_size">6</property> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkLabel"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="valign">start</property> - <property name="vexpand">True</property> - <property name="label" translatable="yes">Drop files or contacts</property> - <property name="wrap">True</property> - <property name="max-width-chars">40</property> - <style> - <class name="bold16"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - </object> - <object class="GtkMenu" id="formattings_menu"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <child> - <object class="GtkMenuItem" id="bold"> - <property name="name">bold</property> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="label" translatable="yes">Bold</property> - <signal name="activate" handler="_on_formatting_menuitem_activate" swapped="no"/> - </object> - </child> - <child> - <object class="GtkMenuItem" id="italic"> - <property name="name">italic</property> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="label" translatable="yes">Italic</property> - <signal name="activate" handler="_on_formatting_menuitem_activate" swapped="no"/> - </object> - </child> - <child> - <object class="GtkMenuItem" id="strike"> - <property name="name">strike</property> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="label" translatable="yes">Strike</property> - <signal name="activate" handler="_on_formatting_menuitem_activate" swapped="no"/> - </object> - </child> - </object> - <object class="GtkBox" id="chat_control_hbox"> + <object class="GtkBox" id="control_box"> <property name="can-focus">True</property> - <property name="margin-start">7</property> - <property name="margin-end">7</property> - <property name="margin-top">5</property> - <property name="margin-bottom">7</property> - <property name="spacing">1</property> <child> - <object class="GtkOverlay" id="overlay"> - <property name="visible">True</property> - <property name="can-focus">False</property> + <object class="GtkBox" id="conv_view_box"> + <property name="can-focus">True</property> + <property name="margin-start">7</property> + <property name="margin-end">7</property> + <property name="spacing">3</property> <child> - <object class="GtkBox" id="textview_box"> + <object class="GtkOverlay" id="conv_view_overlay"> <property name="visible">True</property> <property name="can-focus">False</property> - <property name="orientation">vertical</property> - <child> - <object class="GtkEventBox" id="banner_eventbox"> - <property name="name">ChatControl-BannerEventBox</property> - <property name="can-focus">False</property> - <property name="no-show-all">True</property> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="spacing">6</property> - <child> - <object class="GtkEventBox" id="avatar_eventbox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="visible-window">False</property> - <child> - <object class="GtkImage" id="avatar_image"> - <property name="width-request">48</property> - <property name="height-request">48</property> - <property name="visible">True</property> - <property name="can-focus">False</property> - </object> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">False</property> - <property name="position">0</property> - </packing> - </child> - <child> - <!-- n-columns=3 n-rows=3 --> - <object class="GtkGrid"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="valign">center</property> - <property name="hexpand">True</property> - <property name="row-spacing">3</property> - <property name="column-spacing">3</property> - <child> - <object class="GtkLabel" id="banner_name_label"> - <property name="name">ChatControl-BannerNameLabel</property> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">start</property> - <property name="hexpand">True</property> - <property name="label">Contact name</property> - <property name="ellipsize">end</property> - <property name="xalign">0</property> - <style> - <class name="large-header"/> - </style> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">0</property> - <property name="width">2</property> - </packing> - </child> - <child> - <object class="GtkLabel" id="banner_label"> - <property name="name">ChatControl-BannerLabel</property> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">start</property> - <property name="hexpand">True</property> - <property name="label">label</property> - <property name="selectable">True</property> - <property name="ellipsize">end</property> - <property name="xalign">0</property> - </object> - <packing> - <property name="left-attach">1</property> - <property name="top-attach">1</property> - </packing> - </child> - <child> - <object class="GtkImage" id="phone_image"> - <property name="can-focus">False</property> - <property name="no-show-all">True</property> - <property name="tooltip-text" translatable="yes">The last message was written on a mobile client</property> - <property name="halign">start</property> - <property name="margin-end">4</property> - <property name="icon-name">phone-apple-iphone-symbolic</property> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">1</property> - </packing> - </child> - <child> - <object class="GtkBox" id="account_badge_box"> - <property name="can-focus">False</property> - <property name="no-show-all">True</property> - <property name="halign">end</property> - <property name="valign">center</property> - <property name="orientation">vertical</property> - <child> - <placeholder/> - </child> - </object> - <packing> - <property name="left-attach">2</property> - <property name="top-attach">0</property> - </packing> - </child> - <child> - <placeholder/> - </child> - <child> - <placeholder/> - </child> - <child> - <placeholder/> - </child> - <child> - <placeholder/> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - <child> - <placeholder/> - </child> - </object> - </child> - <style> - <class name="gajim-banner"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkSeparator"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <style> - <class name="chatcontrol-separator-top"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - <child> - <object class="GtkOverlay" id="conv_view_overlay"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <child> - <placeholder/> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">2</property> - </packing> - </child> - <child> - <object class="GtkSeparator"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <style> - <class name="chatcontrol-separator"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">3</property> - </packing> - </child> + <property name="hexpand">True</property> <child> - <object class="GtkBox" id="hbox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="events">GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK</property> - <child> - <object class="GtkMenuButton" id="emoticons_button"> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="tooltip-text" translatable="yes">Show a list of emojis</property> - <property name="relief">none</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">face-smile-symbolic</property> - </object> - </child> - <style> - <class name="chatcontrol-actionbar-button"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkMenuButton" id="formattings_button"> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="focus-on-click">False</property> - <property name="receives-default">True</property> - <property name="tooltip-text" translatable="yes">Format your message…</property> - <property name="relief">none</property> - <property name="popup">formattings_menu</property> - <property name="direction">up</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">format-text-bold-symbolic</property> - <property name="icon_size">1</property> - </object> - </child> - <style> - <class name="chatcontrol-actionbar-button"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - <child> - <placeholder/> - </child> - <child> - <object class="GtkMenuButton" id="settings_menu"> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="relief">none</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">open-menu-symbolic</property> - </object> - </child> - <style> - <class name="chatcontrol-actionbar-button"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="pack-type">end</property> - <property name="position">4</property> - </packing> - </child> - <child> - <object class="GtkButton" id="authentication_button"> - <property name="can-focus">True</property> - <property name="focus-on-click">False</property> - <property name="receives-default">True</property> - <property name="events">GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK</property> - <property name="no-show-all">True</property> - <property name="relief">none</property> - <signal name="clicked" handler="_on_authentication_button_clicked" swapped="no"/> - <child> - <object class="GtkImage" id="lock_image"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon_size">1</property> - </object> - </child> - <style> - <class name="chatcontrol-actionbar-button"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">False</property> - <property name="pack-type">end</property> - <property name="position">5</property> - </packing> - </child> - <child> - <object class="GtkMenuButton" id="encryption_menu"> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="tooltip-text" translatable="yes">Choose encryption</property> - <property name="relief">none</property> - <child> - <placeholder/> - </child> - <style> - <class name="chatcontrol-actionbar-button"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="pack-type">end</property> - <property name="position">6</property> - </packing> - </child> - <child> - <object class="GtkButton" id="sendfile_button"> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="focus-on-click">False</property> - <property name="receives-default">True</property> - <property name="events">GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK</property> - <property name="relief">none</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">mail-attachment-symbolic</property> - <property name="icon_size">1</property> - </object> - </child> - <style> - <class name="chatcontrol-actionbar-button"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">False</property> - <property name="pack-type">end</property> - <property name="position">7</property> - </packing> - </child> - <child> - <object class="GtkButton" id="send_message_button"> - <property name="can-focus">True</property> - <property name="focus-on-click">False</property> - <property name="receives-default">True</property> - <property name="no-show-all">True</property> - <property name="tooltip-text" translatable="yes">Send Message</property> - <property name="relief">none</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">document-send</property> - </object> - </child> - <style> - <class name="chatcontrol-actionbar-button"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">False</property> - <property name="pack-type">end</property> - <property name="position">8</property> - </packing> - </child> - <child> - <placeholder/> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">False</property> - <property name="position">4</property> - </packing> + <placeholder/> </child> </object> <packing> - <property name="index">-1</property> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> </packing> </child> </object> <packing> <property name="expand">True</property> <property name="fill">True</property> - <property name="position">1</property> + <property name="position">0</property> </packing> </child> </object> diff --git a/gajim/data/gui/chat_paned.ui b/gajim/data/gui/chat_paned.ui index d89e34c63..35c050f46 100644 --- a/gajim/data/gui/chat_paned.ui +++ b/gajim/data/gui/chat_paned.ui @@ -22,6 +22,7 @@ <property name="row-spacing">3</property> <child> <object class="GtkBox"> + <property name="height-request">39</property> <property name="visible">True</property> <property name="can-focus">False</property> <property name="spacing">12</property> @@ -31,6 +32,7 @@ <property name="can-focus">True</property> <property name="receives-default">True</property> <property name="tooltip-text" translatable="yes">Edit workspace…</property> + <property name="valign">center</property> <signal name="clicked" handler="_on_edit_workspace_clicked" swapped="no"/> <child> <object class="GtkImage"> @@ -69,6 +71,7 @@ <property name="can-focus">True</property> <property name="receives-default">True</property> <property name="tooltip-text" translatable="yes">Filter Chats…</property> + <property name="valign">center</property> <child> <object class="GtkImage"> <property name="visible">True</property> diff --git a/gajim/data/gui/groupchat_control.ui b/gajim/data/gui/groupchat_control.ui deleted file mode 100644 index 0579ff21e..000000000 --- a/gajim/data/gui/groupchat_control.ui +++ /dev/null @@ -1,1700 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- Generated with glade 3.38.2 --> -<interface> - <requires lib="gtk+" version="3.24"/> - <object class="GtkBox" id="drop_area"> - <property name="name">DropArea</property> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="no-show-all">True</property> - <property name="opacity">0.90</property> - <property name="hexpand">True</property> - <property name="vexpand">True</property> - <property name="orientation">vertical</property> - <property name="spacing">18</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="valign">end</property> - <property name="vexpand">True</property> - <property name="icon-name">mail-attachment-symbolic</property> - <property name="icon_size">6</property> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkLabel"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="valign">start</property> - <property name="vexpand">True</property> - <property name="label" translatable="yes">Drop Files or Contacts</property> - <property name="wrap">True</property> - <property name="max-width-chars">40</property> - <style> - <class name="bold16"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - </object> - <object class="GtkMenu" id="formattings_menu"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <child> - <object class="GtkMenuItem" id="bold"> - <property name="name">bold</property> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="label" translatable="yes">Bold</property> - <signal name="activate" handler="_on_formatting_menuitem_activate" swapped="no"/> - </object> - </child> - <child> - <object class="GtkMenuItem" id="italic"> - <property name="name">italic</property> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="label" translatable="yes">Italic</property> - <signal name="activate" handler="_on_formatting_menuitem_activate" swapped="no"/> - </object> - </child> - <child> - <object class="GtkMenuItem" id="strike"> - <property name="name">strike</property> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="label" translatable="yes">Strike</property> - <signal name="activate" handler="_on_formatting_menuitem_activate" swapped="no"/> - </object> - </child> - </object> - <object class="GtkBox" id="groupchat_control_hbox"> - <property name="can-focus">True</property> - <child> - <object class="GtkOverlay" id="overlay"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <child> - <object class="GtkStack" id="stack"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <signal name="notify::visible-child-name" handler="_on_page_change" swapped="no"/> - <child> - <object class="GtkBox" id="groupchat_control_vbox"> - <property name="can-focus">True</property> - <property name="margin-start">7</property> - <property name="margin-end">7</property> - <property name="margin-top">5</property> - <property name="margin-bottom">7</property> - <property name="spacing">3</property> - <child> - <object class="GtkBox" id="textview_box"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="margin-end">4</property> - <property name="hexpand">True</property> - <property name="orientation">vertical</property> - <child> - <object class="GtkEventBox" id="banner_eventbox"> - <property name="name">GroupChatControl-BannerEventBox</property> - <property name="can-focus">False</property> - <property name="no-show-all">True</property> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="spacing">6</property> - <child> - <object class="GtkImage" id="avatar_image"> - <property name="width-request">48</property> - <property name="height-request">48</property> - <property name="visible">True</property> - <property name="can-focus">False</property> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkLabel" id="banner_name_label"> - <property name="name">GroupChatControl-BannerNameLabel</property> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="hexpand">True</property> - <property name="label">Group Chat Name</property> - <property name="selectable">True</property> - <property name="ellipsize">end</property> - <property name="single-line-mode">True</property> - <property name="xalign">0</property> - <style> - <class name="large-header"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - <child> - <object class="GtkBox" id="visitor_box"> - <property name="can-focus">False</property> - <property name="no-show-all">True</property> - <property name="spacing">12</property> - <child> - <object class="GtkLabel"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="label" translatable="yes">You are a visitor</property> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkMenuButton" id="visitor_menu_button"> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="focus-on-click">False</property> - <property name="receives-default">True</property> - <property name="tooltip-text" translatable="yes">What does this mean?</property> - <property name="valign">center</property> - <property name="popover">visitor_popover</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">dialog-information-symbolic</property> - </object> - </child> - <style> - <class name="suggested-action"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">2</property> - </packing> - </child> - <child> - <object class="GtkBox" id="account_badge_box"> - <property name="can-focus">False</property> - <property name="no-show-all">True</property> - <property name="halign">end</property> - <property name="valign">center</property> - <property name="margin-end">6</property> - <property name="hexpand">True</property> - <property name="orientation">vertical</property> - <child> - <placeholder/> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">3</property> - </packing> - </child> - <child> - <object class="GtkButton" id="toggle_roster_button"> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="tooltip-text" translatable="yes">Toggle participants list</property> - <property name="valign">center</property> - <signal name="clicked" handler="_show_roster" swapped="no"/> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="spacing">3</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">system-users-symbolic</property> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkImage" id="toggle_roster_image"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">go-previous-symbolic</property> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - <style> - <class name="dim-label"/> - </style> - </object> - </child> - <style> - <class name="flat"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">4</property> - </packing> - </child> - </object> - </child> - <style> - <class name="gajim-banner"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkSeparator"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <style> - <class name="chatcontrol-separator-top"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - <child> - <object class="GtkOverlay" id="conv_view_overlay"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <child> - <placeholder/> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">2</property> - </packing> - </child> - <child> - <object class="GtkSeparator"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <style> - <class name="chatcontrol-separator"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">3</property> - </packing> - </child> - <child> - <object class="GtkBox" id="hbox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="events">GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK</property> - <child> - <object class="GtkButton" id="quick_invite_button"> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="tooltip-text" translatable="yes">Invite Contacts…</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">list-add-symbolic</property> - </object> - </child> - <style> - <class name="chatcontrol-actionbar-button"/> - <class name="flat"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="pack-type">end</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkMenuButton" id="settings_menu"> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="relief">none</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">open-menu-symbolic</property> - </object> - </child> - <style> - <class name="chatcontrol-actionbar-button"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="pack-type">end</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkButton" id="authentication_button"> - <property name="can-focus">True</property> - <property name="focus-on-click">False</property> - <property name="receives-default">True</property> - <property name="events">GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK</property> - <property name="no-show-all">True</property> - <property name="relief">none</property> - <signal name="clicked" handler="_on_authentication_button_clicked" swapped="no"/> - <child> - <object class="GtkImage" id="lock_image"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon_size">1</property> - </object> - </child> - <style> - <class name="chatcontrol-actionbar-button"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">False</property> - <property name="pack-type">end</property> - <property name="position">1</property> - </packing> - </child> - <child> - <object class="GtkMenuButton" id="encryption_menu"> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="tooltip-text" translatable="yes">Choose encryption</property> - <property name="relief">none</property> - <child> - <placeholder/> - </child> - <style> - <class name="chatcontrol-actionbar-button"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="pack-type">end</property> - <property name="position">2</property> - </packing> - </child> - <child> - <object class="GtkButton" id="sendfile_button"> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="focus-on-click">False</property> - <property name="receives-default">True</property> - <property name="relief">none</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">mail-attachment-symbolic</property> - </object> - </child> - <style> - <class name="chatcontrol-actionbar-button"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">False</property> - <property name="pack-type">end</property> - <property name="position">3</property> - </packing> - </child> - <child> - <object class="GtkMenuButton" id="emoticons_button"> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="tooltip-text" translatable="yes">Show a list of emojis</property> - <property name="relief">none</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">face-smile-symbolic</property> - </object> - </child> - <style> - <class name="chatcontrol-actionbar-button"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">5</property> - </packing> - </child> - <child> - <object class="GtkButton" id="send_message_button"> - <property name="can-focus">True</property> - <property name="focus-on-click">False</property> - <property name="receives-default">True</property> - <property name="no-show-all">True</property> - <property name="tooltip-text" translatable="yes">Send Message</property> - <property name="relief">none</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">document-send</property> - </object> - </child> - <style> - <class name="chatcontrol-actionbar-button"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">False</property> - <property name="pack-type">end</property> - <property name="position">6</property> - </packing> - </child> - <child> - <object class="GtkMenuButton" id="formattings_button"> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="focus-on-click">False</property> - <property name="receives-default">True</property> - <property name="tooltip-text" translatable="yes">Format your message</property> - <property name="relief">none</property> - <property name="popup">formattings_menu</property> - <property name="direction">up</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">format-text-bold-symbolic</property> - <property name="icon_size">1</property> - </object> - </child> - <style> - <class name="chatcontrol-actionbar-button"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">7</property> - </packing> - </child> - <child> - <placeholder/> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">False</property> - <property name="position">4</property> - </packing> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkRevealer" id="roster_revealer"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="transition-type">none</property> - <child> - <placeholder/> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - </object> - <packing> - <property name="name">groupchat</property> - </packing> - </child> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="orientation">vertical</property> - <child> - <!-- n-columns=1 n-rows=3 --> - <object class="GtkGrid"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">center</property> - <property name="valign">center</property> - <property name="hexpand">True</property> - <property name="vexpand">True</property> - <property name="row-spacing">12</property> - <child> - <object class="GtkLabel"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="label" translatable="yes">Enter Nickname</property> - <property name="xalign">0</property> - <style> - <class name="bold16"/> - </style> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">0</property> - </packing> - </child> - <child> - <object class="GtkEntry" id="nickname_entry"> - <property name="width-request">200</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="activates-default">True</property> - <property name="caps-lock-warning">False</property> - <property name="show-emoji-icon">True</property> - <signal name="notify::text" handler="_on_nickname_text_changed" swapped="no"/> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">1</property> - </packing> - </child> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="homogeneous">True</property> - <child> - <object class="GtkButton"> - <property name="label" translatable="yes">_Cancel</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="halign">start</property> - <property name="valign">start</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_page_cancel_clicked" swapped="no"/> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkButton" id="nickname_change_button"> - <property name="label" translatable="yes">Ch_ange</property> - <property name="visible">True</property> - <property name="sensitive">False</property> - <property name="can-focus">True</property> - <property name="can-default">True</property> - <property name="receives-default">True</property> - <property name="halign">end</property> - <property name="valign">start</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_nickname_change_clicked" swapped="no"/> - <style> - <class name="suggested-action"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">2</property> - </packing> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - </object> - <packing> - <property name="name">nickname</property> - <property name="position">1</property> - </packing> - </child> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="orientation">vertical</property> - <child> - <!-- n-columns=1 n-rows=3 --> - <object class="GtkGrid"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">center</property> - <property name="valign">center</property> - <property name="hexpand">True</property> - <property name="vexpand">True</property> - <property name="row-spacing">12</property> - <child> - <object class="GtkLabel"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="label" translatable="yes">Enter Password</property> - <property name="xalign">0</property> - <style> - <class name="bold16"/> - </style> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">0</property> - </packing> - </child> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="homogeneous">True</property> - <child> - <object class="GtkButton"> - <property name="label" translatable="yes">_Cancel</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="halign">start</property> - <property name="valign">start</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_password_cancel_clicked" swapped="no"/> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkButton" id="password_set_button"> - <property name="label" translatable="yes">_Join</property> - <property name="visible">True</property> - <property name="sensitive">False</property> - <property name="can-focus">True</property> - <property name="can-default">True</property> - <property name="receives-default">True</property> - <property name="halign">end</property> - <property name="valign">start</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_password_set_clicked" swapped="no"/> - <style> - <class name="suggested-action"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">2</property> - </packing> - </child> - <child> - <object class="GtkEntry" id="password_entry"> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="visibility">False</property> - <property name="invisible-char">*</property> - <property name="activates-default">True</property> - <property name="input-purpose">password</property> - <signal name="notify::text" handler="_on_password_changed" swapped="no"/> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">1</property> - </packing> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - </object> - <packing> - <property name="name">password</property> - <property name="position">2</property> - </packing> - </child> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="orientation">vertical</property> - <child> - <!-- n-columns=1 n-rows=2 --> - <object class="GtkGrid"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">center</property> - <property name="valign">center</property> - <property name="hexpand">True</property> - <property name="vexpand">True</property> - <property name="row-spacing">12</property> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="homogeneous">True</property> - <child> - <object class="GtkButton"> - <property name="label" translatable="yes">_Cancel</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="halign">start</property> - <property name="valign">start</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_captcha_cancel_clicked" swapped="no"/> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkButton" id="captcha_set_button"> - <property name="label" translatable="yes">_Join</property> - <property name="visible">True</property> - <property name="sensitive">False</property> - <property name="can-focus">True</property> - <property name="can-default">True</property> - <property name="receives-default">True</property> - <property name="halign">end</property> - <property name="valign">start</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_captcha_set_clicked" swapped="no"/> - <style> - <class name="suggested-action"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">1</property> - </packing> - </child> - <child> - <object class="GtkBox" id="captcha_box"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="orientation">vertical</property> - <child> - <placeholder/> - </child> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">0</property> - </packing> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - </object> - <packing> - <property name="name">captcha</property> - <property name="position">3</property> - </packing> - </child> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="orientation">vertical</property> - <child> - <!-- n-columns=1 n-rows=4 --> - <object class="GtkGrid"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">center</property> - <property name="valign">center</property> - <property name="hexpand">True</property> - <property name="vexpand">True</property> - <property name="row-spacing">12</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">dialog-error-symbolic</property> - <property name="icon_size">6</property> - <style> - <class name="error-color"/> - </style> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">0</property> - </packing> - </child> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">center</property> - <property name="spacing">6</property> - <child> - <object class="GtkButton" id="remove_bookmark_button"> - <property name="label" translatable="yes">_Forget Group Chat</property> - <property name="can-focus">True</property> - <property name="receives-default">False</property> - <property name="no-show-all">True</property> - <property name="tooltip-text" translatable="yes">Gajim will not try to join this group chat again</property> - <property name="halign">start</property> - <property name="valign">start</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_remove_bookmark_button_clicked" swapped="no"/> - <style> - <class name="destructive-action"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkButton" id="retry_button"> - <property name="label" translatable="yes">T_ry Again</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_retry_join_clicked" swapped="no"/> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - <child> - <object class="GtkButton" id="close_button"> - <property name="label" translatable="yes">_Close</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="can-default">True</property> - <property name="receives-default">True</property> - <property name="halign">end</property> - <property name="valign">start</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_page_close_clicked" swapped="no"/> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">2</property> - </packing> - </child> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">3</property> - </packing> - </child> - <child> - <object class="GtkLabel" id="error_label"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="justify">center</property> - <property name="wrap">True</property> - <property name="max-width-chars">40</property> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">2</property> - </packing> - </child> - <child> - <object class="GtkLabel" id="error_heading"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="label" translatable="yes">An Error Occurred</property> - <property name="justify">center</property> - <property name="wrap">True</property> - <property name="max-width-chars">40</property> - <style> - <class name="bold16"/> - </style> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">1</property> - </packing> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - </object> - <packing> - <property name="name">error</property> - <property name="position">4</property> - </packing> - </child> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="orientation">vertical</property> - <child> - <!-- n-columns=1 n-rows=3 --> - <object class="GtkGrid"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">center</property> - <property name="valign">center</property> - <property name="hexpand">True</property> - <property name="vexpand">True</property> - <property name="row-spacing">12</property> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="homogeneous">True</property> - <child> - <object class="GtkButton" id="captcha_close_button"> - <property name="label" translatable="yes">_Close</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="can-default">True</property> - <property name="receives-default">True</property> - <property name="halign">center</property> - <property name="valign">start</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_page_close_clicked" swapped="no"/> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkButton" id="captcha_try_again_button"> - <property name="label" translatable="yes">_Try Again</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="can-default">True</property> - <property name="receives-default">True</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_captcha_try_again_clicked" swapped="no"/> - <style> - <class name="suggested-action"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">2</property> - </packing> - </child> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="icon-name">dialog-error-symbolic</property> - <property name="icon_size">6</property> - <style> - <class name="error-color"/> - </style> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">0</property> - </packing> - </child> - <child> - <object class="GtkLabel" id="captcha_error_label"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="justify">center</property> - <property name="wrap">True</property> - <property name="max-width-chars">40</property> - <style> - <class name="bold16"/> - </style> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">1</property> - </packing> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - </object> - <packing> - <property name="name">captcha-error</property> - <property name="position">5</property> - </packing> - </child> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="orientation">vertical</property> - <child> - <!-- n-columns=1 n-rows=4 --> - <object class="GtkGrid"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">center</property> - <property name="valign">center</property> - <property name="hexpand">True</property> - <property name="vexpand">True</property> - <property name="row-spacing">6</property> - <child> - <object class="GtkLabel" id="kick_label"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="margin-bottom">6</property> - <property name="label" translatable="yes">Kick Participant</property> - <property name="ellipsize">end</property> - <property name="single-line-mode">True</property> - <property name="max-width-chars">22</property> - <property name="xalign">0</property> - <style> - <class name="bold16"/> - </style> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">0</property> - </packing> - </child> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="homogeneous">True</property> - <child> - <object class="GtkButton"> - <property name="label" translatable="yes">_Cancel</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="halign">start</property> - <property name="valign">start</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_page_cancel_clicked" swapped="no"/> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkButton" id="kick_participant_button"> - <property name="label" translatable="yes">_Kick</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="can-default">True</property> - <property name="receives-default">True</property> - <property name="halign">end</property> - <property name="valign">start</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_kick_participant_clicked" swapped="no"/> - <style> - <class name="destructive-action"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">3</property> - </packing> - </child> - <child> - <object class="GtkEntry" id="kick_reason_entry"> - <property name="width-request">200</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="margin-bottom">6</property> - <property name="activates-default">True</property> - <property name="caps-lock-warning">False</property> - <property name="secondary-icon-tooltip-text" translatable="yes">Insert Emoji</property> - <property name="show-emoji-icon">True</property> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">2</property> - </packing> - </child> - <child> - <object class="GtkLabel"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">start</property> - <property name="label" translatable="yes">Reason (optional)</property> - <style> - <class name="dim-label"/> - </style> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">1</property> - </packing> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - </object> - <packing> - <property name="name">kick</property> - <property name="position">6</property> - </packing> - </child> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="orientation">vertical</property> - <child> - <!-- n-columns=1 n-rows=4 --> - <object class="GtkGrid"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">center</property> - <property name="valign">center</property> - <property name="hexpand">True</property> - <property name="vexpand">True</property> - <property name="row-spacing">6</property> - <child> - <object class="GtkLabel" id="ban_label"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="margin-bottom">6</property> - <property name="label" translatable="yes">Ban Participant</property> - <property name="ellipsize">end</property> - <property name="single-line-mode">True</property> - <property name="max-width-chars">22</property> - <property name="xalign">0</property> - <style> - <class name="bold16"/> - </style> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">0</property> - </packing> - </child> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="homogeneous">True</property> - <child> - <object class="GtkButton"> - <property name="label" translatable="yes">_Cancel</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="halign">start</property> - <property name="valign">start</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_page_cancel_clicked" swapped="no"/> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkButton" id="ban_participant_button"> - <property name="label" translatable="yes">_Ban</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="can-default">True</property> - <property name="receives-default">True</property> - <property name="halign">end</property> - <property name="valign">start</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_ban_participant_clicked" swapped="no"/> - <style> - <class name="destructive-action"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">3</property> - </packing> - </child> - <child> - <object class="GtkEntry" id="ban_reason_entry"> - <property name="width-request">200</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="margin-bottom">6</property> - <property name="activates-default">True</property> - <property name="caps-lock-warning">False</property> - <property name="secondary-icon-tooltip-text" translatable="yes">Insert Emoji</property> - <property name="show-emoji-icon">True</property> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">2</property> - </packing> - </child> - <child> - <object class="GtkLabel"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">start</property> - <property name="label" translatable="yes">Reason (optional)</property> - <style> - <class name="dim-label"/> - </style> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">1</property> - </packing> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - </object> - <packing> - <property name="name">ban</property> - <property name="position">7</property> - </packing> - </child> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="orientation">vertical</property> - <child> - <!-- n-columns=1 n-rows=6 --> - <object class="GtkGrid"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">center</property> - <property name="valign">center</property> - <property name="hexpand">True</property> - <property name="vexpand">True</property> - <property name="row-spacing">6</property> - <child> - <object class="GtkLabel"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="margin-bottom">6</property> - <property name="label" translatable="yes">Destroy This Chat</property> - <property name="ellipsize">end</property> - <property name="single-line-mode">True</property> - <property name="max-width-chars">22</property> - <property name="xalign">0</property> - <style> - <class name="bold16"/> - </style> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">0</property> - </packing> - </child> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="homogeneous">True</property> - <child> - <object class="GtkButton"> - <property name="label" translatable="yes">_Cancel</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="halign">start</property> - <property name="valign">start</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_page_cancel_clicked" swapped="no"/> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkButton" id="destroy_button"> - <property name="label" translatable="yes">_Destroy</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="can-default">True</property> - <property name="receives-default">True</property> - <property name="halign">end</property> - <property name="valign">start</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_destroy_confirm" swapped="no"/> - <style> - <class name="destructive-action"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">5</property> - </packing> - </child> - <child> - <object class="GtkEntry" id="destroy_alternate_entry"> - <property name="width-request">200</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="margin-bottom">6</property> - <property name="activates-default">True</property> - <property name="caps-lock-warning">False</property> - <property name="secondary-icon-tooltip-text" translatable="yes">Insert Emoji</property> - <property name="placeholder-text" translatable="yes">Alternate venue (optional)...</property> - <signal name="notify::text" handler="_on_destroy_alternate_changed" swapped="no"/> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">4</property> - </packing> - </child> - <child> - <object class="GtkEntry" id="destroy_reason_entry"> - <property name="width-request">200</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="margin-bottom">6</property> - <property name="activates-default">True</property> - <property name="caps-lock-warning">False</property> - <property name="secondary-icon-tooltip-text" translatable="yes">Insert Emoji</property> - <property name="placeholder-text" translatable="yes">Reason (optional)...</property> - <property name="show-emoji-icon">True</property> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">2</property> - </packing> - </child> - <child> - <object class="GtkLabel"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">start</property> - <property name="label" translatable="yes">Reason for destruction</property> - <style> - <class name="dim-label"/> - </style> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">1</property> - </packing> - </child> - <child> - <object class="GtkLabel"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">start</property> - <property name="label" translatable="yes">Where participants should go</property> - <style> - <class name="dim-label"/> - </style> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">3</property> - </packing> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - </object> - <packing> - <property name="name">destroy</property> - <property name="position">8</property> - </packing> - </child> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="orientation">vertical</property> - <child> - <!-- n-columns=1 n-rows=2 --> - <object class="GtkGrid" id="invite_grid"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="halign">center</property> - <property name="hexpand">True</property> - <property name="vexpand">True</property> - <property name="border-width">18</property> - <property name="row-spacing">6</property> - <property name="column-spacing">12</property> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="margin-top">6</property> - <property name="spacing">12</property> - <child> - <object class="GtkButton"> - <property name="label" translatable="yes">_Cancel</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_page_cancel_clicked" swapped="no"/> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkButton" id="invite_button"> - <property name="label" translatable="yes">_Invite</property> - <property name="visible">True</property> - <property name="sensitive">False</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_invite_clicked" swapped="no"/> - <style> - <class name="suggested-action"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="pack-type">end</property> - <property name="position">1</property> - </packing> - </child> - </object> - <packing> - <property name="left-attach">0</property> - <property name="top-attach">1</property> - </packing> - </child> - <child> - <placeholder/> - </child> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - </object> - <packing> - <property name="name">invite</property> - <property name="position">9</property> - </packing> - </child> - <child> - <placeholder/> - </child> - <child> - <placeholder/> - </child> - </object> - <packing> - <property name="index">-1</property> - </packing> - </child> - </object> - <packing> - <property name="expand">True</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - </object> - <object class="GtkPopover" id="visitor_popover"> - <property name="can-focus">False</property> - <property name="relative-to">visitor_menu_button</property> - <property name="position">bottom</property> - <child> - <object class="GtkBox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="orientation">vertical</property> - <property name="spacing">6</property> - <child> - <object class="GtkLabel"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="label" translatable="yes">You are a visitor</property> - <style> - <class name="bold"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkLabel"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="label" translatable="yes">In order to write messages in this chat, you need to request voice first. -A moderator will process your request.</property> - <property name="wrap">True</property> - <property name="max-width-chars">30</property> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">1</property> - </packing> - </child> - <child> - <object class="GtkButton"> - <property name="label" translatable="yes">_Request</property> - <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="receives-default">True</property> - <property name="halign">center</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_request_voice_clicked" swapped="no"/> - <style> - <class name="suggested-action"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="pack-type">end</property> - <property name="position">2</property> - </packing> - </child> - <style> - <class name="padding-6"/> - </style> - </object> - </child> - </object> -</interface> diff --git a/gajim/data/gui/main.ui b/gajim/data/gui/main.ui index 6a4609f49..f13ecd504 100644 --- a/gajim/data/gui/main.ui +++ b/gajim/data/gui/main.ui @@ -42,7 +42,7 @@ <property name="visible">True</property> <property name="can-focus">False</property> <property name="orientation">vertical</property> - <property name="spacing">12</property> + <property name="spacing">5</property> <child> <placeholder/> </child> diff --git a/gajim/data/gui/message_actions_box.ui b/gajim/data/gui/message_actions_box.ui new file mode 100644 index 000000000..29e94a00b --- /dev/null +++ b/gajim/data/gui/message_actions_box.ui @@ -0,0 +1,249 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Generated with glade 3.38.2 --> +<interface> + <requires lib="gtk+" version="3.24"/> + <object class="GtkBox" id="box"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="events">GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK</property> + <property name="hexpand">True</property> + <property name="spacing">2</property> + <child> + <object class="GtkButton" id="quick_invite_button"> + <property name="visible">True</property> + <property name="can-focus">True</property> + <property name="receives-default">True</property> + <property name="tooltip-text" translatable="yes">Invite Contacts…</property> + <property name="action-name">win.invite</property> + <property name="relief">none</property> + <child> + <object class="GtkImage"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="icon-name">list-add-symbolic</property> + </object> + </child> + <style> + <class name="chatcontrol-actionbar-button"/> + <class name="flat"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="pack-type">end</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkMenuButton" id="settings_menu"> + <property name="visible">True</property> + <property name="can-focus">True</property> + <property name="receives-default">True</property> + <property name="relief">none</property> + <child> + <object class="GtkImage"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="icon-name">open-menu-symbolic</property> + </object> + </child> + <style> + <class name="chatcontrol-actionbar-button"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="pack-type">end</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkButton" id="encryption_details_button"> + <property name="can-focus">True</property> + <property name="focus-on-click">False</property> + <property name="receives-default">True</property> + <property name="events">GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK</property> + <property name="no-show-all">True</property> + <property name="relief">none</property> + <signal name="clicked" handler="_on_encryption_details_clicked" swapped="no"/> + <child> + <object class="GtkImage" id="encryption_details_image"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="icon_size">1</property> + </object> + </child> + <style> + <class name="chatcontrol-actionbar-button"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="pack-type">end</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkMenuButton" id="encryption_menu_button"> + <property name="visible">True</property> + <property name="can-focus">True</property> + <property name="receives-default">True</property> + <property name="tooltip-text" translatable="yes">Choose encryption</property> + <property name="relief">none</property> + <child> + <object class="GtkImage" id="encryption_image"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="icon-name">channel-insecure-symbolic</property> + </object> + </child> + <style> + <class name="chatcontrol-actionbar-button"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="pack-type">end</property> + <property name="position">2</property> + </packing> + </child> + <child> + <object class="GtkButton" id="sendfile_button"> + <property name="visible">True</property> + <property name="can-focus">True</property> + <property name="focus-on-click">False</property> + <property name="receives-default">True</property> + <property name="action-name">win.send-file</property> + <property name="relief">none</property> + <child> + <object class="GtkImage"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="icon-name">mail-attachment-symbolic</property> + </object> + </child> + <style> + <class name="chatcontrol-actionbar-button"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="pack-type">end</property> + <property name="position">3</property> + </packing> + </child> + <child> + <object class="GtkMenuButton" id="emoticons_button"> + <property name="visible">True</property> + <property name="can-focus">True</property> + <property name="receives-default">True</property> + <property name="tooltip-text" translatable="yes">Show a list of emojis</property> + <property name="relief">none</property> + <child> + <object class="GtkImage"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="icon-name">face-smile-symbolic</property> + </object> + </child> + <style> + <class name="chatcontrol-actionbar-button"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">5</property> + </packing> + </child> + <child> + <object class="GtkButton" id="send_message_button"> + <property name="can-focus">True</property> + <property name="focus-on-click">False</property> + <property name="receives-default">True</property> + <property name="no-show-all">True</property> + <property name="tooltip-text" translatable="yes">Send Message</property> + <property name="action-name">win.send-message</property> + <property name="relief">none</property> + <child> + <object class="GtkImage"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="icon-name">document-send</property> + </object> + </child> + <style> + <class name="chatcontrol-actionbar-button"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="pack-type">end</property> + <property name="position">6</property> + </packing> + </child> + <child> + <object class="GtkMenuButton" id="formattings_button"> + <property name="visible">True</property> + <property name="can-focus">True</property> + <property name="focus-on-click">False</property> + <property name="receives-default">True</property> + <property name="tooltip-text" translatable="yes">Format your message</property> + <property name="relief">none</property> + <property name="direction">up</property> + <child> + <object class="GtkImage"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="icon-name">format-text-bold-symbolic</property> + <property name="icon_size">1</property> + </object> + </child> + <style> + <class name="chatcontrol-actionbar-button"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">7</property> + </packing> + </child> + <child> + <placeholder/> + </child> + <child> + <object class="GtkScrolledWindow" id="input_scrolled"> + <property name="visible">True</property> + <property name="can-focus">True</property> + <property name="margin-start">3</property> + <property name="margin-end">3</property> + <property name="shadow-type">in</property> + <property name="overlay-scrolling">False</property> + <property name="max-content-height">100</property> + <property name="propagate-natural-height">True</property> + <child> + <placeholder/> + </child> + <style> + <class name="message-input-border"/> + <class name="scrolled-no-border"/> + <class name="no-scroll-indicator"/> + <class name="scrollbar-style"/> + <class name="one-line-scrollbar"/> + </style> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">9</property> + </packing> + </child> + </object> +</interface> diff --git a/gajim/data/gui/shortcuts_window.ui b/gajim/data/gui/shortcuts_window.ui index 8b173e7fc..fd4c4743c 100644 --- a/gajim/data/gui/shortcuts_window.ui +++ b/gajim/data/gui/shortcuts_window.ui @@ -103,34 +103,6 @@ <child> <object class="GtkShortcutsShortcut"> <property name="visible">1</property> - <property name="accelerator"><primary>Up</property> - <property name="title" translatable="yes">Previously sent message</property> - </object> - </child> - <child> - <object class="GtkShortcutsShortcut"> - <property name="visible">1</property> - <property name="accelerator"><primary>Down</property> - <property name="title" translatable="yes">Next sent messages</property> - </object> - </child> - <child> - <object class="GtkShortcutsShortcut"> - <property name="visible">1</property> - <property name="accelerator"><primary><shift>Up</property> - <property name="title" translatable="yes">Quote previous message</property> - </object> - </child> - <child> - <object class="GtkShortcutsShortcut"> - <property name="visible">1</property> - <property name="accelerator"><primary><shift>Down</property> - <property name="title" translatable="yes">Quote next message</property> - </object> - </child> - <child> - <object class="GtkShortcutsShortcut"> - <property name="visible">1</property> <property name="accelerator"><primary>u</property> <property name="title" translatable="yes">Clear message entry</property> </object> diff --git a/gajim/data/other/shortcuts.json b/gajim/data/other/shortcuts.json index 2cc0887e8..9c3feeb64 100644 --- a/gajim/data/other/shortcuts.json +++ b/gajim/data/other/shortcuts.json @@ -15,7 +15,7 @@ "win.show-contact-info": ["<Primary>I"], "win.show-emoji-chooser": ["<Primary><Shift>M"], "win.clear-chat": ["<Primary>L"], - "win.delete-line": ["<Primary>U"], + "win.input-clear": ["<Primary>U"], "win.close-tab": ["<Primary>W"], "win.switch-next-tab": ["<Primary>Page_Down"], "win.switch-prev-tab": ["<Primary>Page_Up"], diff --git a/gajim/data/style/gajim.css b/gajim/data/style/gajim.css index 58c4ee7f6..24ec7eed7 100644 --- a/gajim/data/style/gajim.css +++ b/gajim/data/style/gajim.css @@ -6,6 +6,8 @@ border: none; } +.message-action-box { margin: 8px 13px 8px 8px } + .no-border { border: none; } .scrolled-no-border { border: none; } .no-scroll-indicator undershoot.top, undershoot.bottom { background-image: none; } @@ -184,10 +186,10 @@ row:backdrop { transition: none; } padding: 4px; } .app-page-box { - margin-top: 6px; + margin-top: 3px; } .app-page-box > separator { - background-color: darker(@theme_bg_color); + background-color: @unfocused_borders; margin-left: 12px; margin-right: 12px; } diff --git a/gajim/gtk/account_page.py b/gajim/gtk/account_page.py index 6909e748b..bccf11b42 100644 --- a/gajim/gtk/account_page.py +++ b/gajim/gtk/account_page.py @@ -75,7 +75,7 @@ class AccountPage(Gtk.Box, EventHelper): roster_menu = Gio.Menu() for action, label in ROSTER_MENU_DICT.items(): - roster_menu.append(label, f'win.{action}-{account}') + roster_menu.append(label, f'win.{action}') self._ui.roster_menu_button.set_menu_model(roster_menu) self._ui.connect_signals(self) diff --git a/gajim/gtk/accounts.py b/gajim/gtk/accounts.py index bd68ff189..8a17044e8 100644 --- a/gajim/gtk/accounts.py +++ b/gajim/gtk/accounts.py @@ -872,8 +872,6 @@ class PrivacyPage(GenericSettingPage): app.settings.set_contact_settings('send_marker', None) app.settings.set_group_chat_settings( 'send_marker', None, context='private') - for ctrl in app.window.get_controls(account=self._account): - ctrl.update_actions() class ConnectionPage(GenericSettingPage): diff --git a/gajim/gtk/application.py b/gajim/gtk/application.py index 89ed1ce01..c3d6dd106 100644 --- a/gajim/gtk/application.py +++ b/gajim/gtk/application.py @@ -526,6 +526,8 @@ class GajimApplication(Gtk.Application, CoreApplication): elif event.feature == Namespace.BLOCKING: action = '%s-blocking' % event.account self.set_action_state(action, True) + action = '%s-block-contact' % event.account + self.set_action_state(action, True) # Action Callbacks diff --git a/gajim/gtk/builder.pyi b/gajim/gtk/builder.pyi index 690e78b7d..4b2b926f1 100644 --- a/gajim/gtk/builder.pyi +++ b/gajim/gtk/builder.pyi @@ -195,32 +195,26 @@ class CertificateBuilder(Builder): image1: Gtk.Image -class ChatControlBuilder(Builder): - drop_area: Gtk.Box - formattings_menu: Gtk.Menu - bold: Gtk.MenuItem - italic: Gtk.MenuItem - strike: Gtk.MenuItem - chat_control_hbox: Gtk.Box - overlay: Gtk.Overlay - textview_box: Gtk.Box - banner_eventbox: Gtk.EventBox - avatar_eventbox: Gtk.EventBox +class ChatBannerBuilder(Builder): + banner_box: Gtk.Box avatar_image: Gtk.Image - banner_name_label: Gtk.Label - banner_label: Gtk.Label + name_label: Gtk.Label phone_image: Gtk.Image + toggle_roster_button: Gtk.Button + toggle_roster_image: Gtk.Image account_badge_box: Gtk.Box + visitor_box: Gtk.Box + visitor_menu_button: Gtk.MenuButton + visitor_popover: Gtk.Popover + + +class ChatControlBuilder(Builder): + control_box: Gtk.Box + overlay: Gtk.Overlay + conv_view_box: Gtk.Box conv_view_overlay: Gtk.Overlay - hbox: Gtk.Box - emoticons_button: Gtk.MenuButton - formattings_button: Gtk.MenuButton - settings_menu: Gtk.MenuButton - authentication_button: Gtk.Button - lock_image: Gtk.Image - encryption_menu: Gtk.MenuButton - sendfile_button: Gtk.Button - send_message_button: Gtk.Button + roster_revealer: Gtk.Revealer + drop_area: Gtk.Box class ChatListRowBuilder(Builder): @@ -395,65 +389,6 @@ class GroupchatConfigBuilder(Builder): error_label: Gtk.Label -class GroupchatControlBuilder(Builder): - drop_area: Gtk.Box - formattings_menu: Gtk.Menu - bold: Gtk.MenuItem - italic: Gtk.MenuItem - strike: Gtk.MenuItem - groupchat_control_hbox: Gtk.Box - overlay: Gtk.Overlay - stack: Gtk.Stack - groupchat_control_vbox: Gtk.Box - textview_box: Gtk.Box - banner_eventbox: Gtk.EventBox - avatar_image: Gtk.Image - banner_name_label: Gtk.Label - visitor_box: Gtk.Box - visitor_menu_button: Gtk.MenuButton - account_badge_box: Gtk.Box - toggle_roster_button: Gtk.Button - toggle_roster_image: Gtk.Image - conv_view_overlay: Gtk.Overlay - hbox: Gtk.Box - quick_invite_button: Gtk.Button - settings_menu: Gtk.MenuButton - authentication_button: Gtk.Button - lock_image: Gtk.Image - encryption_menu: Gtk.MenuButton - sendfile_button: Gtk.Button - emoticons_button: Gtk.MenuButton - send_message_button: Gtk.Button - formattings_button: Gtk.MenuButton - roster_revealer: Gtk.Revealer - nickname_entry: Gtk.Entry - nickname_change_button: Gtk.Button - password_set_button: Gtk.Button - password_entry: Gtk.Entry - captcha_set_button: Gtk.Button - captcha_box: Gtk.Box - remove_bookmark_button: Gtk.Button - retry_button: Gtk.Button - close_button: Gtk.Button - error_label: Gtk.Label - error_heading: Gtk.Label - captcha_close_button: Gtk.Button - captcha_try_again_button: Gtk.Button - captcha_error_label: Gtk.Label - kick_label: Gtk.Label - kick_participant_button: Gtk.Button - kick_reason_entry: Gtk.Entry - ban_label: Gtk.Label - ban_participant_button: Gtk.Button - ban_reason_entry: Gtk.Entry - destroy_button: Gtk.Button - destroy_alternate_entry: Gtk.Entry - destroy_reason_entry: Gtk.Entry - invite_grid: Gtk.Grid - invite_button: Gtk.Button - visitor_popover: Gtk.Popover - - class GroupchatCreationBuilder(Builder): account_liststore: Gtk.ListStore stack: Gtk.Stack @@ -652,6 +587,21 @@ class ManageSoundsBuilder(Builder): filechooser: Gtk.FileChooserButton +class MessageActionsBoxBuilder(Builder): + box: Gtk.Box + quick_invite_button: Gtk.Button + settings_menu: Gtk.MenuButton + encryption_details_button: Gtk.Button + encryption_details_image: Gtk.Image + encryption_menu_button: Gtk.MenuButton + encryption_image: Gtk.Image + sendfile_button: Gtk.Button + emoticons_button: Gtk.MenuButton + send_message_button: Gtk.Button + formattings_button: Gtk.MenuButton + input_scrolled: Gtk.ScrolledWindow + + class PasswordDialogBuilder(Builder): pass_box: Gtk.Box header: Gtk.Label @@ -1002,6 +952,8 @@ def get_builder(file_name: Literal['call_window.ui'], widgets: list[str] = ...) @overload def get_builder(file_name: Literal['certificate.ui'], widgets: list[str] = ...) -> CertificateBuilder: ... @overload +def get_builder(file_name: Literal['chat_banner.ui'], widgets: list[str] = ...) -> ChatBannerBuilder: ... +@overload def get_builder(file_name: Literal['chat_control.ui'], widgets: list[str] = ...) -> ChatControlBuilder: ... @overload def get_builder(file_name: Literal['chat_list_row.ui'], widgets: list[str] = ...) -> ChatListRowBuilder: ... @@ -1026,8 +978,6 @@ def get_builder(file_name: Literal['groupchat_affiliation.ui'], widgets: list[st @overload def get_builder(file_name: Literal['groupchat_config.ui'], widgets: list[str] = ...) -> GroupchatConfigBuilder: ... @overload -def get_builder(file_name: Literal['groupchat_control.ui'], widgets: list[str] = ...) -> GroupchatControlBuilder: ... -@overload def get_builder(file_name: Literal['groupchat_creation.ui'], widgets: list[str] = ...) -> GroupchatCreationBuilder: ... @overload def get_builder(file_name: Literal['groupchat_details.ui'], widgets: list[str] = ...) -> GroupchatDetailsBuilder: ... @@ -1062,6 +1012,8 @@ def get_builder(file_name: Literal['manage_proxies.ui'], widgets: list[str] = .. @overload def get_builder(file_name: Literal['manage_sounds.ui'], widgets: list[str] = ...) -> ManageSoundsBuilder: ... @overload +def get_builder(file_name: Literal['message_actions_box.ui'], widgets: list[str] = ...) -> MessageActionsBoxBuilder: ... +@overload def get_builder(file_name: Literal['password_dialog.ui'], widgets: list[str] = ...) -> PasswordDialogBuilder: ... @overload def get_builder(file_name: Literal['plugins.ui'], widgets: list[str] = ...) -> PluginsBuilder: ... diff --git a/gajim/gtk/chat_action_processor.py b/gajim/gtk/chat_action_processor.py index 6dbe82571..d745e9f4e 100644 --- a/gajim/gtk/chat_action_processor.py +++ b/gajim/gtk/chat_action_processor.py @@ -38,11 +38,7 @@ MAX_ENTRIES = 5 class ChatActionProcessor(Gtk.Popover): - def __init__(self, - account: str, - contact: types.ChatContactT, - message_input: MessageInputTextView - ) -> None: + def __init__(self, message_input: MessageInputTextView) -> None: Gtk.Popover.__init__(self) self._menu = Gio.Menu() self.bind_model(self._menu) @@ -53,23 +49,28 @@ class ChatActionProcessor(Gtk.Popover): self.connect('closed', self._on_popover_closed) self.connect('destroy', self._on_destroy) + self._account: Optional[str] = None + self._contact: Optional[types.ChatContactT] = None + self._message_input = message_input self._message_input.connect('key-press-event', self._on_key_press) self._buf = message_input.get_buffer() self._buf.connect('changed', self._on_changed) - self._nick_completion: Optional[GroupChatNickCompletion] = None - if contact.is_groupchat: - assert isinstance(contact, GroupchatContact) - self._nick_completion = GroupChatNickCompletion( - account, contact, message_input) + self._nick_completion = GroupChatNickCompletion() self._start_mark: Optional[Gtk.TextMark] = None self._current_iter: Optional[Gtk.TextIter] = None self._active = False + def switch_contact(self, contact: types.ChatContactT) -> None: + self._account = contact.account + self._contact = contact + if isinstance(contact, GroupchatContact): + self._nick_completion.switch_contact(contact) + def _on_destroy(self, _popover: Gtk.Popover) -> None: app.check_finalize(self) @@ -77,7 +78,7 @@ class ChatActionProcessor(Gtk.Popover): textview: MessageInputTextView, event: Gdk.EventKey ) -> bool: - if self._nick_completion is not None: + if isinstance(self._contact, GroupchatContact): res = self._nick_completion.process_key_press(textview, event) if res: return True @@ -122,8 +123,9 @@ class ChatActionProcessor(Gtk.Popover): def _get_commands(self) -> list[str]: commands: list[str] = [] - control = app.window.get_control( - self._message_input.account, self._message_input.contact.jid) + assert self._account + assert self._contact + control = app.window.get_control(self._account, self._contact.jid) assert control is not None for command in control.list_commands(): for name in command.names: @@ -324,7 +326,3 @@ class ChatActionProcessor(Gtk.Popover): def _item_has_focus(item: Gtk.ModelButton) -> bool: flags = item.get_state_flags() return 'GTK_STATE_FLAG_FOCUSED' in str(flags) - - def process_outgoing_message(self, contact: str, highlight: bool) -> None: - if self._nick_completion is not None: - self._nick_completion.record_message(contact, highlight) diff --git a/gajim/gtk/chat_banner.py b/gajim/gtk/chat_banner.py new file mode 100644 index 000000000..b69f87e35 --- /dev/null +++ b/gajim/gtk/chat_banner.py @@ -0,0 +1,319 @@ +# 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 __future__ import annotations + +from typing import Any +from typing import Optional + +from gi.repository import Gtk + +import cairo + +from nbxmpp.protocol import JID +from nbxmpp.structs import PresenceProperties + +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 AccountEnabled +from gajim.common.events import BookmarksReceived +from gajim.common.events import MessageReceived +from gajim.common.events import MucDiscoUpdate +from gajim.common.ged import EventHelper +from gajim.common.helpers import get_uf_chatstate +from gajim.common.i18n import _ +from gajim.common.modules.contacts import BareContact +from gajim.common.modules.contacts import GroupchatContact +from gajim.common.modules.contacts import GroupchatParticipant + +from .builder import get_builder +from .util import AccountBadge + + +class ChatBanner(Gtk.Box, EventHelper): + def __init__(self) -> None: + Gtk.Box.__init__(self) + EventHelper.__init__(self) + + self._client: Optional[types.Client] = None + self._contact: Optional[types.ChatContactT] = None + + self._ui = get_builder('chat_banner.ui') + self.add(self._ui.banner_box) + self._ui.connect_signals(self) + + self._account_badge: Optional[AccountBadge] = None + + hide_roster = app.settings.get('hide_groupchat_occupants_list') + self._set_toggle_roster_button_icon(hide_roster) + app.settings.connect_signal( + 'hide_groupchat_occupants_list', + self._set_toggle_roster_button_icon) + + self.show_all() + + def clear(self) -> None: + if self._contact is not None: + self._contact.disconnect_all_from_obj(self) + self._contact = None + + if self._client is not None: + self._client.disconnect_all_from_obj(self) + self._client = None + + self.unregister_events() + + self._contact = None + self._client = None + + def switch_contact(self, + account: str, + jid: JID + ) -> None: + + self._update_account_badge(account) + + if self._client is not None: + self._client.disconnect_all_from_obj(self) + + self._client = app.get_client(account) + self._client.connect_signal('state-changed', + self._on_client_state_changed) + + if self._contact is not None: + self._contact.disconnect_all_from_obj(self) + + self._contact = self._client.get_module('Contacts').get_contact(jid) + self._contact.multi_connect({ + 'chatstate-update': self._on_chatstate_update, + 'nickname-update': self._on_nickname_update, + 'avatar-update': self._on_avatar_update, + 'presence-update': self._on_presence_update, + 'caps-update': self._on_caps_update + }) + + if isinstance(self._contact, GroupchatContact): + self._contact.multi_connect({ + 'user-role-changed': self._on_user_role_changed, + 'state-changed': self._on_muc_state_changed + }) + self._ui.toggle_roster_button.show() + hide_banner = app.settings.get('hide_groupchat_banner') + else: + self._ui.toggle_roster_button.hide() + hide_banner = app.settings.get('hide_chat_banner') + + if isinstance(self._contact, GroupchatParticipant): + self._contact.multi_connect({ + 'user-joined': self._on_user_state_changed, + 'user-left': self._on_user_state_changed, + 'user-avatar-update': self._on_user_avatar_update, + 'user-nickname-changed': self._on_user_nickname_changed + }) + + self.register_events([ + ('message-received', ged.GUI1, self._on_message_received), + ('bookmarks-received', ged.GUI1, self._on_bookmarks_received), + ('muc-disco-update', ged.GUI1, self._on_muc_disco_update), + ('account-enabled', ged.GUI2, self._on_account_changed), + ('account-disabled', ged.GUI2, self._on_account_changed) + ]) + + if hide_banner: + self.set_no_show_all(True) + self.hide() + else: + self.set_no_show_all(False) + self.show_all() + + self._update_avatar() + self._update_content() + + def _on_client_state_changed(self, + _client: types.Client, + _signal_name: str, + state: SimpleClientState): + self._update_avatar() + self._update_content() + + def _on_presence_update(self, + _contact: types.BareContact, + _signal_name: str + ) -> None: + self._update_avatar() + + def _on_chatstate_update(self, + _contact: types.BareContact, + _signal_name: str + ) -> None: + self._update_content() + + def _on_nickname_update(self, + _contact: types.BareContact, + _signal_name: str + ) -> None: + self._update_content() + + def _on_avatar_update(self, + _contact: types.BareContact, + _signal_name: str + ) -> None: + self._update_avatar() + + def _on_caps_update(self, + _contact: types.BareContact, + _signal_name: str + ) -> None: + self._update_avatar() + + def _on_muc_state_changed(self, + contact: GroupchatContact, + _signal_name: str + ) -> None: + if contact.is_joined: + self._update_content() + + def _on_user_role_changed(self, + _contact: GroupchatContact, + _signal_name: str, + user_contact: GroupchatParticipant, + properties: PresenceProperties + ) -> None: + self._update_content() + + def _on_user_nickname_changed(self, + user_contact: GroupchatParticipant, + _signal_name: str, + properties: PresenceProperties, + ) -> None: + if user_contact.name != properties.muc_nickname: + return + + self._update_content() + + def _on_user_state_changed(self, *args: Any) -> None: + self._update_avatar() + + def _on_user_avatar_update(self, *args: Any) -> None: + self._update_avatar() + + def _on_bookmarks_received(self, _event: BookmarksReceived) -> None: + self._update_content() + + def _on_muc_disco_update(self, event: MucDiscoUpdate) -> None: + if self._contact is None or event.jid != self._contact.jid: + return + + self._update_content() + + def _on_account_changed(self, event: AccountEnabled) -> None: + self._update_account_badge(event.account) + + def _on_message_received(self, event: MessageReceived) -> None: + if not event.msgtxt: + return + + if event.properties.is_sent_carbon: + return + + assert self._contact is not None + + if event.jid != self._contact.jid: + return + + self._ui.phone_image.set_visible(False) + + if isinstance(self._contact, ( + GroupchatContact, GroupchatParticipant)): + return + + kind = 'outgoing' if event.properties.is_sent_carbon else 'incoming' + if kind == 'outgoing': + return + + if event.resource is not None: + resource_contact = self._contact.get_resource(event.resource) + self._ui.phone_image.set_visible(resource_contact.is_phone) + + def _update_avatar(self) -> None: + scale = app.window.get_scale_factor() + assert self._contact + surface = self._contact.get_avatar(AvatarSize.CHAT, scale) + assert isinstance(surface, cairo.Surface) + self._ui.avatar_image.set_from_surface(surface) + + def _update_content(self) -> None: + if self._client is None or self._contact is None: + return + + name = self._contact.name + + if self._contact.jid.bare_match(self._client.get_own_jid()): + name = _('Note to myself') + + if self._contact.is_pm_contact: + gc_contact = self._client.get_module('Contacts').get_contact( + self._contact.jid.bare) + name = f'{name} ({gc_contact.name})' + + label_text = f'<span>{name}</span>' + label_tooltip = name + show_chatstate = app.settings.get('show_chatstate_in_banner') + if (show_chatstate and isinstance( + self._contact, (BareContact, GroupchatParticipant))): + chatstate = self._contact.chatstate + if chatstate is not None: + chatstate = get_uf_chatstate(chatstate.value) + else: + chatstate = '' + + label_text = f'<span>{name}</span>' \ + f'<span size="x-small" weight="light">' \ + f' {chatstate}</span>' + label_tooltip = f'{name} {chatstate}' + + self._ui.name_label.set_markup(label_text) + self._ui.name_label.set_tooltip_text(label_tooltip) + + if isinstance(self._contact, GroupchatContact): + self_contact = self._contact.get_self() + if self_contact: + self._ui.visitor_box.set_visible(self_contact.role.is_visitor) + + def _update_account_badge(self, account: str) -> None: + if self._account_badge is not None: + self._account_badge.destroy() + + enabled_accounts = app.get_enabled_accounts_with_labels() + if len(enabled_accounts) > 1: + self._account_badge = AccountBadge(account) + self._ui.account_badge_box.add(self._account_badge) + + def _on_request_voice_clicked(self, _button: Gtk.Button) -> None: + self._ui.visitor_popover.popdown() + app.window.activate_action('muc-request-voice', None) + + def _on_toggle_roster_clicked(self, _button: Gtk.Button) -> None: + state = app.settings.get('hide_groupchat_occupants_list') + app.settings.set('hide_groupchat_occupants_list', not state) + + def _set_toggle_roster_button_icon(self, + show_roster: bool, + *args: Any) -> None: + icon = 'go-next-symbolic' if show_roster else 'go-previous-symbolic' + self._ui.toggle_roster_image.set_from_icon_name( + icon, Gtk.IconSize.BUTTON) diff --git a/gajim/gtk/chat_function_page.py b/gajim/gtk/chat_function_page.py new file mode 100644 index 000000000..53fe2fa82 --- /dev/null +++ b/gajim/gtk/chat_function_page.py @@ -0,0 +1,419 @@ +# 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 __future__ import annotations + +from typing import Optional + +from gi.repository import GObject +from gi.repository import Gtk +from gi.repository import Pango + +from nbxmpp.protocol import InvalidJid +from nbxmpp.protocol import JID +from nbxmpp.protocol import validate_resourcepart + +from gajim.common import app +from gajim.common.client import Client +from gajim.common.i18n import _ +from gajim.common.modules.contacts import GroupchatContact +from gajim.common.types import ChatContactT + +from .dataform import DataFormWidget +from .groupchat_inviter import GroupChatInviter + + +class ChatFunctionPage(Gtk.Box): + + __gsignals__ = { + 'finish': (GObject.SignalFlags.RUN_LAST, None, (bool,)), + 'message': (GObject.SignalFlags.RUN_LAST, None, (str,)), + } + + def __init__(self) -> None: + Gtk.Box.__init__(self, + orientation=Gtk.Orientation.VERTICAL, + spacing=18) + self.set_halign(Gtk.Align.CENTER) + self.set_valign(Gtk.Align.CENTER) + self.get_style_context().add_class('padding-18') + + self._client: Optional[Client] = None + self._contact: Optional[ChatContactT] = None + self._mode: Optional[str] = None + + self._widget: Optional[Gtk.Widget] = None + + self._heading = Gtk.Label() + self._heading.set_max_width_chars(30) + self._heading.set_ellipsize(Pango.EllipsizeMode.END) + self._heading.get_style_context().add_class('large-header') + self.add(self._heading) + + self._content_box = Gtk.Box() + self._content_box.set_halign(Gtk.Align.CENTER) + self.add(self._content_box) + + cancel_button = Gtk.Button(label=_('Cancel')) + cancel_button.connect('clicked', self._on_cancel_clicked) + + self._forget_button = Gtk.Button(label=_('Forget Group Chat')) + self._forget_button.set_no_show_all(True) + self._forget_button.get_style_context().add_class( + 'destructive-action') + self._forget_button.connect('clicked', self._on_forget_clicked) + + self._confirm_button = Gtk.Button() + self._confirm_button.set_can_default(True) + self._confirm_button.connect('clicked', self._on_confirm_clicked) + + button_box = Gtk.Box(spacing=18) + button_box.pack_start(cancel_button, False, True, 0) + button_box.pack_end(self._forget_button, False, True, 0) + button_box.pack_end(self._confirm_button, False, True, 0) + + self.add(button_box) + + def process_escape(self) -> None: + close_control = self._mode in ( + 'join-failed', + 'creation-failed', + 'config-failed', + 'captcha-error') + self.emit('finish', close_control) + + def _clear(self) -> None: + for child in self._content_box.get_children(): + child.destroy() + + def set_mode(self, + contact: ChatContactT, + mode: str, + data: Optional[str] = None + ) -> None: + self._clear() + self._confirm_button.get_style_context().remove_class( + 'destructive-action') + self._confirm_button.get_style_context().remove_class( + 'suggested-action') + self._confirm_button.set_sensitive(False) + self._confirm_button.grab_default() + + self._forget_button.hide() + + self._contact = contact + self._heading.set_text(self._contact.name) + self._client = app.get_client(contact.account) + self._mode = mode + self._data = data + + if self._widget is not None: + self._widget.destroy() + + if mode == 'invite': + self._confirm_button.set_label(_('Invite')) + self._confirm_button.get_style_context().add_class( + 'suggested-action') + self._widget = GroupChatInviter(str(contact.jid)) + self._widget.set_size_request(-1, 500) + self._widget.connect('listbox-changed', self._on_ready) + self._widget.load_contacts() + + elif mode == 'change-nickname': + self._confirm_button.set_label(_('Change')) + self._confirm_button.get_style_context().add_class( + 'suggested-action') + self._widget = InputWidget(self._contact, mode) + self._widget.connect('changed', self._on_ready) + + elif mode == 'kick': + self._confirm_button.set_label(_('Kick')) + self._confirm_button.set_sensitive(True) + self._confirm_button.get_style_context().add_class( + 'destructive-action') + self._widget = InputWidget(self._contact, mode, data) + + elif mode == 'ban': + self._confirm_button.set_label(_('Ban')) + self._confirm_button.set_sensitive(True) + self._confirm_button.get_style_context().add_class( + 'destructive-action') + self._widget = InputWidget(self._contact, mode, data) + + elif mode == 'password-request': + self._confirm_button.set_label(_('Join')) + self._confirm_button.get_style_context().add_class( + 'suggested-action') + self._widget = InputWidget(self._contact, mode) + self._widget.connect('changed', self._on_ready) + + elif mode == 'captcha-request': + self._confirm_button.set_label(_('Join')) + self._confirm_button.get_style_context().add_class( + 'suggested-action') + muc_data = self._client.get_module('MUC').get_muc_data( + self._contact.jid) + form = muc_data.captcha_form + options = {'no-scrolling': True, + 'entry-activates-default': True} + self._widget = DataFormWidget(form, options=options) + self._widget.set_valign(Gtk.Align.START) + self._widget.show_all() + self._widget.connect('is-valid', self._on_ready) + + elif mode == 'captcha-error': + self._confirm_button.set_label(_('Try Again')) + self._confirm_button.set_sensitive(True) + self._confirm_button.get_style_context().add_class( + 'suggested-action') + self._widget = ErrorWidget(error_text=data) + + elif mode in ('join-failed', 'creation-failed', 'config-failed'): + self._confirm_button.set_label(_('Try Again')) + self._confirm_button.set_sensitive(True) + self._confirm_button.get_style_context().add_class( + 'suggested-action') + if mode == 'join-failed': + is_bookmark = self._client.get_module('Bookmarks').is_bookmark( + self._contact.jid) + self._forget_button.set_visible(is_bookmark) + self._widget = ErrorWidget(error_mode=mode, error_text=data) + + assert self._widget is not None + self._content_box.add(self._widget) + if isinstance(self._widget, InputWidget): + self._widget.focus() + elif isinstance(self._widget, DataFormWidget): + self._widget.focus_first_entry() + + def _on_ready(self, + _widget: Gtk.Widget, + state: bool + ) -> None: + self._confirm_button.set_sensitive(state) + + def _on_confirm_clicked(self, _button: Gtk.Button) -> None: + if self._mode == 'invite': + assert isinstance(self._widget, GroupChatInviter) + invitees = self._widget.get_invitees() + for jid in invitees: + self._invite(JID.from_string(jid)) + + elif self._mode == 'change-nickname': + assert isinstance(self._widget, InputWidget) + nickname = self._widget.get_text() + assert self._client is not None + assert self._contact is not None + self._client.get_module('MUC').change_nick( + self._contact.jid, nickname) + + elif self._mode == 'kick': + assert isinstance(self._widget, InputWidget) + reason = self._widget.get_text() + assert self._client is not None + assert self._contact is not None + self._client.get_module('MUC').set_role( + self._contact.jid, self._data, 'none', reason) + + elif self._mode == 'ban': + assert isinstance(self._widget, InputWidget) + reason = self._widget.get_text() + assert self._client is not None + assert self._contact is not None + self._client.get_module('MUC').set_affiliation( + self._contact.jid, + {self._data: {'affiliation': 'outcast', 'reason': reason}}) + + elif self._mode == 'password-request': + assert isinstance(self._widget, InputWidget) + password = self._widget.get_text() + assert self._client is not None + assert self._contact is not None + self._client.get_module('MUC').set_password( + self._contact.jid, password) + self._client.get_module('MUC').join(self._contact.jid) + + elif self._mode == 'captcha-request': + assert isinstance(self._widget, DataFormWidget) + form_node = self._widget.get_submit_form() + assert self._client is not None + assert self._contact is not None + self._client.get_module('MUC').send_captcha( + self._contact.jid, form_node) + + elif self._mode in ('join-failed', 'captcha-error'): + assert self._client is not None + assert self._contact is not None + self._client.get_module('MUC').join(self._contact.jid) + + self.emit('finish', False) + + def _on_cancel_clicked(self, _button: Gtk.Button) -> None: + assert self._client is not None + assert self._contact is not None + + if self._mode == 'captcha-request': + self._client.get_module('MUC').cancel_captcha( + self._contact.jid) + self.emit('finish', True) + return + + if self._mode == 'password-request': + self._client.get_module('MUC').cancel_password_request( + self._contact.jid) + self.emit('finish', True) + return + + if self._mode in ( + 'join-failed', + 'creation-failed', + 'config-failed', + 'captcha-error'): + self.emit('finish', True) + return + + self.emit('finish', False) + + def _on_forget_clicked(self, _button: Gtk.Button) -> None: + assert self._client is not None + assert self._contact is not None + self._client.get_module('Bookmarks').remove(self._contact.jid) + self.emit('finish', True) + + def _invite(self, invited_jid: JID) -> None: + assert self._contact is not None + client = app.get_client(self._contact.account) + client.get_module('MUC').invite( + self._contact.jid, invited_jid) + invited_contact = client.get_module('Contacts').get_contact( + invited_jid) + self.emit( + 'message', + _('%s has been invited to this group chat') % invited_contact.name) + + +class InputWidget(Gtk.Box): + + __gsignals__ = { + 'changed': (GObject.SignalFlags.RUN_LAST, None, (bool,)), + } + + def __init__(self, + contact: ChatContactT, + mode: str, + data: Optional[str] = None + ) -> None: + Gtk.Box.__init__(self, + orientation=Gtk.Orientation.VERTICAL, + spacing=12) + self._contact = contact + self._mode = mode + + heading_label = Gtk.Label() + heading_label.set_xalign(0) + heading_label.get_style_context().add_class('bold16') + self.add(heading_label) + + sub_label = Gtk.Label() + sub_label.set_xalign(0) + sub_label.get_style_context().add_class('dim-label') + self.add(sub_label) + + self._entry = Gtk.Entry() + self._entry.set_activates_default(True) + self._entry.set_size_request(300, -1) + self._entry.connect('changed', self._on_entry_changed) + self.add(self._entry) + + if mode == 'change-nickname': + heading_label.set_text(_('Change Nickname')) + sub_label.set_text(_('Enter your new nickname')) + assert isinstance(self._contact, GroupchatContact) + self._entry.set_text(self._contact.nickname or '') + + elif mode == 'kick': + heading_label.set_text(_('Kick %s') % data) + sub_label.set_text(_('Reason (optional)')) + + elif mode == 'ban': + heading_label.set_text(_('Ban %s') % data) + sub_label.set_text(_('Reason (optional)')) + + elif mode == 'password-request': + heading_label.set_text(_('Password Required')) + sub_label.set_text(_('Enter a password to join this chat')) + self._entry.set_input_purpose(Gtk.InputPurpose.PASSWORD) + self._entry.set_visibility(False) + + self.connect('destroy', self._on_destroy) + self.show_all() + + def _on_destroy(self, _widget: InputWidget) -> None: + app.check_finalize(self) + + def _on_entry_changed(self, entry: Gtk.Entry) -> None: + text = entry.get_text() + + if self._mode == 'change-nickname': + assert isinstance(self._contact, GroupchatContact) + if not text or text == self._contact.nickname: + self.emit('changed', False) + return + try: + validate_resourcepart(text) + except InvalidJid: + self.emit('changed', False) + return + + self.emit('changed', bool(text)) + + def focus(self) -> None: + self._entry.grab_focus() + + def get_text(self) -> str: + return self._entry.get_text() + + +class ErrorWidget(Gtk.Box): + def __init__(self, + error_mode: Optional[str] = None, + error_text: Optional[str] = None + ) -> None: + Gtk.Box.__init__(self, + orientation=Gtk.Orientation.VERTICAL, + spacing=12) + image = Gtk.Image.new_from_icon_name( + 'dialog-error-symbolic', Gtk.IconSize.DIALOG) + image.get_style_context().add_class('error-color') + + heading = Gtk.Label() + heading.get_style_context().add_class('bold16') + heading_text = _('An Error Occurred') + if error_mode == 'join-failed': + heading_text = _('Failed to Join Group Chat') + elif error_mode == 'creation-failed': + heading_text = _('Failed to Create Group Chat') + elif error_mode == 'config-failed': + heading_text = _('Failed to Configure Group Chat') + heading.set_text(heading_text) + + label = Gtk.Label() + label.set_max_width_chars(40) + if error_text is not None: + label.set_text(error_text) + + self.add(image) + self.add(heading) + self.add(label) + self.show_all() diff --git a/gajim/gtk/chat_list_stack.py b/gajim/gtk/chat_list_stack.py index 2b43a74b4..b91bc9f49 100644 --- a/gajim/gtk/chat_list_stack.py +++ b/gajim/gtk/chat_list_stack.py @@ -269,7 +269,7 @@ class ChatListStack(Gtk.Stack): _('Leave Group Chat'), _('Are you sure you want to leave this group chat?'), _('If you close this chat, you will leave ' - '\'%s\'.') % contact.name, + '"%s".') % contact.name, _('_Do not ask me again'), [DialogButton.make('Cancel'), DialogButton.make('Accept', diff --git a/gajim/gtk/chat_page.py b/gajim/gtk/chat_page.py index 0504b409b..b6d39f6fc 100644 --- a/gajim/gtk/chat_page.py +++ b/gajim/gtk/chat_page.py @@ -121,6 +121,9 @@ class ChatPage(Gtk.Box): def get_chat_list_stack(self) -> ChatListStack: return self._chat_list_stack + def get_chat_stack(self) -> ChatStack: + return self._chat_stack + def get_control_stack(self) -> ControlStack: return self._control_stack diff --git a/gajim/gtk/chat_stack.py b/gajim/gtk/chat_stack.py index 16b296f17..f5474577c 100644 --- a/gajim/gtk/chat_stack.py +++ b/gajim/gtk/chat_stack.py @@ -12,15 +12,42 @@ # You should have received a copy of the GNU General Public License # along with Gajim. If not, see <http://www.gnu.org/licenses/>. +from __future__ import annotations + from typing import Any +from typing import Optional +import sys import logging +from gi.repository import Gdk +from gi.repository import GLib +from gi.repository import Gio from gi.repository import Gtk + +from nbxmpp.errors import StanzaError from nbxmpp.protocol import JID +from nbxmpp.structs import MessageProperties +from nbxmpp.structs import PresenceProperties +from gajim.common import app +from gajim.common import helpers +from gajim.common.events import MucDiscoUpdate +from gajim.common.i18n import _ +from gajim.common.const import CallType +from gajim.common.modules.contacts import BareContact +from gajim.common.modules.contacts import GroupchatContact +from gajim.common.modules.contacts import GroupchatParticipant +from gajim.common.structs import OutgoingMessage +from gajim.common.types import ChatContactT + +from .chat_banner import ChatBanner +from .chat_function_page import ChatFunctionPage +from .const import TARGET_TYPE_URI_LIST from .control_stack import ControlStack -from .util import EventHelper +from .controls.groupchat import GroupchatControl +from .message_actions_box import MessageActionsBox +from .util import EventHelper, open_window log = logging.getLogger('gajim.gui.chatstack') @@ -33,23 +60,621 @@ class ChatStack(Gtk.Stack, EventHelper): self.set_vexpand(True) self.set_hexpand(True) + self._current_contact: Optional[ChatContactT] = None + + self.add_named(ChatPlaceholderBox(), 'empty') + + self._chat_function_page = ChatFunctionPage() + self._chat_function_page.connect('finish', self._on_function_finished) + self._chat_function_page.connect('message', self._on_function_message) + self.add_named(self._chat_function_page, 'function') + + self._chat_banner = ChatBanner() self._control_stack = ControlStack() + self._message_action_box = MessageActionsBox() box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + box.add(self._chat_banner) + box.add(Gtk.Separator()) box.add(self._control_stack) + box.add(Gtk.Separator()) + box.add(self._message_action_box) + + dnd_icon = Gtk.Image.new_from_icon_name( + 'mail-attachment-symbolic', Gtk.IconSize.DIALOG) + dnd_icon.set_vexpand(True) + dnd_icon.set_valign(Gtk.Align.END) + dnd_label = Gtk.Label(label=_('Drop files or contacts')) + dnd_label.set_max_width_chars(40) + dnd_label.set_vexpand(True) + dnd_label.set_valign(Gtk.Align.START) + dnd_label.get_style_context().add_class('bold16') - self.add_named(box, 'controls') + self._drop_area = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=18) + self._drop_area.set_no_show_all(True) + self._drop_area.set_hexpand(True) + self._drop_area.set_vexpand(True) + self._drop_area.set_name('DropArea') + self._drop_area.add(dnd_icon) + self._drop_area.add(dnd_label) + overlay = Gtk.Overlay() + overlay.add_overlay(self._drop_area) + overlay.add(box) + overlay.connect('drag-data-received', self._on_drag_data_received) + overlay.connect('drag-motion', self._on_drag_motion) + overlay.connect('drag-leave', self._on_drag_leave) + + uri_entry = Gtk.TargetEntry.new( + 'text/uri-list', + Gtk.TargetFlags.OTHER_APP, + TARGET_TYPE_URI_LIST) + dnd_list = [uri_entry, + Gtk.TargetEntry.new( + 'OBJECT_DROP', + Gtk.TargetFlags.SAME_APP, + 0)] + dst_targets = Gtk.TargetList.new([uri_entry]) + dst_targets.add_text_targets(0) + + overlay.drag_dest_set( + Gtk.DestDefaults.ALL, + dnd_list, + Gdk.DragAction.COPY | Gdk.DragAction.MOVE) + overlay.drag_dest_set_target_list(dst_targets) + + self.add_named(overlay, 'controls') + + self._connect_actions() self.show_all() + def _get_current_contact(self) -> ChatContactT: + assert self._current_contact is not None + return self._current_contact + + def process_escape(self) -> bool: + if self.get_visible_child_name() == 'function': + self._chat_function_page.process_escape() + return True + return False + def get_control_stack(self) -> ControlStack: return self._control_stack + def get_message_action_box(self) -> MessageActionsBox: + return self._message_action_box + def show_chat(self, account: str, jid: JID) -> None: + # Store (preserve) primary clipboard and restore it after switching + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_PRIMARY) + old_primary_clipboard = clipboard.wait_for_text() + + if self._current_contact is not None: + self._current_contact.disconnect_all_from_obj(self) + + client = app.get_client(account) + self._current_contact = client.get_module('Contacts').get_contact(jid) + + self._chat_banner.switch_contact(account, jid) self._control_stack.show_chat(account, jid) + self._message_action_box.switch_contact(self._current_contact) + + self._update_base_actions(self._current_contact) + if isinstance(self._current_contact, GroupchatContact): + self._current_contact.multi_connect({ + 'user-joined': self._on_user_joined, + 'user-role-changed': self._on_user_role_changed, + 'user-affiliation-changed': self._on_user_affiliation_changed, + 'state-changed': self._on_muc_state_changed, + 'room-password-required': self._on_room_password_required, + 'room-captcha-challenge': self._on_room_captcha_challenge, + 'room-captcha-error': self._on_room_captcha_error, + 'room-creation-failed': self._on_room_creation_failed, + 'room-join-failed': self._on_room_join_failed, + 'room-config-failed': self._on_room_config_failed, + }) + self._update_group_chat_actions(self._current_contact) + + elif isinstance(self._current_contact, GroupchatParticipant): + self._update_participant_actions(self._current_contact) + + else: + self._update_chat_actions(self._current_contact) + + if isinstance(self._current_contact, GroupchatContact): + muc_data = client.get_module('MUC').get_muc_data( + str(self._current_contact.jid)) + if muc_data is not None: + if muc_data.state.is_captcha_request: + self._show_chat_function_page('captcha-request') + return + + if muc_data.state.is_password_request: + self._show_chat_function_page('password-request') + return + + if not muc_data.state.is_joined: + if muc_data.error == 'captcha-failed': + self._show_chat_function_page( + 'captcha-error', muc_data.error_text) + return + if muc_data.error in ('join-failed', 'creation-failed'): + self._show_chat_function_page( + muc_data.error, muc_data.error_text) + return + + self.set_transition_type(Gtk.StackTransitionType.NONE) + self.set_visible_child_name('controls') + + if old_primary_clipboard is not None: + GLib.idle_add(clipboard.set_text, # pyright: ignore + old_primary_clipboard, + -1) + + GLib.idle_add(self._message_action_box.msg_textview.grab_focus) + + def _on_room_password_required(self, + _contact: GroupchatContact, + _signal_name: str, + _properties: MessageProperties + ) -> None: + self._show_chat_function_page('password-request') + + def _on_room_captcha_challenge(self, + contact: GroupchatContact, + _signal_name: str, + properties: MessageProperties + ) -> None: + self._show_chat_function_page('captcha-request') + + def _on_room_captcha_error(self, + _contact: GroupchatContact, + _signal_name: str, + error: StanzaError + ) -> None: + error_text = helpers.to_user_string(error) + self._show_chat_function_page('captcha-error', error_text) + + def _on_room_creation_failed(self, + _contact: GroupchatContact, + _signal_name: str, + properties: MessageProperties + ) -> None: + assert properties.error is not None + error_text = helpers.to_user_string(properties.error) + self._show_chat_function_page('creation-failed', error_text) + + def _on_room_join_failed(self, + _contact: GroupchatContact, + _signal_name: str, + error: StanzaError + ) -> None: + self._show_chat_function_page( + 'join-failed', helpers.to_user_string(error)) + + def _on_room_config_failed(self, + _contact: GroupchatContact, + _signal_name: str, + error: StanzaError + ) -> None: + self._show_chat_function_page( + 'config-failed', helpers.to_user_string(error)) + + def _on_muc_state_changed(self, + contact: GroupchatContact, + _signal_name: str + ) -> None: + self._update_group_chat_actions(contact) + + def _on_user_joined(self, + contact: GroupchatContact, + _signal_name: str, + _user_contact: GroupchatParticipant, + _properties: PresenceProperties + ) -> None: + self._update_group_chat_actions(contact) + + def _on_user_role_changed(self, + contact: GroupchatContact, + _signal_name: str, + _user_contact: GroupchatParticipant, + _properties: PresenceProperties + ) -> None: + self._update_group_chat_actions(contact) + + def _on_user_affiliation_changed(self, + contact: GroupchatContact, + _signal_name: str, + _user_contact: GroupchatParticipant, + _properties: PresenceProperties + ) -> None: + self._update_group_chat_actions(contact) + + def _on_muc_disco_update(self, event: MucDiscoUpdate) -> None: + if self._current_contact is None: + return + + if event.jid != self._current_contact.jid: + return + + if isinstance(self._current_contact, GroupchatContact): + self._update_group_chat_actions(self._current_contact) + + def _connect_actions(self) -> None: + actions = [ + 'add-to-roster', + 'clear-chat', + 'invite-contacts', + 'send-file', + 'send-file-httpupload', + 'send-file-jingle', + 'show-contact-info', + 'start-video-call', + 'start-voice-call', + 'send-message', + 'muc-change-nickname', + 'muc-invite', + 'muc-contact-info', + 'muc-execute-command', + 'muc-ban', + 'muc-kick', + 'muc-change-role', + 'muc-change-affiliation', + 'muc-request-voice', + ] + + for action in actions: + action = app.window.lookup_action(action) + assert action is not None + action.connect('activate', self._on_action) + + def _update_base_actions(self, contact: ChatContactT) -> None: + client = app.get_client(contact.account) + online = app.account_is_connected(contact.account) + + has_text = self._message_action_box.msg_textview.has_text + app.window.get_action('send-message').set_enabled( + online and has_text) + + httpupload = app.window.get_action('send-file-httpupload') + httpupload.set_enabled(online and + client.get_module('HTTPUpload').available) + + jingle = app.window.get_action('send-file-jingle') + jingle.set_enabled(online and contact.is_jingle_available) + + app.window.get_action('send-file').set_enabled( + jingle.get_enabled() or + httpupload.get_enabled()) + + app.window.get_action('show-contact-info').set_enabled(online) + app.window.get_action('correct-message').set_enabled(online) + + app.window.get_action('input-bold').set_enabled(True) + app.window.get_action('input-italic').set_enabled(True) + app.window.get_action('input-strike').set_enabled(True) + app.window.get_action('input-clear').set_enabled(True) + app.window.get_action('clear-chat').set_enabled(True) + + def _update_chat_actions(self, contact: BareContact) -> None: + account = contact.account + online = app.account_is_connected(account) + + app.window.get_action('start-voice-call').set_enabled( + online and contact.supports_audio() and + sys.platform != 'win32') + app.window.get_action('start-video-call').set_enabled( + online and contact.supports_video() and + sys.platform != 'win32') + + app.window.get_action('quote').set_enabled(online) + app.window.get_action('mention').set_enabled(online) + + def _update_group_chat_actions(self, contact: GroupchatContact) -> None: + joined = contact.is_joined + is_visitor = False + if joined: + self_contact = contact.get_self() + assert self_contact + is_visitor = self_contact.role.is_visitor + + app.window.get_action('muc-change-nickname').set_enabled( + joined and not contact.is_irc) + + app.window.get_action('muc-contact-info').set_enabled(joined) + app.window.get_action('muc-execute-command').set_enabled(joined) + app.window.get_action('muc-ban').set_enabled(joined) + app.window.get_action('muc-kick').set_enabled(joined) + app.window.get_action('muc-change-role').set_enabled(joined) + app.window.get_action('muc-change-affiliation').set_enabled(joined) + app.window.get_action('muc-invite').set_enabled(joined) + + app.window.get_action('muc-request-voice').set_enabled(is_visitor) + + app.window.get_action('quote').set_enabled(joined) + app.window.get_action('mention').set_enabled(joined) + + def _update_participant_actions(self, + contact: GroupchatParticipant) -> None: + pass + + def _on_action(self, + action: Gio.SimpleAction, + param: Optional[GLib.Variant]) -> None: + + action_name = action.get_name() + contact = self._current_contact + assert contact is not None + account = contact.account + client = app.get_client(account) + jid = contact.jid + current_control = self._control_stack.get_current_control() + + if action_name == 'send-message': + self._on_send_message() + + elif action_name == 'start-voice-call': + app.call_manager.start_call(account, jid, CallType.AUDIO) + + elif action_name == 'start-video-call': + app.call_manager.start_call(account, jid, CallType.VIDEO) + + elif action_name.startswith('send-file'): + name = action.get_name() + if 'httpupload' in name: + app.interface.start_file_transfer(contact, method='httpupload') + return + + if 'jingle' in name: + app.interface.start_file_transfer(contact, method='jingle') + return + + app.interface.start_file_transfer(contact) + + elif action_name == 'invite-contacts': + open_window('AdhocMUC', account=account, contact=contact) + + elif action_name == 'add-to-roster': + if (isinstance(contact, GroupchatParticipant) and + contact.real_jid is not None): + jid = contact.real_jid + open_window('AddContact', account=account, jid=jid) + + elif action_name == 'clear-chat': + assert current_control is not None + current_control.reset_view() + + elif action_name == 'show-contact-info': + if isinstance(contact, GroupchatContact): + open_window('GroupchatDetails', contact=contact) + else: + app.window.contact_info(account, str(jid)) + + elif action_name == 'muc-contact-info': + assert param is not None + nick = param.get_string() + assert isinstance(contact, GroupchatContact) + resource_contact = contact.get_resource(nick) + open_window( + 'ContactInfo', account=account, contact=resource_contact) + + elif action_name == 'muc-invite': + self._show_chat_function_page('invite') + + elif action_name == 'muc-change-nickname': + self._show_chat_function_page('change-nickname') + + elif action_name == 'muc-execute-command': + assert isinstance(current_control, GroupchatControl) + nick = None + if param is not None: + nick = param.get_string() + if nick: + assert isinstance(contact, GroupchatContact) + resource_contact = contact.get_resource(nick) + jid = resource_contact.jid + open_window('AdHocCommands', account=account, jid=jid) + + elif action_name == 'muc-kick': + assert isinstance(current_control, GroupchatControl) + assert param is not None + kick_nick = param.get_string() + self._show_chat_function_page('kick', data=kick_nick) + + elif action_name == 'muc-ban': + assert isinstance(current_control, GroupchatControl) + assert param is not None + ban_jid = param.get_string() + self._show_chat_function_page('ban', data=ban_jid) + + elif action_name == 'muc-change-role': + assert param is not None + nick, role = param.get_strv() + client.get_module('MUC').set_role(contact.jid, nick, role) + + elif action_name == 'muc-change-affiliation': + assert param is not None + jid, affiliation = param.get_strv() + client.get_module('MUC').set_affiliation( + contact.jid, + {jid: {'affiliation': affiliation}}) + + elif action_name == 'muc-request-voice': + client.get_module('MUC').request_voice(contact.jid) + + def _on_drag_data_received(self, + _widget: Gtk.Widget, + _context: Gdk.DragContext, + _x_coord: int, + _y_coord: int, + selection: Gtk.SelectionData, + target_type: int, + _timestamp: int + ) -> None: + if not selection.get_data(): + return + + log.debug('Drop received: %s, %s', selection.get_data(), target_type) + + # TODO: Contact drag and drop for invitations (AdHoc MUC/MUC) + if target_type == TARGET_TYPE_URI_LIST: + control = app.window.get_active_control() + if control is not None: + control.drag_data_file_transfer(selection) + + def _on_drag_leave(self, + _widget: Gtk.Widget, + _context: Gdk.DragContext, + _time: int + ) -> None: + self._drop_area.set_no_show_all(True) + self._drop_area.hide() + + def _on_drag_motion(self, + _widget: Gtk.Widget, + _context: Gdk.DragContext, + _x_coord: int, + _y_coord: int, + _time: int + ) -> bool: + self._drop_area.set_no_show_all(False) + self._drop_area.show_all() + return True + + def _show_chat_function_page(self, + function: str, + data: Optional[str] = None + ) -> None: + assert self._current_contact is not None + self._chat_function_page.set_mode( + self._current_contact, function, data) + self.set_transition_type(Gtk.StackTransitionType.SLIDE_UP_DOWN) + self.set_visible_child_name('function') + + def _on_function_finished(self, + _function_page: ChatFunctionPage, + close_control: bool + ) -> None: + if close_control: + self._close_control() + self.set_visible_child_name('empty') + self.set_transition_type(Gtk.StackTransitionType.NONE) + return + + self.set_visible_child_name('controls') + self.set_transition_type(Gtk.StackTransitionType.NONE) + + def _on_function_message(self, + _function_page: ChatFunctionPage, + message: str + ) -> None: + control = self._control_stack.get_current_control() + assert control is not None + control.add_info_message(message) + + def _on_send_message(self) -> None: + self._message_action_box.msg_textview.replace_emojis() + message = self._message_action_box.msg_textview.get_text() + + control = self._control_stack.get_current_control() + assert control is not None + + contact = self._current_contact + assert contact is not None + + encryption = contact.settings.get('encryption') + if encryption: + self.sendmessage = True + app.plugin_manager.extension_point( + 'send_message' + encryption, + control) + if not self.sendmessage: + return + + client = app.get_client(contact.account) + + message = helpers.remove_invalid_xml_chars(message) + if message in ('', None, '\n'): + return + + # if process_commands and self.process_as_command(message): + # return + + label = self._message_action_box.get_seclabel() + + correct_id = None + if self._message_action_box.is_correcting: + correct_id = self._message_action_box.last_message_id.get( + (contact.account, contact.jid)) + self._message_action_box.is_correcting = False + + chatstate = client.get_module('Chatstate').get_active_chatstate( + contact) + + type_ = 'chat' + if isinstance(contact, GroupchatContact): + type_ = 'groupchat' + + message_ = OutgoingMessage(account=contact.account, + contact=contact, + message=message, + type_=type_, + chatstate=chatstate, + label=label, + control=control, + correct_id=correct_id) + + client.send_message(message_) + + self._message_action_box.msg_textview.clear() + + def get_last_message_id(self, account: str, jid: JID) -> Optional[str]: + return self._message_action_box.last_message_id.get((account, jid)) + + def _close_control(self) -> None: + assert self._current_contact is not None + app.window.activate_action( + 'remove-chat', + GLib.Variant('as', + [self._current_contact.account, + str(self._current_contact.jid)])) def clear(self) -> None: + if self._current_contact is not None: + self._current_contact.disconnect_all_from_obj(self) + + self.set_visible_child_name('empty') + self._chat_banner.clear() + self._message_action_box.clear() self._control_stack.clear() def process_event(self, event: Any) -> None: + if isinstance(event, MucDiscoUpdate): + self._on_muc_disco_update(event) self._control_stack.process_event(event) + self._message_action_box.process_event(event) + + +class ChatPlaceholderBox(Gtk.Box): + def __init__(self): + Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL, + spacing=18) + self.set_valign(Gtk.Align.CENTER) + pixbuf = Gtk.IconTheme.load_icon_for_scale( + Gtk.IconTheme.get_default(), + 'org.gajim.Gajim-symbolic', + 100, + self.get_scale_factor(), + Gtk.IconLookupFlags.FORCE_SIZE) + image = Gtk.Image.new_from_pixbuf(pixbuf) + image.get_style_context().add_class('dim-label') + self.add(image) + + button = Gtk.Button(label=_('Start Chatting…')) + button.set_halign(Gtk.Align.CENTER) + button.connect('clicked', self._on_start_chatting) + self.add(button) + + def _on_start_chatting(self, _button: Gtk.Button) -> None: + app.app.activate_action('start-chat', GLib.Variant('s', '')) diff --git a/gajim/gtk/const.py b/gajim/gtk/const.py index 52d2f1447..97226f6fc 100644 --- a/gajim/gtk/const.py +++ b/gajim/gtk/const.py @@ -187,8 +187,44 @@ APP_ACTIONS = [ ] +MAIN_WIN_ACTIONS = [ + # action name, variant type, enabled + ('input-bold', None, False), + ('input-italic', None, False), + ('input-strike', None, False), + ('input-clear', None, False), + ('show-emoji-chooser', None, False), + ('correct-message', None, False), + ('quote', 's', False), + ('mention', 's', False), + ('send-file-httpupload', None, False), + ('send-file-jingle', None, False), + ('send-file', None, False), + ('invite-contacts', None, False), + ('add-to-roster', None, True), + ('start-voice-call', None, False), + ('start-video-call', None, False), + ('clear-chat', None, False), + ('show-contact-info', None, False), + ('send-message', None, False), + ('muc-change-nickname', None, False), + ('muc-invite', None, False), + ('muc-contact-info', 's', False), + ('muc-execute-command', 's', False), + ('muc-ban', 's', False), + ('muc-kick', 's', False), + ('muc-change-role', 'as', False), + ('muc-change-affiliation', 'as', False), + ('muc-request-voice', None, False), +] + + ACCOUNT_ACTIONS = [ ('add-contact', 'as'), + ('block-contact', 's'), + ('remove-contact', 's'), + ('execute-command', 's'), + ('modify-gateway', 's'), ('archive', 's'), ('blocking', 's'), ('bookmarks', 's'), @@ -214,6 +250,9 @@ ALWAYS_ACCOUNT_ACTIONS = { ONLINE_ACCOUNT_ACTIONS = { 'add-contact', + 'remove-contact', + 'execute-command', + 'modify-gateway', 'bookmarks', 'import-contacts', 'open-chat', @@ -228,4 +267,5 @@ ONLINE_ACCOUNT_ACTIONS = { FEATURE_ACCOUNT_ACTIONS = { 'archive', 'blocking', + 'block-contact', } diff --git a/gajim/gtk/control_stack.py b/gajim/gtk/control_stack.py index 24661c10f..43fe58cfc 100644 --- a/gajim/gtk/control_stack.py +++ b/gajim/gtk/control_stack.py @@ -18,43 +18,30 @@ from typing import Generator import logging -from gi.repository import GLib from gi.repository import Gtk from nbxmpp import JID -from gajim.common import app -from gajim.common import ged -from gajim.common.i18n import _ - from .controls.chat import ChatControl from .controls.groupchat import GroupchatControl from .controls.private import PrivateChatControl from .types import ControlT -from .util import EventHelper log = logging.getLogger('gajim.gui.controlstack') -class ControlStack(Gtk.Stack, EventHelper): +class ControlStack(Gtk.Stack): def __init__(self): Gtk.Stack.__init__(self) - EventHelper.__init__(self) - self.set_vexpand(True) self.set_hexpand(True) - self.add_named(ChatPlaceholderBox(), 'empty') - - self.register_events([ - ('account-enabled', ged.GUI2, self._on_account_changed), - ('account-disabled', ged.GUI2, self._on_account_changed), - ]) + self.add_named(Gtk.Box(), 'empty') self.show_all() self._controls: dict[tuple[str, JID], ControlT] = {} - self._current_control = None + self._current_control: Optional[ControlT] = None def get_control(self, account: str, jid: JID) -> Optional[ControlT]: try: @@ -62,6 +49,9 @@ class ControlStack(Gtk.Stack, EventHelper): except KeyError: return None + def get_current_control(self) -> Optional[ControlT]: + return self._current_control + def get_controls(self, account: Optional[str] ) -> Generator[ControlT, None, None]: if account is None: @@ -117,14 +107,12 @@ class ControlStack(Gtk.Stack, EventHelper): return if self._current_control is not None: - self._current_control.set_control_active(False) self._current_control.reset_view() - control.set_control_active(True) + control.load_messages() self._current_control = control self.set_visible_child_name(new_name) - GLib.idle_add(control.focus) def is_chat_loaded(self, account: str, jid: JID) -> bool: control = self.get_control(account, jid) @@ -147,31 +135,3 @@ class ControlStack(Gtk.Stack, EventHelper): if chat_account != account: continue self.remove_chat(account, jid) - - def _on_account_changed(self, *args: Any) -> None: - for control in self._controls.values(): - control.update_account_badge() - - -class ChatPlaceholderBox(Gtk.Box): - def __init__(self): - Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL, - spacing=18) - self.set_valign(Gtk.Align.CENTER) - pixbuf = Gtk.IconTheme.load_icon_for_scale( - Gtk.IconTheme.get_default(), - 'org.gajim.Gajim-symbolic', - 100, - self.get_scale_factor(), - Gtk.IconLookupFlags.FORCE_SIZE) - image = Gtk.Image.new_from_pixbuf(pixbuf) - image.get_style_context().add_class('dim-label') - self.add(image) - - button = Gtk.Button(label=_('Start Chatting…')) - button.set_halign(Gtk.Align.CENTER) - button.connect('clicked', self._on_start_chatting) - self.add(button) - - def _on_start_chatting(self, _button: Gtk.Button) -> None: - app.app.activate_action('start-chat', GLib.Variant('s', '')) diff --git a/gajim/gtk/controls/base.py b/gajim/gtk/controls/base.py index 43f6d15da..4b3bf694e 100644 --- a/gajim/gtk/controls/base.py +++ b/gajim/gtk/controls/base.py @@ -28,59 +28,40 @@ from __future__ import annotations from typing import Any from typing import Optional from typing import Union +from typing import cast import os import logging -import sys import time import uuid -import tempfile -from functools import partial from gi.repository import Gtk -from gi.repository import Gdk -from gi.repository import GdkPixbuf from gi.repository import GLib -from gi.repository import Gio from nbxmpp import JID -from nbxmpp.const import Chatstate from nbxmpp.modules.security_labels import Displaymarking from gajim.common import app from gajim.common import events from gajim.common import ged -from gajim.common import i18n from gajim.common.helpers import message_needs_highlight from gajim.common.helpers import get_file_path_from_dnd_dropped_uri from gajim.common.i18n import _ from gajim.common.ged import EventHelper from gajim.common.helpers import AdditionalDataDict from gajim.common.helpers import get_retraction_text -from gajim.common.const import Direction, KindConstant +from gajim.common.const import KindConstant from gajim.common.modules.httpupload import HTTPFileTransfer from gajim.common.preview_helpers import filename_from_uri from gajim.common.preview_helpers import guess_simple_file_type from gajim.common.storage.archive import ConversationRow -from gajim.common.structs import OutgoingMessage from gajim.gui.conversation.view import ConversationView from gajim.gui.conversation.scrolled import ScrolledView from gajim.gui.conversation.jump_to_end_button import JumpToEndButton -from gajim.gui.dialogs import DialogButton -from gajim.gui.dialogs import ErrorDialog -from gajim.gui.dialogs import PastePreviewDialog -from gajim.gui.file_transfer_send import SendFileDialog -from gajim.gui.message_input import MessageInputTextView -from gajim.gui.security_label_selector import SecurityLabelSelector -from gajim.gui.util import get_hardware_key_codes from gajim.gui.builder import get_builder from gajim.gui.util import set_urgency_hint -from gajim.gui.util import scroll_to_end -from gajim.gui.util import AccountBadge from gajim.gui.const import ControlType -from gajim.gui.const import TARGET_TYPE_URI_LIST -from gajim.gui.emoji_chooser import emoji_chooser from gajim.command_system.implementation.middleware import ChatCommandProcessor from gajim.command_system.implementation.middleware import CommandTools @@ -92,39 +73,19 @@ from gajim.command_system.implementation.middleware import CommandTools from gajim.command_system.implementation import standard # noqa: F401 from gajim.command_system.implementation import execute # noqa: F401 -if app.is_installed('GSPELL'): - from gi.repository import Gspell # pylint: disable=ungrouped-imports - -# This is needed so copying text from the conversation textview -# works with different language layouts. Pressing the key c on a russian -# layout yields another keyval than with the english layout. -# So we match hardware keycodes instead of keyvals. -# Multiple hardware keycodes can trigger a keyval like Gdk.KEY_c. -KEYCODES_KEY_C = get_hardware_key_codes(Gdk.KEY_c) - -if sys.platform == 'darwin': - COPY_MODIFIER = Gdk.ModifierType.META_MASK - COPY_MODIFIER_KEYS = (Gdk.KEY_Meta_L, Gdk.KEY_Meta_R) -else: - COPY_MODIFIER = Gdk.ModifierType.CONTROL_MASK - COPY_MODIFIER_KEYS = (Gdk.KEY_Control_L, Gdk.KEY_Control_R) log = logging.getLogger('gajim.gui.controls.base') -################################################################################ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): - """ + ''' A base class containing a banner, ConversationView, MessageInputTextView - """ + ''' _type: Optional[ControlType] = None def __init__(self, widget_name: str, account: str, jid: JID) -> None: EventHelper.__init__(self) - # Undo needs this variable to know if space has been pressed. - # Initialize it to True so empty textview is saved in undo list - self.space_pressed: bool = True self.handlers: dict[int, Any] = {} @@ -137,60 +98,13 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): jid, groupchat=groupchat) self._connect_contact_signals() - # control_id is a unique id for the control, - # its used as action name for actions that belong to a control self.control_id: str = str(uuid.uuid4()) - self.session = None - if app.settings.get('use_kib_mib'): - self._units = GLib.FormatSizeFlags.IEC_UNITS - else: - self._units = GLib.FormatSizeFlags.DEFAULT - - self.xml = get_builder(f'{widget_name}.ui') - self.xml.connect_signals(self) - self.widget: Gtk.Box = self.xml.get_object(f'{widget_name}_hbox') - - self._account_badge = AccountBadge(self.account) - self.xml.account_badge_box.add(self._account_badge) - show_account_badge = len(app.settings.get_active_accounts()) > 1 - self.xml.account_badge_box.set_visible(show_account_badge) - - # Drag and drop - self.xml.overlay.add_overlay(self.xml.drop_area) - self.xml.drop_area.hide() - self.xml.overlay.connect( - 'drag-data-received', self._on_drag_data_received) - self.xml.overlay.connect('drag-motion', self._on_drag_motion) - self.xml.overlay.connect('drag-leave', self._on_drag_leave) - - uri_entry = Gtk.TargetEntry.new( - 'text/uri-list', - Gtk.TargetFlags.OTHER_APP, - TARGET_TYPE_URI_LIST) - dst_targets = Gtk.TargetList.new([uri_entry]) - dst_targets.add_text_targets(0) - self._dnd_list = [uri_entry, - Gtk.TargetEntry.new( - 'MY_TREE_MODEL_ROW', - Gtk.TargetFlags.SAME_APP, - 0)] - - self.xml.overlay.drag_dest_set( - Gtk.DestDefaults.ALL, - self._dnd_list, - Gdk.DragAction.COPY | Gdk.DragAction.MOVE) - self.xml.overlay.drag_dest_set_target_list(dst_targets) + self.xml = get_builder('chat_control.ui') + self.widget = cast(Gtk.Box, self.xml.get_object('control_box')) # Create ConversationView and connect signals self.conversation_view = ConversationView(self.account, self.contact) - self.conversation_view.connect('quote', self._on_quote) - self.conversation_view.connect('mention', self._on_mention) - self.conversation_view.connect('scroll-to-end', self._on_scroll_to_end) - - id_ = self.conversation_view.connect( - 'key-press-event', self._on_conversation_view_key_press) - self.handlers[id_] = self.conversation_view self._scrolled_view = ScrolledView() self._scrolled_view.add(self.conversation_view) @@ -207,67 +121,16 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): self._scrolled_view.connect('request-history', self.fetch_n_lines_history, 20) - self._security_label_selector = SecurityLabelSelector( - account, self.contact) - self.xml.hbox.pack_start(self._security_label_selector, False, True, 0) - - self.msg_textview = MessageInputTextView(self.account, self.contact) - self.msg_textview.connect('paste-clipboard', - self._on_message_textview_paste_event) - self.msg_textview.connect('key-press-event', - self._on_message_textview_key_press_event) - self.msg_textview.connect('populate-popup', - self._on_msg_textview_populate_popup) - self.msg_textview.get_buffer().connect( - 'changed', self._on_message_tv_buffer_changed) - - # Send message button - self.xml.send_message_button.set_action_name( - f'win.send-message-{self.control_id}') - self.xml.send_message_button.set_visible( - app.settings.get('show_send_message_button')) - app.settings.bind_signal( - 'show_send_message_button', - self.xml.send_message_button, - 'set_visible') - - self.msg_scrolledwindow = ScrolledWindow() - self.msg_scrolledwindow.set_margin_start(3) - self.msg_scrolledwindow.set_margin_end(3) - self.msg_scrolledwindow.get_style_context().add_class( - 'message-input-border') - self.msg_scrolledwindow.add(self.msg_textview) - - self.xml.hbox.pack_start(self.msg_scrolledwindow, True, True, 0) - # Keeps track of whether the ConversationView is populated self._chat_loaded: bool = False - # the following vars are used to keep history of user's messages - self.sent_history: list[str] = [] - self.sent_history_pos: int = 0 - self.received_history: list[str] = [] - self.received_history_pos: int = 0 - self.orig_msg: Optional[str] = None - # XEP-0333 Chat Markers self.last_msg_id: Optional[str] = None - # XEP-0308 Message Correction - self.correcting: bool = False - self.last_sent_msg: Optional[str] = None - - self.set_emoticon_popover() - - # Attach speller - self.set_speller() - # XEP-0172 User Nickname # TODO: self.user_nick: Optional[str] = None - self.sendmessage: bool = True - self._client.get_module('Chatstate').set_active(self.contact) self.encryption: Optional[str] = self.get_encryption_state() @@ -278,9 +141,9 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): app.plugin_manager.gui_extension_point('chat_control_base', self) self.register_events([ - ('ping-sent', ged.GUI1, self._nec_ping), - ('ping-reply', ged.GUI1, self._nec_ping), - ('ping-error', ged.GUI1, self._nec_ping), + ('ping-sent', ged.GUI1, self._on_ping_event), + ('ping-reply', ged.GUI1, self._on_ping_event), + ('ping-error', ged.GUI1, self._on_ping_event), ]) # This is basically a very nasty hack to surpass the inability @@ -288,10 +151,9 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): CommandTools.__init__(self) def _connect_contact_signals(self) -> None: - raise NotImplementedError - - def _get_action(self, name: str) -> Gio.SimpleAction: - return app.window.lookup_action(f'{name}{self.control_id}') + ''' + Derived types MAY implement this + ''' def process_event(self, event: events.ApplicationEvent) -> None: if event.account != self.account: @@ -305,24 +167,50 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): events.FileRequestSent ) - if self.is_chat: - if isinstance(event, file_transfer_events): - self.add_jingle_file_transfer(event=event) - return - if isinstance(event, events.JingleRequestReceived): - active_jid = app.call_manager.get_active_call_jid() - # Don't add a second row if contact upgrades to video - if active_jid is None: - self.add_call_message(event=event) - return - if isinstance(event, events.CallStopped): - self.conversation_view.update_call_rows() - return + if isinstance(event, file_transfer_events): + self.add_jingle_file_transfer(event=event) + return + + if isinstance(event, events.JingleRequestReceived): + active_jid = app.call_manager.get_active_call_jid() + # Don't add a second row if contact upgrades to video + if active_jid is None: + self.add_call_message(event=event) + return + + if isinstance(event, events.CallStopped): + self.conversation_view.update_call_rows() + return + + if isinstance(event, events.MessageReceived) and not self.is_groupchat: + self._on_message_received(event) + return + + if isinstance(event, events.MessageSent): + self._on_message_sent(event) + return + + if isinstance(event, events.MessageError): + self._on_message_error(event) + return method_name = event.name.replace('-', '_') method_name = f'_on_{method_name}' getattr(self, method_name)(event) + def _on_message_sent(self, event: events.MessageSent) -> None: + ''' + Derived types MAY implement this + ''' + + def _on_message_received(self, event: events.MessageReceived) -> None: + ''' + Derived types MAY implement this + ''' + + def _on_message_error(self, event: events.MessageError) -> None: + self.conversation_view.show_error(event.message_id, event.error) + def _on_message_updated(self, event: events.MessageUpdated) -> None: self.conversation_view.correct_message( event.correct_id, event.msgtxt, event.nickname) @@ -335,181 +223,29 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): self.conversation_view.show_message_retraction( event.moderation.stanza_id, text) - def _on_conversation_view_key_press(self, - _listbox: ConversationView, - event: Gdk.EventKey - ) -> int: - if event.get_state() & Gdk.ModifierType.SHIFT_MASK: - if event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Page_Up): - return Gdk.EVENT_PROPAGATE - - if event.keyval in COPY_MODIFIER_KEYS: - # Don’t route modifier keys for copy action to the Message Input - # otherwise pressing CTRL/META + c (the next event after that) - # will not reach the textview (because the Message Input would get - # focused). - return Gdk.EVENT_PROPAGATE - - # if event.get_state() & COPY_MODIFIER: - # TODO - # # Don’t reroute the event if it is META + c and the - # # textview has a selection - # if event.hardware_keycode in KEYCODES_KEY_C: - # if textview.get_buffer().props.has_selection: - # return Gdk.EVENT_PROPAGATE - - if not self.msg_textview.get_sensitive(): - # If the input textview is not sensitive it can’t get the focus. - # In that case propagate_key_event() would send the event again - # to the conversation textview. This would mean a recursion. - return Gdk.EVENT_PROPAGATE - - # Focus the Message Input and resend the event - self.msg_textview.grab_focus() - self.msg_textview.get_toplevel().propagate_key_event(event) - return Gdk.EVENT_STOP - @property def type(self) -> ControlType: + assert self._type is not None return self._type @property def is_chat(self) -> bool: + assert self._type is not None return self._type.is_chat @property def is_privatechat(self) -> bool: + assert self._type is not None return self._type.is_privatechat @property def is_groupchat(self) -> bool: + assert self._type is not None return self._type.is_groupchat - def focus(self) -> None: + def _on_ping_event(self, event: events.PingEventT) -> None: raise NotImplementedError - def update_actions(self) -> None: - """ - Derived types MAY implement this - """ - - def draw_banner(self) -> None: - """ - Draw the fat line at the top of the window - that houses the icon, jid, etc - - Derived types MAY implement this. - """ - self.draw_banner_text() - - def update_toolbar(self) -> None: - """ - update state of buttons in toolbar - """ - self._update_toolbar() - app.plugin_manager.gui_extension_point( - 'chat_control_base_update_toolbar', self) - - def draw_banner_text(self) -> None: - """ - Derived types SHOULD implement this - """ - - def update_ui(self) -> None: - """ - Derived types SHOULD implement this - """ - self.draw_banner() - - def repaint_themed_widgets(self) -> None: - """ - Derived types MAY implement this - """ - self.draw_banner() - - def _update_toolbar(self) -> None: - """ - Derived types MAY implement this - """ - - def set_session(self, session): - oldsession = None - if hasattr(self, 'session'): - oldsession = self.session - - if oldsession and session == oldsession: - return - - self.session = session - - if session: - session.control = self - - if session and oldsession: - oldsession.control = None - - def remove_session(self, session): - if session != self.session: - return - self.session.control = None - self.session = None - - def _nec_ping(self, obj): - raise NotImplementedError - - def delegate_action(self, action: str) -> int: - if action == 'clear-chat': - self.reset_view() - return Gdk.EVENT_STOP - - if action == 'delete-line': - self.msg_textview.clear() - return Gdk.EVENT_STOP - - if action == 'show-emoji-chooser': - if sys.platform == 'darwin': - # TODO: Remove if colored emoji rendering works well on - # Windows and MacOS - self.xml.emoticons_button.get_popover().show() - return Gdk.EVENT_STOP - self.msg_textview.emit('insert-emoji') - return Gdk.EVENT_STOP - - return Gdk.EVENT_PROPAGATE - - def add_actions(self) -> None: - action = Gio.SimpleAction.new_stateful( - f'set-encryption-{self.control_id}', - GLib.VariantType.new('s'), - GLib.Variant('s', self.encryption or 'disabled')) - action.connect('change-state', self.change_encryption) - app.window.add_action(action) - - actions = { - 'send-message-%s': self._on_send_message, - 'send-file-%s': self._on_send_file, - 'send-file-httpupload-%s': self._on_send_file, - 'send-file-jingle-%s': self._on_send_file, - } - - for name, func in actions.items(): - action = Gio.SimpleAction.new(name % self.control_id, None) - action.connect('activate', func) - action.set_enabled(False) - app.window.add_action(action) - - def remove_actions(self) -> None: - actions = [ - 'send-message-', - 'set-encryption-', - 'send-file-', - 'send-file-httpupload-', - 'send-file-jingle-', - ] - - for action in actions: - app.window.remove_action(f'{action}{self.control_id}') - def mark_as_read(self, send_marker: bool = True) -> None: self._jump_to_end_button.reset_unread_count() @@ -521,58 +257,6 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): str(self._type)) self.last_msg_id = None - def change_encryption(self, - action: Gio.SimpleAction, - param: GLib.Variant - ) -> None: - encryption = param.get_string() - if encryption == 'disabled': - encryption = None - - if self.encryption == encryption: - return - - if encryption: - plugin = app.plugin_manager.encryption_plugins[encryption] - if not plugin.activate_encryption(self): - return - - action.set_state(param) - self.set_encryption_state(encryption) - self.set_encryption_menu_icon() - self.set_lock_image() - - def set_lock_image(self) -> None: - encryption_state = {'visible': self.encryption is not None, - 'enc_type': self.encryption, - 'authenticated': False} - - if self.encryption: - app.plugin_manager.extension_point( - 'encryption_state' + self.encryption, self, encryption_state) - - visible, enc_type, authenticated = encryption_state.values() - - if authenticated: - authenticated_string = _('and authenticated') - self.xml.lock_image.set_from_icon_name( - 'security-high-symbolic', Gtk.IconSize.MENU) - else: - authenticated_string = _('and NOT authenticated') - self.xml.lock_image.set_from_icon_name( - 'security-low-symbolic', Gtk.IconSize.MENU) - - tooltip = _('%(type)s encryption is active %(authenticated)s.') % { - 'type': enc_type, 'authenticated': authenticated_string} - - self.xml.authentication_button.set_tooltip_text(tooltip) - self.xml.authentication_button.set_visible(visible) - self.xml.lock_image.set_sensitive(visible) - - def _on_authentication_button_clicked(self, _button: Gtk.Button) -> None: - app.plugin_manager.extension_point( - 'encryption_dialog' + self.encryption, self) - def set_encryption_state(self, encryption: Optional[str]) -> None: self.encryption = encryption self.conversation_view.encryption_enabled = encryption is not None @@ -587,60 +271,12 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): return None return state - def set_encryption_menu_icon(self) -> None: - image = self.xml.encryption_menu.get_image() - if image is None: - image = Gtk.Image() - self.xml.encryption_menu.set_image(image) - if not self.encryption: - image.set_from_icon_name('channel-insecure-symbolic', - Gtk.IconSize.MENU) - else: - image.set_from_icon_name('channel-secure-symbolic', - Gtk.IconSize.MENU) - - def set_speller(self) -> None: - if (not app.is_installed('GSPELL') or - not app.settings.get('use_speller')): - return - - gspell_lang = self.get_speller_language() - spell_checker = Gspell.Checker.new(gspell_lang) - spell_buffer = Gspell.TextBuffer.get_from_gtk_text_buffer( - self.msg_textview.get_buffer()) - spell_buffer.set_spell_checker(spell_checker) - spell_view = Gspell.TextView.get_from_gtk_text_view(self.msg_textview) - spell_view.set_inline_spell_checking(False) - spell_view.set_enable_language_menu(True) - - spell_checker.connect('notify::language', self.on_language_changed) - - def get_speller_language(self) -> Optional[Gspell.Language]: - lang = self.contact.settings.get('speller_language') - if not lang: - # use the default one - lang = app.settings.get('speller_language') - if not lang: - lang = i18n.LANG - gspell_lang = Gspell.language_lookup(lang) - if gspell_lang is None: - gspell_lang = Gspell.language_get_default() - return gspell_lang - - def on_language_changed(self, checker: Gspell.Checker, _param: Any) -> None: - gspell_lang = checker.get_language() - if gspell_lang is not None: - self.contact.settings.set('speller_language', - gspell_lang.get_code()) - def shutdown(self) -> None: # remove_gui_extension_point() is called on shutdown, but also when # a plugin is getting disabled. Plugins don’t know the difference. # Plugins might want to remove their widgets on # remove_gui_extension_point(), so delete the objects only afterwards. app.plugin_manager.remove_gui_extension_point('chat_control_base', self) - app.plugin_manager.remove_gui_extension_point( - 'chat_control_base_update_toolbar', self) self._client.disconnect_all_from_obj(self) self.contact.disconnect_all_from_obj(self) @@ -655,8 +291,6 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): del self.conversation_view del self._scrolled_view - del self.msg_textview - del self.msg_scrolledwindow self.widget.destroy() del self.widget @@ -665,246 +299,6 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): self.unregister_events() - def _on_msg_textview_populate_popup(self, - _textview: MessageInputTextView, - menu: Gtk.Widget - ) -> None: - """ - Override the default context menu and we prepend an option to switch - languages - """ - assert isinstance(menu, Gtk.Menu) - item = Gtk.MenuItem.new_with_mnemonic(_('_Undo')) - menu.prepend(item) - id_ = item.connect('activate', self.msg_textview.undo) - self.handlers[id_] = item - - item = Gtk.SeparatorMenuItem() - menu.prepend(item) - - item = Gtk.MenuItem.new_with_mnemonic(_('_Clear')) - menu.prepend(item) - id_ = item.connect('activate', self.msg_textview.clear) - self.handlers[id_] = item - - paste_item = Gtk.MenuItem.new_with_label(_('Paste as quote')) - id_ = paste_item.connect('activate', self.paste_clipboard_as_quote) - self.handlers[id_] = paste_item - menu.append(paste_item) - - menu.show_all() - - def insert_as_quote(self, text: str) -> None: - text = '> ' + text.replace('\n', '\n> ') + '\n' - message_buffer = self.msg_textview.get_buffer() - message_buffer.insert_at_cursor(text) - self.msg_textview.grab_focus() - - def paste_clipboard_as_quote(self, _item: Gtk.MenuItem) -> None: - clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) - text = clipboard.wait_for_text() - if text is None: - return - self.insert_as_quote(text) - - def _on_quote(self, _view: ConversationView, text: str) -> None: - self.insert_as_quote(text) - - def _on_mention(self, _view: ConversationView, name: str) -> None: - gc_refer_to_nick_char = app.settings.get('gc_refer_to_nick_char') - text = f'{name}{gc_refer_to_nick_char} ' - message_buffer = self.msg_textview.get_buffer() - message_buffer.insert_at_cursor(text) - GLib.idle_add(self.msg_textview.grab_focus) - - def _on_scroll_to_end(self, _view: ConversationView) -> None: - scroll_to_end(self._scrolled_view) - - def _on_message_textview_paste_event(self, - _texview: MessageInputTextView - ) -> None: - clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) - image = clipboard.wait_for_image() - if image is not None: - if not app.settings.get('confirm_paste_image'): - self._paste_event_confirmed(True, image) - return - PastePreviewDialog( - _('Paste Image'), - _('You are trying to paste an image'), - _('Are you sure you want to paste your ' - 'clipboard\'s image into the chat window?'), - _('_Do not ask me again'), - image, - [DialogButton.make('Cancel'), - DialogButton.make('Accept', - text=_('_Paste'), - callback=self._paste_event_confirmed, - args=[image])]).show() - - def _paste_event_confirmed(self, - is_checked: bool, - image: GdkPixbuf.Pixbuf - ) -> None: - if is_checked: - app.settings.set('confirm_paste_image', False) - - dir_ = tempfile.gettempdir() - path = os.path.join(dir_, f'{uuid.uuid4()}.png') - if image is None: - self.add_info_message(_('Error: Could not process image')) - return - - image.savev(path, 'png', [], []) - - self._start_filetransfer(path) - - def _get_pref_ft_method(self) -> Optional[str]: - ft_pref = app.settings.get_account_setting(self.account, - 'filetransfer_preference') - httpupload = app.window.lookup_action( - f'send-file-httpupload-{self.control_id}') - jingle = app.window.lookup_action( - f'send-file-jingle-{self.control_id}') - - if self.is_groupchat: - if httpupload.get_enabled(): - return 'httpupload' - return None - - if httpupload.get_enabled() and jingle.get_enabled(): - return ft_pref - - if httpupload.get_enabled(): - return 'httpupload' - - if jingle.get_enabled(): - return 'jingle' - return None - - def _start_filetransfer(self, path: str) -> None: - method = self._get_pref_ft_method() - if method is None: - return - - if method == 'httpupload': - app.interface.send_httpupload(self, path) - else: - send_callback = partial( - app.interface.instances['file_transfers'].send_file, - self.account, - self.contact) - SendFileDialog(self.contact, send_callback, app.window, [path]) - - def _on_send_file(self, - action: Gio.SimpleAction, - _param: Optional[GLib.Variant] - ) -> None: - name = action.get_name() - if 'httpupload' in name: - app.interface.send_httpupload(self) - return - - if 'jingle' in name: - app.interface.instances['file_transfers'].show_file_send_request( - self.account, self.contact) - return - - method = self._get_pref_ft_method() - if method is None: - return - - if method == 'httpupload': - app.interface.send_httpupload(self) - else: - app.interface.instances['file_transfers'].show_file_send_request( - self.account, self.contact) - - def _on_message_textview_key_press_event(self, - textview: MessageInputTextView, - event: Gdk.EventKey - ) -> bool: - # pylint: disable=too-many-nested-blocks - if event.keyval == Gdk.KEY_space: - self.space_pressed = True - - elif ((self.space_pressed or self.msg_textview.undo_pressed) and - event.keyval not in (Gdk.KEY_Control_L, Gdk.KEY_Control_R) and - not (event.keyval == Gdk.KEY_z and - event.get_state() & Gdk.ModifierType.CONTROL_MASK)): - # If the space key has been pressed and now it hasn't, - # we save the buffer into the undo list. But be careful we're not - # pressing Control again (as in ctrl+z) - _buffer = textview.get_buffer() - start_iter, end_iter = _buffer.get_bounds() - self.msg_textview.save_undo(_buffer.get_text(start_iter, - end_iter, - True)) - self.space_pressed = False - - # Ctrl [+ Shift] + Tab are not forwarded to notebook. We handle it here - if event.get_state() & Gdk.ModifierType.SHIFT_MASK: - if (event.get_state() & Gdk.ModifierType.CONTROL_MASK and - event.keyval == Gdk.KEY_ISO_Left_Tab): - app.window.select_next_chat(Direction.PREV, unread_first=True) - return True - - if event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Page_Up): - self.conversation_view.event(event) - return True - - if event.get_state() & Gdk.ModifierType.CONTROL_MASK: - if event.keyval == Gdk.KEY_Tab: - app.window.select_next_chat(Direction.NEXT, unread_first=True) - return True - - message_buffer = self.msg_textview.get_buffer() - event_state = event.get_state() - if event.keyval == Gdk.KEY_Up: - if event_state & Gdk.ModifierType.CONTROL_MASK: - if event_state & Gdk.ModifierType.SHIFT_MASK: # Ctrl+Shift+UP - self.scroll_messages('up', message_buffer, 'received') - else: # Ctrl+UP - self.scroll_messages('up', message_buffer, 'sent') - return True - elif event.keyval == Gdk.KEY_Down: - if event_state & Gdk.ModifierType.CONTROL_MASK: - if event_state & Gdk.ModifierType.SHIFT_MASK: # Ctrl+Shift+Down - self.scroll_messages('down', message_buffer, 'received') - else: # Ctrl+Down - self.scroll_messages('down', message_buffer, 'sent') - return True - elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): # ENTER - if event_state & Gdk.ModifierType.SHIFT_MASK: - textview.insert_newline() - return True - - if event_state & Gdk.ModifierType.CONTROL_MASK: - if not app.settings.get('send_on_ctrl_enter'): - textview.insert_newline() - return True - else: - if app.settings.get('send_on_ctrl_enter'): - textview.insert_newline() - return True - - if not app.account_is_available(self.account): - # we are not connected - ErrorDialog( - _('No Connection Available'), - _('Your message can not be sent until you are connected.')) - return True - - self._on_send_message() - return True - - elif event.keyval == Gdk.KEY_z: # CTRL+z - if event_state & Gdk.ModifierType.CONTROL_MASK: - self.msg_textview.undo() - return True - - return False - def _on_autoscroll_changed(self, _widget: ScrolledView, autoscroll: bool @@ -918,40 +312,7 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): app.window.mark_as_read(self.account, self.contact.jid) def _on_jump_to_end(self, _button: Gtk.Button) -> None: - self.scroll_to_end(force=True) - self._jump_to_end_button.reset_unread_count() - - def _on_drag_data_received(self, - widget: Gtk.Widget, - context: Gdk.DragContext, - x_coord: int, - y_coord: int, - selection: Gtk.SelectionData, - target_type: int, - timestamp: int - ) -> None: - """ - Derived types SHOULD implement this - """ - - def _on_drag_leave(self, - _widget: Gtk.Widget, - _context: Gdk.DragContext, - _time: int - ) -> None: - self.xml.drop_area.set_no_show_all(True) - self.xml.drop_area.hide() - - def _on_drag_motion(self, - _widget: Gtk.Widget, - _context: Gdk.DragContext, - _x_coord: int, - _y_coord: int, - _time: int - ) -> bool: - self.xml.drop_area.set_no_show_all(False) - self.xml.drop_area.show_all() - return True + self.reset_view() def drag_data_file_transfer(self, selection: Gtk.SelectionData) -> None: # we may have more than one file dropped @@ -964,114 +325,11 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): 'not uploaded: %s') % path) continue - self._start_filetransfer(path) - - def get_seclabel(self) -> Optional[str]: - return self._security_label_selector.get_seclabel() + app.interface.start_file_transfer(self.contact, path) def get_our_nick(self) -> str: return app.nicks[self.account] - def _on_send_message(self, *args: Any) -> None: - self.msg_textview.replace_emojis() - message = self.msg_textview.get_text() - self.send_message(message) - - def send_message(self, - message: str, - type_: str = 'chat', - resource: Optional[str] = None, - process_commands: bool = True, - attention: bool = False - ) -> None: - """ - Send the given message to the active tab. Doesn't return None if error - """ - if not message or message == '\n': - return None - - if process_commands and self.process_as_command(message): - return - - label = self.get_seclabel() - - correct_id: Optional[str] = None - if self.correcting and self.last_sent_msg: - correct_id = self.last_sent_msg - else: - correct_id = None - - chatstate = self._client.get_module('Chatstate').get_active_chatstate( - self.contact) - - message_ = OutgoingMessage(account=self.account, - contact=self.contact, - message=message, - type_=type_, - chatstate=chatstate, - resource=resource, - user_nick=self.user_nick, - label=label, - control=self, - attention=attention, - correct_id=correct_id) - - self._client.send_message(message_) - - # Record the history of sent messages - self.save_message(message, 'sent') - - # Be sure to send user nickname only once according to JEP-0172 - self.user_nick = None - - self.msg_textview.clear() - - def _on_message_tv_buffer_changed(self, - textbuffer: Gtk.TextBuffer - ) -> None: - has_text = self.msg_textview.has_text() - app.window.lookup_action( - f'send-message-{self.control_id}').set_enabled(has_text) - - if textbuffer.get_char_count() and self.encryption: - app.plugin_manager.extension_point( - 'typing' + self.encryption, self) - - self._client.get_module('Chatstate').set_keyboard_activity( - self.contact) - if not has_text: - self._client.get_module('Chatstate').set_chatstate_delayed( - self.contact, Chatstate.ACTIVE) - return - self._client.get_module('Chatstate').set_chatstate( - self.contact, Chatstate.COMPOSING) - - def save_message(self, message: str, msg_type: str) -> None: - # save the message, so user can scroll though the list with key up/down - if msg_type == 'sent': - history = self.sent_history - pos = self.sent_history_pos - else: - history = self.received_history - pos = self.received_history_pos - size = len(history) - scroll = pos != size - # we don't want size of the buffer to grow indefinitely - max_size = app.settings.get('key_up_lines') - for _i in range(size - max_size + 1): - if pos == 0: - break - history.pop(0) - pos -= 1 - history.append(message) - if not scroll or msg_type == 'sent': - pos = len(history) - if msg_type == 'sent': - self.sent_history_pos = pos - self.orig_msg = None - else: - self.received_history_pos = pos - def _allow_add_message(self) -> bool: # Only add messages if the view is already populated return self.is_chat_loaded and self._scrolled_view.get_lower_complete() @@ -1128,7 +386,7 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): if not self._scrolled_view.get_autoscroll(): if kind == 'outgoing': - self.scroll_to_end() + self.reset_view() else: self._jump_to_end_button.add_unread_count() else: @@ -1141,11 +399,8 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): self.last_msg_id = message_id if kind == 'incoming': - # Record the history of received messages - self.save_message(text, 'received') - - # Issue notification if notify: + # Issue notification self._notify(name, text, tim, additional_data) if not chat_active and notify: @@ -1201,8 +456,10 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): return if self.is_privatechat: + room_contact = self._client.get_module('Contacts').get_contact( + self.contact.jid.bare) msg_type = 'private-chat-message' - title += f' (private in {self.room_name})' + title += f' (private in {room_contact.name})' sound = 'first_message_received' # Is it a history message? Don't want sound-floods when we join. @@ -1222,65 +479,11 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): text=text, sound=sound)) - def toggle_emoticons(self) -> None: - """ - Hide show emoticons_button - """ - if app.settings.get('emoticons_theme'): - self.xml.emoticons_button.set_no_show_all(False) - self.xml.emoticons_button.show() - else: - self.xml.emoticons_button.set_no_show_all(True) - self.xml.emoticons_button.hide() - - def set_emoticon_popover(self) -> None: - if not app.settings.get('emoticons_theme'): - return - - if sys.platform == 'darwin': - # TODO: Remove if colored emoji rendering works well on - # Windows and MacOS - emoji_chooser.text_widget = self.msg_textview - self.xml.emoticons_button.set_popover(emoji_chooser) + def load_messages(self) -> None: + if self._chat_loaded: return - self.xml.emoticons_button.set_sensitive(True) - self.xml.emoticons_button.connect('clicked', - self._on_emoticon_button_clicked) - - def _on_emoticon_button_clicked(self, _widget: Gtk.Button) -> None: - # Present GTK emoji chooser (not cross platform compatible) - self.msg_textview.emit('insert-emoji') - self.xml.emoticons_button.set_active(False) - - def _on_formatting_menuitem_activate(self, - menu_item: Gtk.CheckMenuItem - ) -> None: - formatting = menu_item.get_name() - self.msg_textview.apply_formatting(formatting) - - def set_control_active(self, state: bool) -> None: - if not self._chat_loaded: - self.fetch_n_lines_history(self._scrolled_view, True, 20) - - if state: - self.set_emoticon_popover() - self._security_label_selector.update() - - if self.msg_textview.has_text(): - self._client.get_module('Chatstate').set_chatstate( - self.contact, Chatstate.PAUSED) - else: - self._client.get_module('Chatstate').set_chatstate( - self.contact, Chatstate.ACTIVE) - else: - self._client.get_module('Chatstate').set_chatstate( - self.contact, Chatstate.INACTIVE) - - def set_message_input_state(self, state: bool) -> None: - self.xml.formattings_button.set_sensitive(state) - self.msg_textview.set_sensitive(state) - self.msg_textview.set_editable(state) + self.fetch_n_lines_history(self._scrolled_view, True, 20) @property def is_chat_loaded(self) -> bool: @@ -1294,11 +497,6 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): def get_autoscroll(self) -> bool: return self._scrolled_view.get_autoscroll() - def scroll_to_end(self, force: bool = False) -> None: - # Clear view and reload conversation - self.reset_view() - self.conversation_view.scroll_to_end(force) - def scroll_to_message(self, log_line_id: int, timestamp: float) -> None: row = self.conversation_view.get_row_by_log_line_id(log_line_id) if row is None: @@ -1412,82 +610,3 @@ class BaseControl(ChatCommandProcessor, CommandTools, EventHelper): log_line_id=msg.log_line_id, marker=msg.marker, error=msg.error) - - def has_focus(self) -> bool: - if app.window.get_property('has-toplevel-focus'): - if self == app.window.get_active_control(): - return True - return False - - def scroll_messages(self, - direction: str, - msg_buf: Gtk.TextBuffer, - msg_type: str, - ) -> None: - # pylint: disable=too-many-boolean-expressions - if msg_type == 'sent': - history = self.sent_history - pos = self.sent_history_pos - self.received_history_pos = len(self.received_history) - else: - history = self.received_history - pos = self.received_history_pos - self.sent_history_pos = len(self.sent_history) - size = len(history) - if self.orig_msg is None: - # user was typing something and then went into history, so save - # whatever is already typed - start_iter = msg_buf.get_start_iter() - end_iter = msg_buf.get_end_iter() - self.orig_msg = msg_buf.get_text(start_iter, end_iter, False) - if (pos == size and size > 0 and direction == 'up' and - msg_type == 'sent' and not self.correcting and ( - not history[pos - 1].startswith('/') or - history[pos - 1].startswith('/me'))): - if self.last_sent_msg is not None: - self.correcting = True - self.msg_textview.get_style_context().add_class( - 'gajim-msg-correcting') - message = history[pos - 1] - msg_buf.set_text(message) - return - if self.correcting: - # We were previously correcting - self.msg_textview.get_style_context().remove_class( - 'gajim-msg-correcting') - self.correcting = False - pos += -1 if direction == 'up' else +1 - if pos == -1: - return - if pos >= size: - pos = size - message = self.orig_msg - self.orig_msg = None - else: - message = history[pos] - if msg_type == 'sent': - self.sent_history_pos = pos - else: - self.received_history_pos = pos - if self.orig_msg is not None: - message = '> %s\n' % message.replace('\n', '\n> ') - msg_buf.set_text(message) - - def update_account_badge(self) -> None: - show = len(app.settings.get_active_accounts()) > 1 - self.xml.account_badge_box.set_visible(show) - - -class ScrolledWindow(Gtk.ScrolledWindow): - def __init__(self, *args: Any, **kwargs: Any) -> None: - Gtk.ScrolledWindow.__init__(self, *args, **kwargs) - - self.set_overlay_scrolling(False) - self.set_max_content_height(100) - self.set_propagate_natural_height(True) - self.get_style_context().add_class('scrolled-no-border') - self.get_style_context().add_class('no-scroll-indicator') - self.get_style_context().add_class('scrollbar-style') - self.get_style_context().add_class('one-line-scrollbar') - self.set_shadow_type(Gtk.ShadowType.IN) - self.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) diff --git a/gajim/gtk/controls/chat.py b/gajim/gtk/controls/chat.py index 74cd59e28..ab32532d1 100644 --- a/gajim/gtk/controls/chat.py +++ b/gajim/gtk/controls/chat.py @@ -30,51 +30,31 @@ from typing import Type from typing import Optional import logging -import sys - -from gi.repository import Gtk -from gi.repository import Gio -from gi.repository import GLib -from gi.repository import Gdk from nbxmpp import JID from nbxmpp.const import Chatstate from nbxmpp.modules.security_labels import Displaymarking -from nbxmpp.namespaces import Namespace from gajim.common import app from gajim.common import events -from gajim.common import helpers -from gajim.common import types -from gajim.common.client import Client from gajim.common.i18n import _ from gajim.common.helpers import AdditionalDataDict -from gajim.common.const import AvatarSize -from gajim.common.const import CallType -from gajim.common.const import SimpleClientState from gajim.common.const import KindConstant from gajim.common.modules.contacts import BareContact from gajim.gui.controls.base import BaseControl -from gajim.gui.const import TARGET_TYPE_URI_LIST from gajim.gui.const import ControlType -from gajim.gui.util import open_window from gajim.command_system.implementation.hosts import ChatCommands from gajim.command_system.framework import CommandHost -from ..menus import get_encryption_menu -from ..menus import get_private_chat_menu -from ..menus import get_self_contact_menu -from ..menus import get_singlechat_menu - log = logging.getLogger('gajim.gui.controls.chat') class ChatControl(BaseControl): - """ + ''' A control for standard 1-1 chat - """ + ''' _type = ControlType.CHAT # Set a command host to bound to. Every command given through a chat will be @@ -86,267 +66,14 @@ class ChatControl(BaseControl): 'chat_control', account, jid) - - self.sendmessage: bool = True - - # XEP-0308 Message Correction - self.correcting: bool = False - self.last_sent_msg: Optional[str] = None - - self.toggle_emoticons() - - if self._type == ControlType.CHAT: - self._client.connect_signal('state-changed', - self._on_client_state_changed) - - if not app.settings.get('hide_chat_banner'): - self.xml.banner_eventbox.set_no_show_all(False) - - self.xml.sendfile_button.set_action_name( - f'win.send-file-{self.control_id}') - - # Menu for the HeaderBar - if self._type == ControlType.CHAT: - if self.contact.is_self: - self.control_menu = get_self_contact_menu(self.control_id, - self.contact) - else: - self.control_menu = get_singlechat_menu(self.control_id, - self.contact) - else: - self.control_menu = get_private_chat_menu(self.control_id, - self.contact) - - # Settings menu - self.xml.settings_menu.set_menu_model(self.control_menu) - - self.update_toolbar() - self._update_avatar() - - self.add_actions() - self.update_ui() - self.set_lock_image() - - self.xml.encryption_menu.set_menu_model(get_encryption_menu( - self.control_id, self._type)) - self.set_encryption_menu_icon() - self.msg_textview.grab_focus() - # PluginSystem: adding GUI extension point for this ChatControl # instance object app.plugin_manager.gui_extension_point('chat_control', self) - self.update_actions() - - def _connect_contact_signals(self) -> None: - self.contact.multi_connect({ - 'presence-update': self._on_presence_update, - 'chatstate-update': self._on_chatstate_update, - 'nickname-update': self._on_nickname_update, - 'avatar-update': self._on_avatar_update, - 'caps-update': self._on_caps_update, - }) @property def jid(self) -> JID: return self.contact.jid - def add_actions(self) -> None: - super().add_actions() - actions = [ - ('invite-contacts-', self._on_invite_contacts), - ('add-to-roster-', self._on_add_to_roster), - ('block-contact-', self._on_block_contact), - ('information-', self._on_information), - ('start-voice-call-', self._on_start_voice_call), - ('start-video-call-', self._on_start_video_call), - ] - - for action in actions: - action_name, func = action - act = Gio.SimpleAction.new(action_name + self.control_id, None) - act.connect('activate', func) - app.window.add_action(act) - - chatstate = self.contact.settings.get('send_chatstate') - - act = Gio.SimpleAction.new_stateful( - 'send-chatstate-' + self.control_id, - GLib.VariantType.new("s"), - GLib.Variant("s", chatstate)) - act.connect('change-state', self._on_send_chatstate) - app.window.add_action(act) - - marker = self.contact.settings.get('send_marker') - - act = Gio.SimpleAction.new_stateful( - f'send-marker-{self.control_id}', - None, - GLib.Variant.new_boolean(marker)) - act.connect('change-state', self._on_send_marker) - app.window.add_action(act) - - def update_actions(self) -> None: - online = app.account_is_connected(self.account) - - if self.type.is_chat: - self._get_action('add-to-roster-').set_enabled( - not self.contact.is_in_roster) - - # Block contact - self._get_action('block-contact-').set_enabled( - online and self._client.get_module('Blocking').supported) - - # Jingle AV - if self.type.is_chat: - self._get_action('start-voice-call-').set_enabled( - online and self.contact.supports_audio() and - sys.platform != 'win32') - self._get_action('start-video-call-').set_enabled( - online and self.contact.supports_video() and - sys.platform != 'win32') - - # Send message - has_text = self.msg_textview.has_text() - self._get_action('send-message-').set_enabled(online and has_text) - - # Send file (HTTP File Upload) - httpupload = self._get_action('send-file-httpupload-') - httpupload.set_enabled(online and - self._client.get_module('HTTPUpload').available) - - # Send file (Jingle) - jingle = self._get_action('send-file-jingle-') - jingle.set_enabled(online and self.contact.is_jingle_available) - - # Send file - self._get_action('send-file-').set_enabled(jingle.get_enabled() or - httpupload.get_enabled()) - - # Set File Transfer Button tooltip - if online and (httpupload.get_enabled() or jingle.get_enabled()): - tooltip_text = _('Send File…') - else: - tooltip_text = _('No File Transfer available') - self.xml.sendfile_button.set_tooltip_text(tooltip_text) - - # Chat markers - state = GLib.Variant.new_boolean( - self.contact.settings.get('send_marker')) - self._get_action('send-marker-').change_state(state) - - # Convert to GC - enabled = self.contact.supports(Namespace.MUC) and online - self._get_action('invite-contacts-').set_enabled(enabled) - - # Information - self._get_action('information-').set_enabled(online) - - def remove_actions(self) -> None: - super().remove_actions() - actions = [ - 'invite-contacts-', - 'add-to-roster-', - 'block-contact-', - 'information-', - 'start-voice-call-', - 'start-video-call-', - 'send-chatstate-', - 'send-marker-', - ] - for action in actions: - app.window.remove_action(f'{action}{self.control_id}') - - def focus(self) -> None: - if not hasattr(self, 'msg_textview'): - # focus() is called sometimes with GLib.idle_add() - # This means there is the possibility that shutdown() was called - # before focus is executed. - return - self.msg_textview.grab_focus() - - def delegate_action(self, action: str) -> int: - res = super().delegate_action(action) - if res == Gdk.EVENT_STOP: - return res - - if action == 'show-contact-info': - self._get_action('information-').activate() - return Gdk.EVENT_STOP - - if action == 'send-file': - self._get_action('send-file-').activate() - return Gdk.EVENT_STOP - - return Gdk.EVENT_PROPAGATE - - def _on_add_to_roster(self, - _action: Gio.SimpleAction, - _param: Optional[GLib.Variant] - ) -> None: - jid = self.contact.jid - if self.type.is_privatechat and self.contact.real_jid is not None: - jid = self.contact.real_jid - open_window('AddContact', account=self.account, jid=jid) - - def _on_block_contact(self, - _action: Gio.SimpleAction, - _param: Optional[GLib.Variant] - ) -> None: - app.window.block_contact(self.account, self.contact.jid) - - def _on_information(self, - _action: Gio.SimpleAction, - _param: Optional[GLib.Variant] - ) -> None: - app.window.contact_info(self.account, self.contact.jid) - - def _on_invite_contacts(self, - _action: Gio.SimpleAction, - _param: Optional[GLib.Variant] - ) -> None: - open_window('AdhocMUC', account=self.account, contact=self.contact) - - def _on_send_chatstate(self, - action: Gio.SimpleAction, - param: GLib.Variant - ) -> None: - action.set_state(param) - self.contact.settings.set('send_chatstate', param.get_string()) - - def _on_send_marker(self, - action: Gio.SimpleAction, - param: GLib.Variant - ) -> None: - action.set_state(param) - self.contact.settings.set('send_marker', param.get_boolean()) - - def _on_nickname_received(self, _event): - self.update_ui() - - def _on_chatstate_update(self, - _contact: types.BareContact, - _signal_name: str - ) -> None: - self.draw_banner_text() - - def _on_nickname_update(self, - _contact: types.BareContact, - _signal_name: str - ) -> None: - self.draw_banner_text() - - def _on_presence_update(self, - _contact: types.BareContact, - _signal_name: str - ) -> None: - self._update_avatar() - - def _on_caps_update(self, - _contact: types.BareContact, - _signal_name: str - ) -> None: - self.update_ui() - def _on_mam_message_received(self, event: events.MamMessageReceived) -> None: if event.properties.is_muc_pm: @@ -376,13 +103,6 @@ class ChatControl(BaseControl): if event.properties.is_sent_carbon: kind = 'outgoing' - visible = False - if event.resource is not None: - resource_contact = self.contact.get_resource(event.resource) - visible = resource_contact.is_phone - - self.xml.phone_image.set_visible(visible) - self.add_message(event.msgtxt, kind, tim=event.properties.timestamp, @@ -392,30 +112,16 @@ class ChatControl(BaseControl): stanza_id=event.stanza_id, additional_data=event.additional_data) - def _on_message_error(self, event: events.MessageError) -> None: - self.conversation_view.show_error(event.message_id, event.error) - def _on_message_sent(self, event: events.MessageSent) -> None: if not event.message: return - if event.correct_id is None: - oob_url = event.additional_data.get_value('gajim', 'oob_url') - if oob_url == event.message: - self.last_sent_msg = None - else: - self.last_sent_msg = event.message_id - message_id = event.message_id if event.label: displaymarking = event.label.displaymarking else: displaymarking = None - if self.correcting: - self.correcting = False - self.msg_textview.get_style_context().remove_class( - 'gajim-msg-correcting') if event.correct_id: self.conversation_view.correct_message( @@ -435,7 +141,7 @@ class ChatControl(BaseControl): def _on_displayed_received(self, event: events.DisplayedReceived) -> None: self.conversation_view.set_read_marker(event.marker_id) - def _nec_ping(self, event: events.ApplicationEvent): + def _on_ping_event(self, event: events.PingEventT) -> None: if self.contact != event.contact: return if isinstance(event, events.PingSent): @@ -443,89 +149,8 @@ class ChatControl(BaseControl): elif isinstance(event, events.PingReply): self.add_info_message( _('Pong! (%s seconds)') % event.seconds) - elif isinstance(event, events.PingError): - self.add_info_message(event.error) - - def _on_start_voice_call(self, - _action: Gio.SimpleAction, - _param: Optional[GLib.Variant] - ) -> None: - app.call_manager.start_call(self.account, self.jid, CallType.AUDIO) - - def _on_start_video_call(self, - _action: Gio.SimpleAction, - _param: Optional[GLib.Variant] - ) -> None: - app.call_manager.start_call(self.account, self.jid, CallType.VIDEO) - - def update_ui(self) -> None: - BaseControl.update_ui(self) - self.update_toolbar() - self._update_avatar() - self.update_actions() - - def draw_banner_text(self) -> None: - """ - Draws the chat banner's text (e.g. name, chat state) in the top of the - chat window - """ - contact = self.contact - name = contact.name - - if self.jid == self._client.get_own_jid().bare: - name = _('Note to myself') - - if self._type.is_privatechat: - name = f'{name} ({self.room_name})' - - chatstate = self.contact.chatstate - if chatstate is not None: - chatstate = chatstate.value - - if app.settings.get('show_chatstate_in_banner'): - chatstate = helpers.get_uf_chatstate(chatstate) - - label_text = f'<span>{name}</span>' \ - f'<span size="x-small" weight="light">' \ - f' {chatstate}</span>' - label_tooltip = f'{name} {chatstate}' else: - label_text = f'<span>{name}</span>' - label_tooltip = name - - status_text = '' - self.xml.banner_label.hide() - self.xml.banner_label.set_no_show_all(True) - self.xml.banner_label.set_markup(status_text) - - self.xml.banner_name_label.set_markup(label_text) - self.xml.banner_name_label.set_tooltip_text(label_tooltip) - - def send_message(self, - message: str, - process_commands: bool = True, - attention: bool = False - ) -> None: - """ - Send a message to contact - """ - - if self.encryption: - self.sendmessage = True - app.plugin_manager.extension_point('send_message' + self.encryption, - self) - if not self.sendmessage: - return - - message = helpers.remove_invalid_xml_chars(message) - if message in ('', None, '\n'): - return - - BaseControl.send_message(self, - message, - type_='chat', - process_commands=process_commands, - attention=attention) + self.add_info_message(event.error) def add_message(self, text: str, @@ -561,8 +186,6 @@ class ChatControl(BaseControl): # instance object app.plugin_manager.remove_gui_extension_point('chat_control', self) - self.remove_actions() - # Send 'gone' chatstate self._client.get_module('Chatstate').set_chatstate( self.contact, Chatstate.GONE) @@ -570,50 +193,7 @@ class ChatControl(BaseControl): super(ChatControl, self).shutdown() app.check_finalize(self) - def _on_avatar_update(self, - _contact: types.BareContact, - _signal_name: str - ) -> None: - self._update_avatar() - - def _update_avatar(self) -> None: - scale = app.window.get_scale_factor() - surface = self.contact.get_avatar(AvatarSize.CHAT, scale) - self.xml.avatar_image.set_from_surface(surface) - - def _on_drag_data_received(self, - _widget: Gtk.Widget, - _context: Gdk.DragContext, - _x_coord: int, - _y_coord: int, - selection: Gtk.SelectionData, - target_type: int, - _timestamp: int - ) -> None: - if not selection.get_data(): - return - - log.debug('Drop received: %s, %s', selection.get_data(), target_type) - - # TODO: Contact drag and drop for AdHocMUC - if target_type == TARGET_TYPE_URI_LIST: - # File drag and drop (handled in chat_control_base) - self.drag_data_file_transfer(selection) - - def _on_client_state_changed(self, - _client: Client, - _signal_name: str, - state: SimpleClientState): - self.set_message_input_state(state.is_connected) - - self._update_avatar() - self.update_toolbar() - self.draw_banner() - self.update_actions() - def _on_presence_received(self, event: events.PresenceReceived) -> None: - self.update_ui() - if not app.settings.get('print_status_in_chats'): return diff --git a/gajim/gtk/controls/groupchat.py b/gajim/gtk/controls/groupchat.py index 33096eb5f..1f012d157 100644 --- a/gajim/gtk/controls/groupchat.py +++ b/gajim/gtk/controls/groupchat.py @@ -30,27 +30,17 @@ from typing import Optional import logging from nbxmpp import JID -from nbxmpp.protocol import InvalidJid -from nbxmpp.protocol import validate_resourcepart from nbxmpp.const import StatusCode -from nbxmpp.errors import StanzaError from nbxmpp.modules.security_labels import Displaymarking from nbxmpp.structs import PresenceProperties from nbxmpp.structs import MessageProperties from nbxmpp.structs import MucSubject from gi.repository import Gtk -from gi.repository import Gdk -from gi.repository import GLib -from gi.repository import Gio from gajim.common import app from gajim.common import events -from gajim.common import ged from gajim.common import helpers -from gajim.common.client import Client -from gajim.common.const import AvatarSize -from gajim.common.const import SimpleClientState from gajim.common.helpers import AdditionalDataDict from gajim.common.helpers import event_filter from gajim.common.helpers import to_user_string @@ -58,24 +48,16 @@ from gajim.common.helpers import to_user_string from gajim.common.i18n import _ from gajim.common.modules.contacts import GroupchatContact from gajim.common.modules.contacts import GroupchatParticipant -from gajim.common.structs import OutgoingMessage from gajim.gui.controls.base import BaseControl from gajim.command_system.implementation.hosts import GroupChatCommands from gajim.gui.const import ControlType -from gajim.gui.const import TARGET_TYPE_URI_LIST from gajim.gui.dialogs import DialogButton from gajim.gui.dialogs import ConfirmationDialog -from gajim.gui.dataform import DataFormWidget -from gajim.gui.groupchat_inviter import GroupChatInviter from gajim.gui.groupchat_roster import GroupchatRoster from gajim.gui.groupchat_state import GroupchatState -from gajim.gui.util import open_window - -from ..menus import get_encryption_menu -from ..menus import get_groupchat_menu log = logging.getLogger('gajim.gui.controls.groupchat') @@ -94,111 +76,29 @@ class GroupchatControl(BaseControl): account, jid) - self._client.connect_signal('state-changed', - self._on_client_state_changed) - self.is_anonymous: bool = True - self.toggle_emoticons() - - self.room_jid: str = str(self.contact.jid) - - # Stores nickname we want to kick - self._kick_nick: Optional[str] = None - - # Stores nickname we want to ban - self._ban_jid: Optional[str] = None - - # Last sent message text - self.last_sent_txt: str = '' - - # Attribute, encryption plugins use to signal the message can be sent - self.sendmessage: bool = False + self.room_jid = str(self.contact.jid) - # XEP-0308 Message Correction - self.correcting: bool = False - self.last_sent_msg: Optional[str] = None - - self._groupchat_state = GroupchatState() - self._groupchat_state.connect('join-clicked', - self._on_groupchat_state_join_clicked) - self._groupchat_state.connect('abort-clicked', - self._on_groupchat_state_abort_clicked) + self._groupchat_state = GroupchatState(self.contact) self.xml.conv_view_overlay.add_overlay(self._groupchat_state) self.roster = GroupchatRoster(self.account, self.room_jid, self) - self.xml.roster_revealer.add(self.roster) - - show_roster = app.settings.get('hide_groupchat_occupants_list') - self.xml.roster_revealer.set_reveal_child(show_roster) - app.settings.bind_signal( - 'hide_groupchat_occupants_list', - self.xml.roster_revealer, - 'set_reveal_child') - self._set_toggle_roster_button_icon(show_roster) - app.settings.connect_signal( - 'hide_groupchat_occupants_list', - self._set_toggle_roster_button_icon) self.roster.connect('row-activated', self._on_roster_row_activated) - self.add_actions() - GLib.idle_add(self.update_actions) - - if not app.settings.get('hide_groupchat_banner'): - self.xml.banner_eventbox.set_no_show_all(False) - - # muc attention flag (when we are mentioned in a muc) - # if True, the room has mentioned us - self.attention_flag: bool = False - - # Send file - self.xml.sendfile_button.set_action_name( - f'win.send-file-{self.control_id}') - - # Encryption - self.set_lock_image() - - self.xml.encryption_menu.set_menu_model(get_encryption_menu( - self.control_id, self._type)) - self.set_encryption_menu_icon() - - self._update_avatar() + show_roster = app.settings.get('hide_groupchat_occupants_list') + self._roster_revealer = Gtk.Revealer(no_show_all=not show_roster) + self._roster_revealer.add(self.roster) + self._roster_revealer.set_reveal_child(show_roster) + self.xml.conv_view_box.add(self._roster_revealer) - # Holds CaptchaRequest widget - self._captcha_request: Optional[DataFormWidget] = None + app.settings.connect_signal( + 'hide_groupchat_occupants_list', self._show_roster) self._subject_text = '' - # Groupchat invite - self.xml.quick_invite_button.set_action_name( - f'win.invite-{self.control_id}') - - self._invite_box = GroupChatInviter(self.room_jid) - self.xml.invite_grid.attach(self._invite_box, 0, 0, 1, 1) - self._invite_box.connect('listbox-changed', self._on_invite_ready) - - self.control_menu = get_groupchat_menu(self.control_id, - self.account, - self.contact.jid) - - self.xml.settings_menu.set_menu_model(self.control_menu) - - app.settings.connect_signal('gc_print_join_left_default', - self.update_actions) - app.settings.connect_signal('gc_print_status_default', - self.update_actions) - - self.register_events([ - ('bookmarks-received', ged.GUI1, self._on_bookmarks_received), - ]) - self._set_control_inactive() - # Stack - self.xml.stack.show_all() - self.xml.stack.set_visible_child_name('groupchat') - - self.update_ui() self.widget.show_all() # PluginSystem: adding GUI extension point for this GroupchatControl @@ -208,7 +108,6 @@ class GroupchatControl(BaseControl): def _connect_contact_signals(self) -> None: self.contact.multi_connect({ 'state-changed': self._on_muc_state_changed, - 'avatar-update': self._on_avatar_update, 'user-joined': self._on_user_joined, 'user-left': self._on_user_left, 'user-affiliation-changed': self._on_user_affiliation_changed, @@ -218,189 +117,28 @@ class GroupchatControl(BaseControl): 'room-kicked': self._on_room_kicked, 'room-destroyed': self._on_room_destroyed, 'room-config-finished': self._on_room_config_finished, - 'room-config-failed': self._on_room_config_failed, 'room-config-changed': self._on_room_config_changed, - 'room-password-required': self._on_room_password_required, - 'room-creation-failed': self._on_room_creation_failed, 'room-presence-error': self._on_room_presence_error, 'room-voice-request': self._on_room_voice_request, - 'room-captcha-challenge': self._on_room_captcha_challenge, - 'room-captcha-error': self._on_room_captcha_error, 'room-subject': self._on_room_subject, - 'room-joined': self._on_room_joined, - 'room-join-failed': self._on_room_join_failed, }) def _on_muc_state_changed(self, _contact: GroupchatContact, _signal_name: str ) -> None: - if self.contact.is_joining: - self._groupchat_state.set_joining() - if self.contact.is_joined: self._set_control_active() - self._groupchat_state.set_joined() elif self.contact.is_not_joined: self._set_control_inactive() - def _on_client_state_changed(self, - _client: Client, - _signal_name: str, - state: SimpleClientState - ) -> None: + def _on_muc_disco_update(self, event: events.MucDiscoUpdate) -> None: pass - @property - def disco_info(self): - return app.storage.cache.get_last_disco_info(self.contact.jid) - - def add_actions(self) -> None: - super().add_actions() - actions = [ - ('change-nickname-', None, self._on_change_nick), - ('destroy-', None, self._on_destroy_room), - ('request-voice-', None, self._on_request_voice), - ('groupchat-details-', None, self._on_details), - ('invite-', None, self._on_invite), - ('contact-information-', 's', self._on_contact_information), - ('execute-command-', 's', self._on_execute_command), - ('ban-', 's', self._on_ban), - ('kick-', 's', self._on_kick), - ('change-role-', 'as', self._on_change_role), - ('change-affiliation-', 'as', self._on_change_affiliation), - ] - - for action in actions: - action_name, variant, func = action - if variant is not None: - variant = GLib.VariantType.new(variant) - act = Gio.SimpleAction.new(action_name + self.control_id, variant) - act.connect("activate", func) - app.window.add_action(act) - - def update_actions(self, *args: Any) -> None: - request_voice = False - joined = self.contact.is_joined - if joined: - contact = self.contact.get_self() - request_voice = contact.role.is_visitor - - self._get_action('request-voice-').set_enabled(request_voice) - - # Change Nick - self._get_action('change-nickname-').set_enabled( - joined and not self.is_irc()) - - # Execute command - self._get_action('execute-command-').set_enabled(joined) - - # Send message - has_text = self.msg_textview.has_text() - self._get_action('send-message-').set_enabled( - joined and has_text) - - # Send file (HTTP File Upload) - httpupload = self._get_action( - 'send-file-httpupload-') - httpupload.set_enabled(joined and - self._client.get_module('HTTPUpload').available) - self._get_action('send-file-').set_enabled(httpupload.get_enabled()) - - if joined and httpupload.get_enabled(): - tooltip_text = _('Send File…') - max_file_size = self._client.get_module('HTTPUpload').max_file_size - if max_file_size is not None: - max_file_size = GLib.format_size_full(max_file_size, - self._units) - tooltip_text = _('Send File (max. %s)…') % max_file_size - else: - tooltip_text = _('No File Transfer available') - self.xml.sendfile_button.set_tooltip_text(tooltip_text) - - self._get_action('contact-information-').set_enabled(joined) - - self._get_action('groupchat-details-').set_enabled(joined) - - self._get_action('execute-command-').set_enabled(joined) - - self._get_action('ban-').set_enabled(joined) - - self._get_action('kick-').set_enabled(joined) - - self._get_action('invite-').set_enabled(joined) - - def remove_actions(self) -> None: - super().remove_actions() - actions = [ - 'change-nickname-', - 'destroy-', - 'configure-', - 'request-voice-', - 'groupchat-details-', - 'invite-', - 'contact-information-', - 'execute-command-', - 'ban-', - 'kick-', - 'change-role-', - 'change-affiliation-', - ] - - for action in actions: - app.window.remove_action(f'{action}{self.control_id}') - - def is_irc(self) -> bool: - if self.disco_info is None: - return False - return self.disco_info.is_irc - - def _show_page(self, name: str) -> None: - transition = Gtk.StackTransitionType.SLIDE_DOWN - if name == 'groupchat': - transition = Gtk.StackTransitionType.SLIDE_UP - active_control = app.window.get_active_control() - if active_control == self: - self.msg_textview.grab_focus() - self.xml.stack.set_visible_child_full(name, transition) - - def _get_current_page(self) -> str: - return self.xml.stack.get_visible_child_name() - - def _on_muc_disco_update(self, _event: events.MucDiscoUpdate) -> None: - self.update_actions() - self.draw_banner_text() - # Actions - def _on_details(self, - _action: Gio.SimpleAction, - _param: Optional[GLib.Variant] - ) -> None: - open_window('GroupchatDetails', - contact=self.contact, - subject=self._subject_text) - - def _on_invite(self, - _action: Gio.SimpleAction, - _param: Optional[GLib.Variant] - ) -> None: - self._invite_box.load_contacts() - self._show_page('invite') - - def _on_invite_ready(self, - _listbox: GroupChatInviter, - invitable: bool - ) -> None: - self.xml.invite_button.set_sensitive(invitable) - - def _on_invite_clicked(self, _button: Gtk.Button) -> None: - invitees = self._invite_box.get_invitees() - for jid in invitees: - self.invite(JID.from_string(jid)) - self._show_page('groupchat') - def invite(self, invited_jid: JID) -> None: + # TODO: Remove, used by command system self._client.get_module('MUC').invite( self.contact.jid, invited_jid) invited_contact = self._client.get_module('Contacts').get_contact( @@ -408,128 +146,14 @@ class GroupchatControl(BaseControl): self.add_info_message( _('%s has been invited to this group chat') % invited_contact.name) - def _on_destroy_room(self, _button: Gtk.Button) -> None: - self.xml.destroy_reason_entry.grab_focus() - self.xml.destroy_button.grab_default() - self._show_page('destroy') - - def _on_destroy_alternate_changed(self, - entry: Gtk.Entry, - _param: Any - ) -> None: - jid = entry.get_text() - if jid: - try: - jid = helpers.validate_jid(jid) - except Exception: - icon = 'dialog-warning-symbolic' - text = _('Invalid XMPP Address') - self.xml.destroy_alternate_entry.set_icon_from_icon_name( - Gtk.EntryIconPosition.SECONDARY, icon) - self.xml.destroy_alternate_entry.set_icon_tooltip_text( - Gtk.EntryIconPosition.SECONDARY, text) - self.xml.destroy_button.set_sensitive(False) - return - self.xml.destroy_alternate_entry.set_icon_from_icon_name( - Gtk.EntryIconPosition.SECONDARY, None) - self.xml.destroy_button.set_sensitive(True) - - def _on_destroy_confirm(self, _button: Gtk.Button) -> None: - reason = self.xml.destroy_reason_entry.get_text() - jid = self.xml.destroy_alternate_entry.get_text() - self._client.get_module('MUC').destroy(self.room_jid, reason, jid) - self._show_page('groupchat') - - def _on_request_voice_clicked(self, _button: Gtk.Button) -> None: - self._request_voice() - self.xml.visitor_popover.popdown() - - def _on_request_voice(self, - _action: Gio.SimpleAction, - _param: Optional[GLib.Variant] - ) -> None: - self._request_voice() - - def _request_voice(self) -> None: - self._client.get_module('MUC').request_voice(self.room_jid) - - def _on_execute_command(self, - _action: Gio.SimpleAction, - param: GLib.Variant - ) -> None: - jid = self.room_jid - nick = param.get_string() - if nick: - jid += '/' + nick - open_window('AdHocCommands', account=self.account, jid=jid) - - def _on_contact_information(self, - _action: Gio.SimpleAction, - param: GLib.Variant - ) -> None: - nick = param.get_string() - contact = self.contact.get_resource(nick) - open_window('ContactInfo', account=self.account, contact=contact) - - def _on_kick(self, - _action: Gio.SimpleAction, - param: GLib.Variant - ) -> None: - nick = param.get_string() - self._kick_nick = nick - text = _('Kick %s') % nick - self.xml.kick_label.set_text(text) - self.xml.kick_label.set_tooltip_text(text) - self.xml.kick_reason_entry.grab_focus() - self.xml.kick_participant_button.grab_default() - self._show_page('kick') - - def _on_ban(self, - _action: Gio.SimpleAction, - param: GLib.Variant - ) -> None: - jid = param.get_string() - self._ban_jid = jid - text = _('Ban %s') % jid - self.xml.ban_label.set_text(text) - self.xml.ban_label.set_tooltip_text(text) - self.xml.ban_reason_entry.grab_focus() - self.xml.ban_participant_button.grab_default() - self._show_page('ban') - - def _on_change_role(self, - _action: Gio.SimpleAction, - param: GLib.Variant - ) -> None: - nick, role = param.get_strv() - self._client.get_module('MUC').set_role(self.room_jid, nick, role) - - def _on_change_affiliation(self, - _action: Gio.SimpleAction, - param: GLib.Variant - ) -> None: - jid, affiliation = param.get_strv() - self._client.get_module('MUC').set_affiliation( - self.room_jid, - {jid: {'affiliation': affiliation}}) - - def _set_toggle_roster_button_icon(self, - show_roster: bool, - *args: Any) -> None: - icon = 'go-next-symbolic' if show_roster else 'go-previous-symbolic' - self.xml.toggle_roster_image.set_from_icon_name( - icon, Gtk.IconSize.BUTTON) - - def _show_roster(self, *args: Any) -> None: - show = not self.xml.roster_revealer.get_reveal_child() - self._set_toggle_roster_button_icon(show) - + def _show_roster(self, show_roster: bool, *args: Any) -> None: transition = Gtk.RevealerTransitionType.SLIDE_RIGHT - if show: + if show_roster: + self._roster_revealer.set_no_show_all(False) + self._roster_revealer.show_all() transition = Gtk.RevealerTransitionType.SLIDE_LEFT - self.xml.roster_revealer.set_transition_type(transition) - self.xml.roster_revealer.set_reveal_child(show) - app.settings.set('hide_groupchat_occupants_list', show) + self._roster_revealer.set_transition_type(transition) + self._roster_revealer.set_reveal_child(show_roster) def _on_roster_row_activated(self, _roster: GroupchatRoster, @@ -547,33 +171,13 @@ class GroupchatControl(BaseControl): contact = self.contact.get_resource(nick) app.window.add_private_chat(self.account, contact.jid, select=True) - def _on_avatar_update(self, - _contact: GroupchatContact, - _signal_name: str - ) -> None: - self._update_avatar() - - def _update_avatar(self) -> None: - surface = self.contact.get_avatar( - AvatarSize.CHAT, app.window.get_scale_factor()) - self.xml.avatar_image.set_from_surface(surface) - - def draw_banner_text(self) -> None: - """ - Draw the text in the fat line at the top of the window that houses the - room jid - """ - self.xml.banner_name_label.set_text(self.contact.name) - - def _on_bookmarks_received(self, _event: events.BookmarksReceived) -> None: - self.draw_banner_text() - def _on_room_voice_request(self, _contact: GroupchatContact, _signal_name: str, properties: MessageProperties ) -> None: voice_request = properties.voice_request + assert voice_request is not None def on_approve() -> None: self._client.get_module('MUC').approve_voice_request( @@ -590,10 +194,6 @@ class GroupchatControl(BaseControl): callback=on_approve)], modal=False).show() - def _on_message_received(self, event: events.MessageReceived) -> None: - if not event.msgtxt: - return - def _on_mam_message_received(self, event: events.MamMessageReceived ) -> None: @@ -617,9 +217,6 @@ class GroupchatControl(BaseControl): displaymarking=event.displaymarking, additional_data=event.additional_data) else: - if event.properties.muc_nickname == self.contact.nickname: - self.last_sent_txt = event.msgtxt - self.add_message(event.msgtxt, contact=event.properties.muc_nickname, tim=event.properties.timestamp, @@ -627,10 +224,6 @@ class GroupchatControl(BaseControl): message_id=event.properties.id, stanza_id=event.stanza_id, additional_data=event.additional_data) - event.needs_highlight = helpers.message_needs_highlight( - event.msgtxt, - self.contact.nickname, - self._client.get_own_jid().bare) def add_message(self, text: str, @@ -649,12 +242,6 @@ class GroupchatControl(BaseControl): kind = 'incoming' # muc-specific chatstate - if kind == 'incoming': - highlight = helpers.message_needs_highlight( - text, self.contact.nickname, self._client.get_own_jid().bare) - - self.msg_textview.process_outgoing_message(contact, highlight) - BaseControl.add_message(self, text, kind, @@ -693,6 +280,7 @@ class GroupchatControl(BaseControl): # http://www.xmpp.org/extensions/xep-0045.html#roomconfig-notify status_codes = properties.muc_status_codes + assert status_codes is not None changes: list[str] = [] if StatusCode.SHOWING_UNAVAILABLE in status_codes: @@ -730,7 +318,7 @@ class GroupchatControl(BaseControl): self.add_info_message(change) @event_filter(['account']) - def _nec_ping(self, event: events.ApplicationEvent) -> None: + def _on_ping_event(self, event: events.PingEventT) -> None: if not event.contact.is_groupchat: return @@ -741,43 +329,27 @@ class GroupchatControl(BaseControl): if isinstance(event, events.PingSent): self.add_info_message(_('Ping? (%s)') % nick) elif isinstance(event, events.PingReply): - self.add_info_message( - _('Pong! (%(nick)s %(delay)s s.)') % {'nick': nick, - 'delay': event.seconds}) - elif isinstance(event, events.PingError): + self.add_info_message(_('Pong! (%(nick)s %(delay)s s.)') % { + 'nick': nick, + 'delay': event.seconds}) + else: self.add_info_message(event.error) def _set_control_active(self) -> None: - contact = self.contact.get_self() - if contact.role.is_visitor: - self.xml.visitor_box.show() - else: - self.set_message_input_state(True) - + assert self.roster is not None self.roster.initial_draw() self.conversation_view.update_avatars() - self.update_actions() - def _set_control_inactive(self) -> None: - self.set_message_input_state(False) - self.xml.visitor_box.hide() - + assert self.roster is not None self.roster.enable_sort(False) self.roster.clear() self._client.get_module('Chatstate').remove_delay_timeout(self.contact) - self.update_actions() - def rejoin(self) -> None: self._client.get_module('MUC').join(self.room_jid) - # def send_pm(self, nick, message=None): - # ctrl = self._start_private_message(nick) - # if message is not None: - # ctrl.send_message(message) - def _on_user_joined(self, _contact: GroupchatContact, _signal_name: str, @@ -809,25 +381,12 @@ class GroupchatControl(BaseControl): _('The server has assigned or modified your nickname in this ' 'group chat')) - # Update Actions - self.update_actions() - def _on_room_config_finished(self, _contact: GroupchatContact, _signal_name: str ) -> None: - self._show_page('groupchat') self.add_info_message(_('A new group chat has been created')) - def _on_room_config_failed(self, - _contact: GroupchatContact, - _signal_name: str, - error: StanzaError - ) -> None: - self.xml.error_heading.set_text(_('Failed to Configure Group Chat')) - self.xml.error_label.set_text(to_user_string(error)) - self._show_page('error') - def _on_user_nickname_changed(self, _contact: GroupchatContact, _signal_name: str, @@ -835,6 +394,8 @@ class GroupchatControl(BaseControl): properties: MessageProperties ) -> None: nick = user_contact.name + + assert properties.muc_user is not None new_nick = properties.muc_user.nick if properties.is_muc_self_presence: message = _('You are now known as %s') % new_nick @@ -865,6 +426,8 @@ class GroupchatControl(BaseControl): ) -> None: affiliation = helpers.get_uf_affiliation(user_contact.affiliation) nick = user_contact.name + + assert properties.muc_user is not None reason = properties.muc_user.reason reason = '' if reason is None else f': {reason}' @@ -887,7 +450,6 @@ class GroupchatControl(BaseControl): reason=reason) self.add_info_message(message) - self.update_actions() def _on_user_role_changed(self, _contact: GroupchatContact, @@ -897,6 +459,8 @@ class GroupchatControl(BaseControl): ) -> None: role = helpers.get_uf_role(user_contact.role) nick = user_contact.name + + assert properties.muc_user is not None reason = properties.muc_user.reason reason = '' if reason is None else f': {reason}' @@ -905,11 +469,6 @@ class GroupchatControl(BaseControl): actor = '' if actor is None else _(' by {actor}').format(actor=actor) if properties.is_muc_self_presence: - if user_contact.role.is_visitor: - self.xml.visitor_box.show() - else: - self.xml.visitor_box.hide() - self.set_message_input_state(not user_contact.role.is_visitor) message = _('** Your Role has been set to ' '{role}{actor}{reason}').format(role=role, actor=actor, @@ -922,7 +481,6 @@ class GroupchatControl(BaseControl): reason=reason) self.add_info_message(message) - self.update_actions() def _on_room_kicked(self, _contact: GroupchatContact, @@ -931,6 +489,7 @@ class GroupchatControl(BaseControl): ) -> None: status_codes = properties.muc_status_codes or [] + assert properties.muc_user is not None reason = properties.muc_user.reason reason = '' if reason is None else f': {reason}' @@ -989,6 +548,7 @@ class GroupchatControl(BaseControl): status_codes = properties.muc_status_codes or [] nick = user_contact.name + assert properties.muc_user is not None reason = properties.muc_user.reason reason = '' if reason is None else f': {reason}' @@ -1034,39 +594,6 @@ class GroupchatControl(BaseControl): self.conversation_view.add_muc_user_left( nick, properties.muc_user.reason) - def _on_room_joined(self, - _contact: GroupchatContact, - _signal_name: str - ) -> None: - self._show_page('groupchat') - - def _on_room_password_required(self, - _contact: GroupchatContact, - _signal_name: str, - _properties: MessageProperties): - self._show_page('password') - - def _on_room_join_failed(self, - _contact: GroupchatContact, - _signal_name: str, - error: StanzaError - ) -> None: - if self._client.get_module('Bookmarks').is_bookmark(self.room_jid): - self.xml.remove_bookmark_button.show() - - self.xml.error_heading.set_text(_('Failed to Join Group Chat')) - self.xml.error_label.set_text(to_user_string(error)) - self._show_page('error') - - def _on_room_creation_failed(self, - _contact: GroupchatContact, - _signal_name: str, - properties: MessageProperties - ) -> None: - self.xml.error_heading.set_text(_('Failed to Create Group Chat')) - self.xml.error_label.set_text(to_user_string(properties.error)) - self._show_page('error') - def _on_room_presence_error(self, _contact: GroupchatContact, _signal_name: str, @@ -1094,74 +621,6 @@ class GroupchatControl(BaseControl): 'instead: xmpp:%s?join') % str(alternate) self.add_info_message(join_message) - def _on_message_sent(self, event: events.MessageSent) -> None: - if not event.message: - return - # we'll save sent message text when we'll receive it in - # _nec_gc_message_received - if event.correct_id is None: - oob_url = event.additional_data.get_value('gajim', 'oob_url') - if oob_url == event.message: - self.last_sent_msg = None - else: - self.last_sent_msg = event.message_id - if self.correcting: - self.correcting = False - self.msg_textview.get_style_context().remove_class( - 'gajim-msg-correcting') - - def send_message(self, - message: str, - process_commands: bool = True - ) -> None: - """ - Call this function to send our message - """ - if not message: - return - - if self.encryption: - self.sendmessage = True - app.plugin_manager.extension_point( - 'send_message' + self.encryption, self) - if not self.sendmessage: - return - - if process_commands and self.process_as_command(message): - return - - message = helpers.remove_invalid_xml_chars(message) - - if not message: - return - - label = self.get_seclabel() - if message != '' or message != '\n': - self.save_message(message, 'sent') - - if self.correcting and self.last_sent_msg: - correct_id = self.last_sent_msg - else: - correct_id = None - chatstate = self._client.get_module('Chatstate')\ - .get_active_chatstate(self.contact) - - # Send the message - message_ = OutgoingMessage(account=self.account, - contact=self.contact, - message=message, - type_='groupchat', - label=label, - chatstate=chatstate, - correct_id=correct_id) - self._client.send_message(message_) - - self.msg_textview.get_buffer().set_text('') - self.msg_textview.grab_focus() - - def _on_message_error(self, event: events.MessageError) -> None: - self.conversation_view.show_error(event.message_id, event.error) - def shutdown(self, reason: Optional[str] = None) -> None: app.settings.disconnect_signals(self) self.contact.disconnect(self) @@ -1174,235 +633,5 @@ class GroupchatControl(BaseControl): self.roster.destroy() self.roster = None - self.remove_actions() - super(GroupchatControl, self).shutdown() app.check_finalize(self) - - def _close_control(self) -> None: - app.window.activate_action( - 'remove-chat', - GLib.Variant('as', [self.account, str(self.room_jid)])) - - def set_control_active(self, state: bool) -> None: - self.attention_flag = False - BaseControl.set_control_active(self, state) - - def _on_drag_data_received(self, - _widget: Gtk.Widget, - _context: Gdk.DragContext, - _x_coord: int, - _y_coord: int, - selection: Gtk.SelectionData, - target_type: int, - _timestamp: int - ) -> None: - if not selection.get_data(): - return - - log.debug('Drop received: %s, %s', selection.get_data(), target_type) - - # TODO: Contact drag and drop for Invitations - if target_type == TARGET_TYPE_URI_LIST: - # File drag and drop (handled in chat_control_base) - self.drag_data_file_transfer(selection) - - def delegate_action(self, action: str) -> int: - res = super().delegate_action(action) - if res == Gdk.EVENT_STOP: - return res - - if action == 'change-nickname': - control_action = f'{action}-{self.control_id}' - app.window.lookup_action(control_action).activate() - return Gdk.EVENT_STOP - - if action == 'escape': - if self._get_current_page() == 'groupchat': - return Gdk.EVENT_PROPAGATE - - if self._get_current_page() == 'password': - self._on_password_cancel_clicked() - elif self._get_current_page() == 'captcha': - self._on_captcha_cancel_clicked() - elif self._get_current_page() in ('error', 'captcha-error'): - self._on_page_close_clicked() - else: - self._show_page('groupchat') - return Gdk.EVENT_STOP - - if action == 'change-subject': - open_window('GroupchatDetails', - contact=self.contact, - subject=self._subject_text, - page='manage') - return Gdk.EVENT_STOP - - if action == 'show-contact-info': - app.window.lookup_action( - f'groupchat-details-{self.control_id}').activate() - return Gdk.EVENT_STOP - - return Gdk.EVENT_PROPAGATE - - def focus(self) -> None: - page_name = self._get_current_page() - if page_name == 'groupchat': - self.msg_textview.grab_focus() - elif page_name == 'password': - self.xml.password_entry.grab_focus_without_selecting() - elif page_name == 'nickname': - self.xml.nickname_entry.grab_focus_without_selecting() - elif page_name == 'captcha': - self._captcha_request.focus_first_entry() - elif page_name == 'invite': - self._invite_box.focus_search_entry() - elif page_name == 'destroy': - self.xml.destroy_reason_entry.grab_focus_without_selecting() - - def _on_kick_participant_clicked(self, _button: Gtk.Button) -> None: - reason = self.xml.kick_reason_entry.get_text() - self._client.get_module('MUC').set_role( - self.room_jid, self._kick_nick, 'none', reason) - self._show_page('groupchat') - - def _on_ban_participant_clicked(self, _button: Gtk.Button) -> None: - reason = self.xml.ban_reason_entry.get_text() - self._client.get_module('MUC').set_affiliation( - self.room_jid, - {self._ban_jid: {'affiliation': 'outcast', 'reason': reason}}) - self._show_page('groupchat') - - def _on_page_change(self, stack: Gtk.Stack, _param: Any) -> None: - page_name = stack.get_visible_child_name() - if page_name == 'groupchat': - pass - elif page_name == 'password': - self.xml.password_entry.set_text('') - self.xml.password_entry.grab_focus() - self.xml.password_set_button.grab_default() - elif page_name == 'captcha': - self.xml.captcha_set_button.grab_default() - elif page_name == 'captcha-error': - self.xml.captcha_try_again_button.grab_default() - elif page_name == 'error': - self.xml.close_button.grab_default() - - def _on_change_nick(self, - _action: Gio.SimpleAction, - _param: Optional[GLib.Variant] - ) -> None: - if self._get_current_page() != 'groupchat': - return - self.xml.nickname_entry.set_text(self.contact.nickname) - self.xml.nickname_entry.grab_focus() - self.xml.nickname_change_button.grab_default() - self._show_page('nickname') - - def _on_nickname_text_changed(self, entry: Gtk.Entry, _param: Any) -> None: - text = entry.get_text() - if not text or text == self.contact.nickname: - self.xml.nickname_change_button.set_sensitive(False) - else: - try: - validate_resourcepart(text) - except InvalidJid: - self.xml.nickname_change_button.set_sensitive(False) - else: - self.xml.nickname_change_button.set_sensitive(True) - - def _on_nickname_change_clicked(self, _button: Gtk.Button) -> None: - new_nick = self.xml.nickname_entry.get_text() - self._client.get_module('MUC').change_nick(self.room_jid, new_nick) - self._show_page('groupchat') - - def _on_password_set_clicked(self, _button: Gtk.Button) -> None: - password = self.xml.password_entry.get_text() - self._client.get_module('MUC').set_password(self.room_jid, password) - self._client.get_module('MUC').join(self.room_jid) - self._show_page('groupchat') - - def _on_password_changed(self, entry, _param): - self.xml.password_set_button.set_sensitive(bool(entry.get_text())) - - def _on_password_cancel_clicked(self, *args: Any) -> None: - self._close_control() - - def _on_room_captcha_challenge(self, - _contact: GroupchatContact, - _signal_name: str, - properties: MessageProperties - ) -> None: - self._remove_captcha_request() - form = properties.captcha.form - - options = {'no-scrolling': True, - 'entry-activates-default': True} - self._captcha_request = DataFormWidget(form, options=options) - self._captcha_request.connect('is-valid', self._on_captcha_changed) - self._captcha_request.set_valign(Gtk.Align.START) - self._captcha_request.show_all() - self.xml.captcha_box.add(self._captcha_request) - - self._show_page('captcha') - self._captcha_request.focus_first_entry() - - def _on_room_captcha_error(self, - _contact: GroupchatContact, - _signal_name: str, - error: StanzaError - ) -> None: - error_text = to_user_string(error) - self.xml.captcha_error_label.set_text(error_text) - self._show_page('captcha-error') - - def _remove_captcha_request(self) -> None: - if self._captcha_request is None: - return - if self._captcha_request in self.xml.captcha_box.get_children(): - self.xml.captcha_box.remove(self._captcha_request) - self._captcha_request.destroy() - self._captcha_request = None - - def _on_captcha_changed(self, - _widget: DataFormWidget, - is_valid: bool - ) -> None: - self.xml.captcha_set_button.set_sensitive(is_valid) - - def _on_captcha_set_clicked(self, _button): - form_node = self._captcha_request.get_submit_form() - self._client.get_module('MUC').send_captcha(self.room_jid, form_node) - self._remove_captcha_request() - self._show_page('groupchat') - - def _on_captcha_cancel_clicked(self, *args: Any) -> None: - self._client.get_module('MUC').cancel_captcha(self.room_jid) - self._remove_captcha_request() - self._close_control() - - def _on_captcha_try_again_clicked(self, *args: Any) -> None: - self._client.get_module('MUC').join(self.room_jid) - self._show_page('groupchat') - - def _on_remove_bookmark_button_clicked(self, *args: Any) -> None: - self._client.get_module('Bookmarks').remove(self.room_jid) - self._close_control() - - def _on_retry_join_clicked(self, *args: Any) -> None: - self._client.get_module('MUC').join(self.room_jid) - self._show_page('groupchat') - - def _on_page_cancel_clicked(self, *args: Any) -> None: - self._show_page('groupchat') - - def _on_page_close_clicked(self, *args: Any) -> None: - self._close_control() - - def _on_groupchat_state_abort_clicked(self, _button: Gtk.Button) -> None: - self._close_control() - - def _on_groupchat_state_join_clicked(self, - _groupchat_state: GroupchatState - ) -> None: - self._client.get_module('MUC').join(self.room_jid) diff --git a/gajim/gtk/controls/private.py b/gajim/gtk/controls/private.py index bada80ef1..c91295f62 100644 --- a/gajim/gtk/controls/private.py +++ b/gajim/gtk/controls/private.py @@ -24,22 +24,17 @@ # 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 nbxmpp import JID -from nbxmpp.structs import MessageProperties from nbxmpp.structs import PresenceProperties from gajim.common import app from gajim.common import helpers from gajim.common.i18n import _ -from gajim.common.modules.contacts import GroupchatContact from gajim.common.modules.contacts import GroupchatParticipant from gajim.gui.controls.chat import ChatControl from gajim.command_system.implementation.hosts import PrivateChatCommands -from gajim.gui.dialogs import ErrorDialog from gajim.gui.const import ControlType @@ -60,22 +55,12 @@ class PrivateChatControl(ChatControl): def _connect_contact_signals(self) -> None: self.contact.multi_connect({ - 'user-avatar-update': self._on_user_avatar_update, - 'user-joined': self._on_user_joined, - 'user-left': self._on_user_left, 'user-status-show-changed': self._on_user_status_show_changed, 'user-nickname-changed': self._on_user_nickname_changed, # 'room-kicked': self._on_room_kicked, # 'room-destroyed': self._on_room_destroyed, - 'room-joined': self._on_room_joined, - # 'room-left': self._on_room_left - 'chatstate-update': self._on_chatstate_update, }) - @property - def room_name(self) -> str: - return self._room_contact.name - def get_our_nick(self) -> str: muc_data = self._client.get_module('MUC').get_muc_data( self._room_contact.jid) @@ -88,6 +73,8 @@ class PrivateChatControl(ChatControl): ) -> None: # TODO nick = properties.muc_nickname + + assert properties.muc_user is not None new_nick = properties.muc_user.nick if properties.is_muc_self_presence: message = _('You are now known as %s') % new_nick @@ -97,9 +84,6 @@ class PrivateChatControl(ChatControl): self.add_info_message(message) - self.draw_banner() - self.update_ui() - def _on_user_status_show_changed(self, _user_contact: GroupchatParticipant, _signal_name: str, @@ -112,7 +96,6 @@ class PrivateChatControl(ChatControl): show = helpers.get_uf_show(properties.show.value) if not self._room_contact.settings.get('print_status'): - self.update_ui() return if properties.is_muc_self_presence: @@ -124,65 +107,3 @@ class PrivateChatControl(ChatControl): show=show, status=status) self.add_info_message(message) - self.update_ui() - - # def _on_disconnected(self, event): - # if event.properties.jid != self.contact.jid: - # return - # self.set_message_input_state(False) - - def _on_user_left(self, - _user_contact: GroupchatParticipant, - _signal_name: str, - _properties: MessageProperties - ) -> None: - self.set_message_input_state(False) - - def _on_user_joined(self, - _user_contact: GroupchatParticipant, - _signal_name: str, - _properties: MessageProperties - ) -> None: - self.set_message_input_state(True) - - def _on_room_joined(self, - _contact: GroupchatContact, - _signal_name: str - ) -> None: - if not self.contact.is_available: - return - self.set_message_input_state(True) - - def send_message(self, - message: str, - process_commands: bool = True, - attention: bool = False - ) -> None: - """ - Call this method to send the message - """ - message = helpers.remove_invalid_xml_chars(message) - if not message: - return - - # We need to make sure that we can still send through the room and that - # the recipient did not go away - if not self.contact.is_available: - ErrorDialog( - _('Sending private message failed'), - _('You are no longer joined "%(room)s" or ' - '"%(nick)s" has left the chat.') % { - 'room': self.room_name, - 'nick': self.contact.name}) - return - - ChatControl.send_message(self, - message, - process_commands=process_commands, - attention=attention) - - def update_ui(self) -> None: - ChatControl.update_ui(self) - - def _on_user_avatar_update(self, *args: Any) -> None: - self._update_avatar() diff --git a/gajim/gtk/conversation/jump_to_end_button.py b/gajim/gtk/conversation/jump_to_end_button.py index 2a1019af5..6795c3f85 100644 --- a/gajim/gtk/conversation/jump_to_end_button.py +++ b/gajim/gtk/conversation/jump_to_end_button.py @@ -77,6 +77,7 @@ class JumpToEndButton(Gtk.Overlay): self.set_no_show_all(True) def _on_jump_clicked(self, _button: Gtk.Button) -> None: + self.reset_unread_count() self.emit('clicked') def toggle(self, visible: bool) -> None: diff --git a/gajim/gtk/conversation/rows/message.py b/gajim/gtk/conversation/rows/message.py index 4aa49cf8a..21cc87dd2 100644 --- a/gajim/gtk/conversation/rows/message.py +++ b/gajim/gtk/conversation/rows/message.py @@ -25,7 +25,6 @@ from gi.repository import Gdk from gi.repository import GdkPixbuf from gi.repository import GLib from gi.repository import Gtk -from gi.repository import GObject from gi.repository import Pango import cairo @@ -62,20 +61,6 @@ MERGE_TIMEFRAME = timedelta(seconds=120) class MessageRow(BaseRow): - - __gsignals__ = { - 'mention': ( - GObject.SignalFlags.RUN_LAST, - None, - (str,) - ), - 'quote': ( - GObject.SignalFlags.RUN_LAST, - None, - (str,) - ), - } - def __init__(self, account: str, contact: ChatContactT, @@ -273,7 +258,7 @@ class MessageRow(BaseRow): name: str ) -> int: if event.type == Gdk.EventType.BUTTON_PRESS and event.button == 1: - self.emit('mention', name) + app.window.activate_action('mention', GLib.Variant('s', name)) return Gdk.EVENT_STOP @staticmethod @@ -335,7 +320,11 @@ class MessageRow(BaseRow): clip.set_text(f'{timestamp_formatted} - {self.name}: {text}', -1) def on_quote_message(self, _widget: Gtk.Widget) -> None: - self.emit('quote', self._message_widget.get_text()) + app.window.activate_action( + 'quote', GLib.Variant('s', self._message_widget.get_text())) + + def on_correct_message(self, _widget: Gtk.Widget) -> None: + app.window.activate_action('correct-message', None) def on_retract_message(self, _widget: Gtk.Widget) -> None: def _on_retract(reason: str) -> None: diff --git a/gajim/gtk/conversation/rows/widgets.py b/gajim/gtk/conversation/rows/widgets.py index 2093ddc1f..6e47c4508 100644 --- a/gajim/gtk/conversation/rows/widgets.py +++ b/gajim/gtk/conversation/rows/widgets.py @@ -14,8 +14,7 @@ from __future__ import annotations -from typing import Union -from typing import Any +from typing import TYPE_CHECKING from gi.repository import Gtk from gi.repository import Pango @@ -24,13 +23,13 @@ from gi.repository import GLib from gajim.common import app from gajim.common.i18n import _ from gajim.common.helpers import is_retraction_allowed -from gajim.common.modules.contacts import BareContact from gajim.common.modules.contacts import GroupchatContact -from gajim.common.modules.contacts import GroupchatParticipant +from gajim.common.types import ChatContactT -from ...util import wrap_with_event_box +if TYPE_CHECKING: + from .message import MessageRow -ContactT = Union[BareContact, GroupchatContact, GroupchatParticipant] +from ...util import wrap_with_event_box class SimpleLabel(Gtk.Label): @@ -45,8 +44,8 @@ class SimpleLabel(Gtk.Label): @wrap_with_event_box class MoreMenuButton(Gtk.Button): def __init__(self, - row: Any, - contact: ContactT, + row: MessageRow, + contact: ChatContactT, name: str ) -> None: @@ -70,23 +69,33 @@ class MoreMenuButton(Gtk.Button): show_retract = False if isinstance(self._contact, GroupchatContact): if not self._contact.is_joined: - self._create_popover(False) + self._create_popover(show_retract=False) return - disco_info = app.storage.cache.get_last_disco_info( - self._contact.jid) - assert disco_info is not None - contact = self._contact.get_resource(self._name) self_contact = self._contact.get_self() assert self_contact is not None - is_allowed = is_retraction_allowed(self_contact, contact) + + disco_info = app.storage.cache.get_last_disco_info( + self._contact.jid) + assert disco_info is not None + if disco_info.has_message_moderation and is_allowed: show_retract = True - self._create_popover(show_retract) - def _create_popover(self, show_retract: bool) -> None: + show_correction = False + if self._row.message_id is not None: + show_correction = app.window.is_message_correctable( + self._contact.account, self._contact.jid, self._row.message_id) + + self._create_popover(show_retract=show_retract, + show_correction=show_correction) + + def _create_popover(self, + show_retract: bool = False, + show_correction: bool = False + ) -> None: menu_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) menu_box.get_style_context().add_class('padding-6') @@ -116,6 +125,16 @@ class MoreMenuButton(Gtk.Button): 'edit-copy-symbolic', Gtk.IconSize.MENU)) menu_box.add(copy_button) + if show_correction: + correct_button = Gtk.ModelButton() + correct_button.set_halign(Gtk.Align.START) + correct_button.connect( + 'clicked', self._row.on_correct_message) + correct_button.set_label(_('Correct')) + correct_button.set_image(Gtk.Image.new_from_icon_name( + 'document-edit-symbolic', Gtk.IconSize.MENU)) + menu_box.add(correct_button) + if show_retract: retract_button = Gtk.ModelButton() retract_button.set_halign(Gtk.Align.START) diff --git a/gajim/gtk/conversation/view.py b/gajim/gtk/conversation/view.py index d4a2ab111..5e9167af1 100644 --- a/gajim/gtk/conversation/view.py +++ b/gajim/gtk/conversation/view.py @@ -27,7 +27,6 @@ from datetime import datetime from datetime import timedelta from gi.repository import GLib -from gi.repository import GObject from gi.repository import Gtk from nbxmpp.errors import StanzaError @@ -68,25 +67,6 @@ log = logging.getLogger('gajim.gui.conversation_view') class ConversationView(Gtk.ListBox): - - __gsignals__ = { - 'quote': ( - GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION, - None, - (str, ) - ), - 'mention': ( - GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION, - None, - (str, ) - ), - 'scroll-to-end': ( - GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION, - None, - () - ), - } - def __init__(self, account: str, contact: ChatContactT) -> None: Gtk.ListBox.__init__(self) self.set_selection_mode(Gtk.SelectionMode.NONE) @@ -273,8 +253,6 @@ class ConversationView(Gtk.ListBox): error=error, encryption_enabled=self.encryption_enabled, log_line_id=log_line_id) - message_row.connect('mention', self._on_mention) - message_row.connect('quote', self._on_quote) if message_id is not None: self._message_id_row_map[message_id] = message_row @@ -488,10 +466,6 @@ class ConversationView(Gtk.ListBox): if isinstance(row, MessageRow): row.update_avatar() - def scroll_to_end(self, force: bool = False) -> None: - if self.autoscroll or force: - GLib.idle_add(self.emit, 'scroll-to-end') - def correct_message(self, correct_id: str, text: str, @@ -518,12 +492,6 @@ class ConversationView(Gtk.ListBox): message_row.set_error(to_user_string(error)) message_row.set_merged(False) - def _on_quote(self, _message_row: MessageRow, text: str) -> None: - self.emit('quote', text) - - def _on_mention(self, _message_row: MessageRow, name: str) -> None: - self.emit('mention', name) - def _on_contact_setting_changed(self, value: Any, setting: str, diff --git a/gajim/gtk/groupchat_details.py b/gajim/gtk/groupchat_details.py index f663f83c1..0bdeeccf3 100644 --- a/gajim/gtk/groupchat_details.py +++ b/gajim/gtk/groupchat_details.py @@ -41,7 +41,6 @@ from .structs import RemoveHistoryActionParams class GroupchatDetails(Gtk.ApplicationWindow): def __init__(self, contact: GroupchatContact, - subject: str, page: Optional[str] = None ) -> None: Gtk.ApplicationWindow.__init__(self) @@ -55,7 +54,6 @@ class GroupchatDetails(Gtk.ApplicationWindow): self.account = contact.account self._client = app.get_client(contact.account) - self._subject_text = subject self._contact = contact self._contact.connect('avatar-update', self._on_avatar_update) @@ -136,8 +134,7 @@ class GroupchatDetails(Gtk.ApplicationWindow): def _add_groupchat_manage(self) -> None: self._groupchat_manage = GroupchatManage(self.account, - self._contact, - self._subject_text) + self._contact) self._ui.manage_box.add(self._groupchat_manage) def _add_groupchat_info(self) -> None: diff --git a/gajim/gtk/groupchat_manage.py b/gajim/gtk/groupchat_manage.py index dd6da9d43..480278617 100644 --- a/gajim/gtk/groupchat_manage.py +++ b/gajim/gtk/groupchat_manage.py @@ -43,7 +43,6 @@ class GroupchatManage(Gtk.Box): def __init__(self, account: str, contact: GroupchatContact, - subject: str ) -> None: Gtk.Box.__init__(self) self._account = account @@ -51,8 +50,6 @@ class GroupchatManage(Gtk.Box): self._contact = contact self._contact.connect('room-subject', self._on_room_subject) - self._subject_text = subject - self._room_config_form = None self._ui = get_builder('groupchat_manage.ui') @@ -81,7 +78,11 @@ class GroupchatManage(Gtk.Box): return app.storage.cache.get_last_disco_info(self._contact.jid) def _prepare_subject(self) -> None: - self._ui.subject_textview.get_buffer().set_text(self._subject_text) + text = '' + if self._contact.subject is not None: + text = self._contact.subject.text + + self._ui.subject_textview.get_buffer().set_text(text) joined = self._contact.is_joined change_allowed = self._is_subject_change_allowed() @@ -103,8 +104,11 @@ class GroupchatManage(Gtk.Box): text = buffer_.get_text(buffer_.get_start_iter(), buffer_.get_end_iter(), False) + + assert self._contact.subject is not None + self._ui.subject_change_button.set_sensitive( - text != self._subject_text) + text != self._contact.subject.text) def _on_subject_change_clicked(self, _button: Gtk.Button) -> None: buffer_ = self._ui.subject_textview.get_buffer() @@ -121,7 +125,6 @@ class GroupchatManage(Gtk.Box): if subject is None: return - self._subject_text = subject.text self._ui.subject_textview.get_buffer().set_text(subject.text) def _prepare_manage(self) -> None: diff --git a/gajim/gtk/groupchat_nick_completion.py b/gajim/gtk/groupchat_nick_completion.py index fdb309539..32f74a1ee 100644 --- a/gajim/gtk/groupchat_nick_completion.py +++ b/gajim/gtk/groupchat_nick_completion.py @@ -14,6 +14,7 @@ from __future__ import annotations +from typing import Optional from typing import TYPE_CHECKING import logging @@ -23,8 +24,12 @@ from nbxmpp.structs import PresenceProperties from gi.repository import Gdk from gajim.common import app +from gajim.common import ged from gajim.common import types +from gajim.common.events import GcMessageReceived +from gajim.common.ged import EventHelper from gajim.common.helpers import jid_is_blocked +from gajim.common.helpers import message_needs_highlight if TYPE_CHECKING: from .message_input import MessageInputTextView @@ -32,23 +37,44 @@ if TYPE_CHECKING: log = logging.getLogger('gajim.gui.groupchat_nick_completion') -class GroupChatNickCompletion: - def __init__(self, - account: str, - contact: types.GroupchatContactT, - message_input: MessageInputTextView - ) -> None: - self._account = account +class GroupChatNickCompletion(EventHelper): + def __init__(self) -> None: + EventHelper.__init__(self) - self._contact = contact - self._contact.connect( - 'user-nickname-changed', self._on_user_nickname_changed) + self._account: Optional[str] = None + self._contact: Optional[types.GroupchatContactT] = None self._sender_list: list[str] = [] - self._attention_list: list[str] = [] + self._highlight_list: list[str] = [] self._nick_hits: list[str] = [] self._last_key_tab = False + self._nick_data: dict[str, tuple[list[str], list[str]]] = {} + + self.register_event( + 'gc-message-received', ged.GUI1, self._on_gc_message_received) + + def switch_contact(self, contact: types.GroupchatContactT) -> None: + self._nick_hits.clear() + self._last_key_tab = False + + if self._contact is not None: + self._contact.disconnect_all_from_obj(self) + self._nick_data[str(self._contact.jid)] = ( + self._sender_list, self._highlight_list) + + nick_data = self._nick_data.get(str(contact.jid)) + if nick_data is None: + self._sender_list.clear() + self._highlight_list.clear() + else: + self._sender_list, self._highlight_list = nick_data + + self._account = contact.account + self._contact = contact + self._contact.connect( + 'user-nickname-changed', self._on_user_nickname_changed) + def _on_user_nickname_changed(self, _contact: types.GroupchatContact, _signal_name: str, @@ -63,27 +89,46 @@ class GroupChatNickCompletion: new_name = properties.muc_user.nick assert new_name is not None log.debug('Contact %s renamed to %s', old_name, new_name) - for lst in (self._attention_list, self._sender_list): + for lst in (self._highlight_list, self._sender_list): for idx, contact in enumerate(lst): if contact == old_name: lst[idx] = new_name - def record_message(self, contact_name: str, highlight: bool) -> None: - if contact_name == self._contact.nickname: + def _on_gc_message_received(self, event: GcMessageReceived) -> None: + if event.properties.muc_nickname is None: + # Message from server return - log.debug('Recorded a message from %s, highlight; %s', - contact_name, - highlight) + client = app.get_client(event.account) + gc_contact = client.get_module('Contacts').get_contact( + event.room_jid) + + participant_nick = event.properties.muc_nickname + if participant_nick == gc_contact.nickname: + return + + highlight = message_needs_highlight( + event.msgtxt, gc_contact.nickname, client.get_own_jid().bare) + self._process_message(participant_nick, highlight, event.room_jid) + + def _process_message(self, + participant_nick: str, + highlight: bool, + room_jid: str + ) -> None: + nick_data = self._nick_data.get(room_jid) + if nick_data is None: + return + sender_list, highlight_list = nick_data if highlight: try: - self._attention_list.remove(contact_name) + highlight_list.remove(participant_nick) except ValueError: pass - if len(self._attention_list) > 6: - self._attention_list.pop(0) # remove older - self._attention_list.append(contact_name) + if len(highlight_list) > 6: + highlight_list.pop(0) # remove older + highlight_list.append(participant_nick) # TODO implement it in a more efficient way # Currently it's O(n*m + n*s), where n is the number of participants and @@ -95,10 +140,10 @@ class GroupChatNickCompletion: # 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_name) + sender_list.remove(participant_nick) except ValueError: pass - self._sender_list.append(contact_name) + sender_list.append(participant_nick) def _generate_suggestions(self, nicks: list[str], @@ -111,12 +156,13 @@ class GroupChatNickCompletion: `beginning` is the text already typed by the user ''' def _nick_matching(nick: str) -> bool: + assert self._contact return (nick != self._contact.nickname and nick.lower().startswith(beginning.lower())) if beginning == '': # empty message, so just suggest recent mentions - potential_matches = self._attention_list + potential_matches = self._highlight_list else: # nick partially typed, try completing it potential_matches = self._sender_list @@ -179,6 +225,7 @@ class GroupChatNickCompletion: self._nick_hits.append(self._nick_hits[0]) begin = self._nick_hits.pop(0) else: + assert self._contact list_nick = self._contact.get_user_nicknames() list_nick = list(filter(self._jid_not_blocked, list_nick)) @@ -215,6 +262,7 @@ class GroupChatNickCompletion: else: start_iter.backward_chars(len(begin)) + assert self._account client = app.get_client(self._account) client.get_module('Chatstate').block_chatstates( self._contact, True) @@ -255,6 +303,8 @@ class GroupChatNickCompletion: return True def _jid_not_blocked(self, resource: str) -> bool: + assert self._account + assert self._contact resource_contact = self._contact.get_resource(resource) return not jid_is_blocked( self._account, str(resource_contact.jid)) diff --git a/gajim/gtk/groupchat_roster.py b/gajim/gtk/groupchat_roster.py index 171db7f71..f0168f78d 100644 --- a/gajim/gtk/groupchat_roster.py +++ b/gajim/gtk/groupchat_roster.py @@ -378,7 +378,6 @@ class GroupchatRoster(Gtk.ScrolledWindow, EventHelper): assert self_contact is not None contact = self._group_chat_contact.get_resource(nick) menu = get_groupchat_roster_menu(self._account, - self._control_id, self_contact, contact) diff --git a/gajim/gtk/main.py b/gajim/gtk/main.py index 40038c90b..131d90763 100644 --- a/gajim/gtk/main.py +++ b/gajim/gtk/main.py @@ -38,6 +38,7 @@ from gajim.common.const import SimpleClientState from gajim.common.ged import EventHelper from gajim.common.i18n import _ from gajim.common.modules.bytestream import is_transfer_active +from gajim.gtk.const import MAIN_WIN_ACTIONS from gajim.plugins.pluginmanager import PluginManifest from gajim.plugins.repository import PluginRepository @@ -76,6 +77,10 @@ class MainWindow(Gtk.ApplicationWindow, EventHelper): app.window = self + self._add_actions() + self._add_actions2() + self._add_stateful_actions() + self._startup_finished: bool = False self._ui = get_builder('main.ui') @@ -142,8 +147,6 @@ class MainWindow(Gtk.ApplicationWindow, EventHelper): self._check_for_account() self._load_chats() self._load_unread_counts() - self._add_actions() - self._add_actions2() self._prepare_window() @@ -155,6 +158,11 @@ class MainWindow(Gtk.ApplicationWindow, EventHelper): client.connect_signal('state-changed', self._on_client_state_changed) + def get_action(self, name: str) -> Gio.SimpleAction: + action = self.lookup_action(name) + assert action is not None + return action + def is_minimized(self) -> bool: if app.is_display(Display.WAYLAND): # There is no way to discover if a window is minimized on wayland @@ -319,16 +327,44 @@ class MainWindow(Gtk.ApplicationWindow, EventHelper): act.connect('activate', func) self.add_action(act) + def _add_stateful_actions(self) -> None: + action = Gio.SimpleAction.new_stateful( + 'show-offline', + None, + GLib.Variant('b', app.settings.get('showoffline'))) + + action.connect('change-state', self._on_show_offline) + + self.add_action(action) + + action = Gio.SimpleAction.new_stateful( + 'sort-by-show', + None, + GLib.Variant('b', app.settings.get('sort_by_show_in_roster'))) + + action.connect('change-state', self._on_sort_by_show) + + self.add_action(action) + + action = Gio.SimpleAction.new_stateful( + 'set-encryption', + GLib.VariantType('s'), + GLib.Variant('s', 'disabled')) + + self.add_action(action) + def _add_actions2(self) -> None: + for action, variant_type, enabled in MAIN_WIN_ACTIONS: + if variant_type is not None: + variant_type = GLib.VariantType(variant_type) + act = Gio.SimpleAction.new(action, variant_type) + act.set_enabled(enabled) + self.add_action(act) + actions = [ 'change-nickname', 'change-subject', 'escape', - 'send-file', - 'show-contact-info', - 'show-emoji-chooser', - 'clear-chat', - 'delete-line', 'close-tab', 'switch-next-tab', 'switch-prev-tab', @@ -347,7 +383,6 @@ class MainWindow(Gtk.ApplicationWindow, EventHelper): ] disabled_for_emacs = ( - 'send-file', 'close-tab' ) @@ -370,12 +405,21 @@ class MainWindow(Gtk.ApplicationWindow, EventHelper): if action_name == 'escape' and self._chat_page.hide_search(): return None + chat_stack = self._chat_page.get_chat_stack() + if action_name == 'escape' and chat_stack.process_escape(): + return None + control = self.get_active_control() if control is not None: + if action_name == 'change-nickname': + app.window.activate_action('muc-change-nickname', None) + return None - res = control.delegate_action(action_name) - if res != Gdk.EVENT_PROPAGATE: - return res + if action_name == 'change-subject': + open_window('GroupchatDetails', + contact=control.contact, + page='manage') + return None if action_name == 'escape': if app.settings.get('escape_key_closes'): @@ -409,6 +453,20 @@ class MainWindow(Gtk.ApplicationWindow, EventHelper): return None + def _on_show_offline(self, + action: Gio.SimpleAction, + value: GLib.Variant) -> None: + + action.set_state(value) + app.settings.set('showoffline', value.get_boolean()) + + def _on_sort_by_show(self, + action: Gio.SimpleAction, + value: GLib.Variant) -> None: + + action.set_state(value) + app.settings.set('sort_by_show_in_roster', value.get_boolean()) + def _toggle_chat_list(self) -> None: chat_list_stack = self._chat_page.get_chat_list_stack() chat_list = chat_list_stack.get_current_chat_list() @@ -435,8 +493,10 @@ class MainWindow(Gtk.ApplicationWindow, EventHelper): if self.get_property('has-toplevel-focus'): client = app.get_client(control.account) + chat_stack = self._chat_page.get_chat_stack() + msg_action_box = chat_stack.get_message_action_box() client.get_module('Chatstate').set_mouse_activity( - control.contact, control.msg_textview.has_text()) + control.contact, msg_action_box.msg_textview.has_text) def _on_window_delete(self, _widget: Gtk.ApplicationWindow, @@ -715,6 +775,20 @@ class MainWindow(Gtk.ApplicationWindow, EventHelper): def chat_exists(self, account: str, jid: JID) -> bool: return self._chat_page.chat_exists(account, jid) + def is_message_correctable(self, + account: str, + jid: JID, + message_id: str + ) -> bool: + chat_stack = self._chat_page.get_chat_stack() + last_message_id = chat_stack.get_last_message_id(account, jid) + if last_message_id is None or last_message_id != message_id: + return False + + message_row = app.storage.archive.get_last_correctable_message( + account, jid, last_message_id) + return message_row is not None + def get_total_unread_count(self) -> int: chat_list_stack = self._chat_page.get_chat_list_stack() return chat_list_stack.get_total_unread_count() diff --git a/gajim/gtk/menus.py b/gajim/gtk/menus.py index 86e5278a8..0f3d045a0 100644 --- a/gajim/gtk/menus.py +++ b/gajim/gtk/menus.py @@ -41,6 +41,7 @@ from gajim.common.const import URIType from gajim.common.const import URIAction from gajim.common.structs import URI from gajim.common.structs import VariantMixin +from gajim.common.modules.contacts import GroupchatContact from gajim.common.modules.contacts import can_add_to_roster from gajim.gui.structs import AddChatActionParams @@ -48,22 +49,20 @@ from gajim.gui.structs import AccountJidParam from gajim.gui.structs import ChatListEntryParam from gajim.gui.structs import RemoveHistoryActionParams from gajim.gui.util import GajimMenu -from gajim.gui.const import ControlType MenuValueT = Union[None, str, GLib.Variant, VariantMixin] MenuItemListT = list[tuple[str, str, MenuValueT]] -def get_self_contact_menu(control_id: str, - contact: types.BareContact) -> GajimMenu: +def get_self_contact_menu(contact: types.BareContact) -> GajimMenu: account = contact.account jid = contact.jid menu = GajimMenu() menu.add_item(_('Profile'), f'app.{account}-profile', f'"{account}"') - submenu = get_send_file_submenu(control_id) + submenu = get_send_file_submenu() menu.append_submenu(_('Send File'), submenu) params = RemoveHistoryActionParams(account=account, jid=jid) @@ -72,60 +71,55 @@ def get_self_contact_menu(control_id: str, return menu -def get_singlechat_menu(control_id: str, - contact: Union[types.BareContact, - types.GroupchatParticipant] - ) -> GajimMenu: +def get_singlechat_menu(contact: types.BareContact) -> GajimMenu: + account = contact.account menu = GajimMenu() - menu.add_item(_('Details'), f'win.information-{control_id}') - menu.add_item(_('Block Contact…'), f'win.block-contact-{control_id}') + menu.add_item(_('Details'), 'win.show-contact-info') + menu.add_item(_('Block Contact…'), f'app.{account}-block-contact') - submenu = get_send_file_submenu(control_id) + submenu = get_send_file_submenu() menu.append_submenu(_('Send File'), submenu) - menu.add_item(_('Start Voice Call…'), f'win.start-voice-call-{control_id}') - menu.add_item(_('Start Video Call…'), f'win.start-video-call-{control_id}') - menu.add_item(_('Search…'), 'win.search-history') + menu.add_item(_('Start Voice Call…'), 'win.start-voice-call') + menu.add_item(_('Start Video Call…'), 'win.start-video-call') + + menu.add_item(_('Search…'), 'win.search-history', None) if can_add_to_roster(contact): - menu.append(_('Add to Contact List…'), - f'win.add-to-roster-{control_id}') + params = AccountJidParam(account=account, jid=contact.jid) + menu.add_item(_('Add to Contact List…'), 'win.add-to-roster', params) return menu -def get_private_chat_menu(control_id: str, - contact: types.GroupchatParticipant - ) -> GajimMenu: - +def get_private_chat_menu(contact: types.GroupchatParticipant) -> GajimMenu: menu = GajimMenu() - menu.add_item(_('Details'), f'win.information-{control_id}') - menu.add_item(_('Upload File…'), f'win.send-file-httpupload-{control_id}') + menu.add_item(_('Details'), 'win.show-contact-info') + menu.add_item(_('Upload File…'), 'win.send-file-httpupload') menu.add_item(_('Search…'), 'win.search-history') if can_add_to_roster(contact): - menu.append(_('Add to Contact List…'), - f'win.add-to-roster-{control_id}') + params = AccountJidParam(account=contact.account, jid=contact.jid) + menu.add_item(_('Add to Contact List…'), 'win.add-to-roster', params) return menu -def get_send_file_submenu(control_id: str) -> GajimMenu: +def get_send_file_submenu() -> GajimMenu: menu = GajimMenu() - menu.add_item(_('Upload File…'), f'win.send-file-httpupload-{control_id}') - menu.add_item(_('Send File Directly…'), - f'win.send-file-jingle-{control_id}') + menu.add_item(_('Upload File…'), 'win.send-file-httpupload') + menu.add_item(_('Send File Directly…'), 'win.send-file-jingle') return menu -def get_groupchat_menu(control_id: str, account: str, jid: JID) -> GajimMenu: +def get_groupchat_menu(contact: GroupchatContact) -> GajimMenu: menuitems: MenuItemListT = [ - (_('Details'), f'win.groupchat-details-{control_id}', None), - (_('Change Nickname…'), f'win.change-nickname-{control_id}', None), - (_('Request Voice'), f'win.request-voice-{control_id}', None), - (_('Execute Command…'), f'win.execute-command-{control_id}', '""'), + (_('Details'), 'win.show-contact-info', None), + (_('Change Nickname…'), 'win.muc-change-nickname', None), + (_('Request Voice'), 'win.muc-request-voice', None), + (_('Execute Command…'), 'win.muc-execute-command', '""'), (_('Search…'), 'win.search-history', None) ] @@ -199,24 +193,16 @@ def build_accounts_menu() -> None: menubar.insert_submenu(menu_position, _('Accounts'), acc_menu) -def get_encryption_menu(control_id: str, - control_type: ControlType, - ) -> Optional[GajimMenu]: - menu = GajimMenu() - action = f'win.set-encryption-{control_id}' - menu.add_item(_('Disabled'), action, '"disabled"') - for name, plugin in app.plugin_manager.encryption_plugins.items(): - if control_type.is_groupchat: - if not hasattr(plugin, 'allow_groupchat'): - continue - if control_type.is_privatechat: - if not hasattr(plugin, 'allow_privatechat'): - continue - menu.add_item(name, action, f'"{name}"') - - if menu.get_n_items() == 1: - return None - return menu +def get_encryption_menu() -> GajimMenu: + + menuitems: MenuItemListT = [ + (_('Disabled'), 'win.set-encryption', '""'), + ('OMEMO', 'win.set-encryption', '"OMEMO"'), + ('OpenPGP', 'win.set-encryption', '"OpenPGP"'), + ('PGP', 'win.set-encryption', '"PGP"'), + ] + + return GajimMenu.from_list(menuitems) def get_conv_action_context_menu(account: str, @@ -373,15 +359,15 @@ def get_roster_menu(account: str, jid: str, gateway: bool = False) -> GajimMenu: value = f'"{jid}"' menuitems: MenuItemListT = [ - (_('Details'), f'win.contact-info-{account}', value), - (_('Execute Command…'), f'win.execute-command-{account}', value), - (block_label, f'win.block-contact-{account}', value), - (_('Remove…'), f'win.remove-contact-{account}', value), + (_('Details'), f'app.{account}-contact-info', value), + (_('Execute Command…'), f'app.{account}-execute-command', value), + (block_label, f'app.{account}-block-contact', value), + (_('Remove…'), f'app.{account}-remove-contact', value), ] if gateway: menuitems.insert( - 1, (_('Modify Gateway…'), f'win.modify-gateway-{account}', value)) + 1, (_('Modify Gateway…'), f'app.{account}-modify-gateway', value)) return GajimMenu.from_list(menuitems) @@ -466,13 +452,12 @@ def get_workspace_params(current_workspace_id: str, yield name, params -def get_groupchat_admin_menu(control_id: str, - self_contact: types.GroupchatParticipant, +def get_groupchat_admin_menu(self_contact: types.GroupchatParticipant, contact: types.GroupchatParticipant) -> GajimMenu: menu = GajimMenu() - action = f'win.change-affiliation-{control_id}' + action = 'win.muc-change-affiliation' if is_affiliation_change_allowed(self_contact, contact, 'owner'): value = f'["{contact.real_jid}", "owner"]' @@ -492,7 +477,7 @@ def get_groupchat_admin_menu(control_id: str, if is_affiliation_change_allowed(self_contact, contact, 'outcast'): value = f'"{contact.real_jid}"' - menu.add_item(_('Ban…'), f'win.ban-{control_id}', value) + menu.add_item(_('Ban…'), 'win.muc-ban', value) if not menu.get_n_items(): menu.add_item(_('Not Available'), 'dummy', None) @@ -500,8 +485,7 @@ def get_groupchat_admin_menu(control_id: str, return menu -def get_groupchat_mod_menu(control_id: str, - self_contact: types.GroupchatParticipant, +def get_groupchat_mod_menu(self_contact: types.GroupchatParticipant, contact: types.GroupchatParticipant ) -> GajimMenu: @@ -509,9 +493,9 @@ def get_groupchat_mod_menu(control_id: str, if is_role_change_allowed(self_contact, contact): value = f'"{contact.name}"' - menu.add_item(_('Kick…'), f'win.kick-{control_id}', value) + menu.add_item(_('Kick…'), 'win.muc-kick', value) - action = f'win.change-role-{control_id}' + action = 'win.muc-change-role' if is_role_change_allowed(self_contact, contact): if contact.role.is_visitor: @@ -528,7 +512,6 @@ def get_groupchat_mod_menu(control_id: str, def get_groupchat_roster_menu(account: str, - control_id: str, self_contact: types.GroupchatParticipant, contact: types.GroupchatParticipant ) -> GajimMenu: @@ -536,8 +519,8 @@ def get_groupchat_roster_menu(account: str, value = f'"{contact.name}"' general_items: MenuItemListT = [ - (_('Details'), f'win.contact-information-{control_id}', value), - (_('Execute Command…'), f'win.execute-command-{control_id}', value), + (_('Details'), 'win.muc-contact-info', value), + (_('Execute Command…'), 'win.muc-execute-command', value), ] real_contact = contact.get_real_contact() @@ -546,8 +529,8 @@ def get_groupchat_roster_menu(account: str, action = f'app.{account}-add-contact' general_items.insert(1, (_('Add to Contact List…'), action, value)) - mod_menu = get_groupchat_mod_menu(control_id, self_contact, contact) - admin_menu = get_groupchat_admin_menu(control_id, self_contact, contact) + mod_menu = get_groupchat_mod_menu(self_contact, contact) + admin_menu = get_groupchat_admin_menu(self_contact, contact) menu = GajimMenu.from_list(general_items) menu.append_section(_('Moderation'), mod_menu) @@ -577,6 +560,17 @@ def get_component_search_menu(jid: Optional[str], copy_text: str) -> Gio.Menu: return menu +def get_format_menu() -> GajimMenu: + + menuitems: MenuItemListT = [ + (_('bold'), 'win.input-bold', None), + (_('italic'), 'win.input-italic', None), + (_('strike'), 'win.input-strike', None), + ] + + return GajimMenu.from_list(menuitems) + + def escape_mnemonic(label: Optional[str]) -> Optional[str]: if label is None: return None diff --git a/gajim/gtk/message_actions_box.py b/gajim/gtk/message_actions_box.py new file mode 100644 index 000000000..eba274aa7 --- /dev/null +++ b/gajim/gtk/message_actions_box.py @@ -0,0 +1,698 @@ +# 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 __future__ import annotations + +from typing import Any +from typing import Optional + +import os +import sys +import tempfile +import logging +import uuid + +from gi.repository import GLib +from gi.repository import Gdk +from gi.repository import GdkPixbuf +from gi.repository import Gio +from gi.repository import Gtk +from gi.repository import GObject + +from nbxmpp.const import Chatstate +from nbxmpp.modules.security_labels import SecurityLabel +from nbxmpp.protocol import JID + +from gajim.common import app +from gajim.common import events +from gajim.common import i18n +from gajim.common.i18n import _ +from gajim.common.client import Client +from gajim.common.const import SimpleClientState +from gajim.common.const import Direction +from gajim.common.modules.contacts import GroupchatContact +from gajim.common.modules.contacts import GroupchatParticipant +from gajim.common.types import ChatContactT +from gajim.gui.security_label_selector import SecurityLabelSelector + +from .builder import get_builder +from .dialogs import DialogButton +from .dialogs import ErrorDialog +from .dialogs import PastePreviewDialog +from .emoji_chooser import emoji_chooser +from .menus import get_encryption_menu +from .menus import get_format_menu +from .menus import get_groupchat_menu +from .menus import get_private_chat_menu +from .menus import get_self_contact_menu +from .menus import get_singlechat_menu +from .message_input import MessageInputTextView + +if app.is_installed('GSPELL'): + from gi.repository import Gspell # pylint: disable=ungrouped-imports + +log = logging.getLogger('gajim.gui.messageactionsbox') + + +class MessageActionsBox(Gtk.Grid): + def __init__(self) -> None: + Gtk.Grid.__init__(self) + + self._client: Optional[Client] = None + self._contact: Optional[ChatContactT] = None + + self._ui = get_builder('message_actions_box.ui') + self.get_style_context().add_class('message-action-box') + + self.attach(self._ui.box, 0, 0, 1, 1) + + # For undo + self.space_pressed = False + + # XEP-0308 Message Correction + self.is_correcting = False + self.last_message_id: dict[tuple[str, JID], Optional[str]] = {} + + self._ui.send_message_button.set_visible( + app.settings.get('show_send_message_button')) + app.settings.bind_signal('show_send_message_button', + self._ui.send_message_button, + 'set_visible') + + self._security_label_selector = SecurityLabelSelector() + self._ui.box.pack_start(self._security_label_selector, False, True, 0) + + self.msg_textview = MessageInputTextView() + self.msg_textview.get_buffer().connect('changed', + self._on_buffer_changed) + self.msg_textview.connect('key-press-event', + self._on_msg_textview_key_press_event) + self.msg_textview.connect('paste-clipboard', + self._on_paste_clipboard) + + self._ui.input_scrolled.add(self.msg_textview) + + self._ui.sendfile_button.set_tooltip_text( + _('No File Transfer available')) + self._ui.formattings_button.set_menu_model(get_format_menu()) + self._ui.encryption_menu_button.set_menu_model(get_encryption_menu()) + + self._ui.quick_invite_button.set_action_name('win.muc-invite') + + self._init_emoticon_popover() + # TODO init spellchecker uses a contact on callback, contact might + # be None + self._language_handler_id = self._init_spell_checker() + + self.show_all() + self._ui.connect_signals(self) + + self._connect_actions() + + def _get_encryption_state(self) -> tuple[bool, str]: + assert self._contact is not None + encryption = self._contact.settings.get('encryption') + return bool(encryption), encryption + + def get_current_contact(self) -> ChatContactT: + assert self._contact is not None + return self._contact + + def get_seclabel(self) -> Optional[SecurityLabel]: + return self._security_label_selector.get_seclabel() + + def _connect_actions(self) -> None: + actions = [ + 'input-bold', + 'input-italic', + 'input-strike', + 'input-clear', + 'show-emoji-chooser', + 'quote', + 'mention', + 'correct-message', + ] + + for action in actions: + action = app.window.get_action(action) + action.connect('activate', self._on_action) + + action = app.window.get_action('set-encryption') + action.connect('change-state', self._change_encryption) + + action = app.window.get_action('send-file-jingle') + action.connect('notify::enabled', self._on_send_file_enabled_changed) + + action = app.window.get_action('send-file-httpupload') + action.connect('notify::enabled', self._on_send_file_enabled_changed) + + def _on_action(self, + action: Gio.SimpleAction, + param: Optional[GLib.Variant]) -> Optional[int]: + + if self._contact is None: + return + + action_name = action.get_name() + log.info('Activate action: %s', action_name) + + if action_name == 'input-clear': + self._on_clear() + + elif action_name.startswith('input-'): + self._on_format(action_name) + + elif action_name == 'show-emoji-chooser': + self._on_show_emoji_chooser() + + elif action_name == 'quote': + assert param + self.msg_textview.insert_as_quote(param.get_string()) + + elif action_name == 'mention': + assert param + self.msg_textview.mention_participant(param.get_string()) + + elif action_name == 'correct-message': + self.toggle_message_correction() + + def switch_contact(self, contact: ChatContactT) -> None: + if self._client is not None: + self._set_chatstate(False) + self._client.disconnect_all_from_obj(self) + + if self._contact is not None: + self._contact.disconnect_all_from_obj(self) + + self._client = app.get_client(contact.account) + self._client.connect_signal( + 'state-changed', self._on_client_state_changed) + + self._contact = contact + + if isinstance(self._contact, GroupchatContact): + self._ui.quick_invite_button.show() + self._contact.multi_connect({ + 'state-changed': self._on_muc_state_changed, + 'user-role-changed': self._on_muc_state_changed, + }) + elif isinstance(self._contact, GroupchatParticipant): + self._ui.quick_invite_button.hide() + self._contact.multi_connect({ + 'user-joined': self._on_user_state_changed, + 'user-left': self._on_user_state_changed, + 'room-joined': self._on_user_state_changed, + 'room-left': self._on_user_state_changed, + }) + else: + self._ui.quick_invite_button.hide() + + self._set_settings_menu(contact) + + encryption = self._contact.settings.get('encryption') + self._set_encryption_state(encryption) + self._set_encryption_details(encryption) + + self._set_spell_checker_language(contact) + self._set_chatstate(True) + + self.msg_textview.switch_contact(contact) + self._security_label_selector.switch_contact(contact) + + self._update_message_input_state() + + def clear(self) -> None: + if self._client is not None: + self._set_chatstate(False) + self._client.disconnect_all_from_obj(self) + + if self._contact is not None: + self._contact.disconnect_all_from_obj(self) + + self._contact = None + self._client = None + + def _on_client_state_changed(self, + _client: Client, + _signal_name: str, + state: SimpleClientState + ) -> None: + self._update_message_input_state() + + def _on_muc_state_changed(self, *args: Any) -> None: + self._update_message_input_state() + + def _on_user_state_changed(self, *args: Any) -> None: + self._update_message_input_state() + + def _update_message_input_state(self) -> None: + assert self._client + state = self._client.state.is_available + + if isinstance(self._contact, GroupchatContact): + state = self._contact.is_joined + if self._contact.is_joined: + self_contact = self._contact.get_self() + assert self_contact + state = not self_contact.role.is_visitor + + if isinstance(self._contact, GroupchatParticipant): + state = self._contact.is_available + + self._ui.emoticons_button.set_sensitive(state) + self._ui.formattings_button.set_sensitive(state) + self.msg_textview.set_sensitive(state) + self.msg_textview.set_editable(state) + + def _set_chatstate(self, state: bool) -> None: + assert self._client is not None + if state: + if self.msg_textview.has_text: + self._client.get_module('Chatstate').set_chatstate( + self._contact, Chatstate.PAUSED) + else: + self._client.get_module('Chatstate').set_chatstate( + self._contact, Chatstate.ACTIVE) + else: + self._client.get_module('Chatstate').set_chatstate( + self._contact, Chatstate.INACTIVE) + + def _set_encryption_state(self, state: str) -> None: + action = app.window.get_action('set-encryption') + action.set_state(GLib.Variant('s', state)) + + if state in ('OMEMO', 'OpenPGP', 'PGP'): + icon_name = 'channel-secure-symbolic' + else: + icon_name = 'channel-insecure-symbolic' + + self._ui.encryption_image.set_from_icon_name(icon_name, + Gtk.IconSize.MENU) + + def _change_encryption(self, + action: Gio.SimpleAction, + param: GLib.Variant + ) -> None: + + new_state = param.get_string() + action_state = action.get_state() + assert action_state is not None + current_state = action_state.get_string() + if current_state == new_state: + return + + if new_state: + plugin = app.plugin_manager.encryption_plugins.get(new_state) + if plugin is None: + # TODO: Add GUI error here + return + + if not plugin.activate_encryption(app.window.get_active_control()): + return + + self._set_encryption_state(new_state) + # self.conversation_view.encryption_enabled = encryption is not None + contact = self.get_current_contact() + contact.settings.set('encryption', new_state) + + self._set_encryption_details(new_state) + + def _set_encryption_details(self, state: str) -> None: + encryption_state = {'visible': bool(state), + 'enc_type': state, + 'authenticated': False} + + if state: + app.plugin_manager.extension_point( + f'encryption_state{state}', self, encryption_state) + + visible, enc_type, authenticated = encryption_state.values() + assert isinstance(visible, bool) + + if authenticated: + authenticated_string = _('and authenticated') + self._ui.encryption_details_image.set_from_icon_name( + 'security-high-symbolic', Gtk.IconSize.MENU) + else: + authenticated_string = _('and NOT authenticated') + self._ui.encryption_details_image.set_from_icon_name( + 'security-low-symbolic', Gtk.IconSize.MENU) + + tooltip = _('%(type)s encryption is active %(authenticated)s.') % { + 'type': enc_type, 'authenticated': authenticated_string} + + self._ui.encryption_details_button.set_tooltip_text(tooltip) + self._ui.encryption_details_button.set_visible(visible) + self._ui.encryption_details_image.set_sensitive(visible) + + def _on_encryption_details_clicked(self, _button: Gtk.Button) -> None: + contact = self.get_current_contact() + encryption = contact.settings.get('encryption') + app.plugin_manager.extension_point( + f'encryption_dialog{encryption}', app.window.get_active_control()) + + def _set_settings_menu(self, contact: ChatContactT) -> None: + if isinstance(contact, GroupchatContact): + menu = get_groupchat_menu(contact) + elif isinstance(contact, GroupchatParticipant): + menu = get_private_chat_menu(contact) + elif contact.is_self: + menu = get_self_contact_menu(contact) + else: + menu = get_singlechat_menu(contact) + self._ui.settings_menu.set_menu_model(menu) + + def _init_emoticon_popover(self) -> None: + if not app.settings.get('emoticons_theme'): + return + + if sys.platform == 'darwin': + emoji_chooser.text_widget = self.msg_textview + self._ui.emoticons_button.set_popover(emoji_chooser) + return + + self._ui.emoticons_button.set_sensitive(True) + self._ui.emoticons_button.connect('clicked', + self._on_emoticon_button_clicked) + + def toggle_emoticons(self) -> None: + if app.settings.get('emoticons_theme'): + self._ui.emoticons_button.set_no_show_all(False) + self._ui.emoticons_button.show() + else: + self._ui.emoticons_button.set_no_show_all(True) + self._ui.emoticons_button.hide() + + def _on_emoticon_button_clicked(self, _widget: Gtk.Button) -> None: + self.msg_textview.emit('insert-emoji') + self._ui.emoticons_button.set_active(False) + + def _on_format(self, name: str) -> None: + name = name.removeprefix('input-') + self.msg_textview.apply_formatting(name) + + def _on_clear(self) -> None: + self.msg_textview.clear() + + def _on_show_emoji_chooser(self) -> None: + if sys.platform == 'darwin': + popover = self._ui.emoticons_button.get_popover() + assert popover + popover.show() + else: + self.msg_textview.emit('insert-emoji') + + def _init_spell_checker(self) -> int: + if not app.is_installed('GSPELL'): + return 0 + + checker = Gspell.Checker.new(Gspell.language_get_default()) + + buffer = Gspell.TextBuffer.get_from_gtk_text_buffer( + self.msg_textview.get_buffer()) + buffer.set_spell_checker(checker) + + use_spell_check = app.settings.get('use_speller') + view = Gspell.TextView.get_from_gtk_text_view(self.msg_textview) + view.set_inline_spell_checking(use_spell_check) + view.set_enable_language_menu(True) + + return checker.connect('notify::language', self._on_language_changed) + + def toggle_spell_checker(self) -> None: + if not app.is_installed('GSPELL'): + return + use_spell_check = app.settings.get('use_speller') + view = Gspell.TextView.get_from_gtk_text_view(self.msg_textview) + view.set_inline_spell_checking(use_spell_check) + + def _set_spell_checker_language(self, contact: ChatContactT) -> None: + if not app.is_installed('GSPELL'): + return + + buffer = Gspell.TextBuffer.get_from_gtk_text_buffer( + self.msg_textview.get_buffer()) + checker = buffer.get_spell_checker() + assert checker is not None + lang = self._get_spell_checker_language(contact) + + with checker.handler_block(self._language_handler_id): + checker.set_language(lang) + + def _get_spell_checker_language(self, + contact: ChatContactT + ) -> Optional[Gspell.Language]: + + lang = contact.settings.get('speller_language') + if not lang: + # use the default one + lang = app.settings.get('speller_language') + if not lang: + lang = i18n.LANG + + assert isinstance(lang, str) + lang = Gspell.language_lookup(lang) + if lang is None: + lang = Gspell.language_get_default() + return lang + + def _on_language_changed(self, + checker: Gspell.Checker, + _param: Any) -> None: + + gspell_lang = checker.get_language() + if gspell_lang is not None: + contact = self.get_current_contact() + contact.settings.set('speller_language', gspell_lang.get_code()) + + def _on_send_file_enabled_changed(self, + action: Gio.SimpleAction, + _param: GObject.ParamSpec) -> None: + + self._update_send_file_button_tooltip() + + def _update_send_file_button_tooltip(self): + httpupload = app.window.get_action_enabled('send-file-httpupload') + jingle = app.window.get_action_enabled('send-file-jingle') + + if not httpupload and not jingle: + tooltip_text = _('No File Transfer available') + self._ui.sendfile_button.set_tooltip_text(tooltip_text) + return + + assert self._contact is not None + client = app.get_client(self._contact.account) + + tooltip_text = _('Send File…') + if httpupload and not jingle: + max_file_size = client.get_module('HTTPUpload').max_file_size + if max_file_size is not None: + if app.settings.get('use_kib_mib'): + units = GLib.FormatSizeFlags.IEC_UNITS + else: + units = GLib.FormatSizeFlags.DEFAULT + max_file_size = GLib.format_size_full(max_file_size, units) + tooltip_text = _('Send File (max. %s)…') % max_file_size + + self._ui.sendfile_button.set_tooltip_text(tooltip_text) + + def _on_buffer_changed(self, textbuffer: Gtk.TextBuffer) -> None: + has_text = self.msg_textview.has_text + send_message_action = app.window.get_action('send-message') + send_message_action.set_enabled(has_text) + + encryption_enabled, encryption_name = self._get_encryption_state() + + if has_text and encryption_enabled: + app.plugin_manager.extension_point('typing' + encryption_name, self) + + assert self._contact + client = app.get_client(self._contact.account) + client.get_module('Chatstate').set_keyboard_activity(self._contact) + if not has_text: + client.get_module('Chatstate').set_chatstate_delayed( + self._contact, Chatstate.ACTIVE) + return + + client.get_module('Chatstate').set_chatstate( + self._contact, Chatstate.COMPOSING) + + def _on_msg_textview_key_press_event(self, + textview: MessageInputTextView, + event: Gdk.EventKey + ) -> bool: + # pylint: disable=too-many-nested-blocks + if event.keyval == Gdk.KEY_space: + self.space_pressed = True + + elif ((self.space_pressed or self.msg_textview.undo_pressed) and + event.keyval not in (Gdk.KEY_Control_L, Gdk.KEY_Control_R) and + not (event.keyval == Gdk.KEY_z and + event.get_state() & Gdk.ModifierType.CONTROL_MASK)): + # If the space key has been pressed and now it hasn't, + # we save the buffer into the undo list. But be careful we're not + # pressing Control again (as in ctrl+z) + _buffer = textview.get_buffer() + start_iter, end_iter = _buffer.get_bounds() + self.msg_textview.save_undo(_buffer.get_text(start_iter, + end_iter, + True)) + self.space_pressed = False + + event_state = event.get_state() + if event_state & Gdk.ModifierType.SHIFT_MASK: + if event_state & Gdk.ModifierType.CONTROL_MASK: + if event.keyval == Gdk.KEY_ISO_Left_Tab: + app.window.select_next_chat( + Direction.PREV, unread_first=True) + return True + + if event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Page_Up): + control = app.window.get_active_control() + if control is not None: + control.conversation_view.event(event) + return True + + if event_state & Gdk.ModifierType.CONTROL_MASK: + if event.keyval == Gdk.KEY_Tab: + app.window.select_next_chat(Direction.NEXT, unread_first=True) + return True + + if event.keyval == Gdk.KEY_z: + self.msg_textview.undo() + return True + + if event.keyval == Gdk.KEY_Up: + self.toggle_message_correction() + return True + + if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + if event_state & Gdk.ModifierType.SHIFT_MASK: + textview.insert_newline() + return True + + if event_state & Gdk.ModifierType.CONTROL_MASK: + if not app.settings.get('send_on_ctrl_enter'): + textview.insert_newline() + return True + else: + if app.settings.get('send_on_ctrl_enter'): + textview.insert_newline() + return True + + assert self._contact is not None + if not app.account_is_available(self._contact.account): + # we are not connected + ErrorDialog( + _('No Connection Available'), + _('Your message can not be sent until you are connected.')) + return True + + app.window.activate_action('send-message', None) + return True + + return False + + def _on_paste_clipboard(self, + _texview: MessageInputTextView + ) -> None: + + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + image = clipboard.wait_for_image() + if image is None: + return + + if not app.settings.get('confirm_paste_image'): + self._paste_event_confirmed(True, image) + return + + PastePreviewDialog( + _('Paste Image'), + _('You are trying to paste an image'), + _('Are you sure you want to paste your ' + "clipboard's image into the chat window?"), + _('_Do not ask me again'), + image, + [DialogButton.make('Cancel'), + DialogButton.make('Accept', + text=_('_Paste'), + callback=self._paste_event_confirmed, + args=[image])]).show() + + def _paste_event_confirmed(self, + is_checked: bool, + image: GdkPixbuf.Pixbuf + ) -> None: + if is_checked: + app.settings.set('confirm_paste_image', False) + + dir_ = tempfile.gettempdir() + path = os.path.join(dir_, f'{uuid.uuid4()}.png') + if image is None: + log.error('Could not process pasted image') + return + + image.savev(path, 'png', [], []) + + assert self._contact is not None + app.interface.start_file_transfer(self._contact, path) + + def toggle_message_correction(self) -> None: + if self.is_correcting: + self.is_correcting = False + self.msg_textview.clear() + self.msg_textview.get_style_context().remove_class( + 'gajim-msg-correcting') + self.msg_textview.grab_focus() + return + + assert self._contact is not None + last_message_id = self.last_message_id.get( + (self._contact.account, self._contact.jid)) + if last_message_id is None: + return + + message_row = app.storage.archive.get_last_correctable_message( + self._contact.account, self._contact.jid, last_message_id) + if message_row is None: + return + + self.is_correcting = True + self.msg_textview.clear() + self.msg_textview.insert_text(message_row.message) + self.msg_textview.get_style_context().add_class('gajim-msg-correcting') + self.msg_textview.grab_focus() + + def _on_message_sent(self, event: events.MessageSent) -> None: + if not event.message: + return + + if event.correct_id is None: + # This wasn't a corrected message + assert self._contact is not None + oob_url = event.additional_data.get_value('gajim', 'oob_url') + account = self._contact.account + jid = self._contact.jid + if oob_url == event.message: + # Don't allow to correct HTTP Upload file transfer URLs + self.last_message_id[(account, jid)] = None + else: + self.last_message_id[(account, jid)] = event.message_id + + self.msg_textview.get_style_context().remove_class( + 'gajim-msg-correcting') + + def process_event(self, event: events.ApplicationEvent) -> None: + if isinstance(event, events.MessageSent): + self._on_message_sent(event) diff --git a/gajim/gtk/message_input.py b/gajim/gtk/message_input.py index 0251c8cbc..ff2c6f72a 100644 --- a/gajim/gtk/message_input.py +++ b/gajim/gtk/message_input.py @@ -17,7 +17,10 @@ # You should have received a copy of the GNU General Public License # along with Gajim. If not, see <http://www.gnu.org/licenses/>. +from __future__ import annotations + from typing import Any +from typing import Optional import sys @@ -27,7 +30,10 @@ from gi.repository import GdkPixbuf from gi.repository import GLib from gi.repository import Pango +from nbxmpp.protocol import JID + from gajim.common import app +from gajim.common.i18n import _ from gajim.common.styling import process from gajim.common.styling import PlainBlock from gajim.common.types import ChatContactT @@ -52,11 +58,8 @@ class MessageInputTextView(Gtk.TextView): ''' A Gtk.Textview for chat message input ''' - def __init__(self, account: str, contact: ChatContactT) -> None: + def __init__(self) -> None: Gtk.TextView.__init__(self) - self.account = account - self.contact = contact - self.set_border_width(3) self.set_accepts_tab(True) self.set_editable(True) @@ -73,8 +76,10 @@ class MessageInputTextView(Gtk.TextView): self._undo_list: list[str] = [] self.undo_pressed: bool = False - self._chat_action_processor = ChatActionProcessor( - account, contact, self) + self._contact: Optional[ChatContactT] = None + self._drafts: dict[tuple[str, JID], str] = {} + + self._chat_action_processor = ChatActionProcessor(self) self.get_buffer().create_tag('strong', weight=Pango.Weight.BOLD) self.get_buffer().create_tag('emphasis', style=Pango.Style.ITALIC) @@ -86,6 +91,24 @@ class MessageInputTextView(Gtk.TextView): self.connect('focus-in-event', self._on_focus_in) self.connect('focus-out-event', self._on_focus_out) self.connect('destroy', self._on_destroy) + self.connect('populate-popup', self._on_populate_popup) + + def switch_contact(self, contact: ChatContactT) -> None: + if self._contact is not None: + account = self._contact.account + jid = self._contact.jid + if self.has_text: + self._drafts[(account, jid)] = self.get_text() + else: + self._drafts.pop((account, jid), None) + + self.clear() + draft = self._drafts.get((contact.account, contact.jid)) + if draft is not None: + self.insert_text(draft) + + self._contact = contact + self._chat_action_processor.switch_contact(contact) def _on_destroy(self, _widget: Gtk.Widget) -> None: # We restore the TextView’s drag destination to avoid a GTK warning @@ -117,7 +140,7 @@ class MessageInputTextView(Gtk.TextView): scrolled = self.get_parent() assert scrolled scrolled.get_style_context().remove_class('message-input-focus') - if not self.has_text(): + if not self.has_text: self.toggle_speller(False) return False @@ -167,6 +190,7 @@ class MessageInputTextView(Gtk.TextView): if buf.get_end_iter().equal(iter_): GLib.idle_add(scroll_to_end, self.get_parent()) + @property def has_text(self) -> bool: buf = self.get_buffer() start, end = buf.get_bounds() @@ -179,6 +203,9 @@ class MessageInputTextView(Gtk.TextView): text = self.get_buffer().get_text(start, end, True) return text + def get_draft(self, account: str, jid: JID) -> Optional[str]: + return self._drafts.get((account, jid)) + def toggle_speller(self, activate: bool) -> None: if app.is_installed('GSPELL') and app.settings.get('use_speller'): spell_view = Gspell.TextView.get_from_gtk_text_view(self) @@ -302,6 +329,7 @@ class MessageInputTextView(Gtk.TextView): buf = self.get_buffer() start, end = buf.get_bounds() buf.delete(start, end) + self._undo_list.clear() def save_undo(self, text: str) -> None: self._undo_list.append(text) @@ -315,9 +343,42 @@ class MessageInputTextView(Gtk.TextView): buf.set_text(self._undo_list.pop()) self.undo_pressed = True - def process_outgoing_message(self, - contact_name: str, - highlight: bool - ) -> None: - self._chat_action_processor.process_outgoing_message( - contact_name, highlight) + def _on_populate_popup(self, + _textview: MessageInputTextView, + menu: Gtk.Widget + ) -> None: + assert isinstance(menu, Gtk.Menu) + item = Gtk.MenuItem.new_with_mnemonic(_('_Undo')) + menu.prepend(item) + item.connect('activate', self.undo) + + item = Gtk.SeparatorMenuItem() + menu.prepend(item) + + item = Gtk.MenuItem.new_with_mnemonic(_('_Clear')) + menu.prepend(item) + item.connect('activate', self.clear) + + paste_item = Gtk.MenuItem.new_with_label(_('Paste as quote')) + paste_item.connect('activate', self._paste_clipboard_as_quote) + menu.append(paste_item) + + menu.show_all() + + def mention_participant(self, name: str) -> None: + gc_refer_to_nick_char = app.settings.get('gc_refer_to_nick_char') + text = f'{name}{gc_refer_to_nick_char} ' + self.insert_text(text) + self.grab_focus() + + def insert_as_quote(self, text: str) -> None: + text = '> ' + text.replace('\n', '\n> ') + '\n' + self.insert_text(text) + self.grab_focus() + + def _paste_clipboard_as_quote(self, _item: Gtk.MenuItem) -> None: + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + text = clipboard.wait_for_text() + if text is None: + return + self.insert_as_quote(text) diff --git a/gajim/gtk/notification_manager.py b/gajim/gtk/notification_manager.py index f8bd58d85..a5f140a86 100644 --- a/gajim/gtk/notification_manager.py +++ b/gajim/gtk/notification_manager.py @@ -378,7 +378,7 @@ class UnsubscribedRow(NotificationRow): remove_button.set_valign(Gtk.Align.CENTER) remove_button.set_tooltip_text('Remove from contact list') remove_button.set_action_name( - f'win.remove-contact-{self._account}') + f'win.{self._account}-remove-contact') remove_button.set_action_target_value(GLib.Variant('s', str(self.jid))) self.grid.attach(remove_button, 3, 1, 1, 2) diff --git a/gajim/gtk/roster.py b/gajim/gtk/roster.py index e91de30f8..baddd859e 100644 --- a/gajim/gtk/roster.py +++ b/gajim/gtk/roster.py @@ -117,8 +117,6 @@ class Roster(Gtk.ScrolledWindow, EventHelper): self._ui.connect_signals(self) self.register_events([ - ('account-connected', ged.CORE, self._on_account_state), - ('account-disconnected', ged.CORE, self._on_account_state), ('roster-received', ged.CORE, self._on_roster_received), ('theme-update', ged.GUI2, self._on_theme_update), ('roster-push', ged.GUI2, self._on_roster_push), @@ -135,69 +133,29 @@ class Roster(Gtk.ScrolledWindow, EventHelper): self._filter_enabled = False self._filter_string = '' - self._add_actions() + app.settings.connect_signal('showoffline', self._on_setting_changed) + app.settings.connect_signal('sort_by_show_in_roster', + self._on_setting_changed) + + self._connect_actions() self._initial_draw() - def _add_actions(self): - actions = [ - ('contact-info', self._on_contact_info), - ('modify-gateway', self._on_modify_gateway), - ('execute-command', self._on_execute_command), - ('block-contact', self._on_block_contact), - ('remove-contact', self._on_remove_contact), + def _connect_actions(self): + app_actions = [ + (f'{self._account}-modify-gateway', self._on_modify_gateway), + (f'{self._account}-execute-command', self._on_execute_command), + (f'{self._account}-block-contact', self._on_block_contact), + (f'{self._account}-remove-contact', self._on_remove_contact), ] - for action in actions: + + for action in app_actions: action_name, func = action - act = Gio.SimpleAction.new( - f'{action_name}-{self._account}', GLib.VariantType.new('s')) - act.connect('activate', func) - app.window.add_action(act) - - action = Gio.SimpleAction.new_stateful( - f'show-offline-{self._account}', - None, - GLib.Variant.new_boolean(app.settings.get('showoffline'))) - action.connect('change-state', self._on_show_offline) - app.window.add_action(action) - - action = Gio.SimpleAction.new_stateful( - f'sort-by-show-{self._account}', - None, - GLib.Variant.new_boolean( - app.settings.get('sort_by_show_in_roster'))) - action.connect('change-state', self._on_sort_by_show) - app.window.add_action(action) - - def update_actions(self): - online = app.account_is_connected(self._account) - blocking_support = self._client.get_module('Blocking').supported - - actions = [('contact-info', online), - ('modify-gateway', online), - ('execute-command', online), - ('block-contact', online and blocking_support), - ('remove-contact', online)] - - for action, enabled in actions: - act = app.window.lookup_action(f'{action}-{self._account}') - assert act is not None - act.set_enabled(enabled) - - def _remove_actions(self): - actions = [ - 'contact-info', - 'modify-gateway', - 'execute-command', - 'block-contact', - 'remove-contact', - 'show-offline', - 'sort-by-show', - ] - for action in actions: - app.window.remove_action(f'{action}-{self._account}') + action = app.app.lookup_action(action_name) + assert action is not None + action.connect('activate', func) - def _on_account_state(self, _event: ApplicationEvent) -> None: - self.update_actions() + def _get_contact(self, jid: str) -> types.BareContact: + return self._client.get_module('Contacts').get_contact(jid) def _on_theme_update(self, _event: ApplicationEvent) -> None: self.redraw() @@ -304,20 +262,7 @@ class Roster(Gtk.ScrolledWindow, EventHelper): tooltip.set_custom(widget) return value - def _on_show_offline(self, - action: Gio.SimpleAction, - value: Optional[GLib.Variant]) -> None: - - assert value is not None - action.set_state(value) - app.settings.set('showoffline', value.get_boolean()) - self._refilter() - - def _on_sort_by_show(self, - action: Gio.SimpleAction, - value: GLib.Variant) -> None: - action.set_state(value) - app.settings.set('sort_by_show_in_roster', value.get_boolean()) + def _on_setting_changed(self, *args: Any) -> None: self._refilter() def _on_contact_info(self, @@ -787,7 +732,7 @@ class Roster(Gtk.ScrolledWindow, EventHelper): return def _on_destroy(self, _roster: Roster) -> None: - self._remove_actions() + app.settings.disconnect_signals(self) self._contact_refs.clear() self._group_refs.clear() self._unset_model() diff --git a/gajim/gtk/roster_item_exchange.py b/gajim/gtk/roster_item_exchange.py index bebd75d7f..3c4efbf87 100644 --- a/gajim/gtk/roster_item_exchange.py +++ b/gajim/gtk/roster_item_exchange.py @@ -243,11 +243,6 @@ class RosterItemExchange(Gtk.ApplicationWindow): self._client.get_module('Roster').set_item( jid, model[iter_][2], groups) - # Update opened chats - ctrl = app.window.get_control(self.account, jid) - if ctrl: - ctrl.update_ui() - iter_ = model.iter_next(iter_) elif self._action == 'delete': count = 0 diff --git a/gajim/gtk/security_label_selector.py b/gajim/gtk/security_label_selector.py index 42469e809..0ad967591 100644 --- a/gajim/gtk/security_label_selector.py +++ b/gajim/gtk/security_label_selector.py @@ -19,6 +19,7 @@ from typing import Optional from gi.repository import Gtk from gi.repository import Pango +from nbxmpp.modules.security_labels import SecurityLabel from nbxmpp.protocol import JID from gajim.common import app @@ -31,11 +32,11 @@ from gajim.common.i18n import _ class SecurityLabelSelector(Gtk.ComboBox): - def __init__(self, account: str, contact: ChatContactT) -> None: + def __init__(self) -> None: Gtk.ComboBox.__init__(self, no_show_all=True) - self._account = account - self._client = app.get_client(account) - self._contact = contact + self._account: Optional[str] = None + self._client: Optional[Client] = None + self._contact: Optional[ChatContactT] = None self.set_valign(Gtk.Align.CENTER) self.set_tooltip_text(_('Select a security label for your message…')) @@ -51,14 +52,25 @@ class SecurityLabelSelector(Gtk.ComboBox): app.ged.register_event_handler( 'sec-catalog-received', ged.GUI1, self._sec_labels_received) + self.connect('changed', self._on_changed) + self.connect('destroy', self._on_destroy) + + def switch_contact(self, contact: ChatContactT) -> None: + app.settings.disconnect_signals(self) + if self._client is not None: + self._client.disconnect_all_from_obj(self) + + self._account = contact.account + self._client = app.get_client(contact.account) + self._contact = contact + app.settings.connect_signal( 'enable_security_labels', self._on_setting_changed, account=self._account) self._client.connect_signal( 'state-changed', self._on_client_state_changed) - self.connect('changed', self._on_changed) - self.connect('destroy', self._on_destroy) + self._update() def _on_destroy(self, _widget: SecurityLabelSelector) -> None: app.ged.remove_event_handler('sec-catalog-received', @@ -79,13 +91,14 @@ class SecurityLabelSelector(Gtk.ComboBox): def _on_client_state_changed(self, _client: Client, _signal_name: str, - _state: SimpleClientState): - self.update() + _state: SimpleClientState + ) -> None: + self._update() def _on_setting_changed(self, state: bool, _name: str, - account: str, + _account: Optional[str], _jid: Optional[JID] ) -> None: self.set_no_show_all(not state) @@ -93,9 +106,13 @@ class SecurityLabelSelector(Gtk.ComboBox): self.show_all() else: self.hide() + self._update() def _sec_labels_received(self, event: SecCatalogReceived) -> None: - if event.account != self._account: + if self._account is None or self._account != event.account: + return + + if self._contact is None: return if event.jid != self._contact.jid.bare: @@ -120,11 +137,13 @@ class SecurityLabelSelector(Gtk.ComboBox): self.set_no_show_all(False) self.show_all() - def get_seclabel(self) -> Optional[str]: + def get_seclabel(self) -> Optional[SecurityLabel]: index = self.get_active() if index == -1: return None + assert self._contact is not None + assert self._client is not None jid = self._contact.jid.bare catalog = self._client.get_module('SecLabels').get_catalog(jid) if catalog is None: @@ -132,10 +151,13 @@ class SecurityLabelSelector(Gtk.ComboBox): labels, label_list = catalog.labels, catalog.get_label_names() label_name = label_list[index] - label = labels[label_name] - return label + return labels[label_name] + + def _update(self) -> None: + assert self._account is not None + assert self._client is not None + assert self._contact is not None - def update(self) -> None: chat_active = app.window.is_chat_active( self._account, self._contact.jid) if chat_active: diff --git a/gajim/gtk/util.py b/gajim/gtk/util.py index 73c822857..2e5eebd66 100644 --- a/gajim/gtk/util.py +++ b/gajim/gtk/util.py @@ -23,6 +23,7 @@ from typing import Union import logging import math +import sys import textwrap from io import BytesIO from importlib import import_module @@ -358,6 +359,18 @@ def get_primary_accel_mod() -> Optional[Gdk.ModifierType]: return Gtk.accelerator_parse('<Primary>')[1] +def get_copy_modifier() -> Gdk.ModifierType: + if sys.platform == 'darwin': + return Gdk.ModifierType.META_MASK + return Gdk.ModifierType.CONTROL_MASK + + +def get_copy_modifier_keys() -> tuple[int, int]: + if sys.platform == 'darwin': + return Gdk.KEY_Meta_L, Gdk.KEY_Meta_R + return Gdk.KEY_Control_L, Gdk.KEY_Control_R + + def get_hardware_key_codes(keyval: int) -> list[int]: display = Gdk.Display.get_default() assert display is not None diff --git a/gajim/gui_interface.py b/gajim/gui_interface.py index cc0841bae..0f1d4b8f2 100644 --- a/gajim/gui_interface.py +++ b/gajim/gui_interface.py @@ -67,16 +67,20 @@ from gajim.common.events import FileCompleted from gajim.common.events import FileHashError from gajim.common.events import FileProgress from gajim.common.events import FileError +from gajim.common.modules.contacts import BareContact +from gajim.common.modules.contacts import GroupchatContact from gajim.common.structs import OutgoingMessage from gajim.common.i18n import _ from gajim.common.client import Client from gajim.common.const import FTState from gajim.common.file_props import FileProp +from gajim.common.types import ChatContactT from gajim.common.modules.httpupload import HTTPFileTransfer from gajim.gui.dialogs import ErrorDialog from gajim.gui.filechoosers import FileChooserDialog +from gajim.gui.file_transfer_send import SendFileDialog from gajim.gui.filetransfer import FileTransfersWindow from gajim.gui.menus import build_accounts_menu from gajim.gui.util import get_app_window @@ -125,7 +129,6 @@ class Interface: # pylint: disable=line-too-long self.handlers = { 'signed-in': [self.handle_event_signed_in], - 'presence-received': [self.handle_event_presence], 'message-sent': [self.handle_event_msgsent], 'message-not-sent': [self.handle_event_msgnotsent], } @@ -166,14 +169,6 @@ class Interface: if app.settings.get('ask_online_status'): app.window.show_account_page(account) - def handle_event_presence(self, event): - account = event.conn.name - jid = event.jid - - ctrl = app.window.get_control(account, jid) - if ctrl and ctrl.session and len(event.contact_list) > 1: - ctrl.remove_session(ctrl.session) - @staticmethod def handle_event_msgsent(event): if not event.play_sound: @@ -377,6 +372,60 @@ class Interface: client = app.get_client(transfer.account) client.get_module('HTTPUpload').cancel_transfer(transfer) + def _get_pref_ft_method(self, contact: ChatContactT) -> Optional[str]: + ft_pref = app.settings.get_account_setting(contact.account, + 'filetransfer_preference') + httpupload = app.window.get_action_enabled('send-file-httpupload') + jingle = app.window.get_action_enabled('send-file-jingle') + + if isinstance(contact, GroupchatContact): + if httpupload: + return 'httpupload' + return None + + if httpupload and jingle: + return ft_pref + + if httpupload: + return 'httpupload' + + if jingle: + return 'jingle' + return None + + def start_file_transfer(self, + contact: ChatContactT, + path: Optional[str] = None, + method: Optional[str] = None + ) -> None: + if method is None: + method = self._get_pref_ft_method(contact) + if method is None: + return + + current_control = app.window.get_active_control() + if current_control is None: + return + + if path is None: + if method == 'httpupload': + self.send_httpupload(current_control) + return + if method == 'jingle': + self.instances['file_transfers'].show_file_send_request( + contact.account, contact) + return + + if method == 'httpupload': + self.send_httpupload(current_control, path) + else: + assert isinstance(contact, BareContact) + send_callback = partial( + self.instances['file_transfers'].send_file, + contact.account, + contact) + SendFileDialog(contact, send_callback, app.window, [path]) + @staticmethod def create_groupchat(account: str, room_jid: str, |