Philipp Hörist pushed to branch new-chat-commands at gajim / gajim

Commits:

20 changed files:

Changes:

  • gajim/common/app.py
    ... ... @@ -63,6 +63,7 @@
    63 63
         from gajim.common.call_manager import CallManager
    
    64 64
         from gajim.common.preview import PreviewManager
    
    65 65
         from gajim.common.task_manager import TaskManager
    
    66
    +    from gajim.common.commands import ChatCommands
    
    66 67
     
    
    67 68
     
    
    68 69
     interface = cast(types.InterfaceT, None)
    
    ... ... @@ -77,6 +78,7 @@
    77 78
     ipython_window = None
    
    78 79
     app = None  # type: GajimApplication
    
    79 80
     window = None  # type: MainWindow
    
    81
    +commands = None  # type: ChatCommands
    
    80 82
     
    
    81 83
     ged = ged_module.GlobalEventsDispatcher()  # Global Events Dispatcher
    
    82 84
     plugin_manager = cast(types.PluginManagerT, None)  # Plugins Manager
    

  • gajim/common/application.py
    ... ... @@ -37,6 +37,7 @@
    37 37
     from gajim.common import configpaths
    
    38 38
     from gajim.common import logging_helpers
    
    39 39
     from gajim.common import passwords
    
    40
    +from gajim.common.commands import ChatCommands
    
    40 41
     from gajim.common.dbus import logind
    
    41 42
     from gajim.common.events import AccountDisonnected
    
    42 43
     from gajim.common.events import AllowGajimUpdateCheck
    
    ... ... @@ -68,6 +69,7 @@ def _init_core(self) -> None:
    68 69
             app.settings.init()
    
    69 70
     
    
    70 71
             app.config = LegacyConfig()
    
    72
    +        app.commands = ChatCommands()
    
    71 73
     
    
    72 74
             app.storage.cache = CacheStorage()
    
    73 75
             app.storage.cache.init()
    

  • gajim/common/commands.py
    1
    +# This file is part of Gajim.
    
    2
    +#
    
    3
    +# Gajim is free software; you can redistribute it and/or modify
    
    4
    +# it under the terms of the GNU General Public License as published
    
    5
    +# by the Free Software Foundation; version 3 only.
    
    6
    +#
    
    7
    +# Gajim is distributed in the hope that it will be useful,
    
    8
    +# but WITHOUT ANY WARRANTY; without even the implied warranty of
    
    9
    +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    
    10
    +# GNU General Public License for more details.
    
    11
    +#
    
    12
    +# You should have received a copy of the GNU General Public License
    
    13
    +# along with Gajim.  If not, see <http://www.gnu.org/licenses/>.
    
    14
    +
    
    15
    +from typing import Any
    
    16
    +from typing import NoReturn
    
    17
    +from typing import Callable
    
    18
    +from typing import Optional
    
    19
    +
    
    20
    +import io
    
    21
    +import argparse
    
    22
    +import shlex
    
    23
    +import operator
    
    24
    +
    
    25
    +from nbxmpp.protocol import JID
    
    26
    +
    
    27
    +from gajim.common import app
    
    28
    +from gajim.common.i18n import _
    
    29
    +from gajim.common.helpers import Observable, is_role_change_allowed
    
    30
    +from gajim.common.helpers import is_affiliation_change_allowed
    
    31
    +from gajim.common.modules.contacts import GroupchatContact
    
    32
    +
    
    33
    +
    
    34
    +def split_argument_string(string: str) -> list[str]:
    
    35
    +    '''
    
    36
    +    Split a string with shlex.split
    
    37
    +    '''
    
    38
    +
    
    39
    +    lex = shlex.shlex(string, posix=True)
    
    40
    +    lex.whitespace_split = True
    
    41
    +    lex.commenters = ''
    
    42
    +    result: list[str] = []
    
    43
    +
    
    44
    +    try:
    
    45
    +        for token in lex:
    
    46
    +            result.append(token)
    
    47
    +    except ValueError:
    
    48
    +        # If end-of-string is reached and there is a invalid state
    
    49
    +        # ValueError is raised. Still add the partial token to the result.
    
    50
    +        result.append(lex.token)
    
    51
    +
    
    52
    +    return result
    
    53
    +
    
    54
    +
    
    55
    +def get_usage_from_command(cmd: argparse.ArgumentParser) -> str:
    
    56
    +    with io.StringIO() as output:
    
    57
    +        cmd.print_usage(file=output)
    
    58
    +        usage = output.getvalue()
    
    59
    +
    
    60
    +    usage = usage.split('[-h] ')[1]
    
    61
    +    return usage.strip()
    
    62
    +
    
    63
    +
    
    64
    +class ArgumentParserError(Exception):
    
    65
    +    pass
    
    66
    +
    
    67
    +
    
    68
    +class CommandError(Exception):
    
    69
    +    pass
    
    70
    +
    
    71
    +
    
    72
    +class CommandNotFoundError(Exception):
    
    73
    +    pass
    
    74
    +
    
    75
    +
    
    76
    +class ArgumentParser(argparse.ArgumentParser):
    
    77
    +    def error(self, message: str) -> NoReturn:
    
    78
    +        raise ArgumentParserError(message)
    
    79
    +
    
    80
    +
    
    81
    +class ChatCommands(Observable):
    
    82
    +    def __init__(self) -> None:
    
    83
    +        Observable.__init__(self)
    
    84
    +        self._parser = ArgumentParser(prog='ChatCommands')
    
    85
    +        self._sub_parser = self._parser.add_subparsers()
    
    86
    +        self._commands: dict[str, tuple[list[str], str]] = {}
    
    87
    +
    
    88
    +        self._create_commands()
    
    89
    +
    
    90
    +    def get_commands(self, used_in: str) -> list[tuple[str, str]]:
    
    91
    +        commands: list[tuple[str, str]] = []
    
    92
    +        for cmd_name, cmd in self._commands.items():
    
    93
    +            if used_in in cmd[0]:
    
    94
    +                commands.append((cmd_name, cmd[1]))
    
    95
    +        commands.sort(key=operator.itemgetter(0))
    
    96
    +        return commands
    
    97
    +
    
    98
    +    def make_parser(self,
    
    99
    +                    command_name: str,
    
    100
    +                    callback: Callable[..., Any],
    
    101
    +                    **kwargs: Any) -> ArgumentParser:
    
    102
    +
    
    103
    +        '''Add and return a subparser and initialize
    
    104
    +        it with the command name.
    
    105
    +        '''
    
    106
    +
    
    107
    +        parser = self._sub_parser.add_parser(command_name, **kwargs)
    
    108
    +        parser.set_defaults(command_name=command_name)
    
    109
    +        self.connect(command_name, callback)
    
    110
    +        return parser
    
    111
    +
    
    112
    +    def add_command(self,
    
    113
    +                    command_name: str,
    
    114
    +                    used_in: list[str],
    
    115
    +                    cmd: argparse.ArgumentParser
    
    116
    +                    ) -> None:
    
    117
    +
    
    118
    +        usage = get_usage_from_command(cmd)
    
    119
    +        self._commands[command_name] = (used_in, usage)
    
    120
    +
    
    121
    +    def parse(self, type_: str, arg_string: str) -> None:
    
    122
    +        arg_list = split_argument_string(arg_string[1:])
    
    123
    +
    
    124
    +        command_name = arg_list[0]
    
    125
    +        command = self._commands.get(command_name)
    
    126
    +        if command is None or type_ not in command[0]:
    
    127
    +            raise CommandNotFoundError(_('Unknown command: %s' % command_name))
    
    128
    +
    
    129
    +        args = self._parser.parse_args(arg_list)
    
    130
    +
    
    131
    +        self.notify(args.command_name, args)
    
    132
    +
    
    133
    +    def _create_commands(self) -> None:
    
    134
    +        parser = self.make_parser('status', self._status_command)
    
    135
    +        parser.add_argument('status',
    
    136
    +                            choices=['online', 'away', 'xa', 'dnd'])
    
    137
    +        parser.add_argument('message', default=None, nargs='?')
    
    138
    +        self.add_command('status', ['chat', 'groupchat', 'pm'], parser)
    
    139
    +
    
    140
    +        parser = self.make_parser('invite', self._invite_command)
    
    141
    +        parser.add_argument('address')
    
    142
    +        parser.add_argument('reason', default=None, nargs='?')
    
    143
    +        self.add_command('invite', ['groupchat'], parser)
    
    144
    +
    
    145
    +        parser = self.make_parser('ban', self._ban_command)
    
    146
    +        parser.add_argument('who')
    
    147
    +        parser.add_argument('reason', default=None, nargs='?')
    
    148
    +        self.add_command('ban', ['groupchat'], parser)
    
    149
    +
    
    150
    +        parser = self.make_parser('affiliate', self._affiliate_command)
    
    151
    +        parser.add_argument('who')
    
    152
    +        parser.add_argument('affiliation',
    
    153
    +                            choices=['owner', 'admin', 'member', 'none'])
    
    154
    +        self.add_command('affiliate', ['groupchat'], parser)
    
    155
    +
    
    156
    +        parser = self.make_parser('kick', self._kick_command)
    
    157
    +        parser.add_argument('who')
    
    158
    +        parser.add_argument('reason', default=None, nargs='?')
    
    159
    +        self.add_command('kick', ['groupchat'], parser)
    
    160
    +
    
    161
    +        parser = self.make_parser('role', self._role_command)
    
    162
    +        parser.add_argument('who')
    
    163
    +        parser.add_argument('role',
    
    164
    +                            choices=['moderator', 'participant', 'visitor'])
    
    165
    +        self.add_command('role', ['groupchat'], parser)
    
    166
    +
    
    167
    +    def _status_command(self,
    
    168
    +                        chat_commands: Any,
    
    169
    +                        signal_name: str,
    
    170
    +                        args: Any
    
    171
    +                        ) -> None:
    
    172
    +
    
    173
    +        for client in app.get_clients():
    
    174
    +            if not app.settings.get_account_setting(client.account,
    
    175
    +                                                    'sync_with_global_status'):
    
    176
    +                continue
    
    177
    +
    
    178
    +            if not client.state.is_available:
    
    179
    +                continue
    
    180
    +
    
    181
    +            client.change_status(args.status,
    
    182
    +                                 args.message or client.status_message)
    
    183
    +
    
    184
    +    def _check_if_joined(self) -> GroupchatContact:
    
    185
    +        contact = app.window.get_control().contact
    
    186
    +        assert isinstance(contact, GroupchatContact)
    
    187
    +        if not contact.is_joined:
    
    188
    +            raise CommandError(_('You are currently not '
    
    189
    +                                 'joined this group chat'))
    
    190
    +        return contact
    
    191
    +
    
    192
    +    def _invite_command(self,
    
    193
    +                        chat_commands: Any,
    
    194
    +                        signal_name: str,
    
    195
    +                        args: Any
    
    196
    +                        ) -> None:
    
    197
    +
    
    198
    +        contact = self._check_if_joined()
    
    199
    +
    
    200
    +        try:
    
    201
    +            jid = JID.from_string(args.address)
    
    202
    +        except Exception:
    
    203
    +            raise CommandError(_('Invalid address: %s' % args.address))
    
    204
    +
    
    205
    +        if jid.is_full or jid.localpart is None:
    
    206
    +            raise CommandError(_('Invalid address: %s' % args.address))
    
    207
    +
    
    208
    +        client = app.get_client(contact.account)
    
    209
    +        client.get_module('MUC').invite(contact.jid, args.address, args.reason)
    
    210
    +
    
    211
    +    def _change_affiliation(self,
    
    212
    +                            nick_or_address: str,
    
    213
    +                            affiliation: str,
    
    214
    +                            reason: Optional[str]) -> None:
    
    215
    +
    
    216
    +        contact = self._check_if_joined()
    
    217
    +
    
    218
    +        nick_list = contact.get_user_nicknames()
    
    219
    +        if nick_or_address in nick_list:
    
    220
    +            participant = contact.get_resource(nick_or_address)
    
    221
    +            self_contact = contact.get_self()
    
    222
    +            assert self_contact is not None
    
    223
    +            if not is_affiliation_change_allowed(self_contact,
    
    224
    +                                                 participant,
    
    225
    +                                                 'outcast'):
    
    226
    +                raise CommandError(_('You have insufficient permissions'))
    
    227
    +
    
    228
    +            jid = participant.real_jid
    
    229
    +
    
    230
    +        else:
    
    231
    +            try:
    
    232
    +                jid = JID.from_string(nick_or_address)
    
    233
    +            except Exception:
    
    234
    +                raise CommandError(_('Invalid address: %s' % nick_or_address))
    
    235
    +
    
    236
    +            if jid.is_full or jid.localpart is None:
    
    237
    +                raise CommandError(_('Invalid address: %s' % nick_or_address))
    
    238
    +
    
    239
    +        client = app.get_client(contact.account)
    
    240
    +        client.get_module('MUC').set_affiliation(
    
    241
    +            contact.jid,
    
    242
    +            {jid: {'affiliation': affiliation,
    
    243
    +                   'reason': reason}})
    
    244
    +
    
    245
    +    def _ban_command(self,
    
    246
    +                     chat_commands: Any,
    
    247
    +                     signal_name: str,
    
    248
    +                     args: Any
    
    249
    +                     ) -> None:
    
    250
    +
    
    251
    +        self._change_affiliation(args.who, 'outcast', args.reason)
    
    252
    +
    
    253
    +    def _affiliate_command(self,
    
    254
    +                           chat_commands: Any,
    
    255
    +                           signal_name: str,
    
    256
    +                           args: Any
    
    257
    +                           ) -> None:
    
    258
    +
    
    259
    +        self._change_affiliation(args.who, args.affiliation, None)
    
    260
    +
    
    261
    +    def _change_role(self, nick: str, role: str, reason: Optional[str]) -> None:
    
    262
    +
    
    263
    +        contact = self._check_if_joined()
    
    264
    +
    
    265
    +        nick_list = contact.get_user_nicknames()
    
    266
    +        if nick not in nick_list:
    
    267
    +            raise CommandError(_('User %s not found' % nick))
    
    268
    +
    
    269
    +        participant = contact.get_resource(nick)
    
    270
    +        self_contact = contact.get_self()
    
    271
    +        assert self_contact is not None
    
    272
    +        if not is_role_change_allowed(self_contact, participant):
    
    273
    +            raise CommandError(_('You have insufficient permissions'))
    
    274
    +
    
    275
    +        client = app.get_client(contact.account)
    
    276
    +        client.get_module('MUC').set_role(contact.jid,
    
    277
    +                                          nick,
    
    278
    +                                          role,
    
    279
    +                                          reason)
    
    280
    +
    
    281
    +    def _kick_command(self,
    
    282
    +                      chat_commands: Any,
    
    283
    +                      signal_name: str,
    
    284
    +                      args: Any
    
    285
    +                      ) -> None:
    
    286
    +
    
    287
    +        self._change_role(args.who, 'none', args.reason)
    
    288
    +
    
    289
    +    def _role_command(self,
    
    290
    +                      chat_commands: Any,
    
    291
    +                      signal_name: str,
    
    292
    +                      args: Any
    
    293
    +                      ) -> None:
    
    294
    +
    
    295
    +        self._change_role(args.who, args.role, None)

  • gajim/common/modules/contacts.py
    ... ... @@ -308,6 +308,10 @@ def _supports(jid: JID, requested_feature: str) -> bool:
    308 308
     
    
    309 309
             return disco_info.supports(requested_feature)
    
    310 310
     
    
    311
    +    @property
    
    312
    +    def is_chat(self) -> bool:
    
    313
    +        return False
    
    314
    +
    
    311 315
         @property
    
    312 316
         def is_groupchat(self) -> bool:
    
    313 317
             return False
    
    ... ... @@ -316,6 +320,10 @@ def is_groupchat(self) -> bool:
    316 320
         def is_pm_contact(self) -> bool:
    
    317 321
             return False
    
    318 322
     
    
    323
    +    @property
    
    324
    +    def type_string(self) -> str:
    
    325
    +        raise NotImplementedError
    
    326
    +
    
    319 327
         @property
    
    320 328
         def is_jingle_available(self) -> bool:
    
    321 329
             return False
    
    ... ... @@ -558,6 +566,14 @@ def is_jingle_available(self) -> bool:
    558 566
                 return False
    
    559 567
             return self.is_available
    
    560 568
     
    
    569
    +    @property
    
    570
    +    def is_chat(self) -> bool:
    
    571
    +        return True
    
    572
    +
    
    573
    +    @property
    
    574
    +    def type_string(self) -> str:
    
    575
    +        return 'chat'
    
    576
    +
    
    561 577
     
    
    562 578
     class ResourceContact(CommonContact):
    
    563 579
         def __init__(self, logger: LogAdapter, jid: JID, account: str) -> None:
    
    ... ... @@ -791,6 +807,10 @@ def can_notify(self) -> bool:
    791 807
             room = self.settings.get('notify_on_all_messages')
    
    792 808
             return all_ or room
    
    793 809
     
    
    810
    +    @property
    
    811
    +    def type_string(self) -> str:
    
    812
    +        return 'groupchat'
    
    813
    +
    
    794 814
     
    
    795 815
     class GroupchatParticipant(CommonContact):
    
    796 816
         def __init__(self, logger: LogAdapter, jid: JID, account: str) -> None:
    
    ... ... @@ -936,6 +956,10 @@ def update_avatar(self, *args: Any) -> None:
    936 956
             app.app.avatar_storage.invalidate_cache(self._jid)
    
    937 957
             self.notify('user-avatar-update')
    
    938 958
     
    
    959
    +    @property
    
    960
    +    def type_string(self) -> str:
    
    961
    +        return 'pm'
    
    962
    +
    
    939 963
     
    
    940 964
     def can_add_to_roster(contact: Union[BareContact,
    
    941 965
                                          GroupchatContact,
    

  • gajim/common/modules/muc.py
    ... ... @@ -974,7 +974,7 @@ def invite(self,
    974 974
             password = self._mucs[str(room)].password
    
    975 975
             self._log.info('Invite %s to %s', jid, room)
    
    976 976
             return self._nbxmpp('MUC').invite(
    
    977
    -            room, jid, reason, password, continue_, type_)
    
    977
    +            room, jid, password, reason, continue_, type_)
    
    978 978
     
    
    979 979
         def _on_client_state_changed(self,
    
    980 980
                                      _client: types.Client,
    

  • gajim/common/preview.py
    ... ... @@ -388,6 +388,7 @@ def _on_thumb_load_finished(data: Optional[bytes],
    388 388
         def download_content(self,
    
    389 389
                              preview: Preview,
    
    390 390
                              force: bool = False) -> None:
    
    391
    +        preview.reset_received_size()
    
    391 392
             if preview.account is None:
    
    392 393
                 # History Window can be opened without account context
    
    393 394
                 # This means we can not apply proxy settings
    
    ... ... @@ -552,5 +553,6 @@ def _on_thumb_write_finished(_result: bool,
    552 553
             preview.update_widget(data=pixbuf)
    
    553 554
     
    
    554 555
         def cancel_download(self, preview: Preview) -> None:
    
    556
    +        preview.reset_received_size()
    
    555 557
             session = self._get_session(preview.account)
    
    556 558
             session.cancel_message(preview.soup_message, Soup.Status.CANCELLED)

  • gajim/data/gui/account_page.ui
    1 1
     <?xml version="1.0" encoding="UTF-8"?>
    
    2
    -<!-- Generated with glade 3.38.2 -->
    
    2
    +<!-- Generated with glade 3.40.0 -->
    
    3 3
     <interface>
    
    4 4
       <requires lib="gtk+" version="3.24"/>
    
    5 5
       <object class="GtkPaned" id="paned">
    
    ... ... @@ -207,7 +207,7 @@
    207 207
                         <property name="halign">center</property>
    
    208 208
                         <property name="spacing">12</property>
    
    209 209
                         <child>
    
    210
    -                      <object class="GtkButton" id="account_settings">
    
    210
    +                      <object class="GtkButton">
    
    211 211
                             <property name="visible">True</property>
    
    212 212
                             <property name="can-focus">True</property>
    
    213 213
                             <property name="receives-default">True</property>
    
    ... ... @@ -228,6 +228,27 @@
    228 228
                             <property name="position">0</property>
    
    229 229
                           </packing>
    
    230 230
                         </child>
    
    231
    +                    <child>
    
    232
    +                      <object class="GtkButton">
    
    233
    +                        <property name="visible">True</property>
    
    234
    +                        <property name="can-focus">True</property>
    
    235
    +                        <property name="receives-default">True</property>
    
    236
    +                        <property name="tooltip-text" translatable="yes">Ad-Hoc Commands…</property>
    
    237
    +                        <signal name="clicked" handler="_on_adhoc_commands" swapped="no"/>
    
    238
    +                        <child>
    
    239
    +                          <object class="GtkImage">
    
    240
    +                            <property name="visible">True</property>
    
    241
    +                            <property name="can-focus">False</property>
    
    242
    +                            <property name="icon-name">utilities-terminal-symbolic</property>
    
    243
    +                          </object>
    
    244
    +                        </child>
    
    245
    +                      </object>
    
    246
    +                      <packing>
    
    247
    +                        <property name="expand">False</property>
    
    248
    +                        <property name="fill">True</property>
    
    249
    +                        <property name="position">1</property>
    
    250
    +                      </packing>
    
    251
    +                    </child>
    
    231 252
                         <child>
    
    232 253
                           <placeholder/>
    
    233 254
                         </child>
    

  • gajim/data/gui/shortcuts_window.ui
    ... ... @@ -130,7 +130,7 @@
    130 130
                 <child>
    
    131 131
                   <object class="GtkShortcutsShortcut">
    
    132 132
                     <property name="visible">1</property>
    
    133
    -                <property name="accelerator">&lt;primary&gt;h</property>
    
    133
    +                <property name="accelerator">&lt;primary&gt;f</property>
    
    134 134
                     <property name="title" translatable="yes">Open search bar</property>
    
    135 135
                   </object>
    
    136 136
                 </child>
    
    ... ... @@ -139,7 +139,7 @@
    139 139
             <child>
    
    140 140
               <object class="GtkShortcutsGroup">
    
    141 141
                 <property name="visible">1</property>
    
    142
    -            <property name="title" translatable="yes">Tabs</property>
    
    142
    +            <property name="title" translatable="yes">Chat List</property>
    
    143 143
                 <child>
    
    144 144
                   <object class="GtkShortcutsShortcut">
    
    145 145
                     <property name="visible">1</property>
    

  • gajim/data/other/shortcuts.json
    ... ... @@ -11,7 +11,7 @@
    11 11
         "win.change-nickname": ["<Primary><Shift>N"],
    
    12 12
         "win.change-subject": ["<Primary><Shift>S"],
    
    13 13
         "win.escape": ["Escape"],
    
    14
    -    "win.send-file": ["<Primary>F"],
    
    14
    +    "win.search-history": ["<Primary>F"],
    
    15 15
         "win.show-contact-info": ["<Primary>I"],
    
    16 16
         "win.show-emoji-chooser": ["<Primary><Shift>M"],
    
    17 17
         "win.input-clear": ["<Primary>U"],
    
    ... ... @@ -29,6 +29,5 @@
    29 29
         "win.switch-tab-7": ["<Alt>7", "<Alt>KP_7"],
    
    30 30
         "win.switch-tab-8": ["<Alt>8", "<Alt>KP_8"],
    
    31 31
         "win.switch-tab-9": ["<Alt>9", "<Alt>KP_9"],
    
    32
    -    "win.search-history": ["<Primary>H"],
    
    33 32
         "win.toggle-chat-list": ["<Primary>R"]
    
    34 33
     }

  • gajim/gtk/account_page.py
    ... ... @@ -18,6 +18,8 @@
    18 18
     from gi.repository import Gio
    
    19 19
     from gi.repository import Gtk
    
    20 20
     
    
    21
    +from nbxmpp.protocol import JID
    
    22
    +
    
    21 23
     from gajim.common import app
    
    22 24
     from gajim.common import ged
    
    23 25
     from gajim.common.const import AvatarSize
    
    ... ... @@ -103,6 +105,10 @@ def _on_account_settings(self, _button: Gtk.Button) -> None:
    103 105
             window = open_window('AccountsWindow')
    
    104 106
             window.select_account(self._account)
    
    105 107
     
    
    108
    +    def _on_adhoc_commands(self, _button: Gtk.Button) -> None:
    
    109
    +        server_jid = JID.from_string(self._jid).domain
    
    110
    +        open_window('AdHocCommands', account=self._account, jid=server_jid)
    
    111
    +
    
    106 112
         def _on_search_changed(self, widget: Gtk.SearchEntry) -> None:
    
    107 113
             text = widget.get_text().lower()
    
    108 114
             self._roster.set_search_string(text)
    

  • gajim/gtk/blocking.py
    ... ... @@ -127,6 +127,9 @@ def _on_save(self, _button: Gtk.Button) -> None:
    127 127
     
    
    128 128
             blocked_jids: set[JID] = set()
    
    129 129
             for item in self._ui.blocking_store:
    
    130
    +            if not item[0]:
    
    131
    +                # No address/placeholder
    
    132
    +                continue
    
    130 133
                 blocked_jids.add(JID.from_string(item[0].lower()))
    
    131 134
     
    
    132 135
             unblock_jids = self._prev_blocked_jids - blocked_jids
    

  • gajim/gtk/chat_action_processor.py
    ... ... @@ -121,17 +121,9 @@ def _replace_text(self, selected_action: str) -> None:
    121 121
             self._buf.delete(start_iter, self._current_iter)
    
    122 122
             self._buf.insert(start_iter, selected_action)
    
    123 123
     
    
    124
    -    def _get_commands(self) -> list[str]:
    
    125
    -        # TODO
    
    126
    -        # commands: list[str] = []
    
    127
    -        # assert self._account
    
    128
    -        # assert self._contact
    
    129
    -        # control = app.window.get_control()
    
    130
    -        # for command in control.list_commands():
    
    131
    -        #     for name in command.names:
    
    132
    -        #         commands.append(name)
    
    133
    -        # return commands
    
    134
    -        return []
    
    124
    +    def _get_commands(self) -> list[tuple[str, str]]:
    
    125
    +        assert self._contact is not None
    
    126
    +        return app.commands.get_commands(self._contact.type_string)
    
    135 127
     
    
    136 128
         def _on_changed(self, _textview: MessageInputTextView) -> None:
    
    137 129
             insert = self._buf.get_insert()
    
    ... ... @@ -181,7 +173,7 @@ def _update_commands_menu(self,
    181 173
             self._menu.remove_all()
    
    182 174
             command_list = self._get_commands()
    
    183 175
             num_entries = 0
    
    184
    -        for command in command_list:
    
    176
    +        for command, usage in command_list:
    
    185 177
                 if not command.startswith(action_text[1:]):
    
    186 178
                     continue
    
    187 179
                 if num_entries >= MAX_ENTRIES:
    
    ... ... @@ -189,7 +181,7 @@ def _update_commands_menu(self,
    189 181
     
    
    190 182
                 action_data = GLib.Variant('s', f'/{command}')
    
    191 183
                 menu_item = Gio.MenuItem()
    
    192
    -            menu_item.set_label(f'/{command}')
    
    184
    +            menu_item.set_label(f'/{command} {usage}')
    
    193 185
                 menu_item.set_attribute_value('action-data', action_data)
    
    194 186
                 self._menu.append_item(menu_item)
    
    195 187
                 num_entries += 1
    

  • gajim/gtk/chat_banner.py
    ... ... @@ -78,9 +78,6 @@ def clear(self) -> None:
    78 78
     
    
    79 79
             self.unregister_events()
    
    80 80
     
    
    81
    -        self._contact = None
    
    82
    -        self._client = None
    
    83
    -
    
    84 81
         def switch_contact(self, contact: types.ChatContactT) -> None:
    
    85 82
             self._update_account_badge(contact.account)
    
    86 83
     
    

  • gajim/gtk/chat_stack.py
    ... ... @@ -76,6 +76,10 @@ def __init__(self):
    76 76
             self._chat_banner = ChatBanner()
    
    77 77
             self._chat_control = ChatControl()
    
    78 78
             self._message_action_box = MessageActionsBox()
    
    79
    +        self._message_action_box.connect('command-error',
    
    80
    +                                         self._on_command_error)
    
    81
    +        self._message_action_box.connect('command-not-found',
    
    82
    +                                         self._on_command_not_found)
    
    79 83
     
    
    80 84
             box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
    
    81 85
             box.add(self._chat_banner)
    
    ... ... @@ -659,6 +663,9 @@ def _on_function_message(self,
    659 663
         def _on_send_message(self) -> None:
    
    660 664
             self._message_action_box.msg_textview.replace_emojis()
    
    661 665
             message = self._message_action_box.msg_textview.get_text()
    
    666
    +        if message.startswith('//'):
    
    667
    +            # Escape sequence for chat commands
    
    668
    +            message = message[1:]
    
    662 669
     
    
    663 670
             contact = self._current_contact
    
    664 671
             assert contact is not None
    
    ... ... @@ -683,9 +690,6 @@ def _on_send_message(self) -> None:
    683 690
             if message in ('', None, '\n'):
    
    684 691
                 return
    
    685 692
     
    
    686
    -        # if process_commands and self.process_as_command(message):
    
    687
    -        #     return
    
    688
    -
    
    689 693
             label = self._message_action_box.get_seclabel()
    
    690 694
     
    
    691 695
             correct_id = None
    
    ... ... @@ -734,6 +738,19 @@ def clear(self) -> None:
    734 738
             self._message_action_box.clear()
    
    735 739
             self._chat_control.clear()
    
    736 740
     
    
    741
    +    def _on_command_error(self,
    
    742
    +                          message_actions_box: MessageActionsBox,
    
    743
    +                          error: str) -> None:
    
    744
    +
    
    745
    +        self._chat_control.add_info_message(error)
    
    746
    +
    
    747
    +    def _on_command_not_found(self,
    
    748
    +                              message_actions_box: MessageActionsBox,
    
    749
    +                              error: str
    
    750
    +                              ) -> None:
    
    751
    +
    
    752
    +        self._chat_control.add_info_message(error)
    
    753
    +
    
    737 754
     
    
    738 755
     class ChatPlaceholderBox(Gtk.Box):
    
    739 756
         def __init__(self):
    

  • gajim/gtk/control.py
    ... ... @@ -19,6 +19,9 @@
    19 19
     from typing import Union
    
    20 20
     from typing import cast
    
    21 21
     
    
    22
    +from collections import defaultdict
    
    23
    +from collections import deque
    
    24
    +from functools import partial
    
    22 25
     import os
    
    23 26
     import logging
    
    24 27
     import time
    
    ... ... @@ -99,7 +102,14 @@ def __init__(self) -> None:
    99 102
             # XEP-0333 Chat Markers
    
    100 103
             self.last_msg_id: Optional[str] = None
    
    101 104
     
    
    102
    -        self._subject_text_cache: dict[JID, str] = {}
    
    105
    +        self._muc_subjects: dict[
    
    106
    +            types.ChatContactT, tuple[float, MucSubject]] = {}
    
    107
    +
    
    108
    +        # Store 100 info messages per contact on a FIFO basis
    
    109
    +        self._info_messages: dict[
    
    110
    +            types.ChatContactT,
    
    111
    +            deque[tuple[float, str]]] = defaultdict(
    
    112
    +                partial(deque, maxlen=100))  # pyright: ignore
    
    103 113
     
    
    104 114
             self.widget = cast(Gtk.Box, self._ui.get_object('control_box'))
    
    105 115
             self.widget.show_all()
    
    ... ... @@ -202,6 +212,17 @@ def switch_contact(self, contact: Union[BareContact,
    202 212
                 for transfer in transfers:
    
    203 213
                     self.add_file_transfer(transfer)
    
    204 214
     
    
    215
    +        for timestamp, message in self._info_messages[contact]:
    
    216
    +            self.add_info_message(message, timestamp)
    
    217
    +
    
    218
    +        if isinstance(contact, GroupchatContact):
    
    219
    +            if (app.settings.get('show_subject_on_join') or
    
    220
    +                    not contact.is_joining):
    
    221
    +                timestamp, subject = self._muc_subjects.get(
    
    222
    +                    contact, (None, None))
    
    223
    +                if subject is not None:
    
    224
    +                    self.conversation_view.add_muc_subject(subject, timestamp)
    
    225
    +
    
    205 226
         def _register_events(self) -> None:
    
    206 227
             if self.has_events_registered():
    
    207 228
                 return
    
    ... ... @@ -478,15 +499,24 @@ def get_our_nick(self) -> str:
    478 499
             if isinstance(self.contact, GroupchatParticipant):
    
    479 500
                 muc_data = self.client.get_module('MUC').get_muc_data(
    
    480 501
                     self.contact.jid.bare)
    
    481
    -            return muc_data.nick
    
    502
    +            if muc_data is not None:
    
    503
    +                return muc_data.nick
    
    482 504
     
    
    483 505
             return app.nicks[self.contact.account]
    
    484 506
     
    
    485 507
         def _allow_add_message(self) -> bool:
    
    486 508
             return self._scrolled_view.get_lower_complete()
    
    487 509
     
    
    488
    -    def add_info_message(self, text: str) -> None:
    
    489
    -        self.conversation_view.add_info_message(text)
    
    510
    +    def add_info_message(self,
    
    511
    +                         text: str,
    
    512
    +                         timestamp: Optional[float] = None
    
    513
    +                         ) -> None:
    
    514
    +
    
    515
    +        if timestamp is None:
    
    516
    +            assert self._contact is not None
    
    517
    +            self._info_messages[self._contact].append((time.time(), text))
    
    518
    +
    
    519
    +        self.conversation_view.add_info_message(text, timestamp)
    
    490 520
     
    
    491 521
         def add_file_transfer(self, transfer: HTTPFileTransfer) -> None:
    
    492 522
             self.conversation_view.add_file_transfer(transfer)
    
    ... ... @@ -814,11 +844,12 @@ def _on_room_subject(self,
    814 844
             if subject is None:
    
    815 845
                 return
    
    816 846
     
    
    817
    -        if self._subject_text_cache.get(contact.jid) == subject.text:
    
    847
    +        _timestamp, old_subject = self._muc_subjects.get(contact, (None, None))
    
    848
    +        if old_subject is not None and old_subject.text == subject.text:
    
    818 849
                 # Probably a rejoin, we already showed that subject
    
    819 850
                 return
    
    820 851
     
    
    821
    -        self._subject_text_cache[contact.jid] = subject.text
    
    852
    +        self._muc_subjects[contact] = (time.time(), subject)
    
    822 853
     
    
    823 854
             if (app.settings.get('show_subject_on_join') or
    
    824 855
                     not contact.is_joining):
    

  • gajim/gtk/conversation/rows/info.py
    ... ... @@ -12,10 +12,13 @@
    12 12
     # You should have received a copy of the GNU General Public License
    
    13 13
     # along with Gajim. If not, see <http://www.gnu.org/licenses/>.
    
    14 14
     
    
    15
    +from __future__ import annotations
    
    16
    +
    
    17
    +from typing import Optional
    
    18
    +
    
    15 19
     import time
    
    16 20
     from datetime import datetime
    
    17 21
     
    
    18
    -from gi.repository import GLib
    
    19 22
     from gi.repository import Gtk
    
    20 23
     
    
    21 24
     from gajim.common.const import AvatarSize
    
    ... ... @@ -25,15 +28,18 @@
    25 28
     
    
    26 29
     
    
    27 30
     class InfoMessage(BaseRow):
    
    28
    -    def __init__(self, account: str, text: str) -> None:
    
    31
    +    def __init__(self,
    
    32
    +                 account: str,
    
    33
    +                 text: str,
    
    34
    +                 timestamp: Optional[float]
    
    35
    +                 ) -> None:
    
    36
    +
    
    29 37
             BaseRow.__init__(self, account)
    
    30 38
     
    
    31 39
             self.type = 'info'
    
    32
    -        timestamp = time.time()
    
    33
    -        self.timestamp = datetime.fromtimestamp(timestamp)
    
    34
    -        self.db_timestamp = timestamp
    
    35
    -
    
    36
    -        text = GLib.markup_escape_text(text)
    
    40
    +        current_timestamp = timestamp or time.time()
    
    41
    +        self.timestamp = datetime.fromtimestamp(current_timestamp)
    
    42
    +        self.db_timestamp = current_timestamp
    
    37 43
     
    
    38 44
             avatar_placeholder = Gtk.Box()
    
    39 45
             avatar_placeholder.set_size_request(AvatarSize.ROSTER, -1)
    

  • gajim/gtk/conversation/rows/muc_subject.py
    ... ... @@ -12,6 +12,10 @@
    12 12
     # You should have received a copy of the GNU General Public License
    
    13 13
     # along with Gajim. If not, see <http://www.gnu.org/licenses/>.
    
    14 14
     
    
    15
    +from __future__ import annotations
    
    16
    +
    
    17
    +from typing import Optional
    
    18
    +
    
    15 19
     import time
    
    16 20
     from datetime import datetime
    
    17 21
     
    
    ... ... @@ -29,12 +33,17 @@ class MUCSubject(BaseRow):
    29 33
     
    
    30 34
         type = 'muc-subject'
    
    31 35
     
    
    32
    -    def __init__(self, account: str, subject: MucSubject) -> None:
    
    36
    +    def __init__(self,
    
    37
    +                 account: str,
    
    38
    +                 subject: MucSubject,
    
    39
    +                 timestamp: Optional[float] = None
    
    40
    +                 ) -> None:
    
    41
    +
    
    33 42
             BaseRow.__init__(self, account)
    
    34 43
     
    
    35
    -        timestamp = time.time()
    
    36
    -        self.timestamp = datetime.fromtimestamp(timestamp)
    
    37
    -        self.db_timestamp = timestamp
    
    44
    +        current_timestamp = timestamp or time.time()
    
    45
    +        self.timestamp = datetime.fromtimestamp(current_timestamp)
    
    46
    +        self.db_timestamp = current_timestamp
    
    38 47
     
    
    39 48
             self.grid.set_halign(Gtk.Align.START)
    
    40 49
     
    

  • gajim/gtk/conversation/view.py
    ... ... @@ -130,6 +130,8 @@ def get_row_at_index(self, index: int) -> BaseRow:
    130 130
     
    
    131 131
         def reset(self) -> None:
    
    132 132
             for row in self.get_children()[2:]:
    
    133
    +            if isinstance(row, ReadMarkerRow):
    
    134
    +                continue
    
    133 135
                 row.destroy()
    
    134 136
     
    
    135 137
             self._row_count = 0
    
    ... ... @@ -161,8 +163,12 @@ def _sort_func(row1: BaseRow, row2: BaseRow) -> int:
    161 163
                 return 0
    
    162 164
             return -1 if row1.timestamp < row2.timestamp else 1
    
    163 165
     
    
    164
    -    def add_muc_subject(self, subject: MucSubject) -> None:
    
    165
    -        muc_subject = MUCSubject(self.contact.account, subject)
    
    166
    +    def add_muc_subject(self,
    
    167
    +                        subject: MucSubject,
    
    168
    +                        timestamp: Optional[float] = None
    
    169
    +                        ) -> None:
    
    170
    +
    
    171
    +        muc_subject = MUCSubject(self.contact.account, subject, timestamp)
    
    166 172
             self._insert_message(muc_subject)
    
    167 173
     
    
    168 174
         def add_muc_user_left(self,
    
    ... ... @@ -192,8 +198,12 @@ def add_user_status(self, name: str, show: str, status: str) -> None:
    192 198
             user_status = UserStatus(self.contact.account, name, show, status)
    
    193 199
             self._insert_message(user_status)
    
    194 200
     
    
    195
    -    def add_info_message(self, text: str) -> None:
    
    196
    -        message = InfoMessage(self.contact.account, text)
    
    201
    +    def add_info_message(self,
    
    202
    +                         text: str,
    
    203
    +                         timestamp: Optional[float] = None
    
    204
    +                         ) -> None:
    
    205
    +
    
    206
    +        message = InfoMessage(self.contact.account, text, timestamp)
    
    197 207
             self._insert_message(message)
    
    198 208
     
    
    199 209
         def add_file_transfer(self, transfer: HTTPFileTransfer) -> None:
    

  • gajim/gtk/message_actions_box.py
    ... ... @@ -38,6 +38,9 @@
    38 38
     from gajim.common import events
    
    39 39
     from gajim.common import i18n
    
    40 40
     from gajim.common import ged
    
    41
    +from gajim.common.commands import ArgumentParserError
    
    42
    +from gajim.common.commands import CommandError
    
    43
    +from gajim.common.commands import CommandNotFoundError
    
    41 44
     from gajim.common.i18n import _
    
    42 45
     from gajim.common.client import Client
    
    43 46
     from gajim.common.const import SimpleClientState
    
    ... ... @@ -67,6 +70,12 @@
    67 70
     
    
    68 71
     
    
    69 72
     class MessageActionsBox(Gtk.Grid, ged.EventHelper):
    
    73
    +
    
    74
    +    __gsignals__ = {
    
    75
    +        'command-error': (GObject.SignalFlags.RUN_FIRST, None, (str,)),
    
    76
    +        'command-not-found': (GObject.SignalFlags.RUN_FIRST, None, (str,))
    
    77
    +    }
    
    78
    +
    
    70 79
         def __init__(self) -> None:
    
    71 80
             Gtk.Grid.__init__(self)
    
    72 81
             ged.EventHelper.__init__(self)
    
    ... ... @@ -595,6 +604,21 @@ def _on_msg_textview_key_press_event(self,
    595 604
                         return True
    
    596 605
     
    
    597 606
                 assert self._contact is not None
    
    607
    +
    
    608
    +            message = self.msg_textview.get_text()
    
    609
    +            if (message.startswith('/') and
    
    610
    +                    not message.startswith('//') and
    
    611
    +                    not message.startswith('/me ')):
    
    612
    +                try:
    
    613
    +                    app.commands.parse(self._contact.type_string, message)
    
    614
    +                except (ArgumentParserError, CommandError) as error:
    
    615
    +                    self.emit('command-error', error)
    
    616
    +                except CommandNotFoundError as error:
    
    617
    +                    self.emit('command-not-found', error)
    
    618
    +                else:
    
    619
    +                    self.msg_textview.clear()
    
    620
    +                return True
    
    621
    +
    
    598 622
                 if not app.account_is_available(self._contact.account):
    
    599 623
                     # we are not connected
    
    600 624
                     ErrorDialog(
    

  • pyrightconfig.json
    ... ... @@ -22,6 +22,7 @@
    22 22
             "gajim/common/application.py",
    
    23 23
             "gajim/common/call_manager.py",
    
    24 24
             "gajim/common/cert_store.py",
    
    25
    +        "gajim/common/commands.py",
    
    25 26
             "gajim/common/configpaths.py",
    
    26 27
             "gajim/common/const.py",
    
    27 28
             "gajim/common/events.py",