Philipp Hörist pushed to branch new-chat-commands at gajim / gajim
Commits:
-
5c3af508
by wurstsalat at 2022-08-14T19:18:43+02:00
-
ef3683f3
by wurstsalat at 2022-08-14T19:49:20+02:00
-
6fd1b99d
by wurstsalat at 2022-08-14T20:35:02+02:00
-
f21ec271
by wurstsalat at 2022-08-14T22:57:47+02:00
-
b6e481ce
by wurstsalat at 2022-08-14T23:18:22+02:00
-
39f20787
by wurstsalat at 2022-08-15T10:57:20+02:00
-
cbc05e1b
by wurstsalat at 2022-08-15T10:57:59+02:00
-
95317a4e
by wurstsalat at 2022-08-15T11:03:39+02:00
-
d6b10432
by wurstsalat at 2022-08-15T11:31:41+02:00
-
ba2f930b
by wurstsalat at 2022-08-15T17:00:53+02:00
-
8ac2a1bd
by wurstsalat at 2022-08-15T18:44:38+02:00
-
47137bbb
by wurstsalat at 2022-08-15T19:02:44+02:00
-
0698a1d5
by lovetox at 2022-08-15T20:14:32+02:00
-
f283525a
by lovetox at 2022-08-15T20:14:32+02:00
-
1063e5b0
by lovetox at 2022-08-15T21:00:30+02:00
20 changed files:
- gajim/common/app.py
- gajim/common/application.py
- + gajim/common/commands.py
- gajim/common/modules/contacts.py
- gajim/common/modules/muc.py
- gajim/common/preview.py
- gajim/data/gui/account_page.ui
- gajim/data/gui/shortcuts_window.ui
- gajim/data/other/shortcuts.json
- gajim/gtk/account_page.py
- gajim/gtk/blocking.py
- gajim/gtk/chat_action_processor.py
- gajim/gtk/chat_banner.py
- gajim/gtk/chat_stack.py
- gajim/gtk/control.py
- gajim/gtk/conversation/rows/info.py
- gajim/gtk/conversation/rows/muc_subject.py
- gajim/gtk/conversation/view.py
- gajim/gtk/message_actions_box.py
- pyrightconfig.json
Changes:
| ... | ... | @@ -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
|
| ... | ... | @@ -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()
|
| 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) |
| ... | ... | @@ -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,
|
| ... | ... | @@ -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,
|
| ... | ... | @@ -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) |
| 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>
|
| ... | ... | @@ -130,7 +130,7 @@ |
| 130 | 130 | <child>
|
| 131 | 131 | <object class="GtkShortcutsShortcut">
|
| 132 | 132 | <property name="visible">1</property>
|
| 133 | - <property name="accelerator"><primary>h</property>
|
|
| 133 | + <property name="accelerator"><primary>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>
|
| ... | ... | @@ -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 | } |
| ... | ... | @@ -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)
|
| ... | ... | @@ -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
|
| ... | ... | @@ -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
|
| ... | ... | @@ -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 |
| ... | ... | @@ -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):
|
| ... | ... | @@ -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):
|
| ... | ... | @@ -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)
|
| ... | ... | @@ -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 |
| ... | ... | @@ -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:
|
| ... | ... | @@ -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(
|
| ... | ... | @@ -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",
|